mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
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:
parent
55c8e41137
commit
aac1d4529e
45 changed files with 633 additions and 379 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"reindexIntervalMs": 86400000, // 24 hours
|
"reindexIntervalMs": 86400000 // 24 hours
|
||||||
"enablePublicAccess": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
40
docs/docs/configuration/auth/access-settings.mdx
Normal file
40
docs/docs/configuration/auth/access-settings.mdx
Normal 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**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
@ -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**:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 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:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
@ -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. |
|
||||||
|
|
@ -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> |
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ///////
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}`} />;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
|
||||||
const org = await getOrgFromDomain(domain);
|
const org = await getOrgFromDomain(domain);
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return <PageNotFound />
|
return <PageNotFound />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
35
packages/web/src/app/[domain]/settings/access/page.tsx
Normal file
35
packages/web/src/app/[domain]/settings/access/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
129
packages/web/src/app/components/anonymousAccessToggle.tsx
Normal file
129
packages/web/src/app/components/anonymousAccessToggle.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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" /> : ""}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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'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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue