Revamp onboarding flow (#376)

* sign up copy nits

* first pass at new onboarding page

* wip join onboard logic

* refactor auth provider fetch logic

* add member approval and invite link flag logic

* update join request flow and remove jit logic

* onboard guard

* nits, onboard role check, invite link enabled check

* fix bg color issue in onboarding page

* refactor onboard UI

* ui nits and more onboarding resource cards

* revamp auth docs

* change member approval default behavior and updated docs

* merge prisma migrations

* add id to resource card

* feedback

* feedback

* feedback and fixed build

* settings drop down UI nit

* ui nits

* handle join when max capacity case

* add news data for member toggle

* refactor for public access case

* add iap bridge to onboard logic

* fetch member approval req and invite link enabled flag on server

* ui nits

* fix invite link enable toggle snapping issue

* ui nits

* styling and ui nits, pass in invite id from server

* add mcp resource in onboard step

* get invite link in server

* fix build issue

* refactor docs on config

* minor doc nit
This commit is contained in:
Michael Sukkarieh 2025-07-14 20:14:41 -07:00 committed by GitHub
parent 1384dd870e
commit 173a56ab64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1985 additions and 1435 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364) - Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364)
- Revamped onboarding experience. [#370](https://github.com/sourcebot-dev/sourcebot/pull/376)
- Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377) - Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377)
- Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365) - Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365)

View file

