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
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -238,7 +238,6 @@
|
|||
}
|
||||
},
|
||||
"settings": {
|
||||
"reindexIntervalMs": 86400000, // 24 hours
|
||||
"enablePublicAccess": true
|
||||
"reindexIntervalMs": 86400000 // 24 hours
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
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 |
|
||||
| :--- | :--------- |
|
||||
| `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_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> |
|
||||
| `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_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> |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
<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>
|
||||
|
||||
<Steps>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Plan, Entitlement[]> = {
|
||||
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;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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();
|
||||
|
||||
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 <T>(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<OrgRole | Serv
|
|||
withAuth((userId) =>
|
||||
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<ArrayBuffer | ServiceError> => sew(async () => {
|
||||
|
|
@ -1934,7 +1934,68 @@ export const getRepoImage = async (repoId: number, domain: string): Promise<Arra
|
|||
return notFound();
|
||||
}
|
||||
}, /* 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 ///////
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => {
|
|||
/>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 <GcpIapAuth callbackUrl={`/${domain}`} />;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
|
|||
const org = await getOrgFromDomain(domain);
|
||||
if (!org) {
|
||||
return <PageNotFound />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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`,
|
||||
}
|
||||
] : []),
|
||||
...(userRoleInOrg === OrgRole.OWNER ? [
|
||||
{
|
||||
title: "Access",
|
||||
href: `/${domain}/settings/access`,
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
title: (
|
||||
<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 { 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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between">
|
||||
|
|
@ -86,10 +78,6 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
|
|||
)}
|
||||
</div>
|
||||
|
||||
{userRoleInOrg === OrgRole.OWNER && (
|
||||
<MemberApprovalRequiredToggle memberApprovalRequired={org.memberApprovalRequired} inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} />
|
||||
)}
|
||||
|
||||
<InviteMemberCard
|
||||
currentUserRole={userRoleInOrg}
|
||||
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
|
||||
onClick={handleJoinOrganization}
|
||||
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" />}
|
||||
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
|
||||
type="submit"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function CompleteOnboardingButton() {
|
|||
<Button
|
||||
onClick={handleCompleteOnboarding}
|
||||
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 →"}
|
||||
</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 { 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 <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) {
|
||||
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: (
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -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
|
||||
</a>.
|
||||
|
|
@ -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.{" "}
|
||||
<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: (
|
||||
<div className="space-y-6">
|
||||
<MemberApprovalRequiredToggle memberApprovalRequired={org.memberApprovalRequired} inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} />
|
||||
<Button asChild className="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground transition-all duration-200 font-medium">
|
||||
<OrganizationAccessSettings />
|
||||
<Button asChild className="w-full">
|
||||
<a href="/onboard?step=3">Continue →</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -17,7 +17,7 @@ export const createAuditAction = async (event: Omit<AuditEvent, 'sourcebotVersio
|
|||
withAuth((userId) =>
|
||||
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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
// Auth
|
||||
FORCE_ENABLE_ANONYMOUS_ACCESS: booleanSchema.default('false'),
|
||||
|
||||
AUTH_SECRET: z.string(),
|
||||
AUTH_URL: z.string().url(),
|
||||
AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.default('true'),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<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> => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -452,3 +454,8 @@ export const getRepoImageSrc = (imageUrl: string | undefined, repoId: number, do
|
|||
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";
|
||||
|
||||
export const orgMetadataSchema = z.object({
|
||||
publicAccessEnabled: z.boolean().optional(),
|
||||
anonymousAccessEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type OrgMetadata = z.infer<typeof orgMetadataSchema>;
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue