[member approval] configurable via environment

This commit is contained in:
Drew Rothstein 2025-09-26 08:22:47 -04:00
parent 66c9ec044e
commit e401810235
No known key found for this signature in database
GPG key ID: 9E45089C24FC5E36
8 changed files with 69 additions and 3 deletions

View file

@ -23,6 +23,9 @@ AUTH_URL="http://localhost:3000"
# AUTH_EE_GOOGLE_CLIENT_ID="" # AUTH_EE_GOOGLE_CLIENT_ID=""
# AUTH_EE_GOOGLE_CLIENT_SECRET="" # AUTH_EE_GOOGLE_CLIENT_SECRET=""
# FORCE_ENABLE_ANONYMOUS_ACCESS="false"
# FORCE_MEMBER_APPROVAL_REQUIRED="false"
DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot)
SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem
# CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists) # CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists)

View file

@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
- Enable configuration of member approval via Env Var. [#542](https://github.com/sourcebot-dev/sourcebot/pull/542)
## [4.7.2] - 2025-09-22 ## [4.7.2] - 2025-09-22

View file

@ -22,6 +22,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` | <p>Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.</p><p>If you'd like to use a non-default schema, you can provide it as a parameter in the database url </p> | | `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` | <p>Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.</p><p>If you'd like to use a non-default schema, you can provide it as a parameter in the database url </p> |
| `EMAIL_FROM_ADDRESS` | `-` | <p>The email address that transactional emails will be sent from. See [this doc](/docs/configuration/transactional-emails) for more info.</p> | | `EMAIL_FROM_ADDRESS` | `-` | <p>The email address that transactional emails will be sent from. See [this doc](/docs/configuration/transactional-emails) for more info.</p> |
| `FORCE_ENABLE_ANONYMOUS_ACCESS` | `false` | <p>When enabled, [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) to the organization will always be enabled</p> | `FORCE_ENABLE_ANONYMOUS_ACCESS` | `false` | <p>When enabled, [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) to the organization will always be enabled</p>
| `FORCE_MEMBER_APPROVAL_REQUIRED` | `-` | <p>When set to `true` or `false`, forces the member approval requirement setting and disables the UI toggle. When enabled, new users will need approval from an organization owner before they can access your deployment. See [access settings docs](/docs/configuration/auth/access-settings) for more info</p>
| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` | <p>The data directory for the default Redis instance.</p> | | `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` | <p>The data directory for the default Redis instance.</p> |
| `REDIS_URL` | `redis://localhost:6379` | <p>Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.</p> | | `REDIS_URL` | `redis://localhost:6379` | <p>Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.</p> |
| `REDIS_REMOVE_ON_COMPLETE` | `0` | <p>Controls how many completed jobs are allowed to remain in Redis queues</p> | | `REDIS_REMOVE_ON_COMPLETE` | `0` | <p>Controls how many completed jobs are allowed to remain in Redis queues</p> |

View file

@ -10,9 +10,10 @@ import { useToast } from "@/components/hooks/use-toast"
interface MemberApprovalRequiredToggleProps { interface MemberApprovalRequiredToggleProps {
memberApprovalRequired: boolean memberApprovalRequired: boolean
onToggleChange?: (checked: boolean) => void onToggleChange?: (checked: boolean) => void
forceMemberApprovalRequired?: string
} }
export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleChange }: MemberApprovalRequiredToggleProps) { export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleChange, forceMemberApprovalRequired }: MemberApprovalRequiredToggleProps) {
const [enabled, setEnabled] = useState(memberApprovalRequired) const [enabled, setEnabled] = useState(memberApprovalRequired)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast() const { toast } = useToast()
@ -45,6 +46,9 @@ export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleC
} }
} }
const isDisabled = isLoading || forceMemberApprovalRequired !== undefined;
const showForceMessage = forceMemberApprovalRequired !== undefined;
return ( return (
<div className="p-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"> <div className="p-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@ -56,13 +60,35 @@ export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleC
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed"> <p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
When enabled, new users will need approval from an organization owner before they can access your deployment. When enabled, new users will need approval from an organization owner before they can access your deployment.
</p> </p>
{showForceMessage && (
<div className="mt-3">
<p className="flex items-start gap-2 text-sm text-[var(--muted-foreground)] p-3 rounded-md bg-[var(--muted)] border border-[var(--border)]">
<svg
className="w-4 h-4 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
The <code className="bg-[var(--secondary)] px-1 py-0.5 rounded text-xs font-mono">FORCE_MEMBER_APPROVAL_REQUIRED</code> environment variable is set, so this cannot be changed from the UI.
</span>
</p>
</div>
)}
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Switch <Switch
checked={enabled} checked={enabled}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
disabled={isLoading} disabled={isDisabled}
/> />
</div> </div>
</div> </div>

View file

@ -24,6 +24,7 @@ export async function OrganizationAccessSettings() {
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
const forceEnableAnonymousAccess = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; const forceEnableAnonymousAccess = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
const forceMemberApprovalRequired = env.FORCE_MEMBER_APPROVAL_REQUIRED;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -37,6 +38,7 @@ export async function OrganizationAccessSettings() {
memberApprovalRequired={org.memberApprovalRequired} memberApprovalRequired={org.memberApprovalRequired}
inviteLinkEnabled={org.inviteLinkEnabled} inviteLinkEnabled={org.inviteLinkEnabled}
inviteLink={inviteLink} inviteLink={inviteLink}
forceMemberApprovalRequired={forceMemberApprovalRequired}
/> />
</div> </div>
) )

View file

@ -8,12 +8,14 @@ interface OrganizationAccessSettingsWrapperProps {
memberApprovalRequired: boolean memberApprovalRequired: boolean
inviteLinkEnabled: boolean inviteLinkEnabled: boolean
inviteLink: string | null inviteLink: string | null
forceMemberApprovalRequired?: string
} }
export function OrganizationAccessSettingsWrapper({ export function OrganizationAccessSettingsWrapper({
memberApprovalRequired, memberApprovalRequired,
inviteLinkEnabled, inviteLinkEnabled,
inviteLink inviteLink,
forceMemberApprovalRequired
}: OrganizationAccessSettingsWrapperProps) { }: OrganizationAccessSettingsWrapperProps) {
const [showInviteLink, setShowInviteLink] = useState(memberApprovalRequired) const [showInviteLink, setShowInviteLink] = useState(memberApprovalRequired)
@ -27,6 +29,7 @@ export function OrganizationAccessSettingsWrapper({
<MemberApprovalRequiredToggle <MemberApprovalRequiredToggle
memberApprovalRequired={memberApprovalRequired} memberApprovalRequired={memberApprovalRequired}
onToggleChange={handleMemberApprovalToggle} onToggleChange={handleMemberApprovalToggle}
forceMemberApprovalRequired={forceMemberApprovalRequired}
/> />
</div> </div>

View file

@ -21,6 +21,7 @@ export const env = createEnv({
// Auth // Auth
FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.default('false'), FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.default('false'),
FORCE_MEMBER_APPROVAL_REQUIRED: booleanSchema.optional(),
AUTH_SECRET: z.string(), AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(), AUTH_URL: z.string().url(),

View file

@ -131,6 +131,19 @@ const syncDeclarativeConfig = async (configPath: string) => {
} }
} }
// Apply FORCE_MEMBER_APPROVAL_REQUIRED environment variable setting
if (env.FORCE_MEMBER_APPROVAL_REQUIRED !== undefined) {
const forceMemberApprovalRequired = env.FORCE_MEMBER_APPROVAL_REQUIRED === 'true';
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (org) {
await prisma.org.update({
where: { id: org.id },
data: { memberApprovalRequired: forceMemberApprovalRequired },
});
logger.info(`Member approval required set to ${forceMemberApprovalRequired} via FORCE_MEMBER_APPROVAL_REQUIRED environment variable`);
}
}
await syncConnections(config.connections); await syncConnections(config.connections);
await syncSearchContexts({ await syncSearchContexts({
contexts: config.contexts, contexts: config.contexts,
@ -180,6 +193,9 @@ const initSingleTenancy = async () => {
name: SINGLE_TENANT_ORG_NAME, name: SINGLE_TENANT_ORG_NAME,
domain: SINGLE_TENANT_ORG_DOMAIN, domain: SINGLE_TENANT_ORG_DOMAIN,
inviteLinkId: crypto.randomUUID(), inviteLinkId: crypto.randomUUID(),
memberApprovalRequired: env.FORCE_MEMBER_APPROVAL_REQUIRED === 'true' ? true :
env.FORCE_MEMBER_APPROVAL_REQUIRED === 'false' ? false :
true, // default to true if FORCE_MEMBER_APPROVAL_REQUIRED is not set
} }
}); });
} else if (!org.inviteLinkId) { } else if (!org.inviteLinkId) {
@ -220,6 +236,19 @@ const initSingleTenancy = async () => {
} }
} }
// Apply FORCE_MEMBER_APPROVAL_REQUIRED environment variable setting
if (env.FORCE_MEMBER_APPROVAL_REQUIRED !== undefined) {
const forceMemberApprovalRequired = env.FORCE_MEMBER_APPROVAL_REQUIRED === 'true';
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (org) {
await prisma.org.update({
where: { id: org.id },
data: { memberApprovalRequired: forceMemberApprovalRequired },
});
logger.info(`Member approval required set to ${forceMemberApprovalRequired} via FORCE_MEMBER_APPROVAL_REQUIRED environment variable`);
}
}
// Load any connections defined declaratively in the config file. // Load any connections defined declaratively in the config file.
const configPath = env.CONFIG_PATH; const configPath = env.CONFIG_PATH;
if (configPath) { if (configPath) {