diff --git a/CHANGELOG.md b/CHANGELOG.md index f1248604..79daca9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - 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 diff --git a/demo-site-config.json b/demo-site-config.json index 42b8b346..c689a60d 100644 --- a/demo-site-config.json +++ b/demo-site-config.json @@ -238,7 +238,6 @@ } }, "settings": { - "reindexIntervalMs": 86400000, // 24 hours - "enablePublicAccess": true + "reindexIntervalMs": 86400000 // 24 hours } } diff --git a/docs/docs.json b/docs/docs.json index 3af230d7..e0834f28 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -73,7 +73,7 @@ "pages": [ "docs/configuration/auth/overview", "docs/configuration/auth/providers", - "docs/configuration/auth/inviting-members", + "docs/configuration/auth/access-settings", "docs/configuration/auth/roles-and-permissions", "docs/configuration/auth/faq" ] diff --git a/docs/docs/configuration/auth/access-settings.mdx b/docs/docs/configuration/auth/access-settings.mdx new file mode 100644 index 00000000..5bc638e7 --- /dev/null +++ b/docs/docs/configuration/auth/access-settings.mdx @@ -0,0 +1,40 @@ +--- +title: Access Settings +sidebarTitle: Access settings +--- + +There are various settings to control how users access your Sourcebot deployment. + +# Anonymous access + +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). + +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) \ No newline at end of file diff --git a/docs/docs/configuration/auth/inviting-members.mdx b/docs/docs/configuration/auth/inviting-members.mdx deleted file mode 100644 index d67e497b..00000000 --- a/docs/docs/configuration/auth/inviting-members.mdx +++ /dev/null @@ -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) \ No newline at end of file diff --git a/docs/docs/configuration/auth/roles-and-permissions.mdx b/docs/docs/configuration/auth/roles-and-permissions.mdx index b639b521..e766b4ee 100644 --- a/docs/docs/configuration/auth/roles-and-permissions.mdx +++ b/docs/docs/configuration/auth/roles-and-permissions.mdx @@ -10,4 +10,5 @@ Each member has a role which defines their permissions within an organization: | Role | Permission | | :--- | :--------- | | `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. | \ No newline at end of file +| `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. | \ No newline at end of file diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 9378b023..e8fe8cf0 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -21,6 +21,7 @@ The following environment variables allow you to configure your Sourcebot deploy | `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` |

The data directory for the default Postgres database.

| | `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` |

Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.

If you'd like to use a non-default schema, you can provide it as a parameter in the database url

| | `EMAIL_FROM_ADDRESS` | `-` |

The email address that transactional emails will be sent from. See [this doc](/docs/configuration/transactional-emails) for more info.

| +| `FORCE_ENABLE_ANONYMOUS_ACCESS` | `false` |

When enabled, [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) to the organization will always be enabled

| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` |

The data directory for the default Redis instance.

| | `REDIS_URL` | `redis://localhost:6379` |

Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.

| | `REDIS_REMOVE_ON_COMPLETE` | `0` |

Controls how many completed jobs are allowed to remain in Redis queues

| diff --git a/docs/docs/deployment-guide.mdx b/docs/docs/deployment-guide.mdx index 75b09dc4..7e785486 100644 --- a/docs/docs/deployment-guide.mdx +++ b/docs/docs/deployment-guide.mdx @@ -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. -## Walkthrough video ---- - -Watch this quick walkthrough video to learn how to deploy Sourcebot using Docker. - - - -## Step-by-step guide ---- - 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). diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 79bcda80..0b7907ed 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -66,7 +66,8 @@ }, "enablePublicAccess": { "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 } }, @@ -180,7 +181,8 @@ }, "enablePublicAccess": { "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 } }, diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 19bbc978..c0d77f05 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -15,5 +15,5 @@ export const DEFAULT_SETTINGS: Settings = { maxRepoGarbageCollectionJobConcurrency: 8, repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours - enablePublicAccess: false, + enablePublicAccess: false // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead } diff --git a/packages/schemas/src/v2/index.schema.ts b/packages/schemas/src/v2/index.schema.ts index de564ccd..a37f2f3c 100644 --- a/packages/schemas/src/v2/index.schema.ts +++ b/packages/schemas/src/v2/index.schema.ts @@ -2258,4 +2258,4 @@ const schema = { }, "additionalProperties": false } as const; -export { schema as indexSchema }; +export { schema as indexSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 35e7a4fe..1eafaffe 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -65,7 +65,8 @@ const schema = { }, "enablePublicAccess": { "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 } }, @@ -179,7 +180,8 @@ const schema = { }, "enablePublicAccess": { "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 } }, diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index d239245f..1390bf4e 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -80,7 +80,8 @@ export interface Settings { */ 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; } diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 0381e451..965989c1 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -33,7 +33,7 @@ export type Plan = keyof typeof planLabels; const entitlements = [ "search-contexts", "billing", - "public-access", + "anonymous-access", "multi-tenancy", "sso", "code-nav", @@ -43,12 +43,12 @@ const entitlements = [ export type Entitlement = (typeof entitlements)[number]; const entitlementsByPlan: Record = { - oss: [], + oss: ["anonymous-access"], "cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"], "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 - "cloud:demo": ["public-access", "code-nav", "search-contexts"], + "cloud:demo": ["anonymous-access", "code-nav", "search-contexts"], } as const; diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 73dbdcf3..ed73950a 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -32,12 +32,13 @@ import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/bill import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; import { getPlan, hasEntitlement } from "@sourcebot/shared"; -import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import { createLogger } from "@sourcebot/logger"; import { getAuditService } from "@/ee/features/audit/factory"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; +import { getOrgMetadata } from "@/lib/utils"; +import { getOrgFromDomain } from "./data/org"; const ajv = new Ajv({ validateFormats: false, @@ -62,13 +63,13 @@ export const sew = async (fn: () => Promise): Promise => } } -export const withAuth = async (fn: (userId: string, apiKeyHash: string | undefined) => Promise, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { +export const withAuth = async (fn: (userId: string, apiKeyHash: string | undefined) => Promise, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { const session = await auth(); 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, // 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) { const apiKeyOrError = await verifyApiKey(apiKey); if (isServiceError(apiKeyOrError)) { @@ -98,18 +99,17 @@ export const withAuth = async (fn: (userId: string, apiKeyHash: string | unde return fn(user.id, apiKeyOrError.apiKey.hash); } else if ( - env.SOURCEBOT_TENANCY_MODE === 'single' && - allowSingleTenantUnauthedAccess && - !isServiceError(publicAccessEnabled) && - publicAccessEnabled + allowAnonymousAccess && + !isServiceError(anonymousAccessEnabled) && + anonymousAccessEnabled ) { - if (!hasEntitlement("public-access")) { + if (!hasEntitlement("anonymous-access")) { 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(); } - // 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 notAuthenticated(); @@ -672,7 +672,7 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, })); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true )); 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, 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(() => @@ -933,7 +933,7 @@ export const getCurrentUserRole = async (domain: string): Promise withOrgMembership(userId, domain, async ({ 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(() => @@ -1863,7 +1863,7 @@ export const getSearchContexts = async (domain: string) => sew(() => name: context.name, description: context.description ?? undefined, })); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true )); export const getRepoImage = async (repoId: number, domain: string): Promise => sew(async () => { @@ -1934,7 +1934,68 @@ export const getRepoImage = async (repoId: number, domain: string): Promise => 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 => 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 /////// diff --git a/packages/web/src/app/[domain]/components/submitJoinRequest.tsx b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx index b79fbfa5..7160a65c 100644 --- a/packages/web/src/app/[domain]/components/submitJoinRequest.tsx +++ b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx @@ -27,7 +27,7 @@ export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => { />
-
+
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index f24accd3..6a3e34da 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -16,10 +16,9 @@ import { getSubscriptionInfo } from "@/ee/features/billing/actions"; import { PendingApprovalCard } from "./components/pendingApproval"; import { SubmitJoinRequest } from "./components/submitJoinRequest"; import { hasEntitlement } from "@sourcebot/shared"; -import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess"; import { env } from "@/env.mjs"; import { GcpIapAuth } from "./components/gcpIapAuth"; -import { getMemberApprovalRequired } from "@/actions"; +import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; @@ -39,7 +38,7 @@ export default async function Layout({ } 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 (session) { @@ -84,8 +83,8 @@ export default async function Layout({ } } } else { - // If the user isn't authenticated and public access isn't enabled, we need to redirect them to the login page. - if (!publicAccessEnabled) { + // If the user isn't authenticated and anonymous access isn't enabled, we need to redirect them to the login page. + if (!anonymousAccessEnabled) { const ssoEntitlement = await hasEntitlement("sso"); if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { return ; diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index ece5f038..f0ffa1e8 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -7,7 +7,7 @@ export default async function ReposPage({ params: { domain } }: { params: { doma const org = await getOrgFromDomain(domain); if (!org) { return -} + } return (
diff --git a/packages/web/src/app/[domain]/settings/access/page.tsx b/packages/web/src/app/[domain]/settings/access/page.tsx new file mode 100644 index 00000000..cbce7b50 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/access/page.tsx @@ -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 ( +
+
+

Access Control

+

Configure how users can access your Sourcebot deployment.{" "} + + Learn more + +

+
+ + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 21fc834f..014bb467 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -64,6 +64,12 @@ export default async function SettingsLayout({ href: `/${domain}/settings/billing`, } ] : []), + ...(userRoleInOrg === OrgRole.OWNER ? [ + { + title: "Access", + href: `/${domain}/settings/access`, + } + ] : []), { title: (
diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index aa984a93..2c23f3f7 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -12,9 +12,6 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { RequestsList } from "./components/requestsList"; 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 { params: { @@ -62,11 +59,6 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa const usedSeats = members.length 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 (
@@ -86,10 +78,6 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa )}
- {userRoleInOrg === OrgRole.OWNER && ( - - )} - 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 ( +
+
+
+

+ Enable anonymous access +

+
+

+ When enabled, users can access your deployment without logging in. +

+ {showPlanMessage && ( +
+

+ + + + + Your current plan doesn't allow for anonymous access. Please{" "} + + reach out + + {" "}for assistance. + +

+
+ )} + {showForceEnableMessage && ( +
+

+ + + + + The forceEnableAnonymousAccess is set, so this cannot be changed from the UI. + +

+
+ )} +
+
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/joinOrganizationButton.tsx b/packages/web/src/app/components/joinOrganizationButton.tsx index 9bed8556..eba3b8e8 100644 --- a/packages/web/src/app/components/joinOrganizationButton.tsx +++ b/packages/web/src/app/components/joinOrganizationButton.tsx @@ -46,7 +46,7 @@ export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string diff --git a/packages/web/src/app/onboard/components/memberApprovalRequiredToggle.tsx b/packages/web/src/app/onboard/components/memberApprovalRequiredToggle.tsx deleted file mode 100644 index 325f6ba8..00000000 --- a/packages/web/src/app/onboard/components/memberApprovalRequiredToggle.tsx +++ /dev/null @@ -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 ( -
-
-
-
-

- Require approval for new members -

-
-

- When enabled, new users will need approval from an organization owner before they can access your deployment.{" "} - - Learn More - -

-
-
-
- -
-
-
- -
- -
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index 01cb3d43..fbc746d9 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -6,7 +6,7 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector" import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { auth } from "@/auth"; import { getAuthProviders } from "@/lib/authProviders"; -import { MemberApprovalRequiredToggle } from "./components/memberApprovalRequiredToggle"; +import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings"; import { CompleteOnboardingButton } from "./components/completeOnboardingButton"; import { getOrgFromDomain } from "@/data/org"; 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 { env } from "@/env.mjs"; import { GcpIapAuth } from "@/app/[domain]/components/gcpIapAuth"; -import { headers } from "next/headers"; -import { getBaseUrl, createInviteLink } from "@/lib/utils"; interface OnboardingProps { searchParams?: { step?: string }; @@ -49,11 +47,6 @@ export default async function Onboarding({ searchParams }: OnboardingProps) { return
Error loading organization
; } - // 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) { 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.", component: ( @@ -133,7 +126,7 @@ export default async function Onboarding({ searchParams }: OnboardingProps) { href="https://docs.sourcebot.dev/docs/configuration/auth/overview" target="_blank" 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 . @@ -152,12 +145,24 @@ export default async function Onboarding({ searchParams }: OnboardingProps) { }, { id: "configure-org", - title: "Configure Your Organization", - subtitle: "Set up your organization's security settings.", + title: "Configure Access Settings", + subtitle: ( + <> + Set up your organization's access settings.{" "} + + Learn more + + + ), component: (
- -
diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts index e1f10e71..28f07ec6 100644 --- a/packages/web/src/ee/features/analytics/actions.ts +++ b/packages/web/src/ee/features/analytics/actions.ts @@ -99,5 +99,5 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined = return rows; - }, /* minRequiredRole = */ OrgRole.MEMBER), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) + }, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); \ No newline at end of file diff --git a/packages/web/src/ee/features/audit/actions.ts b/packages/web/src/ee/features/audit/actions.ts index ea087dd4..57455cb0 100644 --- a/packages/web/src/ee/features/audit/actions.ts +++ b/packages/web/src/ee/features/audit/actions.ts @@ -17,7 +17,7 @@ export const createAuditAction = async (event: Omit withOrgMembership(userId, domain, async ({ 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(() => @@ -55,5 +55,5 @@ export const fetchAuditRecords = async (domain: string, apiKey: string | undefin message: "Failed to fetch audit logs", } satisfies ServiceError; } - }, /* minRequiredRole = */ OrgRole.OWNER), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) + }, /* minRequiredRole = */ OrgRole.OWNER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); diff --git a/packages/web/src/ee/features/publicAccess/publicAccess.tsx b/packages/web/src/ee/features/publicAccess/publicAccess.tsx deleted file mode 100644 index 3ad65e27..00000000 --- a/packages/web/src/ee/features/publicAccess/publicAccess.tsx +++ /dev/null @@ -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 => 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 => 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 => 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; -}); diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index 4ba3f3c1..5218f94e 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -20,6 +20,8 @@ export const env = createEnv({ ZOEKT_MAX_WALL_TIME_MS: numberSchema.default(10000), // Auth + FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.default('false'), + AUTH_SECRET: z.string(), AUTH_URL: z.string().url(), AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.default('true'), diff --git a/packages/web/src/features/codeNav/actions.ts b/packages/web/src/features/codeNav/actions.ts index 92ea01ff..f342de6e 100644 --- a/packages/web/src/features/codeNav/actions.ts +++ b/packages/web/src/features/codeNav/actions.ts @@ -41,7 +41,7 @@ export const findSearchBasedSymbolReferences = async ( } return parseRelatedSymbolsSearchResponse(searchResult); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) ); @@ -74,7 +74,7 @@ export const findSearchBasedSymbolDefinitions = async ( } return parseRelatedSymbolsSearchResponse(searchResult); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) ); const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse) => { diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index 8b4ca224..001a6d10 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -77,7 +77,7 @@ export const getTree = async (params: { repoName: string, revisionName: string } 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; - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) ); 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; - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true) ); const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts index 6b98ef7d..e1fd756b 100644 --- a/packages/web/src/features/search/fileSourceApi.ts +++ b/packages/web/src/features/search/fileSourceApi.ts @@ -48,5 +48,5 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource webUrl: file.webUrl, } satisfies FileSourceResponse; - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); diff --git a/packages/web/src/features/search/listReposApi.ts b/packages/web/src/features/search/listReposApi.ts index 82ba3ad3..90a685bd 100644 --- a/packages/web/src/features/search/listReposApi.ts +++ b/packages/web/src/features/search/listReposApi.ts @@ -45,5 +45,5 @@ export const listRepositories = async (domain: string, apiKey: string | undefine const result = parser.parse(listBody); return result; - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts index 71aa6da5..05a913f2 100644 --- a/packages/web/src/features/search/searchApi.ts +++ b/packages/web/src/features/search/searchApi.ts @@ -346,5 +346,5 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ }); return parser.parseAsync(searchBody); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) ); diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 02b74485..df1d0839 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -5,11 +5,12 @@ import { SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID import { watch } from 'fs'; import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { hasEntitlement, loadConfig, isRemotePath, syncSearchContexts } from '@sourcebot/shared'; -import { createGuestUser, setPublicAccessStatus } from '@/ee/features/publicAccess/publicAccess'; -import { isServiceError } from './lib/utils'; +import { isServiceError, getOrgMetadata } from './lib/utils'; import { ServiceErrorException } from './lib/serviceError'; import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; import { createLogger } from "@sourcebot/logger"; +import { createGuestUser } from '@/lib/authUtils'; +import { getOrgFromDomain } from './data/org'; const logger = createLogger('web-initialize'); @@ -105,23 +106,28 @@ const syncConnections = async (connections?: { [key: string]: ConnectionConfig } const syncDeclarativeConfig = async (configPath: string) => { const config = await loadConfig(configPath); - const hasPublicAccessEntitlement = hasEntitlement("public-access"); - const enablePublicAccess = config.settings?.enablePublicAccess; - if (enablePublicAccess !== undefined && !hasPublicAccessEntitlement) { - 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.`); - process.exit(1); - } - - if (hasPublicAccessEntitlement) { - if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') { - 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.`); - process.exit(1); - } - - 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); + const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; + if (forceEnableAnonymousAccess) { + const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); + if (!hasAnonymousAccessEntitlement) { + 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, + }; + + await prisma.org.update({ + where: { id: org.id }, + data: { + metadata: mergedMetadata, + }, + }); + logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`); + } } } @@ -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 await pruneOldGuestUser(); - const hasPublicAccessEntitlement = hasEntitlement("public-access"); - if (hasPublicAccessEntitlement) { + const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); + if (hasAnonymousAccessEntitlement) { const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN); if (isServiceError(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. diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index e67f5c2a..bf7e5ea9 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -1,8 +1,8 @@ import type { User as AuthJsUser } from "next-auth"; import { prisma } from "@/prisma"; import { OrgRole } from "@sourcebot/db"; -import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; -import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; +import { SINGLE_TENANT_ORG_ID, SOURCEBOT_GUEST_USER_EMAIL, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; +import { getPlan, getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { isServiceError } from "@/lib/utils"; import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError"; import { createLogger } from "@sourcebot/logger"; @@ -11,6 +11,7 @@ import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "./errorCodes"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { incrementOrgSeatCount } from "@/ee/features/billing/serverUtils"; +import { getOrgFromDomain } from "@/data/org"; const logger = createLogger('web-auth-utils'); const auditService = getAuditService(); @@ -124,6 +125,67 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { }; + +export const createGuestUser = async (domain: string): Promise => { + 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 => { const org = await prisma.org.findUnique({ where: { diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts index 79f533c5..abd0b2b8 100644 --- a/packages/web/src/lib/newsData.ts +++ b/packages/web/src/lib/newsData.ts @@ -1,11 +1,17 @@ import { NewsItem } from "./types"; 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", header: "Member Approval", 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", diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 1a3ff68c..1f0fae89 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -14,6 +14,8 @@ import { ServiceError } from "./serviceError"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "./errorCodes"; import { NextRequest } from "next/server"; +import { Org } from "@sourcebot/db"; +import { OrgMetadata, orgMetadataSchema } from "@/types"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -451,4 +453,9 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, do // If URL parsing fails, use the original URL return imageUrl; } -}; \ No newline at end of file +}; + +export const getOrgMetadata = (org: Org): OrgMetadata | null => { + const currentMetadata = orgMetadataSchema.safeParse(org.metadata); + return currentMetadata.success ? currentMetadata.data : null; +} \ No newline at end of file diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index b5ddca24..394c3835 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const orgMetadataSchema = z.object({ - publicAccessEnabled: z.boolean().optional(), + anonymousAccessEnabled: z.boolean().optional(), }) export type OrgMetadata = z.infer; \ No newline at end of file diff --git a/schemas/v3/index.json b/schemas/v3/index.json index 655a4466..3f6b7f66 100644 --- a/schemas/v3/index.json +++ b/schemas/v3/index.json @@ -64,7 +64,8 @@ }, "enablePublicAccess": { "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 } },