Add anonymous access option to core (#385)

* migrate anonymous access logic out of ee

* add anonymous access toggle

* handle anon toggle properly based on perms

* add forceEnableAnonymousAccess setting

* add docs for access settings

* change forceEnableAnonymousAccess to be an env var

* add FORCE_ENABLE_ANONYMOUS_ACCESS to list in docs

* add back the enablePublicAccess setting as deprecated

* add changelog entry

* fix build errors

* add news entry for anonymous access

* feedback
This commit is contained in:
Michael Sukkarieh 2025-07-19 14:04:41 -07:00 committed by GitHub
parent 55c8e41137
commit aac1d4529e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 633 additions and 379 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Fixed typos in UI, docs, code [#369](https://github.com/sourcebot-dev/sourcebot/pull/369) - Fixed typos in UI, docs, code [#369](https://github.com/sourcebot-dev/sourcebot/pull/369)
- Add anonymous access option to core and deprecate the `enablePublicAccess` config setting. [#385](https://github.com/sourcebot-dev/sourcebot/pull/385)
## [4.5.1] - 2025-07-14 ## [4.5.1] - 2025-07-14

View file

@ -238,7 +238,6 @@
} }
}, },
"settings": { "settings": {
"reindexIntervalMs": 86400000, // 24 hours "reindexIntervalMs": 86400000 // 24 hours
"enablePublicAccess": true
} }
} }

View file

@ -73,7 +73,7 @@
"pages": [ "pages": [
"docs/configuration/auth/overview", "docs/configuration/auth/overview",
"docs/configuration/auth/providers", "docs/configuration/auth/providers",
"docs/configuration/auth/inviting-members", "docs/configuration/auth/access-settings",
"docs/configuration/auth/roles-and-permissions", "docs/configuration/auth/roles-and-permissions",
"docs/configuration/auth/faq" "docs/configuration/auth/faq"
] ]

View file