@ -52,7 +52,7 @@
"group": "Configuration", "group": "Configuration",
"pages": [ "pages": [
{ {
"group": "Connecting your code", "group": "Indexing your code",
"pages": [ "pages": [
"docs/connections/overview", "docs/connections/overview",
"docs/connections/github", "docs/connections/github",
@ -72,7 +72,10 @@
"group": "Authentication", "group": "Authentication",
"pages": [ "pages": [
"docs/configuration/auth/overview", "docs/configuration/auth/overview",
"docs/configuration/auth/roles-and-permissions" "docs/configuration/auth/providers",
"docs/configuration/auth/inviting-members",
"docs/configuration/auth/roles-and-permissions",
"docs/configuration/auth/faq"
] ]
}, },
"docs/configuration/transactional-emails", "docs/configuration/transactional-emails",

View file

@ -125,7 +125,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.join_requested` | `user` | `org` | | `user.join_requested` | `user` | `org` |
| `user.join_request_approve_failed` | `user` | `account_join_request` | | `user.join_request_approve_failed` | `user` | `account_join_request` |
| `user.join_request_approved` | `user` | `account_join_request` | | `user.join_request_approved` | `user` | `account_join_request` |
| `user.join_request_removed` | `user` | `account_join_request` |
| `user.invite_failed` | `user` | `org` | | `user.invite_failed` | `user` | `org` |
| `user.invites_created` | `user` | `org` | | `user.invites_created` | `user` | `org` |
| `user.invite_accept_failed` | `user` | `invite` | | `user.invite_accept_failed` | `user` | `invite` |

View file

@ -0,0 +1,46 @@
---
title: FAQ
---
This page covers a range of frequently asked questions about Sourcebot's built-in authentication system.
<AccordionGroup>
<Accordion title="Can I disable the authentication system?">
No, at this time it's not possible to disable the authentication system. If this is preventing you from deploying Sourcebot
within your organization please [reach out](https://www.sourcebot.dev/contact)
</Accordion>
<Accordion title="I don't want to restrict access to my Sourcebot deployment, what should I do?">
Every user must register an account within your Sourcebot deployment. However, this dosn't mean their access
is restricted.
Unless member approval is required, anyone can sign up for an account on your deployment and immediately be granted access.
</Accordion>
<Accordion title="Does any data related to authentication (emails, passwords, etc) leave my deployment?">
**No data related to authentication (or your code) leaves your deployment**. Authentication is handled
purely by your deployment and the authentication providers you configure.
This data does not leave your device and is stored within in the database managed by your deployment. If you're
using credential login, passwords are encrypted at rest and in transit.
</Accordion>
<Accordion title="I'm deploying Sourcebot behind an identity proxy, do I still need to create an account in Sourcebot?">
<Note>Please note that IAP bridges are an enterprise feature</Note>
Sourcebot supports connecting your identity proxy directly into the built-in auth system using an IAP bridge. This allows Sourcebot to
register and authenticate automatically on a successful identity proxy log in.
Sourcebot currently supports [GCP IAP](/docs/configuration/auth/providers#gcp-iap). If you're using a different IAP
and require support, please [reach out](https://www.sourcebot.dev/contact)
</Accordion>
<Accordion title="How does Sourcebot implement authentication?">
Sourcebot uses [Auth.js](https://authjs.dev/) as its underlying authentication framework. Auth.js provides authentication providers
(credientials, Google, GitHub, etc) and an interface to enable user registration and log in. Internally, Auth.js uses JWT to provide
Sourcebot secure and reliable information about user authentication.
</Accordion>
</AccordionGroup>
Have a question that's not answered here? Submit it on our [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions)
page and we'll get back to you as soon as we can!

View file

@ -0,0 +1,30 @@
---
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

@ -4,124 +4,23 @@ title: Overview
<Warning>If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable.</Warning> <Warning>If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable.</Warning>
Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported. Sourcebot's built-in authentication system gates your deployment, and allows administrators to manage users and their permissions.
The first account that's registered on a Sourcebot deployment is made the owner. All other users who register must be [approved](/docs/configuration/auth/overview#approving-new-members) by the owner. <CardGroup cols={2}>
<Card horizontal title="Authentication providers" icon="lock" href="/docs/configuration/auth/providers">
Configure additional authentication providers for your deployment.
</Card>
<Card horizontal title="Inviting members" icon="user" href="/docs/configuration/auth/inviting-members">
Learn how to configure how members join your deployment.
</Card>
<Card horizontal title="Roles and permissions" icon="shield" href="/docs/configuration/auth/roles-and-permissions">
Learn more about the different roles and permissions in Sourcebot.
</Card>
<Card horizontal title="FAQ" icon="question" href="/docs/configuration/auth/faq">
Have a question about Sourcebot's auth system? We might have the answers here.
</Card>
</CardGroup>
![Login Page](/images/login.png)
# Approving New Members
All account registrations after the first account must be approved by the owner. The owner can see all join requests by going into **Settings -> Members**.
If you have an [enterprise license](/docs/license-key), you can enable [AUTH_EE_ENABLE_JIT_PROVISIONING](/docs/configuration/auth/overview#enterprise-authentication-providers) to
have Sourcebot accounts automatically created and approved on registration.
You can setup emails to be sent when new join requests are created/approved by configurating [transactional emails](/docs/configuration/transactional-emails)
# Authentication Providers
To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider.
## Core Authentication Providers
### Email / Password
---
Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`.
### Email codes
---
Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables:
- `AUTH_EMAIL_CODE_LOGIN_ENABLED`
- `SMTP_CONNECTION_URL`
- `EMAIL_FROM_ADDRESS`
See [transactional emails](/docs/configuration/transactional-emails) for more details.
## Enterprise Authentication Providers
The following authentication providers require an [enterprise license](/docs/license-key) to be enabled.
By default, a new user registering using these providers must have their join request accepted by the owner of the organization to join. To allow a user to join automatically when
they register for the first time, set the `AUTH_EE_ENABLE_JIT_PROVISIONING` environment variable to `true`.
### GitHub
---
[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
**Required environment variables:**
- `AUTH_EE_GITHUB_CLIENT_ID`
- `AUTH_EE_GITHUB_CLIENT_SECRET`
Optional environment variables:
- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com)
### GitLab
---
[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab)
**Required environment variables:**
- `AUTH_EE_GITLAB_CLIENT_ID`
- `AUTH_EE_GITLAB_CLIENT_SECRET`
Optional environment variables:
- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com)
### Google
---
[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google)
**Required environment variables:**
- `AUTH_EE_GOOGLE_CLIENT_ID`
- `AUTH_EE_GOOGLE_CLIENT_SECRET`
### GCP IAP
---
<Note>If you're running Sourcebot in an environment that blocks egress, make sure you allow the [IAP IP ranges](https://www.gstatic.com/ipranges/goog.json)</Note>
Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP.
**Required environment variables**
- `AUTH_EE_GCP_IAP_ENABLED`
- `AUTH_EE_GCP_IAP_AUDIENCE`
- This can be found by selecting the ⋮ icon next to the IAP-enabled backend service and pressing `Get JWT audience code`
### Okta
---
[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta)
**Required environment variables:**
- `AUTH_EE_OKTA_CLIENT_ID`
- `AUTH_EE_OKTA_CLIENT_SECRET`
- `AUTH_EE_OKTA_ISSUER`
### Keycloak
---
[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak)
**Required environment variables:**
- `AUTH_EE_KEYCLOAK_CLIENT_ID`
- `AUTH_EE_KEYCLOAK_CLIENT_SECRET`
- `AUTH_EE_KEYCLOAK_ISSUER`
### Microsoft Entra ID
[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id)
**Required environment variables:**
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID`
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET`
- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER`
---
# Troubleshooting # Troubleshooting

View file

@ -0,0 +1,105 @@
---
title: Providers
---
Sourcebot supports a wide range of different authentication providers through it's integration with [Auth.js](https://authjs.dev/). This page
highlights how to configure the various supported providers.
If theres an authentication provider you'd like us to support, please [reach out](https://www.sourcebot.dev/contact).
# Core Authentication Providers
### Email / Password
---
Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`.
### Email codes
---
Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables:
- `AUTH_EMAIL_CODE_LOGIN_ENABLED`
- `SMTP_CONNECTION_URL`
- `EMAIL_FROM_ADDRESS`
See [transactional emails](/docs/configuration/transactional-emails) for more details.
# Enterprise Authentication Providers
The following authentication providers require an [enterprise license](/docs/license-key) to be enabled.
### GitHub
---
[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
**Required environment variables:**
- `AUTH_EE_GITHUB_CLIENT_ID`
- `AUTH_EE_GITHUB_CLIENT_SECRET`
Optional environment variables:
- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com)
### GitLab
---
[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab)
**Required environment variables:**
- `AUTH_EE_GITLAB_CLIENT_ID`
- `AUTH_EE_GITLAB_CLIENT_SECRET`
Optional environment variables:
- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com)
### Google
---
[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google)
**Required environment variables:**
- `AUTH_EE_GOOGLE_CLIENT_ID`
- `AUTH_EE_GOOGLE_CLIENT_SECRET`
### GCP IAP
---
<Note>If you're running Sourcebot in an environment that blocks egress, make sure you allow the [IAP IP ranges](https://www.gstatic.com/ipranges/goog.json)</Note>
Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP.
**Required environment variables**
- `AUTH_EE_GCP_IAP_ENABLED`
- `AUTH_EE_GCP_IAP_AUDIENCE`
- This can be found by selecting the ⋮ icon next to the IAP-enabled backend service and pressing `Get JWT audience code`
### Okta
---
[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta)
**Required environment variables:**
- `AUTH_EE_OKTA_CLIENT_ID`
- `AUTH_EE_OKTA_CLIENT_SECRET`
- `AUTH_EE_OKTA_ISSUER`
### Keycloak
---
[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak)
**Required environment variables:**
- `AUTH_EE_KEYCLOAK_CLIENT_ID`
- `AUTH_EE_KEYCLOAK_CLIENT_SECRET`
- `AUTH_EE_KEYCLOAK_ISSUER`
### Microsoft Entra ID
[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id)
**Required environment variables:**
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID`
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET`
- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER`
---

View file

@ -41,7 +41,6 @@ The following environment variables allow you to configure your Sourcebot deploy
| Variable | Default | Description | | Variable | Default | Description |
| :------- | :------ | :---------- | | :------- | :------ | :---------- |
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | <p>Enables/disables audit logging</p> | | `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | <p>Enables/disables audit logging</p> |
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> |
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> | | `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> | | `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |
| `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` | <p>The client secret for GitHub Enterprise SSO authentication.</p> | | `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` | <p>The client secret for GitHub Enterprise SSO authentication.</p> |

View file

@ -5,7 +5,7 @@ icon: folder
import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx'
Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaing Sourcebot will **not** `git fetch` new revisions. Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaning Sourcebot will **not** `git fetch` new revisions.
## Getting Started ## Getting Started

View file

@ -6,12 +6,24 @@ sidebarTitle: Overview
import SupportedPlatforms from '/snippets/platform-support.mdx' import SupportedPlatforms from '/snippets/platform-support.mdx'
import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx'
A **connection** in Sourcebot represents a link to a code host (such as GitHub, GitLab, Bitbucket, etc.). Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name. To index your code with Sourcebot, you must provide a configuration file. When running Sourcebot, this file must be mounted in a volume that is accessible to the container, with its
path specified in the `CONFIG_PATH` environment variable. For example:
A JSON configuration file is used to specify connections. For example: ```bash icon="terminal" Passing in a CONFIG_PATH to Sourcebot
docker run \
-v $(pwd)/config.json:/data/config.json \
-e CONFIG_PATH=/data/config.json \
... \ # other config
ghcr.io/sourcebot-dev/sourcebot:latest
```
```json ## Config Schema
// Specifies two connections:
The configuration file defines a set of **connections**. A connection in Sourcebot represents a link to a code host (such as GitHub, GitLab, Bitbucket, etc.).
Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name.
```json wrap icon="code" Example config with two connections
{ {
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
"connections": { "connections": {
@ -43,16 +55,7 @@ A JSON configuration file is used to specify connections. For example:
Configuration files must conform to the [JSON schema](#schema-reference). Configuration files must conform to the [JSON schema](#schema-reference).
When running Sourcebot, this file must be mounted in a volume that is accessible to the container, with its path specified in the `CONFIG_PATH` environment variable. For example: ## Config Syncing
```bash
docker run \
-v $(pwd)/config.json:/data/config.json \
-e CONFIG_PATH=/data/config.json \
... \ # other config
ghcr.io/sourcebot-dev/sourcebot:latest
```
Sourcebot performs syncing in the background. Syncing consists of two steps: Sourcebot performs syncing in the background. Syncing consists of two steps:
1. Fetch the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host. 1. Fetch the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host.
2. Re-indexes the repository. 2. Re-indexes the repository.
@ -70,10 +73,9 @@ On the home page, you can view the sync status of ongoing jobs:
src="https://framerusercontent.com/assets/7YyxK8ctPEy9Rf68X2kIdMI.mp4" src="https://framerusercontent.com/assets/7YyxK8ctPEy9Rf68X2kIdMI.mp4"
></video> ></video>
## Getting started ## Platform Connection Guides
---
To get started, pick a platform below and follow the instructions to connect your code. To learn more about how to create a connection for a specific code host, check out the guides below.
<SupportedPlatforms /> <SupportedPlatforms />

View file

@ -2,7 +2,7 @@
title: "Overview" title: "Overview"
--- ---
Sourcebot is an open-source ([GitHub](https://github.com/sourcebot-dev/sourcebot)), self-hosted code search tool that is purpose built to help teams find and navigate code quickly, at scale. [Sourcebot]((https://github.com/sourcebot-dev/sourcebot)) is an open-source, self-hosted code search tool. It allows you to search and navigate across millions of lines of code across several code host platforms.
<CardGroup> <CardGroup>
<Card title="Deployment guide" icon="server" href="/docs/deployment-guide" horizontal="true"> <Card title="Deployment guide" icon="server" href="/docs/deployment-guide" horizontal="true">
@ -16,10 +16,10 @@ Sourcebot is an open-source ([GitHub](https://github.com/sourcebot-dev/sourcebot
<AccordionGroup> <AccordionGroup>
<Accordion title="Why Sourcebot?"> <Accordion title="Why Sourcebot?">
- **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more. - **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more.
- **Self-hosted:** Ships as a single [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot) that can be deployed anywhere. - **Self-hosted:** Deploy it in minutes using our official [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). All of your data stays on your machine.
- **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc. - **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc.
- **Scalable:** Scales to millions of lines of code. - **Scalable:** Scales to millions of lines of code.
- **Open-source:** Core features are MIT licensed, no vendor lock-in. - **Open-source:** Core features are MIT licensed.
</Accordion> </Accordion>
</AccordionGroup> </AccordionGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

View file

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `pendingApproval` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "inviteLinkEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "inviteLinkId" TEXT,
ADD COLUMN "memberApprovalRequired" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "User" DROP COLUMN "pendingApproval";

View file

@ -165,12 +165,18 @@ model Org {
imageUrl String? imageUrl String?
metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
memberApprovalRequired Boolean @default(true)
stripeCustomerId String? stripeCustomerId String?
stripeSubscriptionStatus StripeSubscriptionStatus? stripeSubscriptionStatus StripeSubscriptionStatus?
stripeLastUpdatedAt DateTime? stripeLastUpdatedAt DateTime?
/// List of pending invites to this organization /// List of pending invites to this organization
invites Invite[] invites Invite[]
/// The invite id for this organization
inviteLinkEnabled Boolean @default(false)
inviteLinkId String?
audits Audit[] audits Audit[]
@ -263,7 +269,6 @@ model User {
image String? image String?
accounts Account[] accounts Account[]
orgs UserToOrg[] orgs UserToOrg[]
pendingApproval Boolean @default(true)
accountRequest AccountRequest? accountRequest AccountRequest?
/// List of pending invites that the user has created /// List of pending invites that the user has created

View file

@ -110,7 +110,7 @@ export const getPlan = (): Plan => {
} }
export const getSeats = (): number => { export const getSeats = (): number => {
const licenseKey = getLicenseKey(); const licenseKey = getLicenseKey();
return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS;
} }

View file

@ -2,7 +2,7 @@
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
import { CodeHostType, isServiceError } from "@/lib/utils"; import { CodeHostType, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { render } from "@react-email/components"; import { render } from "@react-email/components";
@ -28,15 +28,16 @@ import InviteUserEmail from "./emails/inviteUserEmail";
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { TenancyMode, ApiKeyPayload } from "./lib/types"; import { TenancyMode, ApiKeyPayload } from "./lib/types";
import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils"; import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
import { getPlan, getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { getPlan, hasEntitlement } from "@sourcebot/shared";
import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess"; import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { getAuditService } from "@/ee/features/audit/factory"; import { getAuditService } from "@/ee/features/audit/factory";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
@ -116,35 +117,6 @@ export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | unde
return fn(session.user.id, undefined); return fn(session.user.id, undefined);
} }
export const orgHasAvailability = async (domain: string): Promise<boolean> => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
return false;
}
const members = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
role: {
not: OrgRole.GUEST,
},
},
});
const maxSeats = getSeats();
const memberCount = members.length;
if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) {
return false;
}
return true;
}
export const withOrgMembership = async <T>(userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise<T>, minRequiredRole: OrgRole = OrgRole.MEMBER) => { export const withOrgMembership = async <T>(userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise<T>, minRequiredRole: OrgRole = OrgRole.MEMBER) => {
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
where: { where: {
@ -1169,6 +1141,13 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* minRequiredRole = */ OrgRole.OWNER)
)); ));
export const getOrgInviteId = async (domain: string) => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
return org.inviteLinkId;
}, /* minRequiredRole = */ OrgRole.OWNER)
));
export const getMe = async () => sew(() => export const getMe = async () => sew(() =>
withAuth(async (userId) => { withAuth(async (userId) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@ -1208,7 +1187,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
if (isServiceError(user)) { if (isServiceError(user)) {
return user; return user;
} }
const invite = await prisma.invite.findUnique({ const invite = await prisma.invite.findUnique({
where: { where: {
id: inviteId, id: inviteId,
@ -1257,73 +1236,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
return notFound(); return notFound();
} }
const res = await prisma.$transaction(async (tx) => { const addUserToOrgRes = await addUserToOrganization(user.id, invite.orgId);
await tx.userToOrg.create({ if (isServiceError(addUserToOrgRes)) {
data: { await failAuditCallback(addUserToOrgRes.message);
userId: user.id, return addUserToOrgRes;
orgId: invite.orgId,
role: "MEMBER",
}
});
await tx.user.update({
where: {
id: user.id,
},
data: {
pendingApproval: false,
}
});
await tx.invite.delete({
where: {
id: invite.id,
}
});
// Delete the account request if it exists since we've redeemed an invite
const accountRequest = await tx.accountRequest.findUnique({
where: {
requestedById_orgId: {
requestedById: user.id,
orgId: invite.orgId,
}
},
});
if (accountRequest) {
logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've redeemed an invite`);
await auditService.createAudit({
action: "user.join_request_removed",
actor: {
id: user.id,
type: "user"
},
orgId: invite.org.id,
target: {
id: accountRequest.id,
type: "account_join_request"
}
});
await tx.accountRequest.delete({
where: {
id: accountRequest.id,
}
});
}
if (IS_BILLING_ENABLED) {
const result = await incrementOrgSeatCount(invite.orgId, tx);
if (isServiceError(result)) {
throw result;
}
}
});
if (isServiceError(res)) {
await failAuditCallback(res.message);
return res;
} }
await auditService.createAudit({ await auditService.createAudit({
@ -1519,19 +1435,6 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
} }
}); });
// TODO: The fact that pendingApproval is set in the user is a bit weird here, since it will prevent approval from working in the multi-tenant case.
// We need to set pendingApproval to be true here though so that if the user tries to sign into the deployment again it will send another request. Without
// this, the user will never be able to request to join the org again.
// TODO(multitenant): Handle this better
await tx.user.update({
where: {
id: memberId,
},
data: {
pendingApproval: true,
}
});
if (IS_BILLING_ENABLED) { if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx); const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) { if (isServiceError(result)) {
@ -1677,14 +1580,6 @@ export const createAccountRequest = async (userId: string, domain: string) => se
return notFound("User not found"); return notFound("User not found");
} }
if (user.pendingApproval == false) {
logger.warn(`User ${userId} isn't pending approval. Skipping account request creation.`);
return {
success: true,
existingRequest: false,
}
}
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
where: { where: {
domain, domain,
@ -1776,6 +1671,64 @@ export const createAccountRequest = async (userId: string, domain: string) => se
} }
}); });
export const getMemberApprovalRequired = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
return orgNotFound();
}
return org.memberApprovalRequired;
});
export const setMemberApprovalRequired = async (domain: string, required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
await prisma.org.update({
where: { id: org.id },
data: { memberApprovalRequired: required },
});
return {
success: true,
};
}, /* minRequiredRole = */ OrgRole.OWNER)
)
);
export const getInviteLinkEnabled = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
return orgNotFound();
}
return org.inviteLinkEnabled;
});
export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
await prisma.org.update({
where: { id: org.id },
data: { inviteLinkEnabled: enabled },
});
return {
success: true,
};
}, /* minRequiredRole = */ OrgRole.OWNER)
)
);
export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () => export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () =>
withAuth(async (userId) => withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
@ -1784,7 +1737,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
action: "user.join_request_approve_failed", action: "user.join_request_approve_failed",
actor: { actor: {
id: userId, id: userId,
type: "user" type: "user"
}, },
target: { target: {
id: requestId, id: requestId,
@ -1811,60 +1764,10 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
return notFound(); return notFound();
} }
const hasAvailability = await orgHasAvailability(domain); const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id);
if (!hasAvailability) { if (isServiceError(addUserToOrgRes)) {
await failAuditCallback("Organization is at max capacity"); await failAuditCallback(addUserToOrgRes.message);
return { return addUserToOrgRes;
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
message: "Organization is at max capacity",
} satisfies ServiceError;
}
const res = await prisma.$transaction(async (tx) => {
await tx.user.update({
where: {
id: request.requestedById,
},
data: {
pendingApproval: false,
},
});
await tx.userToOrg.create({
data: {
userId: request.requestedById,
orgId: org.id,
role: "MEMBER",
},
});
await tx.accountRequest.delete({
where: {
id: requestId,
},
});
const invites = await tx.invite.findMany({
where: {
recipientEmail: request.requestedBy.email!,
orgId: org.id,
},
})
for (const invite of invites) {
logger.info(`Account request approved. Deleting invite ${invite.id} for ${request.requestedBy.email}`);
await tx.invite.delete({
where: {
id: invite.id,
},
});
}
});
if (isServiceError(res)) {
await failAuditCallback(res.message);
return res;
} }
// Send approval email to the user // Send approval email to the user
@ -1936,19 +1839,6 @@ export const rejectAccountRequest = async (requestId: string, domain: string) =>
}, },
}); });
await auditService.createAudit({
action: "user.join_request_removed",
actor: {
id: userId,
type: "user"
},
orgId: org.id,
target: {
id: requestId,
type: "account_join_request"
}
});
return { return {
success: true, success: true,
} }

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { Redirect } from "@/app/components/redirect"; import { Redirect } from "@/app/components/redirect";
import { useDomain } from "@/hooks/useDomain";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useMemo } from "react"; import { useMemo } from "react";
@ -10,20 +9,19 @@ interface OnboardGuardProps {
} }
export const OnboardGuard = ({ children }: OnboardGuardProps) => { export const OnboardGuard = ({ children }: OnboardGuardProps) => {
const domain = useDomain();
const pathname = usePathname(); const pathname = usePathname();
const content = useMemo(() => { const content = useMemo(() => {
if (!pathname.endsWith('/onboard')) { if (!pathname.endsWith('/onboard')) {
return ( return (
<Redirect <Redirect
to={`/${domain}/onboard`} to={`/onboard`}
/> />
) )
} else { } else {
return children; return children;
} }
}, [domain, children, pathname]); }, [children, pathname]);
return content; return content;
} }

