Merge branch 'sourcebot-dev:main' into main

This commit is contained in:
Andre Nogueira 2025-07-22 13:25:52 +01:00 committed by GitHub
commit 7f0ca0a0c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 764 additions and 405 deletions

View file

@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [4.5.3] - 2025-07-20
### Changed
- Relicense core to FSL-1.1-ALv2. [#388](https://github.com/sourcebot-dev/sourcebot/pull/388)
### Added
- Added `GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS` env var to configure the GitLab client's query timeout. [#390](https://github.com/sourcebot-dev/sourcebot/pull/390)
## [4.5.2] - 2025-07-19
### 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

25
LICENSE
View file

@ -1,25 +0,0 @@
Copyright (c) 2025 Taqla Inc.
Portions of this software are licensed as follows:
- All content that resides under the "ee/", "packages/web/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

115
LICENSE.md Normal file
View file

@ -0,0 +1,115 @@
Copyright (c) 2025 Taqla Inc.
Portions of this software are licensed as follows:
- All content that resides under the "ee/", "packages/web/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "Functional Source License" as defined below.
---
# Functional Source License, Version 1.1, ALv2 Future License
## Abbreviation
FSL-1.1-ALv2
## Notice
Copyright 2025 Taqla Inc.
## Terms and Conditions
### Licensor ("We")
The party offering the Software under these Terms and Conditions.
### The Software
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the Apache License, Version 2.0 that is effective on the second anniversary of
the date we make the Software available. On or after that date, you may use the
Software under the Apache License, Version 2.0, in which case the following
will apply:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.

View file

@ -44,7 +44,7 @@
# About
Sourcebot is the open source Sourcegraph alternative. Index all your repos and branches across multiple code hosts (GitHub, GitLab, Bitbucket, Gitea, or Gerrit) and search through them using a blazingly fast interface.
Sourcebot lets you index all your repos and branches across multiple code hosts (GitHub, GitLab, Bitbucket, Gitea, or Gerrit) and search through them using a blazingly fast interface.
https://github.com/user-attachments/assets/ced355f3-967e-4f37-ae6e-74ab8c06b9ec

View file

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

View file

@ -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"
]

View file

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

View file

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

View file

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

View file

@ -21,11 +21,13 @@ 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> |
| `REDIS_REMOVE_ON_FAIL` | `100` | <p>Controls how many failed jobs are allowed to remain in Redis queues</p> |
| `REPO_SYNC_RETRY_BASE_SLEEP_SECONDS` | `60` | <p>The base sleep duration (in seconds) for exponential backoff when retrying repository sync operations that fail</p> |
| `GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS` | `600` | <p>The timeout duration (in seconds) for GitLab client queries</p> |
| `SHARD_MAX_MATCH_COUNT` | `10000` | <p>The maximum shard count per query</p> |
| `SMTP_CONNECTION_URL` | `-` | <p>The url to the SMTP service used for sending transactional emails. See [this doc](/docs/configuration/transactional-emails) for more info.</p> |
| `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` | <p>Used to encrypt connection secrets and generate API keys.</p> |

View file

@ -175,6 +175,10 @@ To connect to a GitLab host other than `gitlab.com`, provide the `url` property
}
```
## Troubleshooting
- If you're seeing errors like `GitbeakerTimeoutError: Query timeout was reached` when syncing a large number of projects, you can increase the client's timeout by setting the `GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS` environment variable. [#162](https://github.com/sourcebot-dev/sourcebot/issues/162)
## Schema reference
<Accordion title="Reference">

View file

@ -7,23 +7,6 @@ import SupportedPlatforms from '/snippets/platform-support.mdx'
The following guide will walk you through the steps to deploy Sourcebot on your own infrastructure. Sourcebot is distributed as a [single docker container](/docs/overview#architecture) that can be deployed to a k8s cluster, a VM, or any platform that supports docker.
## 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>

View file

@ -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
}
},

View file

@ -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
}

View file

@ -49,6 +49,8 @@ export const env = createEnv({
CONNECTION_MANAGER_UPSERT_TIMEOUT_MS: numberSchema.default(300000),
REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60),
GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS: numberSchema.default(60 * 10),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,

View file

@ -29,6 +29,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
...(config.url ? {
host: config.url,
} : {}),
queryTimeout: env.GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS * 1000,
});
let allRepos: ProjectSchema[] = [];

View file

@ -2258,4 +2258,4 @@ const schema = {
},
"additionalProperties": false
} as const;
export { schema as indexSchema };
export { schema as indexSchema };

View file

@ -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
}
},

View file

@ -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;
}

View file

@ -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;

View file

@ -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 ///////

View file

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

View file

@ -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}`} />;

View file

@ -7,7 +7,7 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
const org = await getOrgFromDomain(domain);
if (!org) {
return <PageNotFound />
}
}
return (
<div>

View file

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

View file

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

View file

@ -12,9 +12,6 @@ import { ServiceErrorException } from "@/lib/serviceError";
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { 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}

View file

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

View file

@ -46,7 +46,7 @@ export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string
<Button
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

View file

@ -0,0 +1,71 @@
"use client"
import { useState } from "react"
import { Switch } from "@/components/ui/switch"
import { setMemberApprovalRequired } from "@/actions"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { isServiceError } from "@/lib/utils"
import { useToast } from "@/components/hooks/use-toast"
interface MemberApprovalRequiredToggleProps {
memberApprovalRequired: boolean
onToggleChange?: (checked: boolean) => void
}
export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleChange }: MemberApprovalRequiredToggleProps) {
const [enabled, setEnabled] = useState(memberApprovalRequired)
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const handleToggle = async (checked: boolean) => {
setIsLoading(true)
try {
const result = await setMemberApprovalRequired(SINGLE_TENANT_ORG_DOMAIN, checked)
if (isServiceError(result)) {
toast({
title: "Error",
description: "Failed to update member approval setting",
variant: "destructive",
})
return
}
setEnabled(checked)
onToggleChange?.(checked)
} catch (error) {
console.error("Error updating member approval setting:", error)
toast({
title: "Error",
description: "Failed to update member approval setting",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
return (
<div className="p-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-[var(--foreground)] mb-2">
Require approval for new members
</h3>
<div className="max-w-2xl">
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
When enabled, new users will need approval from an organization owner before they can access your deployment.
</p>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,43 @@
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}
/>
</div>
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector"
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { 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&apos;s access settings.{" "}
<a
href="https://docs.sourcebot.dev/docs/configuration/auth/access-settings"
target="_blank"
rel="noopener"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Learn more
</a>
</>
),
component: (
<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>

View file

@ -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)
);

View file

@ -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)
);

View file

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

View file

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

View file

@ -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) => {

View file

@ -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 => {

View file

@ -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)
);

View file

@ -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)
);

View file

@ -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)
);

View file

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

View 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: {

View file

@ -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",

View file

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

View file

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

View file

@ -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
}
},