@ -0,0 +1,40 @@
---
title: Access Settings
sidebarTitle: Access settings
---
There are various settings to control how users access your Sourcebot deployment.
# Anonymous access
<Note>Anonymous access cannot be enabled if you have an enterprise license. If you have any questions about this restriction [reach out to us](https://www.sourcebot.dev/contact).</Note>
By default, your Sourcebot deployment is gated with a login page. If you'd like users to access the deployment anonymously, you can enable anonymous access.
This can be enabled by navigating to **Settings -> Access** or by setting the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable.
When accessing Sourcebot anonymously, a user's permissions are limited to that of the [Guest](/docs/configuration/auth/roles-and-permissions) role.
# Member Approval
By default, Sourcebot requires new members to be approved by the owner of the deployment. This section explains how approvals work and how
to configure this behavior.
### Configuration
Member approval can be configured by the owner of the deployment by navigating to **Settings -> Members**:
![Member Approval Toggle](/images/member_approval_toggle.png)
### Managing Requests
If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment
until this request is approved by the owner.
The owner can see and manage all pending join requests by navigating to **Settings -> Members**.
## Invite link
If member approval is required, an owner of the deployment can enable an invite link. When enabled, users
can use this invite link to register and be automatically added to the organization without approval:
![Invite Link Toggle](/images/invite_link_toggle.png)

View file

@ -1,30 +0,0 @@
---
title: Inviting Members
sidebarTitle: Inviting members
---
There are various ways to configure how members can join a Sourcebot deployment.
## Member Approval
**By default, Sourcebot requires new members to be approved by the owner of the deployment**. This section explains how approvals work and how
to configure this behavior.
### Configuration
Member approval can be configured by the owner of the deployment by navigating to **Settings -> Members**:
![Member Approval Toggle](/images/member_approval_toggle.png)
### Managing Requests
If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment
until this request is approved by the owner.
The owner can see and manage all pending join requests by navigating to **Settings -> Members**.
## Invite link
If member approval is required, an owner of the deployment can enable an invite link. When enabled, users
can use this invite link to register and be automatically added to the organization without approval:
![Invite Link Toggle](/images/invite_link_toggle.png)

View file

@ -10,4 +10,5 @@ Each member has a role which defines their permissions within an organization:
| Role | Permission | | Role | Permission |
| :--- | :--------- | | :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. | | `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, but may not manage the organization or its connections. | | `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, as well as view the organizations configuration and member list. However, they cannot modify this configuration or invite new members. |
| `Guest` | When accessing Sourcebot [anonymously](/docs/configuration/auth/access-settings#anonymous-access), a user has the `Guest` role. `Guest`'s can search across repos indexed by an organization's connections, but cannot view any information regarding the organizations configuration or members. |

View file

@ -21,6 +21,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` | <p>The data directory for the default Postgres database.</p> | | `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` | <p>The data directory for the default Postgres database.</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> | | `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>
| `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

@ -7,23 +7,6 @@ import SupportedPlatforms from '/snippets/platform-support.mdx'
The following guide will walk you through the steps to deploy Sourcebot on your own infrastructure. Sourcebot is distributed as a [single docker container](/docs/overview#architecture) that can be deployed to a k8s cluster, a VM, or any platform that supports docker. The following guide will walk you through the steps to deploy Sourcebot on your own infrastructure. Sourcebot is distributed as a [single docker container](/docs/overview#architecture) that can be deployed to a k8s cluster, a VM, or any platform that supports docker.
## Walkthrough video
---
Watch this quick walkthrough video to learn how to deploy Sourcebot using Docker.
<iframe
src="https://youtube.com/embed/TPQh0z7Qcjg"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
className="aspect-video w-full"
></iframe>
## Step-by-step guide
---
<Note>Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev).</Note> <Note>Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev).</Note>
<Steps> <Steps>

View file

@ -66,7 +66,8 @@
}, },
"enablePublicAccess": { "enablePublicAccess": {
"type": "boolean", "type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", "deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false "default": false
} }
}, },
@ -180,7 +181,8 @@
}, },
"enablePublicAccess": { "enablePublicAccess": {
"type": "boolean", "type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", "deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false "default": false
} }
}, },

View file

@ -15,5 +15,5 @@ export const DEFAULT_SETTINGS: Settings = {
maxRepoGarbageCollectionJobConcurrency: 8, maxRepoGarbageCollectionJobConcurrency: 8,
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
enablePublicAccess: false, enablePublicAccess: false // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
} }

View file

@ -65,7 +65,8 @@ const schema = {
}, },
"enablePublicAccess": { "enablePublicAccess": {
"type": "boolean", "type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", "deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false "default": false
} }
}, },
@ -179,7 +180,8 @@ const schema = {
}, },
"enablePublicAccess": { "enablePublicAccess": {
"type": "boolean", "type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", "deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false "default": false
} }
}, },

View file

@ -80,7 +80,8 @@ export interface Settings {
*/ */
repoIndexTimeoutMs?: number; repoIndexTimeoutMs?: number;
/** /**
* [Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats. * @deprecated
* This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.
*/ */
enablePublicAccess?: boolean; enablePublicAccess?: boolean;
} }

View file

@ -33,7 +33,7 @@ export type Plan = keyof typeof planLabels;
const entitlements = [ const entitlements = [
"search-contexts", "search-contexts",
"billing", "billing",
"public-access", "anonymous-access",
"multi-tenancy", "multi-tenancy",
"sso", "sso",
"code-nav", "code-nav",
@ -43,12 +43,12 @@ const entitlements = [
export type Entitlement = (typeof entitlements)[number]; export type Entitlement = (typeof entitlements)[number];
const entitlementsByPlan: Record<Plan, Entitlement[]> = { const entitlementsByPlan: Record<Plan, Entitlement[]> = {
oss: [], oss: ["anonymous-access"],
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"], "cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"], "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
"self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit", "analytics"], "self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics"],
// Special entitlement for https://demo.sourcebot.dev // Special entitlement for https://demo.sourcebot.dev
"cloud:demo": ["public-access", "code-nav", "search-contexts"], "cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
} as const; } as const;

View file

@ -32,12 +32,13 @@ import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/bill
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
import { getPlan, hasEntitlement } from "@sourcebot/shared"; import { getPlan, hasEntitlement } from "@sourcebot/shared";
import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { getAuditService } from "@/ee/features/audit/factory"; import { getAuditService } from "@/ee/features/audit/factory";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { getOrgMetadata } from "@/lib/utils";
import { getOrgFromDomain } from "./data/org";
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
@ -62,13 +63,13 @@ export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> =>
} }
} }
export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
// First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not, // First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not,
// then this is an invalid unauthed request and we return a 401. // then this is an invalid unauthed request and we return a 401.
const publicAccessEnabled = await getPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN); const anonymousAccessEnabled = await getAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN);
if (apiKey) { if (apiKey) {
const apiKeyOrError = await verifyApiKey(apiKey); const apiKeyOrError = await verifyApiKey(apiKey);
if (isServiceError(apiKeyOrError)) { if (isServiceError(apiKeyOrError)) {
@ -98,18 +99,17 @@ export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | unde
return fn(user.id, apiKeyOrError.apiKey.hash); return fn(user.id, apiKeyOrError.apiKey.hash);
} else if ( } else if (
env.SOURCEBOT_TENANCY_MODE === 'single' && allowAnonymousAccess &&
allowSingleTenantUnauthedAccess && !isServiceError(anonymousAccessEnabled) &&
!isServiceError(publicAccessEnabled) && anonymousAccessEnabled
publicAccessEnabled
) { ) {
if (!hasEntitlement("public-access")) { if (!hasEntitlement("anonymous-access")) {
const plan = getPlan(); const plan = getPlan();
logger.error(`Public access isn't supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); logger.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return notAuthenticated(); return notAuthenticated();
} }
// To support unauthed access a guest user is created in initialize.ts, which we return here // To support anonymous access a guest user is created in initialize.ts, which we return here
return fn(SOURCEBOT_GUEST_USER_ID, undefined); return fn(SOURCEBOT_GUEST_USER_ID, undefined);
} }
return notAuthenticated(); return notAuthenticated();
@ -672,7 +672,7 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
indexedAt: repo.indexedAt ?? undefined, indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus, repoIndexingStatus: repo.repoIndexingStatus,
})); }));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
)); ));
export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() => export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
@ -734,7 +734,7 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew
indexedAt: repo.indexedAt ?? undefined, indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus, repoIndexingStatus: repo.repoIndexingStatus,
} }
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
)); ));
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
@ -933,7 +933,7 @@ export const getCurrentUserRole = async (domain: string): Promise<OrgRole | Serv
withAuth((userId) => withAuth((userId) =>
withOrgMembership(userId, domain, async ({ userRole }) => { withOrgMembership(userId, domain, async ({ userRole }) => {
return userRole; return userRole;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
)); ));
export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
@ -1863,7 +1863,7 @@ export const getSearchContexts = async (domain: string) => sew(() =>
name: context.name, name: context.name,
description: context.description ?? undefined, description: context.description ?? undefined,
})); }));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
)); ));
export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => { export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => {
@ -1934,7 +1934,68 @@ export const getRepoImage = async (repoId: number, domain: string): Promise<Arra
return notFound(); return notFound();
} }
}, /* minRequiredRole = */ OrgRole.GUEST); }, /* minRequiredRole = */ OrgRole.GUEST);
}, /* allowSingleTenantUnauthedAccess = */ true); }, /* allowAnonymousAccess = */ true);
});
export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
const org = await getOrgFromDomain(domain);
if (!org) {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.NOT_FOUND,
message: "Organization not found",
} satisfies ServiceError;
}
// If no metadata is set we don't try to parse it since it'll result in a parse error
if (org.metadata === null) {
return false;
}
const orgMetadata = getOrgMetadata(org);
if (!orgMetadata) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.INVALID_ORG_METADATA,
message: "Invalid organization metadata",
} satisfies ServiceError;
}
return !!orgMetadata.anonymousAccessEnabled;
});
export const setAnonymousAccessStatus = async (domain: string, enabled: boolean): Promise<ServiceError | boolean> => sew(async () => {
return await withAuth(async (userId) => {
return await withOrgMembership(userId, domain, async ({ org }) => {
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
if (!hasAnonymousAccessEntitlement) {
const plan = getPlan();
console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "Anonymous access is not supported in your current plan",
} satisfies ServiceError;
}
const currentMetadata = getOrgMetadata(org);
const mergedMetadata = {
...(currentMetadata ?? {}),
anonymousAccessEnabled: enabled,
};
await prisma.org.update({
where: {
id: org.id,
},
data: {
metadata: mergedMetadata,
},
});
return true;
}, /* minRequiredRole = */ OrgRole.OWNER);
});
}); });
////// Helpers /////// ////// Helpers ///////

View file

@ -27,7 +27,7 @@ export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => {
/> />
<div className="space-y-6"> <div className="space-y-6">
<div className="w-12 h-12 mx-auto bg-[var(--primary)] rounded-full flex items-center justify-center"> <div className="w-12 h-12 mx-auto bg-primary rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-[var(--primary-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6 text-[var(--primary-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg> </svg>

View file

@ -16,10 +16,9 @@ import { getSubscriptionInfo } from "@/ee/features/billing/actions";
import { PendingApprovalCard } from "./components/pendingApproval"; import { PendingApprovalCard } from "./components/pendingApproval";
import { SubmitJoinRequest } from "./components/submitJoinRequest"; import { SubmitJoinRequest } from "./components/submitJoinRequest";
import { hasEntitlement } from "@sourcebot/shared"; import { hasEntitlement } from "@sourcebot/shared";
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { GcpIapAuth } from "./components/gcpIapAuth"; import { GcpIapAuth } from "./components/gcpIapAuth";
import { getMemberApprovalRequired } from "@/actions"; import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions";
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
@ -39,7 +38,7 @@ export default async function Layout({
} }
const session = await auth(); const session = await auth();
const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain); const anonymousAccessEnabled = hasEntitlement("anonymous-access") && await getAnonymousAccessStatus(domain);
// If the user is authenticated, we must check if they're a member of the org // If the user is authenticated, we must check if they're a member of the org
if (session) { if (session) {
@ -84,8 +83,8 @@ export default async function Layout({
} }
} }
} else { } else {
// If the user isn't authenticated and public access isn't enabled, we need to redirect them to the login page. // If the user isn't authenticated and anonymous access isn't enabled, we need to redirect them to the login page.
if (!publicAccessEnabled) { if (!anonymousAccessEnabled) {
const ssoEntitlement = await hasEntitlement("sso"); const ssoEntitlement = await hasEntitlement("sso");
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
return <GcpIapAuth callbackUrl={`/${domain}`} />; return <GcpIapAuth callbackUrl={`/${domain}`} />;

View file

@ -0,0 +1,35 @@
import { getOrgFromDomain } from "@/data/org";
import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings";
interface AccessPageProps {
params: {
domain: string;
}
}
export default async function AccessPage({ params: { domain } }: AccessPageProps) {
const org = await getOrgFromDomain(domain);
if (!org) {
throw new Error("Organization not found");
}
return (
<div className="flex flex-col gap-6">
<div>
<h3 className="text-lg font-medium">Access Control</h3>
<p className="text-sm text-muted-foreground">Configure how users can access your Sourcebot deployment.{" "}
<a
href="https://docs.sourcebot.dev/docs/configuration/auth/access-settings"
target="_blank"
rel="noopener"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Learn more
</a>
</p>
</div>
<OrganizationAccessSettings />
</div>
)
}

View file

@ -64,6 +64,12 @@ export default async function SettingsLayout({
href: `/${domain}/settings/billing`, href: `/${domain}/settings/billing`,
} }
] : []), ] : []),
...(userRoleInOrg === OrgRole.OWNER ? [
{
title: "Access",
href: `/${domain}/settings/access`,
}
] : []),
{ {
title: ( title: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -12,9 +12,6 @@ import { ServiceErrorException } from "@/lib/serviceError";
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { RequestsList } from "./components/requestsList"; import { RequestsList } from "./components/requestsList";
import { OrgRole } from "@prisma/client"; import { OrgRole } from "@prisma/client";
import { MemberApprovalRequiredToggle } from "@/app/onboard/components/memberApprovalRequiredToggle";
import { headers } from "next/headers";
import { getBaseUrl, createInviteLink } from "@/lib/utils";
interface MembersSettingsPageProps { interface MembersSettingsPageProps {
params: { params: {
@ -62,11 +59,6 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
const usedSeats = members.length const usedSeats = members.length
const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats; const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats;
// Get the current URL to construct the full invite link
const headersList = headers();
const baseUrl = getBaseUrl(headersList);
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -86,10 +78,6 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
)} )}
</div> </div>
{userRoleInOrg === OrgRole.OWNER && (
<MemberApprovalRequiredToggle memberApprovalRequired={org.memberApprovalRequired} inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} />
)}
<InviteMemberCard <InviteMemberCard
currentUserRole={userRoleInOrg} currentUserRole={userRoleInOrg}
isBillingEnabled={IS_BILLING_ENABLED} isBillingEnabled={IS_BILLING_ENABLED}

View file

@ -0,0 +1,129 @@
"use client"
import { useState } from "react"
import { Switch } from "@/components/ui/switch"
import { setAnonymousAccessStatus } from "@/actions"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { isServiceError } from "@/lib/utils"
import { useToast } from "@/components/hooks/use-toast"
interface AnonymousAccessToggleProps {
hasAnonymousAccessEntitlement: boolean;
anonymousAccessEnabled: boolean
forceEnableAnonymousAccess: boolean
onToggleChange?: (checked: boolean) => void
}
export function AnonymousAccessToggle({ hasAnonymousAccessEntitlement, anonymousAccessEnabled, forceEnableAnonymousAccess, onToggleChange }: AnonymousAccessToggleProps) {
const [enabled, setEnabled] = useState(anonymousAccessEnabled)
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const handleToggle = async (checked: boolean) => {
setIsLoading(true)
try {
const result = await setAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN, checked)
if (isServiceError(result)) {
toast({
title: "Error",
description: result.message || "Failed to update anonymous access setting",
variant: "destructive",
})
return
}
setEnabled(checked)
onToggleChange?.(checked)
} catch (error) {
console.error("Error updating anonymous access setting:", error)
toast({
title: "Error",
description: "Failed to update anonymous access setting",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const isDisabled = isLoading || !hasAnonymousAccessEntitlement || forceEnableAnonymousAccess;
const showPlanMessage = !hasAnonymousAccessEntitlement;
const showForceEnableMessage = !showPlanMessage && forceEnableAnonymousAccess;
return (
<div className={`p-4 rounded-lg border border-[var(--border)] bg-[var(--card)] ${(!hasAnonymousAccessEntitlement || forceEnableAnonymousAccess) ? 'opacity-60' : ''}`}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-[var(--foreground)] mb-2">
Enable anonymous access
</h3>
<div className="max-w-2xl">
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
When enabled, users can access your deployment without logging in.
</p>
{showPlanMessage && (
<div className="mt-3 p-3 rounded-md bg-[var(--muted)] border border-[var(--border)]">
<p className="text-sm text-[var(--foreground)] leading-relaxed flex items-center gap-2">
<svg
className="w-4 h-4 flex-shrink-0 text-[var(--muted-foreground)]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
Your current plan doesn&apos;t allow for anonymous access. Please{" "}
<a
href="https://www.sourcebot.dev/contact"
target="_blank"
rel="noopener"
className="font-medium text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
>
reach out
</a>
{" "}for assistance.
</span>
</p>
</div>
)}
{showForceEnableMessage && (
<div className="mt-3 p-3 rounded-md bg-[var(--muted)] border border-[var(--border)]">
<p className="text-sm text-[var(--foreground)] leading-relaxed flex items-center gap-2">
<svg
className="w-4 h-4 flex-shrink-0 text-[var(--muted-foreground)]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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">forceEnableAnonymousAccess</code> is set, so this cannot be changed from the UI.
</span>
</p>
</div>
)}
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isDisabled}
/>
</div>
</div>
</div>
)
}

View file

@ -46,7 +46,7 @@ export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string
<Button <Button
onClick={handleJoinOrganization} onClick={handleJoinOrganization}
disabled={isLoading} disabled={isLoading}
className="w-full h-11 bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)] transition-all duration-200 font-medium" className="w-full h-11 bg-primary hover:bg-primary/90 text-[var(--primary-foreground)] transition-all duration-200 font-medium"
> >
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} {isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Join Organization Join Organization

View file

@ -0,0 +1,71 @@
"use client"
import { useState } from "react"
import { Switch } from "@/components/ui/switch"
import { setMemberApprovalRequired } from "@/actions"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { isServiceError } from "@/lib/utils"
import { useToast } from "@/components/hooks/use-toast"
interface MemberApprovalRequiredToggleProps {
memberApprovalRequired: boolean
onToggleChange?: (checked: boolean) => void
}
export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleChange }: MemberApprovalRequiredToggleProps) {
const [enabled, setEnabled] = useState(memberApprovalRequired)
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const handleToggle = async (checked: boolean) => {
setIsLoading(true)
try {
const result = await setMemberApprovalRequired(SINGLE_TENANT_ORG_DOMAIN, checked)
if (isServiceError(result)) {
toast({
title: "Error",
description: "Failed to update member approval setting",
variant: "destructive",
})
return
}
setEnabled(checked)
onToggleChange?.(checked)
} catch (error) {
console.error("Error updating member approval setting:", error)
toast({
title: "Error",
description: "Failed to update member approval setting",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
return (
<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-1 min-w-0">
<h3 className="font-medium text-[var(--foreground)] mb-2">
Require approval for new members
</h3>
<div className="max-w-2xl">
<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.
</p>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,44 @@
import { createInviteLink, getBaseUrl } from "@/lib/utils"
import { AnonymousAccessToggle } from "./anonymousAccessToggle"
import { OrganizationAccessSettingsWrapper } from "./organizationAccessSettingsWrapper"
import { getOrgFromDomain } from "@/data/org"
import { getOrgMetadata } from "@/lib/utils"
import { headers } from "next/headers"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { hasEntitlement } from "@sourcebot/shared"
import { env } from "@/env.mjs"
export async function OrganizationAccessSettings() {
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org) {
return <div>Error loading organization</div>
}
const metadata = getOrgMetadata(org);
const anonymousAccessEnabled = metadata?.anonymousAccessEnabled ?? false;
const headersList = headers();
const baseUrl = getBaseUrl(headersList);
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId)
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
const forceEnableAnonymousAccess = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
return (
<div className="space-y-6">
<AnonymousAccessToggle
hasAnonymousAccessEntitlement={hasAnonymousAccessEntitlement}
anonymousAccessEnabled={anonymousAccessEnabled}
forceEnableAnonymousAccess={forceEnableAnonymousAccess}
/>
<OrganizationAccessSettingsWrapper
memberApprovalRequired={org.memberApprovalRequired}
inviteLinkEnabled={org.inviteLinkEnabled}
inviteLink={inviteLink}
anonymousAccessEnabled={anonymousAccessEnabled}
/>
</div>
)
}

View file

@ -0,0 +1,47 @@
"use client"
import { useState } from "react"
import { MemberApprovalRequiredToggle } from "./memberApprovalRequiredToggle"
import { InviteLinkToggle } from "./inviteLinkToggle"
interface OrganizationAccessSettingsWrapperProps {
memberApprovalRequired: boolean
inviteLinkEnabled: boolean
inviteLink: string | null
anonymousAccessEnabled: boolean
}
export function OrganizationAccessSettingsWrapper({
memberApprovalRequired,
inviteLinkEnabled,
inviteLink,
anonymousAccessEnabled
}: OrganizationAccessSettingsWrapperProps) {
const [showInviteLink, setShowInviteLink] = useState(memberApprovalRequired && !anonymousAccessEnabled)
const handleMemberApprovalToggle = (checked: boolean) => {
setShowInviteLink(checked)
}
return (
<>
<div className={`transition-all duration-300 ease-in-out overflow-hidden max-h-96 opacity-100`}>
<MemberApprovalRequiredToggle
memberApprovalRequired={memberApprovalRequired}
onToggleChange={handleMemberApprovalToggle}
/>
</div>
<div className={`transition-all duration-300 ease-in-out overflow-hidden ${
showInviteLink
? 'max-h-96 opacity-100'
: 'max-h-0 opacity-0 pointer-events-none'
}`}>
<InviteLinkToggle
inviteLinkEnabled={inviteLinkEnabled}
inviteLink={inviteLink}
/>
</div>
</>
)
}

View file

@ -77,7 +77,6 @@ export const CredentialsForm = ({ callbackUrl, context }: CredentialsFormProps)
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
variant="outline"
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""} {isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}

View file

@ -45,7 +45,7 @@ export function CompleteOnboardingButton() {
<Button <Button
onClick={handleCompleteOnboarding} onClick={handleCompleteOnboarding}
disabled={isLoading} disabled={isLoading}
className="w-full h-11 bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)] transition-all duration-200 font-medium" className="w-full"
> >
{isLoading ? "Completing..." : "Complete Onboarding →"} {isLoading ? "Completing..." : "Complete Onboarding →"}
</Button> </Button>

View file

@ -1,90 +0,0 @@
"use client"
import { useState } from "react"
import { Switch } from "@/components/ui/switch"
import { setMemberApprovalRequired } from "@/actions"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { isServiceError } from "@/lib/utils"
import { useToast } from "@/components/hooks/use-toast"
import { InviteLinkToggle } from "@/app/components/inviteLinkToggle"
interface MemberApprovalRequiredToggleProps {
memberApprovalRequired: boolean
inviteLinkEnabled: boolean
inviteLink: string | null
}
export function MemberApprovalRequiredToggle({ memberApprovalRequired, inviteLinkEnabled, inviteLink }: MemberApprovalRequiredToggleProps) {
const [enabled, setEnabled] = useState(memberApprovalRequired)
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const handleToggle = async (checked: boolean) => {
setIsLoading(true)
try {
const result = await setMemberApprovalRequired(SINGLE_TENANT_ORG_DOMAIN, checked)
if (isServiceError(result)) {
toast({
title: "Error",
description: "Failed to update member approval setting",
variant: "destructive",
})
return
}
setEnabled(checked)
} catch (error) {
console.error("Error updating member approval setting:", error)
toast({
title: "Error",
description: "Failed to update member approval setting",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
return (
<div className="space-y-6">
<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-1 min-w-0">
<h3 className="font-medium text-[var(--foreground)] mb-2">
Require approval for new members
</h3>
<div className="max-w-2xl">
<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.{" "}
<a
href="https://docs.sourcebot.dev/docs/configuration/auth/inviting-members"
target="_blank"
rel="noopener"
className="underline text-[var(--primary)] hover:text-[var(--primary)]/80 transition-colors"
>
Learn More
</a>
</p>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
</div>
</div>
<div className={`transition-all duration-300 ease-in-out overflow-hidden ${
enabled
? 'max-h-96 opacity-100'
: 'max-h-0 opacity-0 pointer-events-none'
}`}>
<InviteLinkToggle inviteLinkEnabled={inviteLinkEnabled} inviteLink={inviteLink} />
</div>
</div>
)
}

View file

@ -6,7 +6,7 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector"
import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getAuthProviders } from "@/lib/authProviders"; import { getAuthProviders } from "@/lib/authProviders";
import { MemberApprovalRequiredToggle } from "./components/memberApprovalRequiredToggle"; import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings";
import { CompleteOnboardingButton } from "./components/completeOnboardingButton"; import { CompleteOnboardingButton } from "./components/completeOnboardingButton";
import { getOrgFromDomain } from "@/data/org"; import { getOrgFromDomain } from "@/data/org";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
@ -18,8 +18,6 @@ import { BetweenHorizontalStart, GitBranchIcon, LockIcon } from "lucide-react";
import { hasEntitlement } from "@sourcebot/shared"; import { hasEntitlement } from "@sourcebot/shared";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { GcpIapAuth } from "@/app/[domain]/components/gcpIapAuth"; import { GcpIapAuth } from "@/app/[domain]/components/gcpIapAuth";
import { headers } from "next/headers";
import { getBaseUrl, createInviteLink } from "@/lib/utils";
interface OnboardingProps { interface OnboardingProps {
searchParams?: { step?: string }; searchParams?: { step?: string };
@ -49,11 +47,6 @@ export default async function Onboarding({ searchParams }: OnboardingProps) {
return <div>Error loading organization</div>; return <div>Error loading organization</div>;
} }
// Get the current URL to construct the full invite link
const headersList = headers();
const baseUrl = getBaseUrl(headersList);
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId);
if (org && org.isOnboarded) { if (org && org.isOnboarded) {
redirect('/'); redirect('/');
} }
@ -117,7 +110,7 @@ export default async function Onboarding({ searchParams }: OnboardingProps) {
subtitle: "This onboarding flow will guide you through creating your owner account and configuring your organization.", subtitle: "This onboarding flow will guide you through creating your owner account and configuring your organization.",
component: ( component: (
<div className="space-y-6"> <div className="space-y-6">
<Button asChild className="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground transition-all duration-200 font-medium"> <Button asChild className="w-full">
<a href="/onboard?step=1">Get Started </a> <a href="/onboard?step=1">Get Started </a>
</Button> </Button>
</div> </div>
@ -133,7 +126,7 @@ export default async function Onboarding({ searchParams }: OnboardingProps) {
href="https://docs.sourcebot.dev/docs/configuration/auth/overview" href="https://docs.sourcebot.dev/docs/configuration/auth/overview"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
className="underline text-[var(--primary)] hover:text-[var(--primary)]/80 transition-colors" className="underline text-primary hover:text-primary/80 transition-colors"
> >
documentation documentation
</a>. </a>.
@ -152,12 +145,24 @@ export default async function Onboarding({ searchParams }: OnboardingProps) {
}, },
{ {
id: "configure-org", id: "configure-org",
title: "Configure Your Organization", title: "Configure Access Settings",
subtitle: "Set up your organization's security settings.", subtitle: (
<>
Set up your organization&apos;s access settings.{" "}
<a
href="https://docs.sourcebot.dev/docs/configuration/auth/access-settings"
target="_blank"
rel="noopener"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Learn more
</a>
</>
),
component: ( component: (
<div className="space-y-6"> <div className="space-y-6">
<MemberApprovalRequiredToggle memberApprovalRequired={org.memberApprovalRequired} inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} /> <OrganizationAccessSettings />
<Button asChild className="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground transition-all duration-200 font-medium"> <Button asChild className="w-full">
<a href="/onboard?step=3">Continue </a> <a href="/onboard?step=3">Continue </a>
</Button> </Button>
</div> </div>

View file

@ -99,5 +99,5 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
return rows; return rows;
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) }, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
); );

View file

@ -17,7 +17,7 @@ export const createAuditAction = async (event: Omit<AuditEvent, 'sourcebotVersio
withAuth((userId) => withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
await auditService.createAudit({ ...event, orgId: org.id, actor: { id: userId, type: "user" }, target: { id: org.id.toString(), type: "org" } }) await auditService.createAudit({ ...event, orgId: org.id, actor: { id: userId, type: "user" }, target: { id: org.id.toString(), type: "org" } })
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowSingleTenantUnauthedAccess = */ true) }, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true)
); );
export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() => export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() =>
@ -55,5 +55,5 @@ export const fetchAuditRecords = async (domain: string, apiKey: string | undefin
message: "Failed to fetch audit logs", message: "Failed to fetch audit logs",
} satisfies ServiceError; } satisfies ServiceError;
} }
}, /* minRequiredRole = */ OrgRole.OWNER), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) }, /* minRequiredRole = */ OrgRole.OWNER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
); );