View file

@ -1,15 +1,8 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HelpCircle } from "lucide-react"
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { auth } from "@/auth" import { auth } from "@/auth"
import { ResubmitAccountRequestButton } from "./resubmitAccountRequestButton"
interface PendingApprovalCardProps { export const PendingApprovalCard = async () => {
domain: string
}
export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps) => {
const session = await auth() const session = await auth()
const userId = session?.user?.id const userId = session?.user?.id
@ -18,42 +11,45 @@ export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps)
} }
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen py-24 bg-background text-foreground relative"> <div className="min-h-screen bg-[var(--background)] flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" /> <LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<div className="w-full max-w-md mx-auto"> <div className="w-full max-w-md">
<Card className="shadow-xl"> <div className="text-center space-y-8">
<CardHeader className="pb-4"> <SourcebotLogo
<SourcebotLogo className="h-10 mx-auto"
className="h-16 w-auto mx-auto mb-2" size="large"
size="large" />
/>
<CardTitle className="text-2xl font-bold text-center">Pending Approval</CardTitle> <div className="space-y-6">
<CardDescription className="text-center mt-2"> <div className="w-12 h-12 mx-auto bg-[var(--accent)] rounded-full flex items-center justify-center">
Your request to join the organization is being reviewed <svg className="w-6 h-6 text-[var(--accent-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</CardDescription> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</CardHeader> </svg>
<CardContent className="space-y-4">
<div className="flex flex-col items-center space-y-2 mt-4">
<ResubmitAccountRequestButton domain={domain} userId={userId} />
</div> </div>
<div className="flex justify-center">
<div className="inline-flex items-center space-x-3 p-3 bg-muted/50 rounded-md"> <div className="space-y-2">
<HelpCircle className="h-5 w-5 text-primary" /> <h1 className="text-2xl font-semibold text-[var(--foreground)]">
<div className="text-sm text-muted-foreground text-center"> Approval Pending
<p>Need help or have questions?</p> </h1>
<a <p className="text-[var(--muted-foreground)] text-base">
href="https://github.com/sourcebot-dev/sourcebot/discussions/categories/support" Your request is being reviewed.
className="text-primary hover:text-primary/80 underline underline-offset-2" </p>
> </div>
Submit a support request </div>
</a>
</div> <div className="pt-4">
<div className="inline-flex items-center gap-3 px-4 py-2 rounded-full bg-[var(--accent)] text-sm">
<div className="flex gap-1">
<span className="block w-2 h-2 bg-[var(--accent-foreground)] rounded-full opacity-40 animate-pulse"></span>
<span className="block w-2 h-2 bg-[var(--accent-foreground)] rounded-full opacity-60 animate-pulse" style={{ animationDelay: '0.15s' }}></span>
<span className="block w-2 h-2 bg-[var(--accent-foreground)] rounded-full opacity-80 animate-pulse" style={{ animationDelay: '0.3s' }}></span>
</div> </div>
<span className="text-[var(--accent-foreground)]">Awaiting review</span>
</div> </div>
</CardContent> </div>
</Card>
</div>
</div> </div>
</div> </div>
) )

View file

@ -83,8 +83,8 @@ export const SettingsDropdown = ({
<DropdownMenuContent className="w-64"> <DropdownMenuContent className="w-64">
{session?.user ? ( {session?.user ? (
<DropdownMenuGroup> <DropdownMenuGroup>
<div className="flex flex-row items-center gap-1 p-2"> <div className="flex flex-row items-start gap-3 p-2">
<Avatar> <Avatar className="flex-shrink-0">
<AvatarImage <AvatarImage
src={session.user.image ?? ""} src={session.user.image ?? ""}
/> />
@ -92,7 +92,7 @@ export const SettingsDropdown = ({
{session.user.name && session.user.name.length > 0 ? session.user.name[0] : 'U'} {session.user.name && session.user.name.length > 0 ? session.user.name[0] : 'U'}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<p className="text-sm font-medium text-ellipsis">{session.user.email ?? "User"}</p> <p className="text-sm font-medium break-all flex-1 leading-relaxed">{session.user.email ?? "User"}</p>
</div> </div>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {

View file

@ -6,14 +6,16 @@ import { useState } from "react"
import { useToast } from "@/components/hooks/use-toast" import { useToast } from "@/components/hooks/use-toast"
import { createAccountRequest } from "@/actions" import { createAccountRequest } from "@/actions"
import { isServiceError } from "@/lib/utils" import { isServiceError } from "@/lib/utils"
import { useRouter } from "next/navigation"
interface ResubmitButtonProps { interface SubmitButtonProps {
domain: string domain: string
userId: string userId: string
} }
export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonProps) { export function SubmitAccountRequestButton({ domain, userId }: SubmitButtonProps) {
const { toast } = useToast() const { toast } = useToast()
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async () => { const handleSubmit = async () => {
@ -28,19 +30,20 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP
}) })
} else { } else {
toast({ toast({
title: "Request Resubmitted", title: "Request Submitted",
description: "Your request to join the organization has been resubmitted.", description: "Your request to join the organization has been submitted.",
variant: "default", variant: "default",
}) })
} }
// Refresh the page to trigger layout re-render and show PendingApprovalCard
router.refresh()
} else { } else {
toast({ toast({
title: "Failed to Resubmit", title: "Failed to Submit",
description: `There was an error resubmitting your request. Reason: ${result.message}`, description: `There was an error submitting your request. Reason: ${result.message}`,
variant: "destructive", variant: "destructive",
}) })
} }
setIsSubmitting(false) setIsSubmitting(false)
} }
@ -57,7 +60,7 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP
disabled={isSubmitting} disabled={isSubmitting}
> >
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
{isSubmitting ? "Submitting..." : "Resubmit Request"} {isSubmitting ? "Submitting..." : "Submit Request"}
</Button> </Button>
</form> </form>
) )

View file

@ -0,0 +1,55 @@
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { auth } from "@/auth"
import { SubmitAccountRequestButton } from "./submitAccountRequestButton"
interface SubmitJoinRequestProps {
domain: string
}
export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
return null
}
return (
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<div className="w-full max-w-md">
<div className="text-center space-y-8">
<SourcebotLogo
className="h-10 mx-auto"
size="large"
/>
<div className="space-y-6">
<div className="w-12 h-12 mx-auto bg-[var(--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>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-[var(--foreground)]">
Request Access
</h1>
<p className="text-[var(--muted-foreground)] text-base">
Submit a request to join this organization
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex justify-center">
<SubmitAccountRequestButton domain={domain} userId={userId} />
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -14,10 +14,14 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getSubscriptionInfo } from "@/ee/features/billing/actions"; import { getSubscriptionInfo } from "@/ee/features/billing/actions";
import { PendingApprovalCard } from "./components/pendingApproval"; import { PendingApprovalCard } from "./components/pendingApproval";
import { SubmitJoinRequest } from "./components/submitJoinRequest";
import { hasEntitlement } from "@sourcebot/shared"; import { hasEntitlement } from "@sourcebot/shared";
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess"; import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { GcpIapAuth } from "./components/gcpIapAuth"; import { GcpIapAuth } from "./components/gcpIapAuth";
import { getMemberApprovalRequired } from "@/actions";
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode, children: React.ReactNode,
@ -34,43 +38,60 @@ export default async function Layout({
return notFound(); return notFound();
} }
const session = await auth();
const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain); const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain);
if (!publicAccessEnabled) {
const session = await auth(); // If the user is authenticated, we must check if they're a member of the org
if (!session) { if (session) {
const ssoEntitlement = await hasEntitlement("sso");
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
return <GcpIapAuth callbackUrl={`/${domain}`} />;
} else {
redirect('/login');
}
}
const membership = await prisma.userToOrg.findUnique({ const membership = await prisma.userToOrg.findUnique({
where: { where: {
orgId_userId: { orgId_userId: {
orgId: org.id, orgId: org.id,
userId: session.user.id userId: session.user.id
} }
}, },
include: { include: {
user: true user: true
} }
}); });
// There's two reasons why a user might not be a member of an org:
// 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them
// the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats.
// 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org.
if (!membership) { if (!membership) {
const user = await prisma.user.findUnique({ const memberApprovalRequired = await getMemberApprovalRequired(domain);
if (!memberApprovalRequired) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<JoinOrganizationCard />
</div>
)
} else {
const hasPendingApproval = await prisma.accountRequest.findFirst({
where: { where: {
id: session.user.id orgId: org.id,
requestedById: session.user.id
} }
}); });
// TODO: Organization join requests are only supported in single-tenant mode if (hasPendingApproval) {
if (env.SOURCEBOT_TENANCY_MODE === "single" && user?.pendingApproval) { return <PendingApprovalCard />
return <PendingApprovalCard domain={domain} />
} else { } else {
return notFound(); return <SubmitJoinRequest domain={domain} />
} }
}
}
} else {
// If the user isn't authenticated and public access isn't enabled, we need to redirect them to the login page.
if (!publicAccessEnabled) {
const ssoEntitlement = await hasEntitlement("sso");
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
return <GcpIapAuth callbackUrl={`/${domain}`} />;
} else {
redirect('/login');
}
} }
} }

View file