View file

@ -1,138 +0,0 @@
"use server";
import { ServiceError } from "@/lib/serviceError";
import { getOrgFromDomain } from "@/data/org";
import { orgMetadataSchema } from "@/types";
import { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes";
import { prisma } from "@/prisma";
import { sew } from "@/actions";
import { getPlan, hasEntitlement } from "@sourcebot/shared";
import { SOURCEBOT_GUEST_USER_EMAIL, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
import { OrgRole } from "@sourcebot/db";
export const getPublicAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
const org = await getOrgFromDomain(domain);
if (!org) {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.NOT_FOUND,
message: "Organization not found",
} satisfies ServiceError;
}
// If no metadata is set we don't try to parse it since it'll result in a parse error
if (org.metadata === null) {
return false;
}
const orgMetadata = orgMetadataSchema.safeParse(org.metadata);
if (!orgMetadata.success) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.INVALID_ORG_METADATA,
message: "Invalid organization metadata",
} satisfies ServiceError;
}
return !!orgMetadata.data.publicAccessEnabled;
});
export const setPublicAccessStatus = async (domain: string, enabled: boolean): Promise<ServiceError | boolean> => sew(async () => {
const hasPublicAccessEntitlement = hasEntitlement("public-access");
if (!hasPublicAccessEntitlement) {
const plan = getPlan();
console.error(`Public access isn't supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "Public access is not supported in your current plan",
} satisfies ServiceError;
}
const org = await getOrgFromDomain(domain);
if (!org) {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.NOT_FOUND,
message: "Organization not found",
} satisfies ServiceError;
}
const currentMetadata = orgMetadataSchema.safeParse(org.metadata);
const mergedMetadata = {
...(currentMetadata.success ? currentMetadata.data : {}),
publicAccessEnabled: enabled,
};
await prisma.org.update({
where: {
id: org.id,
},
data: {
metadata: mergedMetadata,
},
});
return true;
});
export const createGuestUser = async (domain: string): Promise<ServiceError | boolean> => sew(async () => {
const hasPublicAccessEntitlement = hasEntitlement("public-access");
if (!hasPublicAccessEntitlement) {
console.error(`Public access isn't supported in your current plan: ${getPlan()}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "Public access is not supported in your current plan",
} satisfies ServiceError;
}
const org = await getOrgFromDomain(domain);
if (!org) {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.NOT_FOUND,
message: "Organization not found",
} satisfies ServiceError;
}
const user = await prisma.user.upsert({
where: {
id: SOURCEBOT_GUEST_USER_ID,
},
update: {},
create: {
id: SOURCEBOT_GUEST_USER_ID,
name: "Guest",
email: SOURCEBOT_GUEST_USER_EMAIL
},
});
await prisma.org.update({
where: {
id: org.id,
},
data: {
members: {
upsert: {
where: {
orgId_userId: {
orgId: org.id,
userId: user.id,
},
},
update: {},
create: {
role: OrgRole.GUEST,
user: {
connect: { id: user.id },
},
},
},
},
},
});
return true;
});

View file

@ -20,6 +20,8 @@ export const env = createEnv({
ZOEKT_MAX_WALL_TIME_MS: numberSchema.default(10000), ZOEKT_MAX_WALL_TIME_MS: numberSchema.default(10000),
// Auth // Auth
FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.default('false'),
AUTH_SECRET: z.string(), AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(), AUTH_URL: z.string().url(),
AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.default('true'), AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.default('true'),

View file

@ -41,7 +41,7 @@ export const findSearchBasedSymbolReferences = async (
} }
return parseRelatedSymbolsSearchResponse(searchResult); return parseRelatedSymbolsSearchResponse(searchResult);
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
); );
@ -74,7 +74,7 @@ export const findSearchBasedSymbolDefinitions = async (
} }
return parseRelatedSymbolsSearchResponse(searchResult); return parseRelatedSymbolsSearchResponse(searchResult);
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
); );
const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => { const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => {

View file

@ -77,7 +77,7 @@ export const getTree = async (params: { repoName: string, revisionName: string }
tree, tree,
} }
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
); );
/** /**
@ -154,7 +154,7 @@ export const getFolderContents = async (params: { repoName: string, revisionName
}); });
return contents; return contents;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
); );
export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() => export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
@ -205,7 +205,7 @@ export const getFiles = async (params: { repoName: string, revisionName: string
return files; return files;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
); );
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {

View file

@ -48,5 +48,5 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource
webUrl: file.webUrl, webUrl: file.webUrl,
} satisfies FileSourceResponse; } satisfies FileSourceResponse;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
); );

View file

@ -45,5 +45,5 @@ export const listRepositories = async (domain: string, apiKey: string | undefine
const result = parser.parse(listBody); const result = parser.parse(listBody);
return result; return result;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
); );

View file

@ -346,5 +346,5 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
}); });
return parser.parseAsync(searchBody); return parser.parseAsync(searchBody);
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
); );

View file

@ -5,11 +5,12 @@ import { SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID
import { watch } from 'fs'; import { watch } from 'fs';
import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { hasEntitlement, loadConfig, isRemotePath, syncSearchContexts } from '@sourcebot/shared'; import { hasEntitlement, loadConfig, isRemotePath, syncSearchContexts } from '@sourcebot/shared';
import { createGuestUser, setPublicAccessStatus } from '@/ee/features/publicAccess/publicAccess'; import { isServiceError, getOrgMetadata } from './lib/utils';
import { isServiceError } from './lib/utils';
import { ServiceErrorException } from './lib/serviceError'; import { ServiceErrorException } from './lib/serviceError';
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { createGuestUser } from '@/lib/authUtils';
import { getOrgFromDomain } from './data/org';
const logger = createLogger('web-initialize'); const logger = createLogger('web-initialize');
@ -105,23 +106,28 @@ const syncConnections = async (connections?: { [key: string]: ConnectionConfig }
const syncDeclarativeConfig = async (configPath: string) => { const syncDeclarativeConfig = async (configPath: string) => {
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const hasPublicAccessEntitlement = hasEntitlement("public-access"); const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
const enablePublicAccess = config.settings?.enablePublicAccess; if (forceEnableAnonymousAccess) {
if (enablePublicAccess !== undefined && !hasPublicAccessEntitlement) { const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
logger.error(`Public access flag is set in the config file but your license doesn't have public access entitlement. Please contact ${SOURCEBOT_SUPPORT_EMAIL} to request a license upgrade.`); if (!hasAnonymousAccessEntitlement) {
process.exit(1); logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`);
} } else {
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (org) {
const currentMetadata = getOrgMetadata(org);
const mergedMetadata = {
...(currentMetadata ?? {}),
anonymousAccessEnabled: true,
};
if (hasPublicAccessEntitlement) { await prisma.org.update({
if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') { where: { id: org.id },
logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging (SOURCEBOT_EE_AUDIT_LOGGING_ENABLED) or disable public access.`); data: {
process.exit(1); metadata: mergedMetadata,
},
});
logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`);
} }
logger.info(`Setting public access status to ${!!enablePublicAccess} for org ${SINGLE_TENANT_ORG_DOMAIN}`);
const res = await setPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN, !!enablePublicAccess);
if (isServiceError(res)) {
throw new ServiceErrorException(res);
} }
} }
@ -192,12 +198,26 @@ const initSingleTenancy = async () => {
// To keep things simple, we'll just delete the old guest user if it exists in the DB // To keep things simple, we'll just delete the old guest user if it exists in the DB
await pruneOldGuestUser(); await pruneOldGuestUser();
const hasPublicAccessEntitlement = hasEntitlement("public-access"); const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
if (hasPublicAccessEntitlement) { if (hasAnonymousAccessEntitlement) {
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN); const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);
if (isServiceError(res)) { if (isServiceError(res)) {
throw new ServiceErrorException(res); throw new ServiceErrorException(res);
} }
} else {
// If anonymous access entitlement is not enabled, set the flag to false in the org on init
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (org) {
const currentMetadata = getOrgMetadata(org);
const mergedMetadata = {
...(currentMetadata ?? {}),
anonymousAccessEnabled: false,
};
await prisma.org.update({
where: { id: org.id },
data: { metadata: mergedMetadata },
});
}
} }
// Load any connections defined declaratively in the config file. // Load any connections defined declaratively in the config file.

View file

@ -1,8 +1,8 @@
import type { User as AuthJsUser } from "next-auth"; import type { User as AuthJsUser } from "next-auth";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { SINGLE_TENANT_ORG_ID, SOURCEBOT_GUEST_USER_EMAIL, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { getPlan, getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError"; import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
@ -11,6 +11,7 @@ import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./errorCodes"; import { ErrorCode } from "./errorCodes";
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { incrementOrgSeatCount } from "@/ee/features/billing/serverUtils"; import { incrementOrgSeatCount } from "@/ee/features/billing/serverUtils";
import { getOrgFromDomain } from "@/data/org";
const logger = createLogger('web-auth-utils'); const logger = createLogger('web-auth-utils');
const auditService = getAuditService(); const auditService = getAuditService();
@ -124,6 +125,67 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
}; };
export const createGuestUser = async (domain: string): Promise<ServiceError | boolean> => {
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
if (!hasAnonymousAccessEntitlement) {
console.error(`Anonymous access isn't supported in your current plan: ${getPlan()}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "Public access is not supported in your current plan",
} satisfies ServiceError;
}
const org = await getOrgFromDomain(domain);
if (!org) {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.NOT_FOUND,
message: "Organization not found",
} satisfies ServiceError;
}
const user = await prisma.user.upsert({
where: {
id: SOURCEBOT_GUEST_USER_ID,
},
update: {},
create: {
id: SOURCEBOT_GUEST_USER_ID,
name: "Guest",
email: SOURCEBOT_GUEST_USER_EMAIL,
},
});
await prisma.org.update({
where: {
id: org.id,
},
data: {
members: {
upsert: {
where: {
orgId_userId: {
orgId: org.id,
userId: user.id,
},
},
update: {},
create: {
role: OrgRole.GUEST,
user: {
connect: { id: user.id },
},
},
},
},
},
});
return true;
};
export const orgHasAvailability = async (domain: string): Promise<boolean> => { export const orgHasAvailability = async (domain: string): Promise<boolean> => {
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
where: { where: {

View file

@ -1,11 +1,17 @@
import { NewsItem } from "./types"; import { NewsItem } from "./types";
export const newsData: NewsItem[] = [ export const newsData: NewsItem[] = [
{
unique_id: "anonymous-access",
header: "Anonymous Access",
sub_header: "We've added the ability to disable the need for users to login to Sourcebot.",
url: "https://docs.sourcebot.dev/docs/configuration/auth/access-settings"
},
{ {
unique_id: "member-approval", unique_id: "member-approval",
header: "Member Approval", header: "Member Approval",
sub_header: "We've added a toggle to control whether new users need to be approved.", sub_header: "We've added a toggle to control whether new users need to be approved.",
url: "https://docs.sourcebot.dev/docs/configuration/auth/inviting-members" url: "https://docs.sourcebot.dev/docs/configuration/auth/access-settings"
}, },
{ {
unique_id: "analytics", unique_id: "analytics",

View file

@ -14,6 +14,8 @@ import { ServiceError } from "./serviceError";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "./errorCodes"; import { ErrorCode } from "./errorCodes";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { Org } from "@sourcebot/db";
import { OrgMetadata, orgMetadataSchema } from "@/types";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -452,3 +454,8 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, do
return imageUrl; return imageUrl;
} }
}; };
export const getOrgMetadata = (org: Org): OrgMetadata | null => {
const currentMetadata = orgMetadataSchema.safeParse(org.metadata);
return currentMetadata.success ? currentMetadata.data : null;
}

View file

@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const orgMetadataSchema = z.object({ export const orgMetadataSchema = z.object({
publicAccessEnabled: z.boolean().optional(), anonymousAccessEnabled: z.boolean().optional(),
}) })
export type OrgMetadata = z.infer<typeof orgMetadataSchema>; export type OrgMetadata = z.infer<typeof orgMetadataSchema>;

View file

@ -64,7 +64,8 @@
}, },
"enablePublicAccess": { "enablePublicAccess": {
"type": "boolean", "type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", "deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false "default": false
} }
}, },