@ -1,30 +0,0 @@
'use client';
import { completeOnboarding } from "@/actions";
import { OnboardingSteps } from "@/lib/constants";
import { isServiceError } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useDomain } from "@/hooks/useDomain";
export const CompleteOnboarding = () => {
const router = useRouter();
const domain = useDomain();
useEffect(() => {
const complete = async () => {
const response = await completeOnboarding(domain);
if (isServiceError(response)) {
router.push(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`);
return;
}
router.push(`/${domain}`);
router.refresh();
};
complete();
}, [domain, router]);
return null;
}

View file

@ -1,164 +0,0 @@
'use client';
import { useState } from "react";
import { CodeHostType } from "@/lib/utils";
import { getCodeHostIcon } from "@/lib/utils";
import {
GitHubConnectionCreationForm,
GitLabConnectionCreationForm,
GiteaConnectionCreationForm,
GerritConnectionCreationForm,
BitbucketCloudConnectionCreationForm,
BitbucketDataCenterConnectionCreationForm
} from "@/app/[domain]/components/connectionCreationForms";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { OnboardingSteps } from "@/lib/constants";
import { BackButton } from "./onboardBackButton";
import { CodeHostIconButton } from "../../components/codeHostIconButton";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import SecurityCard from "@/app/components/securityCard";
interface ConnectCodeHostProps {
nextStep: OnboardingSteps;
securityCardEnabled: boolean;
}
export const ConnectCodeHost = ({ nextStep, securityCardEnabled }: ConnectCodeHostProps) => {
const [selectedCodeHost, setSelectedCodeHost] = useState<CodeHostType | null>(null);
const router = useRouter();
const onCreated = useCallback(() => {
router.push(`?step=${nextStep}`);
}, [nextStep, router]);
const onBack = useCallback(() => {
setSelectedCodeHost(null);
}, []);
if (!selectedCodeHost) {
return (
<>
<CodeHostSelection onSelect={setSelectedCodeHost} />
{securityCardEnabled && <SecurityCard />}
</>
)
}
if (selectedCodeHost === "github") {
return (
<>
<BackButton onClick={onBack} />
<GitHubConnectionCreationForm onCreated={onCreated} />
</>
)
}
if (selectedCodeHost === "gitlab") {
return (
<>
<BackButton onClick={onBack} />
<GitLabConnectionCreationForm onCreated={onCreated} />
</>
)
}
if (selectedCodeHost === "gitea") {
return (
<>
<BackButton onClick={onBack} />
<GiteaConnectionCreationForm onCreated={onCreated} />
</>
)
}
if (selectedCodeHost === "gerrit") {
return (
<>
<BackButton onClick={onBack} />
<GerritConnectionCreationForm onCreated={onCreated} />
</>
)
}
if (selectedCodeHost === "bitbucket-cloud") {
return (
<>
<BackButton onClick={onBack} />
<BitbucketCloudConnectionCreationForm onCreated={onCreated} />
</>
)
}
if (selectedCodeHost === "bitbucket-server") {
return (
<>
<BackButton onClick={onBack} />
<BitbucketDataCenterConnectionCreationForm onCreated={onCreated} />
</>
)
}
return null;
}
interface CodeHostSelectionProps {
onSelect: (codeHost: CodeHostType) => void;
}
const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
const captureEvent = useCaptureEvent();
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 max-w-3xl mx-auto">
<CodeHostIconButton
name="GitHub"
logo={getCodeHostIcon("github")!}
onClick={() => {
onSelect("github");
captureEvent("wa_onboard_github_selected", {});
}}
/>
<CodeHostIconButton
name="GitLab"
logo={getCodeHostIcon("gitlab")!}
onClick={() => {
onSelect("gitlab");
captureEvent("wa_onboard_gitlab_selected", {});
}}
/>
<CodeHostIconButton
name="Bitbucket Cloud"
logo={getCodeHostIcon("bitbucket-cloud")!}
onClick={() => {
onSelect("bitbucket-cloud");
captureEvent("wa_onboard_bitbucket_cloud_selected", {});
}}
/>
<CodeHostIconButton
name="Bitbucket DC"
logo={getCodeHostIcon("bitbucket-server")!}
onClick={() => {
onSelect("bitbucket-server");
captureEvent("wa_onboard_bitbucket_server_selected", {});
}}
/>
<CodeHostIconButton
name="Gitea"
logo={getCodeHostIcon("gitea")!}
onClick={() => {
onSelect("gitea");
captureEvent("wa_onboard_gitea_selected", {});
}}
/>
<CodeHostIconButton
name="Gerrit"
logo={getCodeHostIcon("gerrit")!}
onClick={() => {
onSelect("gerrit");
captureEvent("wa_onboard_gerrit_selected", {});
}}
/>
</div>
)
}

View file

@ -1,135 +0,0 @@
'use client';
import { createInvites } from "@/actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, PlusCircleIcon } from "lucide-react";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { inviteMemberFormSchema } from "../../settings/members/components/inviteMemberCard";
import { useDomain } from "@/hooks/useDomain";
import { useToast } from "@/components/hooks/use-toast";
import { OnboardingSteps } from "@/lib/constants";
import { useRouter } from "next/navigation";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface InviteTeamProps {
nextStep: OnboardingSteps;
}
export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
const domain = useDomain();
const { toast } = useToast();
const router = useRouter();
const captureEvent = useCaptureEvent();
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
resolver: zodResolver(inviteMemberFormSchema),
defaultValues: {
emails: [{ email: "" }]
},
});
const addEmailField = useCallback(() => {
const emails = form.getValues().emails;
form.setValue('emails', [...emails, { email: "" }]);
}, [form]);
const onComplete = useCallback(() => {
router.push(`?step=${nextStep}`);
}, [nextStep, router]);
const onSubmit = useCallback(async (data: z.infer<typeof inviteMemberFormSchema>) => {
const response = await createInvites(data.emails.map(e => e.email), domain);
if (isServiceError(response)) {
toast({
description: `❌ Failed to invite members. Reason: ${response.message}`
});
captureEvent('wa_onboard_invite_team_invite_fail', {
error: response.errorCode,
num_emails: data.emails.length,
});
} else {
toast({
description: `✅ Successfully invited ${data.emails.length} members`
});
captureEvent('wa_onboard_invite_team_invite_success', {
num_emails: data.emails.length,
});
onComplete();
}
}, [domain, toast, onComplete, captureEvent]);
const onSkip = useCallback(() => {
captureEvent('wa_onboard_invite_team_skip', {
num_emails: form.getValues().emails.length,
});
onComplete();
}, [onComplete, form, captureEvent]);
return (
<Card className="p-12 w-full sm:max-w-[500px]">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormLabel>Email Address</FormLabel>
<FormDescription>{`Invite members to access your organization's Sourcebot instance.`}</FormDescription>
{form.watch('emails').map((_, index) => (
<FormField
key={index}
control={form.control}
name={`emails.${index}.email`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
placeholder="melissa@example.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
{form.formState.errors.emails?.root?.message && (
<FormMessage>{form.formState.errors.emails.root.message}</FormMessage>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={addEmailField}
>
<PlusCircleIcon className="w-4 h-4 mr-0.5" />
Add more
</Button>
</CardContent>
<CardFooter className="flex justify-end">
<Button
size="sm"
variant="outline"
className="mr-2"
type="button"
onClick={onSkip}
>
Skip for now
</Button>
<Button
size="sm"
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting && <Loader2 className="w-4 h-4 mr-0.5 animate-spin" />}
Invite
</Button>
</CardFooter>
</form>
</Form>
</Card >
)
}

View file

@ -1,23 +0,0 @@
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
interface BackButtonProps {
onClick: () => void
}
export function BackButton({ onClick }: BackButtonProps) {
return (
<div className="mb-4">
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="text-gray-400 hover:text-white hover:bg-gray-800 focus-visible:ring-offset-gray-900 h-8 px-3 rounded-md"
>
<ArrowLeft size={16} className="mr-1" />
<span>Back</span>
</Button>
</div>
)
}

View file

@ -1,87 +0,0 @@
import { OnboardHeader } from "@/app/onboard/components/onboardHeader";
import { getOrgFromDomain } from "@/data/org";
import { OnboardingSteps } from "@/lib/constants";
import { notFound, redirect } from "next/navigation";
import { ConnectCodeHost } from "./components/connectCodeHost";
import { InviteTeam } from "./components/inviteTeam";
import { CompleteOnboarding } from "./components/completeOnboarding";
import { Checkout } from "@/ee/features/billing/components/checkout";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { env } from "@/env.mjs";
interface OnboardProps {
params: {
domain: string
},
searchParams: {
step?: string
stripe_session_id?: string
}
}
export default async function Onboard({ params, searchParams }: OnboardProps) {
const org = await getOrgFromDomain(params.domain);
if (!org) {
notFound();
}
if (org.isOnboarded) {
redirect(`/${params.domain}`);
}
const step = searchParams.step ?? OnboardingSteps.ConnectCodeHost;
if (
!Object.values(OnboardingSteps)
.filter(s => s !== OnboardingSteps.CreateOrg)
.filter(s => !IS_BILLING_ENABLED ? s !== OnboardingSteps.Checkout : true)
.map(s => s.toString())
.includes(step)
) {
redirect(`/${params.domain}/onboard?step=${OnboardingSteps.ConnectCodeHost}`);
}
const lastRequiredStep = IS_BILLING_ENABLED ? OnboardingSteps.Checkout : OnboardingSteps.Complete;
return (
<div className="flex flex-col items-center py-12 px-4 sm:px-12 min-h-screen bg-backgroundSecondary relative">
{step !== OnboardingSteps.Complete && (
<LogoutEscapeHatch className="absolute top-0 right-0 p-4 sm:p-12" />
)}
{step === OnboardingSteps.ConnectCodeHost && (
<>
<OnboardHeader
title="Connect your code host"
description="Connect your code host to start searching your code."
step={step as OnboardingSteps}
/>
<ConnectCodeHost
nextStep={OnboardingSteps.InviteTeam}
securityCardEnabled={env.SECURITY_CARD_ENABLED === 'true'}
/>
</>
)}
{step === OnboardingSteps.InviteTeam && (
<>
<OnboardHeader
title="Invite your team"
description="Invite your team to get the most out of Sourcebot."
step={step as OnboardingSteps}
/>
<InviteTeam
nextStep={lastRequiredStep}
/>
</>
)}
{step === OnboardingSteps.Checkout && (
<>
<Checkout />
</>
)}
{step === OnboardingSteps.Complete && (
<CompleteOnboarding />
)}
</div>
)
}

View file

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

View file

@ -0,0 +1,77 @@
'use client';
import { signIn } from "next-auth/react";
import { useCallback } from "react";
import { getAuthProviderInfo } from "@/lib/utils";
import { MagicLinkForm } from "@/app/login/components/magicLinkForm";
import { CredentialsForm } from "@/app/login/components/credentialsForm";
import { DividerSet } from "@/app/components/dividerSet";
import { ProviderButton } from "@/app/components/providerButton";
import { AuthSecurityNotice } from "@/app/components/authSecurityNotice";
import type { AuthProvider } from "@/lib/authProviders";
interface AuthMethodSelectorProps {
providers: AuthProvider[];
callbackUrl?: string;
context: "login" | "signup";
onProviderClick?: (providerId: string) => void;
securityNoticeClosable?: boolean;
}
export const AuthMethodSelector = ({
providers,
callbackUrl,
context,
onProviderClick,
securityNoticeClosable = false
}: AuthMethodSelectorProps) => {
const onSignInWithOauth = useCallback((provider: string) => {
// Call the optional analytics callback first
onProviderClick?.(provider);
signIn(provider, {
redirectTo: callbackUrl ?? "/"
});
}, [callbackUrl, onProviderClick]);
// Separate OAuth providers from special auth methods
const oauthProviders = providers.filter(p =>
!["credentials", "nodemailer"].includes(p.id)
);
const hasCredentials = providers.some(p => p.id === "credentials");
const hasMagicLink = providers.some(p => p.id === "nodemailer");
return (
<>
<AuthSecurityNotice closable={securityNoticeClosable} />
<DividerSet
elements={[
...(oauthProviders.length > 0 ? [
<div key="oauth-providers" className="w-full space-y-3">
{oauthProviders.map((provider) => {
const providerInfo = getAuthProviderInfo(provider.id);
return (
<ProviderButton
key={provider.id}
name={providerInfo.displayName}
logo={providerInfo.icon}
onClick={() => {
onSignInWithOauth(provider.id);
}}
context={context}
/>
);
})}
</div>
] : []),
...(hasMagicLink ? [
<MagicLinkForm key="magic-link" callbackUrl={callbackUrl} context={context} />
] : []),
...(hasCredentials ? [
<CredentialsForm key="credentials" callbackUrl={callbackUrl} context={context} />
] : [])
]}
/>
</>
);
};

View file

@ -0,0 +1,98 @@
'use client';
import React, { useState, useEffect } from "react";
import { env } from "@/env.mjs";
interface AuthSecurityNoticeProps {
closable?: boolean;
}
const AUTH_SECURITY_NOTICE_COOKIE = "auth-security-notice-dismissed";
const getSecurityNoticeDismissed = (): boolean => {
if (typeof document === "undefined") return false;
const cookies = document.cookie.split(';').map(cookie => cookie.trim());
const targetCookie = cookies.find(cookie => cookie.startsWith(`${AUTH_SECURITY_NOTICE_COOKIE}=`));
if (!targetCookie) return false;
try {
const cookieValue = targetCookie.substring(`${AUTH_SECURITY_NOTICE_COOKIE}=`.length);
return JSON.parse(decodeURIComponent(cookieValue));
} catch (error) {
console.warn('Failed to parse security notice cookie:', error);
return false;
}
};
const setSecurityNoticeDismissed = (dismissed: boolean) => {
if (typeof document === "undefined") return;
try {
const expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
const cookieValue = encodeURIComponent(JSON.stringify(dismissed));
document.cookie = `${AUTH_SECURITY_NOTICE_COOKIE}=${cookieValue}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
} catch (error) {
console.warn('Failed to set security notice cookie:', error);
}
};
export const AuthSecurityNotice = ({ closable = false }: AuthSecurityNoticeProps) => {
const [isDismissed, setIsDismissed] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
// Only check cookie after component mounts to avoid hydration error
useEffect(() => {
setHasMounted(true);
if (closable) {
setIsDismissed(getSecurityNoticeDismissed());
}
}, [closable]);
const handleDismiss = () => {
setIsDismissed(true);
setSecurityNoticeDismissed(true);
};
// Don't render if dismissed when closable, or if closable but not yet mounted
if (closable && (!hasMounted || isDismissed)) {
return null;
}
// Only render for self-hosted deployments
if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined) {
return null;
}
return (
<div className={`p-4 rounded-lg bg-[var(--highlight)]/10 border border-[var(--highlight)]/20 relative ${closable ? 'pr-10' : ''}`}>
{closable && (
<button
onClick={handleDismiss}
className="absolute top-3 right-3 p-1 text-[var(--highlight)] hover:text-[var(--highlight)]/80 transition-colors"
aria-label="Dismiss security notice"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<p className="text-sm text-[var(--highlight)] leading-6 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span>
<strong>Security Notice:</strong> Authentication data is managed by your deployment and is encrypted at rest. Zero data leaves your deployment.{' '}
<a
href="https://docs.sourcebot.dev/docs/configuration/auth/faq"
target="_blank"
rel="noopener"
className="underline text-[var(--highlight)] hover:text-[var(--highlight)]/80 font-medium"
>
Learn more
</a>
</span>
</p>
</div>
);
};

View file

@ -0,0 +1,13 @@
import { Fragment } from "react";
import { TextSeparator } from "./textSeparator";
export const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => {
return elements.map((child, index) => {
return (
<Fragment key={index}>
{child}
{index < elements.length - 1 && <TextSeparator key={`divider-${index}`} />}
</Fragment>
);
});
};

View file

@ -0,0 +1,130 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Copy, Check } from "lucide-react"
import { useToast } from "@/components/hooks/use-toast"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { setInviteLinkEnabled } from "@/actions"
import { isServiceError } from "@/lib/utils"
interface InviteLinkToggleProps {
inviteLinkEnabled: boolean
inviteLink: string | null
}
export function InviteLinkToggle({ inviteLinkEnabled, inviteLink }: InviteLinkToggleProps) {
const [enabled, setEnabled] = useState(inviteLinkEnabled)
const [isLoading, setIsLoading] = useState(false)
const [copied, setCopied] = useState(false)
const { toast } = useToast()
const handleToggle = async (checked: boolean) => {
setIsLoading(true)
try {
const result = await setInviteLinkEnabled(SINGLE_TENANT_ORG_DOMAIN, checked)
if (isServiceError(result)) {
toast({
title: "Error",
description: "Failed to update invite link setting",
variant: "destructive",
})
return
}
setEnabled(checked)
} catch (error) {
console.error("Error updating invite link setting:", error)
toast({
title: "Error",
description: "Failed to update invite link setting",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const handleCopy = async () => {
if (!inviteLink) return
try {
await navigator.clipboard.writeText(inviteLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error("Failed to copy text: ", err)
toast({
title: "Error",
description: "Failed to copy invite link to clipboard",
variant: "destructive",
})
}
}
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">
Enable invite link
</h3>
<div className="max-w-2xl">
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
When enabled, team members can use the invite link to join your organization without requiring approval.
</p>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
</div>
<div className={`transition-all duration-300 ease-in-out ${
enabled
? 'max-h-96 opacity-100 transform translate-y-0 mt-4'
: 'max-h-0 opacity-0 transform -translate-y-2 overflow-hidden'
}`}>
<div className="space-y-4 pt-4 border-t border-[var(--border)]">
<div className="space-y-2">
<div className="flex gap-2">
<Input
value={inviteLink || "Failed to fetch invite link: org doesn't have inviteId property."}
readOnly
className={`flex-1 bg-[var(--muted)] border-[var(--border)] ${
inviteLink ? 'text-[var(--foreground)]' : 'text-red-500'
}`}
/>
<Button
onClick={handleCopy}
variant="outline"
size="icon"
className="shrink-0 border-[var(--border)] hover:bg-[var(--muted)]"
disabled={!inviteLink}
>
{copied ? (
<Check className="h-4 w-4 text-[var(--chart-2)]" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
You can find this link again in the <strong>Settings Members</strong> page.
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,55 @@
"use client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { useToast } from "@/components/hooks/use-toast";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { joinOrganization } from "../invite/actions";
import { isServiceError } from "@/lib/utils";
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string }) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { toast } = useToast();
const handleJoinOrganization = async () => {
setIsLoading(true);
try {
const result = await joinOrganization(SINGLE_TENANT_ORG_ID, inviteLinkId);
if (isServiceError(result)) {
toast({
title: "Failed to join organization",
description: result.message,
variant: "destructive",
});
return;
}
router.refresh();
} catch (error) {
console.error("Error joining organization:", error);
toast({
title: "Error",
description: "An unexpected error occurred. Please try again.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<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"
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Join Organization
</Button>
);
}

View file

@ -0,0 +1,23 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { JoinOrganizationButton } from "./joinOrganizationButton";
export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) {
return (
<div className="min-h-screen bg-gradient-to-br from-[var(--background)] to-[var(--accent)]/30 flex items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<SourcebotLogo className="h-12 mb-4 mx-auto" size="large" />
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center space-y-4">
<p className="text-[var(--muted-foreground)] text-[15px] leading-6">
Welcome to Sourcebot! Click the button below to join this organization.
</p>
</div>
<JoinOrganizationButton inviteLinkId={inviteLinkId} />
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,45 @@
'use client';
import { useState } from "react";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { LoadingButton } from "@/components/ui/loading-button";
interface ProviderButtonProps {
name: string;
logo: { src: string, className?: string } | null;
onClick: () => void | Promise<void>;
className?: string;
context: "login" | "signup";
}
export const ProviderButton = ({
name,
logo,
onClick,
className,
context,
}: ProviderButtonProps) => {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
return (
<LoadingButton
onClick={handleClick}
className={cn("w-full", className)}
variant="outline"
loading={isLoading}
>
{logo && <Image src={logo.src} alt={name} className={cn("w-5 h-5 mr-2", logo.className)} />}
{context === "login" ? `Sign in with ${name}` : `Sign up with ${name}`}
</LoadingButton>
);
};

View file

@ -0,0 +1,52 @@
"use server";
import { withAuth } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { orgNotFound, ServiceError } from "@/lib/serviceError";
import { sew } from "@/actions";
import { addUserToOrganization } from "@/lib/authUtils";
import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
export const joinOrganization = (orgId: number, inviteLinkId?: string) => sew(async () =>
withAuth(async (userId) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
return orgNotFound();
}
// If member approval is required we must be using a valid invite link
if (org.memberApprovalRequired) {
if (!org.inviteLinkEnabled) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVITE_LINK_NOT_ENABLED,
message: "Invite link is not enabled.",
} satisfies ServiceError;
}
if (org.inviteLinkId !== inviteLinkId) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_INVITE_LINK,
message: "Invalid invite link.",
} satisfies ServiceError;
}
}
const addUserToOrgRes = await addUserToOrganization(userId, org.id);
if (isServiceError(addUserToOrgRes)) {
return addUserToOrgRes;
}
return {
success: true,
}
})
)

View file

@ -0,0 +1,86 @@
import { auth } from "@/auth";
import { prisma } from "@/prisma";
import { getOrgFromDomain } from "@/data/org";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
import { notFound, redirect } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { getAuthProviders } from "@/lib/authProviders";
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
interface InvitePageProps {
searchParams: {
id?: string;
};
}
export default async function InvitePage({ searchParams }: InvitePageProps) {
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
}
const inviteLinkId = searchParams.id;
if (!org.inviteLinkEnabled || !inviteLinkId || org.inviteLinkId !== inviteLinkId) {
return notFound();
}
const session = await auth();
if (!session) {
const providers = getAuthProviders();
return <WelcomeCard inviteLinkId={inviteLinkId} providers={providers} />;
}
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: session.user.id
}
}
});
// If already a member, redirect to the organization
if (membership) {
redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`);
}
// User is logged in but not a member, show join invitation
return (
<div className="min-h-screen flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<JoinOrganizationCard inviteLinkId={inviteLinkId} />
</div>
);
}
function WelcomeCard({ inviteLinkId, providers }: { inviteLinkId: string; providers: import("@/lib/authProviders").AuthProvider[] }) {
return (
<div className="min-h-screen bg-gradient-to-br from-[var(--background)] to-[var(--accent)]/30 flex items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<SourcebotLogo className="h-12 mb-4 mx-auto" size="large" />
<CardTitle className="text-2xl font-semibold">
Welcome to Sourcebot
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center space-y-3">
<p className="text-[var(--muted-foreground)] text-[15px] leading-6">
You&apos;ve been invited to join this Sourcebot deployment. Sign up to get started.
</p>
</div>
<AuthMethodSelector
providers={providers}
callbackUrl={`/invite?id=${inviteLinkId}`}
context="signup"
securityNoticeClosable={true}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -14,9 +14,10 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
interface CredentialsFormProps { interface CredentialsFormProps {
callbackUrl?: string; callbackUrl?: string;
context: "login" | "signup";
} }
export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => { export const CredentialsForm = ({ callbackUrl, context }: CredentialsFormProps) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof verifyCredentialsRequestSchema>>({ const form = useForm<z.infer<typeof verifyCredentialsRequestSchema>>({
@ -80,7 +81,7 @@ export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => {
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""} {isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
Sign in with credentials {context === "login" ? "Sign in with credentials" : "Sign up with credentials"}
</Button> </Button>
</form> </form>
</Form> </Form>

View file

@ -1,19 +1,14 @@
'use client'; 'use client';
import Image from "next/image"; import { useMemo } from "react";
import { signIn } from "next-auth/react";
import { Fragment, useCallback, useMemo, useState } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { cn, getAuthProviderInfo } from "@/lib/utils";
import { MagicLinkForm } from "./magicLinkForm";
import { CredentialsForm } from "./credentialsForm";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { TextSeparator } from "@/app/components/textSeparator"; import { AuthMethodSelector } from "@/app/components/authMethodSelector";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import DemoCard from "@/app/[domain]/onboard/components/demoCard"; import DemoCard from "@/app/login/components/demoCard";
import Link from "next/link"; import Link from "next/link";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { LoadingButton } from "@/components/ui/loading-button"; import type { AuthProvider } from "@/lib/authProviders";
const TERMS_OF_SERVICE_URL = "https://sourcebot.dev/terms"; const TERMS_OF_SERVICE_URL = "https://sourcebot.dev/terms";
const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy"; const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
@ -21,17 +16,12 @@ const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
interface LoginFormProps { interface LoginFormProps {
callbackUrl?: string; callbackUrl?: string;
error?: string; error?: string;
providers: Array<{ id: string; name: string }>; providers: AuthProvider[];
context: "login" | "signup"; context: "login" | "signup";
} }
export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormProps) => { export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormProps) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const onSignInWithOauth = useCallback((provider: string) => {
signIn(provider, {
redirectTo: callbackUrl ?? "/"
});
}, [callbackUrl]);
const errorMessage = useMemo(() => { const errorMessage = useMemo(() => {
if (!error) { if (!error) {
@ -47,13 +37,6 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
} }
}, [error]); }, [error]);
// Separate OAuth providers from special auth methods
const oauthProviders = providers.filter(p =>
!["credentials", "nodemailer"].includes(p.id)
);
const hasCredentials = providers.some(p => p.id === "credentials");
const hasMagicLink = providers.some(p => p.id === "nodemailer");
// Helper function to get the correct analytics event name // Helper function to get the correct analytics event name
const getLoginEventName = (providerId: string) => { const getLoginEventName = (providerId: string) => {
switch (providerId) { switch (providerId) {
@ -74,6 +57,11 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
} }
}; };
// Analytics callback for provider clicks
const handleProviderClick = (providerId: string) => {
captureEvent(getLoginEventName(providerId), {});
};
return ( return (
<div className="flex flex-col items-center justify-center w-full"> <div className="flex flex-col items-center justify-center w-full">
<div className="mb-6 flex flex-col items-center"> <div className="mb-6 flex flex-col items-center">
@ -95,38 +83,17 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
{errorMessage} {errorMessage}
</div> </div>
)} )}
<DividerSet <AuthMethodSelector
elements={[ providers={providers}
...(oauthProviders.length > 0 ? [ callbackUrl={callbackUrl}
<div key="oauth-providers" className="w-full space-y-3"> context={context}
{oauthProviders.map((provider) => { onProviderClick={handleProviderClick}
const providerInfo = getAuthProviderInfo(provider.id); securityNoticeClosable={true}
return (
<ProviderButton
key={provider.id}
name={providerInfo.displayName}
logo={providerInfo.icon}
onClick={() => {
captureEvent(getLoginEventName(provider.id), {});
onSignInWithOauth(provider.id);
}}
/>
);
})}
</div>
] : []),
...(hasMagicLink ? [
<MagicLinkForm key="magic-link" callbackUrl={callbackUrl} />
] : []),
...(hasCredentials ? [
<CredentialsForm key="credentials" callbackUrl={callbackUrl} />
] : [])
]}
/> />
<p className="text-sm text-muted-foreground mt-8"> <p className="text-sm text-muted-foreground mt-8">
{context === "login" ? {context === "login" ?
<> <>
No account yet? <Link className="underline" href="/signup">Sign up</Link> Don&apos;t have an account? <Link className="underline" href="/signup">Sign up</Link>
</> </>
: :
<> <>
@ -141,43 +108,3 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
</div> </div>
) )
} }
const ProviderButton = ({
name,
logo,
onClick,
className,
}: {
name: string;
logo: { src: string, className?: string } | null;
onClick: () => void;
className?: string;
}) => {
const [isLoading, setIsLoading] = useState(false);
return (
<LoadingButton
onClick={() => {
setIsLoading(true);
onClick();
}}
className={cn("w-full", className)}
variant="outline"
loading={isLoading}
>
{logo && <Image src={logo.src} alt={name} className={cn("w-5 h-5 mr-2", logo.className)} />}
Sign in with {name}
</LoadingButton>
)
}
const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => {
return elements.map((child, index) => {
return (
<Fragment key={index}>
{child}
{index < elements.length - 1 && <TextSeparator key={`divider-${index}`} />}
</Fragment>
)
})
}

View file

@ -18,9 +18,10 @@ const magicLinkSchema = z.object({
interface MagicLinkFormProps { interface MagicLinkFormProps {
callbackUrl?: string; callbackUrl?: string;
context: "login" | "signup";
} }
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => { export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@ -76,7 +77,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""} {isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
Sign in with login code {context === "login" ? "Sign in with login code" : "Sign up with login code"}
</Button> </Button>
</form> </form>
</Form> </Form>

View file

@ -1,9 +1,11 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { LoginForm } from "./components/loginForm"; import { LoginForm } from "./components/loginForm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getProviders } from "@/auth";
import { Footer } from "@/app/components/footer"; import { Footer } from "@/app/components/footer";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { getAuthProviders } from "@/lib/authProviders";
import { getOrgFromDomain } from "@/data/org";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
const logger = createLogger('login-page'); const logger = createLogger('login-page');
@ -22,24 +24,19 @@ export default async function Login({ searchParams }: LoginProps) {
return redirect("/"); return redirect("/");
} }
const providers = getProviders(); const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
const providerData = providers if (!org || !org.isOnboarded) {
.map((provider) => { return redirect("/onboard");
if (typeof provider === "function") { }
const providerInfo = provider()
return { id: providerInfo.id, name: providerInfo.name }
} else {
return { id: provider.id, name: provider.name }
}
});
const providers = getAuthProviders();
return ( return (
<div className="flex flex-col min-h-screen bg-backgroundSecondary"> <div className="flex flex-col min-h-screen bg-backgroundSecondary">
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full"> <div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
<LoginForm <LoginForm
callbackUrl={searchParams.callbackUrl} callbackUrl={searchParams.callbackUrl}
error={searchParams.error} error={searchParams.error}
providers={providerData} providers={providers}
context="login" context="login"
/> />
</div> </div>

View file

@ -0,0 +1,53 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { completeOnboarding } from "@/actions"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { isServiceError } from "@/lib/utils"
import { useToast } from "@/components/hooks/use-toast"
export function CompleteOnboardingButton() {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const { toast } = useToast()
const handleCompleteOnboarding = async () => {
setIsLoading(true)
try {
const result = await completeOnboarding(SINGLE_TENANT_ORG_DOMAIN)
if (isServiceError(result)) {
toast({
title: "Error",
description: "Failed to complete onboarding. Please try again.",
variant: "destructive",
})
setIsLoading(false)
return
}
router.push("/")
} catch (error) {
console.error("Error completing onboarding:", error)
toast({
title: "Error",
description: "Failed to complete onboarding. Please try again.",
variant: "destructive",
})
setIsLoading(false)
}
}
return (
<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"
>
{isLoading ? "Completing..." : "Complete Onboarding →"}
</Button>
)
}

View file

@ -0,0 +1,90 @@
"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

@ -1,38 +0,0 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { OnboardingSteps } from "@/lib/constants";
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
interface OnboardHeaderProps {
title: string
description: string
step: OnboardingSteps
}
export const OnboardHeader = ({ title, description, step: currentStep }: OnboardHeaderProps) => {
const steps = Object.values(OnboardingSteps)
.filter(s => s !== OnboardingSteps.Complete)
.filter(s => !IS_BILLING_ENABLED ? s !== OnboardingSteps.Checkout : true);
return (
<div className="flex flex-col items-center text-center mb-10">
<SourcebotLogo
className="h-16 mb-2"
size="large"
/>
<h1 className="text-3xl font-bold mb-3">
{title}
</h1>
<p className="text-sm text-muted-foreground mb-5">
{description}
</p>
<div className="flex justify-center gap-2">
{steps.map((step, index) => (
<div
key={index}
className={`h-1.5 w-6 rounded-full transition-colors ${step === currentStep ? "bg-gray-400" : "bg-gray-200"}`}
/>
))}
</div>
</div>
)
}

View file

@ -1,122 +0,0 @@
"use client"
import { createOrg } from "../../../actions"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useState } from "react";
import { isServiceError } from "@/lib/utils"
import { Loader2 } from "lucide-react"
import { useToast } from "@/components/hooks/use-toast"
import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card"
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { orgNameSchema, orgDomainSchema } from "@/lib/schemas"
interface OrgCreateFormProps {
rootDomain: string;
}
export function OrgCreateForm({ rootDomain }: OrgCreateFormProps) {
const { toast } = useToast();
const router = useRouter();
const captureEvent = useCaptureEvent();
const [isLoading, setIsLoading] = useState(false);
const onboardingFormSchema = z.object({
name: orgNameSchema,
domain: orgDomainSchema,
})
const form = useForm<z.infer<typeof onboardingFormSchema>>({
resolver: zodResolver(onboardingFormSchema),
defaultValues: {
name: "",
domain: "",
}
});
const onSubmit = useCallback(async (data: z.infer<typeof onboardingFormSchema>) => {
setIsLoading(true);
const response = await createOrg(data.name, data.domain);
if (isServiceError(response)) {
toast({
description: `❌ Failed to create organization. Reason: ${response.message}`
})
captureEvent('wa_onboard_org_create_fail', {
error: response.errorCode,
})
setIsLoading(false);
} else {
router.push(`/${data.domain}/onboard`);
captureEvent('wa_onboard_org_create_success', {});
// @note: we don't want to set isLoading to false here since we want to show the loading
// spinner until the page is redirected.
}
}, [router, toast, captureEvent]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value
const domain = name.toLowerCase().replace(/[^a-zA-Z\s]/g, "").replace(/\s+/g, "-")
form.setValue("domain", domain)
}
return (
<Card className="flex flex-col border p-8 bg-background w-full max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-10">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Organization Name</FormLabel>
<FormDescription>{`Your organization's visible name within Sourcebot. For example, the name of your company or department.`}</FormDescription>
<FormControl>
<Input
placeholder="Aperture Labs"
{...field}
autoFocus
onChange={(e) => {
field.onChange(e)
handleNameChange(e)
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Organization URL</FormLabel>
<FormDescription>{`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`}</FormDescription>
<FormControl>
<div className="flex items-center w-full">
<div className="flex-shrink-0 text-sm text-muted-foreground bg-backgroundSecondary rounded-md rounded-r-none border border-r-0 px-3 py-[9px]">{rootDomain}/</div>
<Input
placeholder="aperture-labs"
{...field}
className="flex-1 rounded-l-none"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button variant="default" className="w-full" type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Create
</Button>
</form>
</Form>
</Card>
)
}

View file

@ -1,28 +1,410 @@
import { OrgCreateForm } from "./components/orgCreateForm"; import type React from "react"
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { OnboardHeader } from "./components/onboardHeader";
import { OnboardingSteps } from "@/lib/constants";
import { LogoutEscapeHatch } from "../components/logoutEscapeHatch";
import { headers } from "next/headers";
export default async function Onboarding() { import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
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 { CompleteOnboardingButton } from "./components/completeOnboardingButton";
import { getOrgFromDomain } from "@/data/org";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
import { prisma } from "@/prisma";
import { OrgRole } from "@sourcebot/db";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { redirect } from "next/navigation";
import { BetweenHorizonalStart, 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 };
}
interface OnboardingStep {
id: string
title: string
subtitle: React.ReactNode
component: React.ReactNode
}
interface ResourceCard {
id: string
title: string
description: string
href: string
icon?: React.ReactNode
}
export default async function Onboarding({ searchParams }: OnboardingProps) {
const providers = getAuthProviders();
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
const session = await auth(); const session = await auth();
if (!session) {
redirect("/login"); if (!org) {
return <div>Error loading organization</div>;
} }
const host = (await headers()).get('host') ?? ''; // 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('/');
}
// Check if user is authenticated but not the owner
if (session?.user) {
if (org) {
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: session.user.id
}
}
});
if (!membership || membership.role !== OrgRole.OWNER) {
return <NonOwnerOnboardingMessage />;
}
}
}
// If we're using an IAP bridge we need to sign them in now and then redirect them back to the onboarding page
const ssoEntitlement = await hasEntitlement("sso");
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
return <GcpIapAuth callbackUrl={`/onboard`} />;
}
// Determine current step based on URL parameter and authentication state
const stepParam = searchParams?.step ? parseInt(searchParams.step) : 0;
const currentStep = session?.user ? Math.max(2, stepParam) : Math.max(0, Math.min(stepParam, 1));
const resourceCards: ResourceCard[] = [
{
id: "code-host-connections",
title: "Code Host Connections",
description: "Learn how to index repos across Sourcebot's supported platforms",
href: "https://docs.sourcebot.dev/docs/connections/overview",
icon: <GitBranchIcon className="w-4 h-4" />,
},
{
id: "authentication-system",
title: "Authentication System",
description: "Learn how to setup additional auth providers, invite members, and more",
href: "https://docs.sourcebot.dev/docs/configuration/auth",
icon: <LockIcon className="w-4 h-4" />,
},
{
id: "mcp-server",
title: "MCP Server",
description: "Learn how to setup Sourcebot's MCP server to provide code context to your AI agents",
href: "https://docs.sourcebot.dev/docs/features/mcp-server",
icon: <BetweenHorizonalStart className="w-4 h-4" />,
}
]
const steps: OnboardingStep[] = [
{
id: "welcome",
title: "Welcome to Sourcebot",
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">
<a href="/onboard?step=1">Get Started </a>
</Button>
</div>
),
},
{
id: "owner-signup",
title: "Create Owner Account",
subtitle: (
<>
Use your preferred authentication method to create your owner account. To set up additional authentication providers, check out our{" "}
<a
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"
>
documentation
</a>.
</>
),
component: (
<div className="space-y-6">
<AuthMethodSelector
providers={providers}
callbackUrl="/onboard"
context="signup"
securityNoticeClosable={false}
/>
</div>
),
},
{
id: "configure-org",
title: "Configure Your Organization",
subtitle: "Set up your organization's security settings.",
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">
<a href="/onboard?step=3">Continue </a>
</Button>
</div>
),
},
{
id: "complete",
title: "You're All Set!",
subtitle: (
<>
Your Sourcebot deployment is ready. Check out these resources to learn how to get the most out of Sourcebot.
<div className="text-center space-y-4 mt-6">
<div className="w-16 h-16 mx-auto bg-primary rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</>
),
component: (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-3">
{resourceCards.map((resourceCard) => (
<a
key={resourceCard.id}
href={resourceCard.href}
target="_blank"
rel="noopener"
className="p-4 rounded-lg bg-accent hover:bg-accent/80 border border-border hover:border-primary/20 transition-all duration-200 group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
{resourceCard.icon && (
<div className="text-primary">
{resourceCard.icon}
</div>
)}
</div>
<div className="flex-1 text-left">
<div className="font-medium text-foreground text-sm group-hover:text-primary transition-colors">
{resourceCard.title}
</div>
<div className="text-muted-foreground text-xs mt-1 leading-4">
{resourceCard.description}
</div>
</div>
<svg className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</a>
))}
</div>
<CompleteOnboardingButton />
</div>
),
},
]
const currentStepData = steps[currentStep]
return ( return (
<div className="flex flex-col items-center min-h-screen py-12 px-4 sm:px-12 bg-backgroundSecondary relative"> <div className="min-h-screen bg-background flex items-center justify-center p-6">
<OnboardHeader <div className="w-full max-w-6xl mx-auto">
title="Setup your organization" <div className="overflow-hidden bg-background">
description="Create a organization for your team to search and share code across your repositories." <div className="flex min-h-[700px]">
step={OnboardingSteps.CreateOrg} {/* Left Panel - Progress & Branding */}
/> <div className="w-2/5 bg-background p-10 border-r border-border">
<OrgCreateForm rootDomain={host} /> <div className="h-full flex flex-col">
<LogoutEscapeHatch className="absolute top-0 right-0 p-4 sm:p-12" /> <div className="flex-1">
<div className="mb-16">
<SourcebotLogo
className="w-full h-auto mb-12"
size="large"
/>
</div>
{/* Step Progress Indicators */}
<div className="space-y-8">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center group">
<div className="flex items-center space-x-4 flex-1">
<div className="relative">
{/* Connecting line */}
{index < steps.length - 1 && (
<div
className={`absolute top-10 left-1/2 transform -translate-x-1/2 w-0.5 h-8 transition-all duration-300 ${
index < currentStep ? "bg-primary" : "bg-border"
}`}
/>
)}
{/* Circle - positioned above the line with z-index */}
<div
className={`relative z-10 w-10 h-10 rounded-full border-2 flex items-center justify-center font-semibold text-sm transition-all duration-300 ${
index < currentStep
? "bg-primary border-primary text-primary-foreground"
: index === currentStep
? "bg-primary border-primary text-primary-foreground scale-110 shadow-lg"
: "bg-background border-border text-muted-foreground"
}`}
>
{index < currentStep ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : (
<span>{index + 1}</span>
)}
</div>
</div>
<div className="flex-1">
<div className={`font-medium text-sm transition-all duration-200 ${
index <= currentStep ? "text-foreground" : "text-muted-foreground"
}`}>
{step.title}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div className="pt-8 border-t border-border">
<p className="text-xs text-muted-foreground leading-5">
Need help? Check out our{" "}
<a
href="https://docs.sourcebot.dev/docs/overview"
className="text-primary hover:underline font-medium transition-colors"
target="_blank"
rel="noopener"
>
documentation
</a>{" "}
or{" "}
<a
href="https://github.com/sourcebot-dev/sourcebot/discussions"
className="text-primary hover:underline font-medium transition-colors"
target="_blank"
rel="noopener"
>
reach out
</a>
.
</p>
</div>
</div>
</div>
{/* Right Panel - Content */}
<div className="w-3/5 bg-background p-10">
<div className="h-full flex flex-col justify-center max-w-lg mx-auto">
<div className="space-y-8">
{/* Step Header */}
<div className="space-y-6">
<div className="flex items-center space-x-3">
<div className="text-sm font-medium text-muted-foreground">
Step {currentStep + 1} of {steps.length}
</div>
<div className="flex-1 h-px bg-border"></div>
</div>
<div className="space-y-3">
<h1 className="text-3xl font-bold text-foreground leading-tight">
{currentStepData.title}
</h1>
<div className="text-muted-foreground text-base leading-relaxed">
{currentStepData.subtitle}
</div>
</div>
</div>
{/* Step Content */}
<div className="transition-all duration-300 ease-out">
{currentStepData.component}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
function NonOwnerOnboardingMessage() {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<div className="w-full max-w-md mx-auto">
<Card className="overflow-hidden shadow-lg border border-border bg-card">
<CardContent className="p-8">
<div className="text-center space-y-6">
<div className="w-16 h-16 mx-auto bg-muted rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-foreground">
Onboarding In Progress
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
Your Sourcebot deployment is being configured by the organization owner.
</p>
</div>
<div className="p-4 rounded-lg bg-accent/50 border border-border">
<div className="flex items-start gap-3">
<div className="w-5 h-5 mt-0.5 flex-shrink-0">
<svg className="w-full h-full text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</div>
<div className="text-left">
<p className="text-sm font-medium text-foreground mb-1">
Owner Access Required
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
Only the organization owner can complete the initial setup and configuration. Once onboarding is complete, you&apos;ll be able to access Sourcebot.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<div className="text-xs text-muted-foreground leading-relaxed">
Need help? Contact your organization owner or check out our{" "}
<a
href="https://docs.sourcebot.dev/docs/overview"
className="text-primary hover:text-primary/80 underline transition-colors"
target="_blank"
rel="noopener"
>
documentation
</a>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div> </div>
); );
} }

View file

@ -1,35 +1,19 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/prisma";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
import { getOrgFromDomain } from "@/data/org";
export default async function Page() { export default async function Page() {
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
}
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
return redirect("/login"); return redirect("/login");
} }
const firstOrg = await prisma.userToOrg.findFirst({ return redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`);
where: {
userId: session.user.id,
org: {
members: {
some: {
userId: session.user.id,
}
}
}
},
include: {
org: true
},
orderBy: {
joinedAt: "asc"
}
});
if (!firstOrg) {
return redirect("/onboard");
}
return redirect(`/${firstOrg.org.domain}`);
} }

View file

@ -5,6 +5,8 @@ import { isServiceError } from "@/lib/utils";
import { AcceptInviteCard } from './components/acceptInviteCard'; import { AcceptInviteCard } from './components/acceptInviteCard';
import { LogoutEscapeHatch } from '../components/logoutEscapeHatch'; import { LogoutEscapeHatch } from '../components/logoutEscapeHatch';
import { InviteNotFoundCard } from './components/inviteNotFoundCard'; import { InviteNotFoundCard } from './components/inviteNotFoundCard';
import { getOrgFromDomain } from '@/data/org';
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
interface RedeemPageProps { interface RedeemPageProps {
searchParams: { searchParams: {
@ -13,6 +15,11 @@ interface RedeemPageProps {
} }
export default async function RedeemPage({ searchParams }: RedeemPageProps) { export default async function RedeemPage({ searchParams }: RedeemPageProps) {
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
}
const inviteId = searchParams.invite_id; const inviteId = searchParams.invite_id;
if (!inviteId) { if (!inviteId) {
return notFound(); return notFound();

View file

@ -1,9 +1,11 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { LoginForm } from "../login/components/loginForm"; import { LoginForm } from "../login/components/loginForm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getProviders } from "@/auth";
import { Footer } from "@/app/components/footer"; import { Footer } from "@/app/components/footer";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { getAuthProviders } from "@/lib/authProviders";
import { getOrgFromDomain } from "@/data/org";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
const logger = createLogger('signup-page'); const logger = createLogger('signup-page');
@ -21,24 +23,19 @@ export default async function Signup({ searchParams }: LoginProps) {
return redirect("/"); return redirect("/");
} }
const providers = getProviders(); const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
const providerData = providers if (!org || !org.isOnboarded) {
.map((provider) => { return redirect("/onboard");
if (typeof provider === "function") { }
const providerInfo = provider()
return { id: providerInfo.id, name: providerInfo.name }
} else {
return { id: provider.id, name: provider.name }
}
});
const providers = getAuthProviders();
return ( return (
<div className="flex flex-col min-h-screen bg-backgroundSecondary"> <div className="flex flex-col min-h-screen bg-backgroundSecondary">
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full"> <div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
<LoginForm <LoginForm
callbackUrl={searchParams.callbackUrl} callbackUrl={searchParams.callbackUrl}
error={searchParams.error} error={searchParams.error}
providers={providerData} providers={providers}
context="signup" context="signup"
/> />
</div> </div>

View file

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 bg-muted border border-input", "peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=unchecked]:bg-muted data-[state=checked]:bg-[var(--chart-2)] border border-input data-[state=checked]:border-[var(--chart-2)]",
className className
)} )}
{...props} {...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-primary dark:bg-primary shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>

View file

@ -2,11 +2,18 @@ import 'server-only';
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
export const getOrgFromDomain = async (domain: string) => { export const getOrgFromDomain = async (domain: string) => {
const org = await prisma.org.findUnique({ try {
where: { const org = await prisma.org.findUnique({
domain: domain where: {
} domain: domain
}); }
});
return org; return org;
} catch (error) {
// During build time we won't be able to access the database, so we catch and return null in this case
// so that we can statically build pages that hit the DB (ex. to check if the org is onboarded)
console.error('Error fetching org from domain:', error);
return null;
}
} }

View file

@ -105,8 +105,7 @@ export const createGuestUser = async (domain: string): Promise<ServiceError | bo
create: { create: {
id: SOURCEBOT_GUEST_USER_ID, id: SOURCEBOT_GUEST_USER_ID,
name: "Guest", name: "Guest",
email: SOURCEBOT_GUEST_USER_EMAIL, email: SOURCEBOT_GUEST_USER_EMAIL
pendingApproval: false,
}, },
}); });

View file

@ -7,13 +7,7 @@ import Keycloak from "next-auth/providers/keycloak";
import Gitlab from "next-auth/providers/gitlab"; import Gitlab from "next-auth/providers/gitlab";
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { notFound, ServiceError } from "@/lib/serviceError";
import { OrgRole } from "@sourcebot/db";
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
import { OAuth2Client } from "google-auth-library"; import { OAuth2Client } from "google-auth-library";
import { sew } from "@/actions";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
import type { User as AuthJsUser } from "next-auth"; import type { User as AuthJsUser } from "next-auth";
import { onCreateUser } from "@/lib/authUtils"; import { onCreateUser } from "@/lib/authUtils";
@ -172,79 +166,4 @@ export const getSSOProviders = (): Provider[] => {
} }
return providers; return providers;
} }
export const handleJITProvisioning = async (userId: string, domain: string): Promise<ServiceError | boolean> => sew(async () => {
const org = await prisma.org.findUnique({
where: {
domain,
},
include: {
members: {
where: {
role: {
not: OrgRole.GUEST,
}
}
}
}
});
if (!org) {
return notFound(`Org ${domain} not found`);
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
return notFound(`User ${userId} not found`);
}
const userToOrg = await prisma.userToOrg.findFirst({
where: {
userId,
orgId: org.id,
}
});
if (userToOrg) {
logger.warn(`JIT provisioning skipped for user ${userId} since they're already a member of org ${domain}`);
return true;
}
const seats = getSeats();
const memberCount = org.members.length;
if (seats != SOURCEBOT_UNLIMITED_SEATS && memberCount >= seats) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
message: "Failed to provision user since the organization is at max capacity",
} satisfies ServiceError;
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: {
id: userId,
},
data: {
pendingApproval: false,
},
});
await tx.userToOrg.create({
data: {
userId,
orgId: org.id,
role: OrgRole.MEMBER,
},
});
});
return true;
});

View file

@ -26,8 +26,6 @@ export const env = createEnv({
AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'), AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'),
// Enterprise Auth // Enterprise Auth
AUTH_EE_ENABLE_JIT_PROVISIONING: booleanSchema.default('false'),
AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(), AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(), AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(),
AUTH_EE_GITHUB_BASE_URL: z.string().optional(), AUTH_EE_GITHUB_BASE_URL: z.string().optional(),

View file

@ -114,7 +114,7 @@ const syncDeclarativeConfig = async (configPath: string) => {
if (hasPublicAccessEntitlement) { if (hasPublicAccessEntitlement) {
if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') { 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 or disable public access.`); 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); process.exit(1);
} }
@ -159,15 +159,32 @@ const pruneOldGuestUser = async () => {
} }
const initSingleTenancy = async () => { const initSingleTenancy = async () => {
await prisma.org.upsert({ // Back fill the inviteId if the org has already been created to prevent needing to wipe the db
where: { await prisma.$transaction(async (tx) => {
id: SINGLE_TENANT_ORG_ID, const org = await tx.org.findUnique({
}, where: {
update: {}, id: SINGLE_TENANT_ORG_ID,
create: { },
name: SINGLE_TENANT_ORG_NAME, });
domain: SINGLE_TENANT_ORG_DOMAIN,
id: SINGLE_TENANT_ORG_ID if (!org) {
await tx.org.create({
data: {
id: SINGLE_TENANT_ORG_ID,
name: SINGLE_TENANT_ORG_NAME,
domain: SINGLE_TENANT_ORG_DOMAIN,
inviteLinkId: crypto.randomUUID(),
}
});
} else if (!org.inviteLinkId) {
await tx.org.update({
where: {
id: SINGLE_TENANT_ORG_ID,
},
data: {
inviteLinkId: crypto.randomUUID(),
}
});
} }
}); });
@ -186,17 +203,6 @@ const initSingleTenancy = async () => {
// Load any connections defined declaratively in the config file. // Load any connections defined declaratively in the config file.
const configPath = env.CONFIG_PATH; const configPath = env.CONFIG_PATH;
if (configPath) { if (configPath) {
// If we're given a config file, mark the org as onboarded so we don't go through
// the UI connection onboarding flow
await prisma.org.update({
where: {
id: SINGLE_TENANT_ORG_ID,
},
data: {
isOnboarded: true,
}
});
await syncDeclarativeConfig(configPath); await syncDeclarativeConfig(configPath);
// watch for changes assuming it is a local file // watch for changes assuming it is a local file

View file

@ -0,0 +1,18 @@
import { getProviders } from "@/auth";
export interface AuthProvider {
id: string;
name: string;
}
export const getAuthProviders = (): AuthProvider[] => {
const providers = getProviders();
return providers.map((provider) => {
if (typeof provider === "function") {
const providerInfo = provider();
return { id: providerInfo.id, name: providerInfo.name };
} else {
return { id: provider.id, name: provider.name };
}
});
};

View file

@ -1,15 +1,16 @@
import type { User as AuthJsUser } from "next-auth"; import type { User as AuthJsUser } from "next-auth";
import { env } from "@/env.mjs";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { hasEntitlement } from "@sourcebot/shared"; import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError"; import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
import { createAccountRequest } from "@/actions";
import { handleJITProvisioning } from "@/ee/features/sso/sso";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { getAuditService } from "@/ee/features/audit/factory"; import { getAuditService } from "@/ee/features/audit/factory";
import { 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";
const logger = createLogger('web-auth-utils'); const logger = createLogger('web-auth-utils');
const auditService = getAuditService(); const auditService = getAuditService();
@ -27,7 +28,7 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
id: "undefined", id: "undefined",
type: "user" type: "user"
}, },
orgId: SINGLE_TENANT_ORG_ID, // TODO(mt) orgId: SINGLE_TENANT_ORG_ID,
metadata: { metadata: {
message: "User ID is undefined on user creation" message: "User ID is undefined on user creation"
} }
@ -35,158 +36,216 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
throw new Error("User ID is undefined on user creation"); throw new Error("User ID is undefined on user creation");
} }
// In single-tenant mode, we assign the first user to sign const defaultOrg = await prisma.org.findUnique({
// up as the owner of the default org. where: {
if (env.SOURCEBOT_TENANCY_MODE === 'single') { id: SINGLE_TENANT_ORG_ID,
const defaultOrg = await prisma.org.findUnique({ },
where: { include: {
id: SINGLE_TENANT_ORG_ID, members: {
}, where: {
include: { role: {
members: { not: OrgRole.GUEST,
where: {
role: {
not: OrgRole.GUEST,
}
} }
}, }
},
}
});
// We expect the default org to have been created on app initialization
if (defaultOrg === null) {
await auditService.createAudit({
action: "user.creation_failed",
actor: {
id: user.id,
type: "user"
},
target: {
id: user.id,
type: "user"
},
orgId: SINGLE_TENANT_ORG_ID,
metadata: {
message: "Default org not found on single tenant user creation"
} }
}); });
throw new Error("Default org not found on single tenant user creation");
}
if (defaultOrg === null) { // If this is the first user to sign up, we make them the owner of the default org.
await auditService.createAudit({ const isFirstUser = defaultOrg.members.length === 0;
action: "user.creation_failed", if (isFirstUser) {
actor: { await prisma.$transaction(async (tx) => {
id: user.id, await tx.org.update({
type: "user" where: {
id: SINGLE_TENANT_ORG_ID,
}, },
target: { data: {
id: user.id, members: {
type: "user" create: {
}, role: OrgRole.OWNER,
orgId: SINGLE_TENANT_ORG_ID, user: {
metadata: { connect: {
message: "Default org not found on single tenant user creation" id: user.id,
}
});
throw new Error("Default org not found on single tenant user creation");
}
// Only the first user to sign up will be an owner of the default org.
const isFirstUser = defaultOrg.members.length === 0;
if (isFirstUser) {
await prisma.$transaction(async (tx) => {
await tx.org.update({
where: {
id: SINGLE_TENANT_ORG_ID,
},
data: {
members: {
create: {
role: OrgRole.OWNER,
user: {
connect: {
id: user.id,
}
} }
} }
} }
} }
}); }
await tx.user.update({
where: {
id: user.id,
},
data: {
pendingApproval: false,
}
});
}); });
});
await auditService.createAudit({ await auditService.createAudit({
action: "user.owner_created", action: "user.owner_created",
actor: { actor: {
id: user.id, id: user.id,
type: "user" type: "user"
}, },
orgId: SINGLE_TENANT_ORG_ID,
target: {
id: SINGLE_TENANT_ORG_ID.toString(),
type: "org"
}
});
} else if (!defaultOrg.memberApprovalRequired) {
const hasAvailability = await orgHasAvailability(defaultOrg.domain);
if (!hasAvailability) {
logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`);
return;
}
await prisma.userToOrg.create({
data: {
userId: user.id,
orgId: SINGLE_TENANT_ORG_ID, orgId: SINGLE_TENANT_ORG_ID,
target: { role: OrgRole.MEMBER,
id: SINGLE_TENANT_ORG_ID.toString(), }
type: "org" });
} }
});
} else {
// TODO(auth): handle multi tenant case
if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) {
const res = await handleJITProvisioning(user.id, SINGLE_TENANT_ORG_DOMAIN);
if (isServiceError(res)) {
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
await auditService.createAudit({
action: "user.jit_provisioning_failed",
actor: {
id: user.id,
type: "user"
},
target: {
id: SINGLE_TENANT_ORG_ID.toString(),
type: "org"
},
orgId: SINGLE_TENANT_ORG_ID,
metadata: {
message: `Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`
}
});
throw new ServiceErrorException(res);
}
await auditService.createAudit({ };
action: "user.jit_provisioned",
actor: {
id: user.id,
type: "user"
},
target: {
id: SINGLE_TENANT_ORG_ID.toString(),
type: "org"
},
orgId: SINGLE_TENANT_ORG_ID,
});
} else {
const res = await createAccountRequest(user.id, SINGLE_TENANT_ORG_DOMAIN);
if (isServiceError(res)) {
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
await auditService.createAudit({
action: "user.join_request_creation_failed",
actor: {
id: user.id,
type: "user"
},
target: {
id: SINGLE_TENANT_ORG_ID.toString(),
type: "org"
},
orgId: SINGLE_TENANT_ORG_ID,
metadata: {
message: res.message
}
});
throw new ServiceErrorException(res);
}
await auditService.createAudit({ export const orgHasAvailability = async (domain: string): Promise<boolean> => {
action: "user.join_requested", const org = await prisma.org.findUnique({
actor: { where: {
id: user.id, domain,
type: "user" },
}, });
orgId: SINGLE_TENANT_ORG_ID,
target: { if (!org) {
id: SINGLE_TENANT_ORG_ID.toString(), logger.error(`orgHasAvailability: org not found for domain ${domain}`);
type: "org" return false;
}, }
}); const members = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
role: {
not: OrgRole.GUEST,
},
},
});
const maxSeats = getSeats();
const memberCount = members.length;
if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) {
logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`);
return false;
}
return true;
}
export const addUserToOrganization = async (userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
logger.error(`addUserToOrganization: user not found for id ${userId}`);
return userNotFound();
}
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
logger.error(`addUserToOrganization: org not found for id ${orgId}`);
return orgNotFound();
}
const hasAvailability = await orgHasAvailability(org.domain);
if (!hasAvailability) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
message: "Organization is at max capacity",
} satisfies ServiceError;
}
const res = await prisma.$transaction(async (tx) => {
await tx.userToOrg.create({
data: {
userId: user.id,
orgId: org.id,
role: OrgRole.MEMBER,
}
});
if (IS_BILLING_ENABLED) {
const result = await incrementOrgSeatCount(orgId, tx);
if (isServiceError(result)) {
throw result;
} }
} }
// Delete the account request if it exists since we've added the user to the org
const accountRequest = await tx.accountRequest.findUnique({
where: {
requestedById_orgId: {
requestedById: user.id,
orgId: orgId,
}
},
});
if (accountRequest) {
logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've been added to the org`);
await tx.accountRequest.delete({
where: {
id: accountRequest.id,
}
});
}
// Delete any invites that may exist for this user since we've added them to the org
const invites = await tx.invite.findMany({
where: {
recipientEmail: user.email!,
orgId: org.id,
},
})
for (const invite of invites) {
logger.info(`Deleting invite ${invite.id} for ${user.email} since they've been added to the org`);
await tx.invite.delete({
where: {
id: invite.id,
},
});
}
});
if (isServiceError(res)) {
logger.error(`addUserToOrganization: failed to add user ${userId} to org ${orgId}: ${res.message}`);
return res;
} }
};
return {
success: true,
}
};

View file

@ -7,16 +7,19 @@ export enum ErrorCode {
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY', INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
NOT_AUTHENTICATED = 'NOT_AUTHENTICATED', NOT_AUTHENTICATED = 'NOT_AUTHENTICATED',
NOT_FOUND = 'NOT_FOUND', NOT_FOUND = 'NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
ORG_NOT_FOUND = 'ORG_NOT_FOUND',
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED', CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS', ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION', ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND',
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS', INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED', CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS', CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS',
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG', OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
INVALID_INVITE = 'INVALID_INVITE', INVALID_INVITE = 'INVALID_INVITE',
INVALID_INVITE_LINK = 'INVALID_INVITE_LINK',
INVITE_LINK_NOT_ENABLED = 'INVITE_LINK_NOT_ENABLED',
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR', STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS', SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',

View file

@ -1,6 +1,12 @@
import { NewsItem } from "./types"; import { NewsItem } from "./types";
export const newsData: NewsItem[] = [ export const newsData: NewsItem[] = [
{
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"
},
{ {
unique_id: "analytics", unique_id: "analytics",
header: "Analytics Dashboard", header: "Analytics Dashboard",
@ -9,25 +15,25 @@ export const newsData: NewsItem[] = [
}, },
{ {
unique_id: "audit-logs", unique_id: "audit-logs",
header: "Audit logs", header: "Audit Logs",
sub_header: "We've added support for audit logs", sub_header: "We've added support for audit logs",
url: "https://docs.sourcebot.dev/docs/configuration/audit-logs" url: "https://docs.sourcebot.dev/docs/configuration/audit-logs"
}, },
{ {
unique_id: "file-explorer", unique_id: "file-explorer",
header: "File explorer", header: "File Explorer",
sub_header: "We've added support for a file explorer when browsing files.", sub_header: "We've added support for a file explorer when browsing files.",
url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0" url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0"
}, },
{ {
unique_id: "structured-logging", unique_id: "structured-logging",
header: "Structured logging", header: "Structured Logging",
sub_header: "We've added support for structured logging", sub_header: "We've added support for structured logging",
url: "https://docs.sourcebot.dev/docs/configuration/structured-logging" url: "https://docs.sourcebot.dev/docs/configuration/structured-logging"
}, },
{ {
unique_id: "code-nav", unique_id: "code-nav",
header: "Code navigation", header: "Code Navigation",
sub_header: "Built in go-to definition and find references", sub_header: "Built in go-to definition and find references",
url: "https://docs.sourcebot.dev/docs/features/code-navigation" url: "https://docs.sourcebot.dev/docs/features/code-navigation"
}, },
@ -39,7 +45,7 @@ export const newsData: NewsItem[] = [
}, },
{ {
unique_id: "search-contexts", unique_id: "search-contexts",
header: "Search contexts", header: "Search Contexts",
sub_header: "Filter searches by groups of repos", sub_header: "Filter searches by groups of repos",
url: "https://docs.sourcebot.dev/docs/features/search/search-contexts" url: "https://docs.sourcebot.dev/docs/features/search/search-contexts"
} }

View file

@ -96,6 +96,22 @@ export const notFound = (message?: string): ServiceError => {
} }
} }
export const userNotFound = (): ServiceError => {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.USER_NOT_FOUND,
message: "User not found",
}
}
export const orgNotFound = (): ServiceError => {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.ORG_NOT_FOUND,
message: "Organization not found",
}
}
export const orgDomainExists = (): ServiceError => { export const orgDomainExists = (): ServiceError => {
return { return {
statusCode: StatusCodes.CONFLICT, statusCode: StatusCodes.CONFLICT,

View file

@ -19,6 +19,27 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
/**
* Gets the base URL from Next.js headers
* @param headersList The headers from Next.js headers() function
* @returns The base URL (e.g., "https://example.com")
*/
export const getBaseUrl = (headersList: Headers): string => {
const host = headersList.get('host') || 'localhost:3000';
const protocol = headersList.get('x-forwarded-proto') || 'http';
return `${protocol}://${host}`;
}
/**
* Creates an invite link URL from the base URL and invite ID
* @param baseUrl The base URL of the application
* @param inviteLinkId The invite link ID
* @returns The complete invite link URL or null if no inviteLinkId
*/
export const createInviteLink = (baseUrl: string, inviteLinkId?: string | null): string | null => {
return inviteLinkId ? `${baseUrl}/invite?id=${inviteLinkId}` : null;
}
/** /**
* Adds a list of (potentially undefined) query parameters to a path. * Adds a list of (potentially undefined) query parameters to a path.
* *
@ -119,7 +140,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => {
id: "microsoft-entra-id", id: "microsoft-entra-id",
name: "Microsoft Entra ID", name: "Microsoft Entra ID",
displayName: "Microsoft Entra ID", displayName: "Microsoft Entra ID",
icon: { icon: {
src: microsoftLogo, src: microsoftLogo,
}, },
}; };

View file

@ -13,7 +13,9 @@ export async function middleware(request: NextRequest) {
if ( if (
url.pathname.startsWith('/login') || url.pathname.startsWith('/login') ||
url.pathname.startsWith('/redeem') || url.pathname.startsWith('/redeem') ||
url.pathname.startsWith('/signup') url.pathname.startsWith('/signup') ||
url.pathname.startsWith('/invite') ||
url.pathname.startsWith('/onboard')
) { ) {
return NextResponse.next(); return NextResponse.next();
} }