Merge branch 'sourcebot-dev:main' into main

This commit is contained in:
Andre Nogueira 2025-07-19 19:52:45 +01:00 committed by GitHub
commit 14fac64c14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 2481 additions and 1825 deletions

View file

@ -0,0 +1,17 @@
name: Changelog Reminder
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
remind:
name: Changelog Reminder
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.draft }}
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: mskelton/changelog-reminder-action@v3

View file

@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Fixed typos in UI, docs, code [#369](https://github.com/sourcebot-dev/sourcebot/pull/369)
## [4.5.1] - 2025-07-14
### Changed
- Revamped onboarding experience. [#376](https://github.com/sourcebot-dev/sourcebot/pull/376)
### Fixed
- Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364)
- 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)
## [4.5.0] - 2025-06-21

View file

@ -38,7 +38,7 @@
6. Create a copy of `.env.development` and name it `.env.development.local`. Update the required environment variables.
7. If you're using a declerative configuration file, create a configuration file and update the `CONFIG_PATH` environment variable in your `.env.development.local` file.
7. If you're using a declarative configuration file, create a configuration file and update the `CONFIG_PATH` environment variable in your `.env.development.local` file.
8. Start Sourcebot with the command:
```sh

View file

@ -10,7 +10,7 @@ yarn:
zoekt:
mkdir -p bin
go build -C vendor/zoekt -o $(PWD)/bin ./cmd/...
export PATH=$(PWD)/bin:$(PATH)
export PATH="$(PWD)/bin:$(PATH)"
export CTAGS_COMMANDS=ctags
clean:

6
_typos.toml Normal file
View file

@ -0,0 +1,6 @@
[default.extend-words]
# Don't correct the surname "Do Not Exists"
dne = "dne"
[files]
extend-exclude = ["vendor/**/*", "CHANGELOG.md", "packages/web/src/lib/languageMetadata.ts"]

View file

@ -52,7 +52,7 @@
"group": "Configuration",
"pages": [
{
"group": "Connecting your code",
"group": "Indexing your code",
"pages": [
"docs/connections/overview",
"docs/connections/github",
@ -72,7 +72,10 @@
"group": "Authentication",
"pages": [
"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",

View file

@ -125,7 +125,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.join_requested` | `user` | `org` |
| `user.join_request_approve_failed` | `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.invites_created` | `user` | `org` |
| `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>
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

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

@ -25,6 +25,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `REDIS_URL` | `redis://localhost:6379` | <p>Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.</p> |
| `REDIS_REMOVE_ON_COMPLETE` | `0` | <p>Controls how many completed jobs are allowed to remain in Redis queues</p> |
| `REDIS_REMOVE_ON_FAIL` | `100` | <p>Controls how many failed jobs are allowed to remain in Redis queues</p> |
| `REPO_SYNC_RETRY_BASE_SLEEP_SECONDS` | `60` | <p>The base sleep duration (in seconds) for exponential backoff when retrying repository sync operations that fail</p> |
| `SHARD_MAX_MATCH_COUNT` | `10000` | <p>The maximum shard count per query</p> |
| `SMTP_CONNECTION_URL` | `-` | <p>The url to the SMTP service used for sending transactional emails. See [this doc](/docs/configuration/transactional-emails) for more info.</p> |
| `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` | <p>Used to encrypt connection secrets and generate API keys.</p> |
@ -40,7 +41,6 @@ The following environment variables allow you to configure your Sourcebot deploy
| Variable | Default | Description |
| :------- | :------ | :---------- |
| `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_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> |

View file

@ -104,11 +104,29 @@ Sourcebot can sync code from GitHub.com, GitHub Enterprise Server, and GitHub En
## Authenticating with GitHub
In order to index private repositories, you'll need to generate a GitHub Personal Access Token (PAT). Create a new PAT [here](https://github.com/settings/tokens/new) and make sure you select the `repo` scope:
In order to index private repositories, you'll need to generate a access token and provide it to Sourcebot. GitHub provides [two types](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#types-of-personal-access-tokens) of access tokens:
![GitHub PAT Scope](/images/github_pat_scopes.png)
Next, provide the PAT via the `token` property, either as an environment variable or a secret:
<AccordionGroup>
<Accordion title="Fine-grained personal access tokens" defaultOpen>
Create a new fine-grained PAT [here](https://github.com/settings/personal-access-tokens/new). First, select the resource owner and the repositories that you want Sourcebot to have access to.
Next, under "Repository permissions", select permissions `Contents` and `Metadata` with access `Read-only`. The permissions should look like the following:
![GitHub PAT Scope](/images/github_pat_scopes_fine_grained.png)
[GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens)
</Accordion>
<Accordion title="Personal access tokens (classic)">
Create a new PAT [here](https://github.com/settings/tokens/new) and make sure you select the `repo` scope:
![GitHub PAT Scope](/images/github_pat_scopes.png)
[GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#personal-access-tokens-classic)
</Accordion>
</AccordionGroup>
Next, provide the access token via the `token` property, either as an environment variable or a secret:
<Tabs>
<Tab title="Environment Variable">

View file

@ -5,7 +5,7 @@ icon: folder
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
@ -27,7 +27,7 @@ To get Sourcebot to index these repositories:
<Steps>
<Step title="Mount a volume">
We need to mount a docker volume to the `repos` directory so Sourcebot can read it's contents. Sourcebot will **not** write to local repositories, so we can mount a seperate **read-only** volume:
We need to mount a docker volume to the `repos` directory so Sourcebot can read it's contents. Sourcebot will **not** write to local repositories, so we can mount a separate **read-only** volume:
``` bash
docker run \

View file

@ -6,12 +6,24 @@ sidebarTitle: Overview
import SupportedPlatforms from '/snippets/platform-support.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
// Specifies two connections:
## Config Schema
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",
"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).
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:
```bash
docker run \
-v $(pwd)/config.json:/data/config.json \
-e CONFIG_PATH=/data/config.json \
... \ # other config
ghcr.io/sourcebot-dev/sourcebot:latest
```
## Config Syncing
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.
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"
></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 />

View file

@ -10,10 +10,10 @@ The following guide will walk you through the steps to deploy Sourcebot on your
## Walkthrough video
---
Watch this 1:51 minute video to get a quick overview of how to deploy Sourcebot using Docker.
Watch this quick walkthrough video to learn how to deploy Sourcebot using Docker.
<iframe
src="https://www.youtube.com/embed/1_JCr05haWc"
src="https://youtube.com/embed/TPQh0z7Qcjg"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@ -69,8 +69,6 @@ Watch this 1:51 minute video to get a quick overview of how to deploy Sourcebot
ghcr.io/sourcebot-dev/sourcebot:latest
```
Navigate to `localhost:3000` to start searching the Sourcebot repo.
<Accordion title="Details">
**This command**:
- pulls the latest version of the `sourcebot` docker image.
@ -82,8 +80,8 @@ Watch this 1:51 minute video to get a quick overview of how to deploy Sourcebot
</Step>
<Step title="Login">
Navigate to `http://localhost:3000` and create an account. The first account which is registered on a fresh Sourcebot deployment is given the [owner role](/docs/configuration/auth/roles-and-permissions).
<Step title="Complete onboarding">
Navigate to `http://localhost:3000` and complete the onboarding flow.
<Note>
By default, only email / password authentication is enabled. [Learn more about authentication](/docs/configuration/auth/overview).

View file

@ -81,20 +81,18 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP)
<Accordion title="VS Code">
[VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
Add the following to your [settings.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers):
Add the following to your [.vscode/mcp.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server-to-your-workspace) file:
```json
{
"mcp": {
"servers": {
"sourcebot": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@sourcebot/mcp@latest"],
"env": {
"SOURCEBOT_HOST": "http://localhost:3000",
"SOURCEBOT_API_KEY": "your-api-key"
}
"servers": {
"sourcebot": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@sourcebot/mcp@latest"],
"env": {
"SOURCEBOT_HOST": "http://localhost:3000",
"SOURCEBOT_API_KEY": "your-api-key"
}
}
}

View file

@ -2,7 +2,7 @@
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>
<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>
<Accordion title="Why Sourcebot?">
- **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.
- **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>
</AccordionGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

View file

@ -685,7 +685,7 @@
"type": "string",
"pattern": ".+"
},
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.",
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.",
"default": [],
"examples": [
[
@ -1387,7 +1387,7 @@
"type": "string",
"pattern": ".+"
},
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.",
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.",
"default": [],
"examples": [
[
@ -2171,7 +2171,7 @@
"type": "string",
"pattern": ".+"
},
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.",
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.",
"default": [],
"examples": [
[

View file

@ -48,6 +48,7 @@ export const env = createEnv({
CONFIG_PATH: z.string().optional(),
CONNECTION_MANAGER_UPSERT_TIMEOUT_MS: numberSchema.default(300000),
REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,

View file

@ -376,7 +376,7 @@ export const compileBitbucketConfig = async (
throw new Error(`No links found for ${isServer ? 'server' : 'cloud'} repo ${repoName}`);
}
// In server case we get an array of lenth == 1 links in the self field, while in cloud case we get a single
// In server case we get an array of length == 1 links in the self field, while in cloud case we get a single
// link object in the html field
const link = isServer ? (repoLinks.self as { name: string, href: string }[])?.[0] : repoLinks.html as { href: string };
if (!link || !link.href) {

View file

@ -174,7 +174,7 @@ export class RepoManager implements IRepoManager {
// We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to
// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referrencing.
// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing.
private async getCloneCredentialsForRepo(repo: RepoWithConnections, db: PrismaClient): Promise<{ username?: string, password: string } | undefined> {
for (const { connection } of repo.connections) {
@ -337,7 +337,7 @@ export class RepoManager implements IRepoManager {
throw error;
}
const sleepDuration = 5000 * Math.pow(2, attempts - 1);
const sleepDuration = (env.REPO_SYNC_RETRY_BASE_SLEEP_SECONDS * 1000) * Math.pow(2, attempts - 1);
logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}), attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`);
await new Promise(resolve => setTimeout(resolve, sleepDuration));
}

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

View file

@ -19,7 +19,7 @@ The Sourcebot MCP server gives your LLM agents the ability to fetch code context
- Building custom LLM horizontal agents like like compliance auditing agents, migration agents, etc.
- _"Find all instances of hardcoded credentials"_
- _"Identify repositories that depend on this depreacted api"_
- _"Identify repositories that depend on this deprecated api"_
## Getting Started
@ -87,20 +87,18 @@ The Sourcebot MCP server gives your LLM agents the ability to fetch code context
[VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
Add the following to your [settings.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers):
Add the following to your [.vscode/mcp.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server-to-your-workspace) file:
```json
{
"mcp": {
"servers": {
"sourcebot": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@sourcebot/mcp@latest"],
// Optional - if not specified, https://demo.sourcebot.dev is used
"env": {
"SOURCEBOT_HOST": "http://localhost:3000"
}
"servers": {
"sourcebot": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@sourcebot/mcp@latest"],
// Optional - if not specified, https://demo.sourcebot.dev is used
"env": {
"SOURCEBOT_HOST": "http://localhost:3000"
}
}
}

View file

@ -684,7 +684,7 @@ const schema = {
"type": "string",
"pattern": ".+"
},
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.",
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.",
"default": [],
"examples": [
[
@ -1386,7 +1386,7 @@ const schema = {
"type": "string",
"pattern": ".+"
},
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.",
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.",
"default": [],
"examples": [
[
@ -2170,7 +2170,7 @@ const schema = {
"type": "string",
"pattern": ".+"
},
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.",
"description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.",
"default": [],
"examples": [
[

View file

@ -309,7 +309,7 @@ export interface LocalConfig {
watch?: boolean;
exclude?: {
/**
* List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.
* List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.
*/
paths?: string[];
};

View file

@ -2,7 +2,7 @@ This package contains shared code between the backend & webapp packages.
### Why two index files?
This package contains two index files: `index.server.ts` and `index.client.ts`. There is some code in this package that will only work in a Node.JS runtime (e.g., because it depends on the `fs` pacakge. Entitlements are a good example of this), and other code that is runtime agnostic (e.g., `constants.ts`). To deal with this, we these two index files export server code and client code, respectively.
This package contains two index files: `index.server.ts` and `index.client.ts`. There is some code in this package that will only work in a Node.JS runtime (e.g., because it depends on the `fs` package. Entitlements are a good example of this), and other code that is runtime agnostic (e.g., `constants.ts`). To deal with this, we these two index files export server code and client code, respectively.
For package consumers, the usage would look like the following:
- Server: `import { ... } from @sourcebot/shared`

View file

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

View file

@ -2,7 +2,7 @@
import { env } from "@/env.mjs";
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 { prisma } from "@/prisma";
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 { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
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 { 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 JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import { createLogger } from "@sourcebot/logger";
import { getAuditService } from "@/ee/features/audit/factory";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
const ajv = new Ajv({
validateFormats: false,
@ -116,35 +117,6 @@ export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | unde
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) => {
const org = await prisma.org.findUnique({
where: {
@ -169,7 +141,7 @@ export const withOrgMembership = async <T>(userId: string, domain: string, fn: (
return notFound("User not a member of this organization");
}
const getAuthorizationPrecendence = (role: OrgRole): number => {
const getAuthorizationPrecedence = (role: OrgRole): number => {
switch (role) {
case OrgRole.GUEST:
return 0;
@ -181,7 +153,7 @@ export const withOrgMembership = async <T>(userId: string, domain: string, fn: (
}
if (getAuthorizationPrecendence(membership.role) < getAuthorizationPrecendence(minRequiredRole)) {
if (getAuthorizationPrecedence(membership.role) < getAuthorizationPrecedence(minRequiredRole)) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
@ -738,7 +710,7 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew
// In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot".
// We will leave this as an edge case for now since it's unlikely to happen in practice.
//
// @v4-todo: we could add a unique contraint on repo name + orgId to help de-duplicate
// @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate
// these cases.
// @see: repoCompileUtils.ts
const repo = await prisma.repo.findFirst({
@ -1169,6 +1141,13 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
}, /* 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(() =>
withAuth(async (userId) => {
const user = await prisma.user.findUnique({
@ -1257,73 +1236,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
return notFound();
}
const res = await prisma.$transaction(async (tx) => {
await tx.userToOrg.create({
data: {
userId: user.id,
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;
const addUserToOrgRes = await addUserToOrganization(user.id, invite.orgId);
if (isServiceError(addUserToOrgRes)) {
await failAuditCallback(addUserToOrgRes.message);
return addUserToOrgRes;
}
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) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
@ -1677,14 +1580,6 @@ export const createAccountRequest = async (userId: string, domain: string) => se
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({
where: {
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 () =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
@ -1811,60 +1764,10 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
return notFound();
}
const hasAvailability = await orgHasAvailability(domain);
if (!hasAvailability) {
await failAuditCallback("Organization is at max capacity");
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.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;
const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id);
if (isServiceError(addUserToOrgRes)) {
await failAuditCallback(addUserToOrgRes.message);
return addUserToOrgRes;
}
// 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 {
success: true,
}

View file

@ -1,63 +1,39 @@
'use client';
import { getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { useQuery } from "@tanstack/react-query";
import { getFileSource } from "@/features/search/fileSourceApi";
import { useDomain } from "@/hooks/useDomain";
import { Loader2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { getRepoInfoByName } from "@/actions";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useMemo } from "react";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { Separator } from "@/components/ui/separator";
import { getFileSource } from "@/features/search/fileSourceApi";
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
import Image from "next/image";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
export const CodePreviewPanel = () => {
const { path, repoName, revisionName } = useBrowseParams();
const domain = useDomain();
interface CodePreviewPanelProps {
path: string;
repoName: string;
revisionName?: string;
domain: string;
}
const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({
queryKey: ['fileSource', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(getFileSource({
export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }: CodePreviewPanelProps) => {
const [fileSourceResponse, repoInfoResponse] = await Promise.all([
getFileSource({
fileName: path,
repository: repoName,
branch: revisionName
}, domain)),
});
branch: revisionName,
}, domain),
getRepoInfoByName(repoName, domain),
]);
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({
queryKey: ['repoInfo', repoName, domain],
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
});
const codeHostInfo = useMemo(() => {
if (!repoInfoResponse) {
return undefined;
}
return getCodeHostInfoForRepo({
codeHostType: repoInfoResponse.codeHostType,
name: repoInfoResponse.name,
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
});
}, [repoInfoResponse]);
if (isFileSourcePending || isRepoInfoPending) {
return (
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
)
}
if (isFileSourceError || isRepoInfoError) {
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
return <div>Error loading file source</div>
}
const codeHostInfo = getCodeHostInfoForRepo({
codeHostType: repoInfoResponse.codeHostType,
name: repoInfoResponse.name,
displayName: repoInfoResponse.displayName,
webUrl: repoInfoResponse.webUrl,
});
return (
<>
<div className="flex flex-row py-1 px-2 items-center justify-between">

View file

@ -0,0 +1,46 @@
'use client';
import { useCallback, useRef } from "react";
import { FileTreeItem } from "@/features/fileTree/actions";
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useBrowseParams } from "../../hooks/useBrowseParams";
interface PureTreePreviewPanelProps {
items: FileTreeItem[];
}
export const PureTreePreviewPanel = ({ items }: PureTreePreviewPanelProps) => {
const { repoName, revisionName } = useBrowseParams();
const { navigateToPath } = useBrowseNavigation();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const onNodeClicked = useCallback((node: FileTreeItem) => {
navigateToPath({
repoName: repoName,
revisionName: revisionName,
path: node.path,
pathType: node.type === 'tree' ? 'tree' : 'blob',
});
}, [navigateToPath, repoName, revisionName]);
return (
<ScrollArea
className="flex flex-col p-0.5"
ref={scrollAreaRef}
>
{items.map((item) => (
<FileTreeItemComponent
key={item.path}
node={item}
isActive={false}
depth={0}
isCollapseChevronVisible={false}
onClick={() => onNodeClicked(item)}
parentRef={scrollAreaRef}
/>
))}
</ScrollArea>
)
}

View file

@ -1,74 +1,30 @@
'use client';
import { Loader2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { getRepoInfoByName } from "@/actions";
import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { useCallback, useRef } from "react";
import { FileTreeItem, getFolderContents } from "@/features/fileTree/actions";
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
import { ScrollArea } from "@/components/ui/scroll-area";
import { unwrapServiceError } from "@/lib/utils";
import { useBrowseParams } from "../../hooks/useBrowseParams";
import { useDomain } from "@/hooks/useDomain";
import { useQuery } from "@tanstack/react-query";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents";
import { getFolderContents } from "@/features/fileTree/actions";
import { isServiceError } from "@/lib/utils";
import { PureTreePreviewPanel } from "./pureTreePreviewPanel";
export const TreePreviewPanel = () => {
const { path } = useBrowseParams();
const { repoName, revisionName } = useBrowseParams();
const domain = useDomain();
const { navigateToPath } = useBrowseNavigation();
const { prefetchFileSource } = usePrefetchFileSource();
const { prefetchFolderContents } = usePrefetchFolderContents();
const scrollAreaRef = useRef<HTMLDivElement>(null);
interface TreePreviewPanelProps {
path: string;
repoName: string;
revisionName?: string;
domain: string;
}
const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({
queryKey: ['repoInfo', repoName, domain],
queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)),
});
export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }: TreePreviewPanelProps) => {
const [repoInfoResponse, folderContentsResponse] = await Promise.all([
getRepoInfoByName(repoName, domain),
getFolderContents({
repoName,
revisionName: revisionName ?? 'HEAD',
path,
}, domain)
]);
const { data, isPending: isFolderContentsPending, isError: isFolderContentsError } = useQuery({
queryKey: ['tree', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(
getFolderContents({
repoName,
revisionName: revisionName ?? 'HEAD',
path,
}, domain)
),
});
const onNodeClicked = useCallback((node: FileTreeItem) => {
navigateToPath({
repoName: repoName,
revisionName: revisionName,
path: node.path,
pathType: node.type === 'tree' ? 'tree' : 'blob',
});
}, [navigateToPath, repoName, revisionName]);
const onNodeMouseEnter = useCallback((node: FileTreeItem) => {
if (node.type === 'blob') {
prefetchFileSource(repoName, revisionName ?? 'HEAD', node.path);
} else if (node.type === 'tree') {
prefetchFolderContents(repoName, revisionName ?? 'HEAD', node.path);
}
}, [prefetchFileSource, prefetchFolderContents, repoName, revisionName]);
if (isFolderContentsPending || isRepoInfoPending) {
return (
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
)
}
if (isFolderContentsError || isRepoInfoError) {
return <div>Error loading tree</div>
if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
return <div>Error loading tree preview</div>
}
return (
@ -86,23 +42,7 @@ export const TreePreviewPanel = () => {
/>
</div>
<Separator />
<ScrollArea
className="flex flex-col p-0.5"
ref={scrollAreaRef}
>
{data.map((item) => (
<FileTreeItemComponent
key={item.path}
node={item}
isActive={false}
depth={0}
isCollapseChevronVisible={false}
onClick={() => onNodeClicked(item)}
onMouseEnter={() => onNodeMouseEnter(item)}
parentRef={scrollAreaRef}
/>
))}
</ScrollArea>
<PureTreePreviewPanel items={folderContentsResponse} />
</>
)
}

View file

@ -1,19 +1,44 @@
'use client';
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { Suspense } from "react";
import { getBrowseParamsFromPathParam } from "../hooks/utils";
import { CodePreviewPanel } from "./components/codePreviewPanel";
import { Loader2 } from "lucide-react";
import { TreePreviewPanel } from "./components/treePreviewPanel";
export default function BrowsePage() {
const { pathType } = useBrowseParams();
interface BrowsePageProps {
params: {
path: string[];
domain: string;
};
}
export default async function BrowsePage({ params: { path: _rawPath, domain } }: BrowsePageProps) {
const rawPath = decodeURIComponent(_rawPath.join('/'));
const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath);
return (
<div className="flex flex-col h-full">
{pathType === 'blob' ? (
<CodePreviewPanel />
) : (
<TreePreviewPanel />
)}
<Suspense fallback={
<div className="flex flex-col w-full min-h-full items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</div>
}>
{pathType === 'blob' ? (
<CodePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
domain={domain}
/>
) : (
<TreePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
domain={domain}
/>
)}
</Suspense>
</div>
)
}

View file

@ -10,7 +10,6 @@ import { useDomain } from "@/hooks/useDomain";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
import { useBrowseState } from "../hooks/useBrowseState";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import { useBrowseParams } from "../hooks/useBrowseParams";
import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon";
import { useLocalStorage } from "usehooks-ts";
@ -36,7 +35,6 @@ export const FileSearchCommandDialog = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const { navigateToPath } = useBrowseNavigation();
const { prefetchFileSource } = usePrefetchFileSource();
const [recentlyOpened, setRecentlyOpened] = useLocalStorage<FileTreeItem[]>(`recentlyOpenedFiles-${repoName}`, []);
@ -122,14 +120,6 @@ export const FileSearchCommandDialog = () => {
});
}, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]);
const onMouseEnter = useCallback((file: FileTreeItem) => {
prefetchFileSource(
repoName,
revisionName ?? 'HEAD',
file.path
);
}, [prefetchFileSource, repoName, revisionName]);
// @note: We were hitting issues when the user types into the input field while the files are still
// loading. The workaround was to set `disabled` when loading and then focus the input field when
// the files are loaded, hence the `useEffect` below.
@ -181,7 +171,6 @@ export const FileSearchCommandDialog = () => {
key={file.path}
file={file}
onSelect={() => onSelect(file)}
onMouseEnter={() => onMouseEnter(file)}
/>
);
})}
@ -196,7 +185,6 @@ export const FileSearchCommandDialog = () => {
file={file}
match={match}
onSelect={() => onSelect(file)}
onMouseEnter={() => onMouseEnter(file)}
/>
);
})}
@ -223,20 +211,17 @@ interface SearchResultComponentProps {
to: number;
};
onSelect: () => void;
onMouseEnter: () => void;
}
const SearchResultComponent = ({
file,
match,
onSelect,
onMouseEnter,
}: SearchResultComponentProps) => {
return (
<CommandItem
key={file.path}
onSelect={onSelect}
onMouseEnter={onMouseEnter}
>
<div className="flex flex-row gap-2 w-full cursor-pointer relative">
<FileTreeItemIcon item={file} className="mt-1" />

View file

@ -1,48 +1,18 @@
'use client';
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { getBrowseParamsFromPathParam } from "./utils";
export const useBrowseParams = () => {
const pathname = usePathname();
const startIndex = pathname.indexOf('/browse/');
if (startIndex === -1) {
throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`);
}
const rawPath = pathname.substring(startIndex + '/browse/'.length);
const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//);
if (sentinalIndex === -1) {
throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/-/(tree|blob)/" pattern`);
}
const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@');
const repoName = repoAndRevisionName[0];
const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined;
const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => {
const path = rawPath.substring(sentinalIndex + '/-/'.length);
const pathType = path.startsWith('tree/') ? 'tree' : 'blob';
// @note: decodedURIComponent is needed here incase the path contains a space.
switch (pathType) {
case 'tree':
return {
path: decodeURIComponent(path.substring('tree/'.length)),
pathType,
};
case 'blob':
return {
path: decodeURIComponent(path.substring('blob/'.length)),
pathType,
};
return useMemo(() => {
const startIndex = pathname.indexOf('/browse/');
if (startIndex === -1) {
throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`);
}
})();
return {
repoName,
revisionName,
path,
pathType,
}
const rawPath = pathname.substring(startIndex + '/browse/'.length);
return getBrowseParamsFromPathParam(rawPath);
}, [pathname]);
}

View file

@ -0,0 +1,194 @@
import { describe, it, expect } from 'vitest';
import { getBrowseParamsFromPathParam } from './utils';
describe('getBrowseParamsFromPathParam', () => {
describe('tree paths', () => {
it('should parse tree path with trailing slash', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: '',
pathType: 'tree',
});
});
it('should parse tree path without trailing slash', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: '',
pathType: 'tree',
});
});
it('should parse tree path with nested directory', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/packages/web/src');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'packages/web/src',
pathType: 'tree',
});
});
it('should parse tree path without revision', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt/-/tree/docs');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: undefined,
path: 'docs',
pathType: 'tree',
});
});
});
describe('blob paths', () => {
it('should parse blob path with file', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/README.md');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'README.md',
pathType: 'blob',
});
});
it('should parse blob path with nested file', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/packages/web/src/app/page.tsx');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'packages/web/src/app/page.tsx',
pathType: 'blob',
});
});
it('should parse blob path without revision', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt/-/blob/main.go');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: undefined,
path: 'main.go',
pathType: 'blob',
});
});
});
describe('URL decoding', () => {
it('should decode URL-encoded spaces in path', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/folder%20with%20spaces');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'folder with spaces',
pathType: 'tree',
});
});
it('should decode URL-encoded special characters in path', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/file%20with%20%26%20symbols.txt');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'file with & symbols.txt',
pathType: 'blob',
});
});
});
describe('different revision formats', () => {
it('should parse with branch name', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@main/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'main',
path: '',
pathType: 'tree',
});
});
it('should parse with commit hash', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@a1b2c3d/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'a1b2c3d',
path: '',
pathType: 'tree',
});
});
it('should parse with tag', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@v1.0.0/-/tree/');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'v1.0.0',
path: '',
pathType: 'tree',
});
});
});
describe('edge cases', () => {
it('should handle repo name with multiple @ symbols', () => {
const result = getBrowseParamsFromPathParam('gitlab.com/user@domain/repo@main/-/tree/');
expect(result).toEqual({
repoName: 'gitlab.com/user@domain/repo',
revisionName: 'main',
path: '',
pathType: 'tree',
});
});
it('should handle paths with @ symbols', () => {
const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/file@v1.0.0.txt');
expect(result).toEqual({
repoName: 'github.com/sourcebot-dev/zoekt',
revisionName: 'HEAD',
path: 'file@v1.0.0.txt',
pathType: 'blob',
});
});
});
describe('error cases', () => {
it('should throw error for blob path with trailing slash and no path', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/');
}).toThrow();
});
it('should throw error for blob path without trailing slash and no path', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob');
}).toThrow();
});
it('should throw error for invalid pattern - missing /-/', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/tree/');
}).toThrow();
});
it('should throw error for invalid pattern - missing tree/blob', () => {
expect(() => {
getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/invalid/');
}).toThrow();
});
it('should throw error for completely invalid format', () => {
expect(() => {
getBrowseParamsFromPathParam('invalid-path');
}).toThrow();
});
it('should throw error for empty string', () => {
expect(() => {
getBrowseParamsFromPathParam('');
}).toThrow();
});
});
});

View file

@ -0,0 +1,43 @@
export const getBrowseParamsFromPathParam = (pathParam: string) => {
const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/);
if (sentinelIndex === -1) {
throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob)/" pattern`);
}
const repoAndRevisionPart = pathParam.substring(0, sentinelIndex);
const lastAtIndex = repoAndRevisionPart.lastIndexOf('@');
const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex);
const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1);
const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => {
const path = pathParam.substring(sentinelIndex + '/-/'.length);
const pathType = path.startsWith('tree') ? 'tree' : 'blob';
// @note: decodedURIComponent is needed here incase the path contains a space.
switch (pathType) {
case 'tree':
return {
path: decodeURIComponent(path.startsWith('tree/') ? path.substring('tree/'.length) : path.substring('tree'.length)),
pathType,
};
case 'blob':
return {
path: decodeURIComponent(path.startsWith('blob/') ? path.substring('blob/'.length) : path.substring('blob'.length)),
pathType,
};
}
})();
if (pathType === 'blob' && path === '') {
throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`);
}
return {
repoName,
revisionName,
path,
pathType,
}
}

View file

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

View file

@ -8,8 +8,6 @@ import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation";
import { Copy, CheckCircle2, ChevronRight, MoreHorizontal } from "lucide-react";
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
import { useToast } from "@/components/hooks/use-toast";
import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
import {
DropdownMenu,
DropdownMenuContent,
@ -62,8 +60,6 @@ export const PathHeader = ({
const { navigateToPath } = useBrowseNavigation();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const { prefetchFolderContents } = usePrefetchFolderContents();
const { prefetchFileSource } = usePrefetchFileSource();
const containerRef = useRef<HTMLDivElement>(null);
const breadcrumbsRef = useRef<HTMLDivElement>(null);
@ -188,19 +184,6 @@ export const PathHeader = ({
});
}, [repo.name, branchDisplayName, navigateToPath, pathType]);
const onBreadcrumbMouseEnter = useCallback((segment: BreadcrumbSegment) => {
if (segment.isLastSegment && pathType === 'blob') {
prefetchFileSource(repo.name, branchDisplayName ?? 'HEAD', segment.fullPath);
} else {
prefetchFolderContents(repo.name, branchDisplayName ?? 'HEAD', segment.fullPath);
}
}, [
repo.name,
branchDisplayName,
prefetchFolderContents,
pathType,
prefetchFileSource,
]);
const renderSegmentWithHighlight = (segment: BreadcrumbSegment) => {
if (!segment.highlightRange) {
@ -274,7 +257,6 @@ export const PathHeader = ({
<DropdownMenuItem
key={segment.fullPath}
onClick={() => onBreadcrumbClick(segment)}
onMouseEnter={() => onBreadcrumbMouseEnter(segment)}
className="font-mono text-sm cursor-pointer"
>
{renderSegmentWithHighlight(segment)}
@ -292,7 +274,6 @@ export const PathHeader = ({
"font-mono text-sm truncate cursor-pointer hover:underline",
)}
onClick={() => onBreadcrumbClick(segment)}
onMouseEnter={() => onBreadcrumbMouseEnter(segment)}
>
{renderSegmentWithHighlight(segment)}
</span>

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

View file

@ -34,7 +34,7 @@ test('splitQuery groups all parts together when a quote capture group is not clo
expect(cursorIndex).toBe(0);
});
test('splitQuery correclty locates the cursor index given the cursor position (1)', () => {
test('splitQuery correctly locates the cursor index given the cursor position (1)', () => {
const query = 'foo bar "fizz buzz"';
const { queryParts: parts1, cursorIndex: index1 } = splitQuery(query, 0);
@ -50,7 +50,7 @@ test('splitQuery correclty locates the cursor index given the cursor position (1
expect(parts3[index3]).toBe('"fizz buzz"');
});
test('splitQuery correclty locates the cursor index given the cursor position (2)', () => {
test('splitQuery correctly locates the cursor index given the cursor position (2)', () => {
const query = 'a b';
expect(splitQuery(query, 0).cursorIndex).toBe(0);
expect(splitQuery(query, 1).cursorIndex).toBe(0);

View file

@ -438,7 +438,7 @@ export { SearchSuggestionsBox };
export const splitQuery = (query: string, cursorPos: number) => {
const queryParts = [];
const seperator = " ";
const separator = " ";
let cursorIndex = 0;
let accumulator = "";
let isInQuoteCapture = false;
@ -452,7 +452,7 @@ export const splitQuery = (query: string, cursorPos: number) => {
isInQuoteCapture = !isInQuoteCapture;
}
if (!isInQuoteCapture && query[i] === seperator) {
if (!isInQuoteCapture && query[i] === separator) {
queryParts.push(accumulator);
accumulator = "";
continue;

View file

@ -21,7 +21,7 @@ export const useSuggestionModeAndQuery = ({
const suggestionModeMappings = useSuggestionModeMappings();
const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => {
// When suggestions are not enabled, fallback to using a sentinal
// When suggestions are not enabled, fallback to using a sentinel
// suggestion mode of "none".
if (!isSuggestionsEnabled) {
return {

View file

@ -83,8 +83,8 @@ export const SettingsDropdown = ({
<DropdownMenuContent className="w-64">
{session?.user ? (
<DropdownMenuGroup>
<div className="flex flex-row items-center gap-1 p-2">
<Avatar>
<div className="flex flex-row items-start gap-3 p-2">
<Avatar className="flex-shrink-0">
<AvatarImage
src={session.user.image ?? ""}
/>
@ -92,7 +92,7 @@ export const SettingsDropdown = ({
{session.user.name && session.user.name.length > 0 ? session.user.name[0] : 'U'}
</AvatarFallback>
</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>
<DropdownMenuItem
onClick={() => {

View file

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

@ -66,7 +66,7 @@ export const SyntaxReferenceGuide = () => {
<DialogHeader>
<DialogTitle>Syntax Reference Guide</DialogTitle>
<DialogDescription className="text-sm text-foreground">
Queries consist of space-seperated regular expressions. Wrapping expressions in <Code>{`""`}</Code> combines them. By default, a file must have at least one match for each expression to be included.
Queries consist of space-separated regular expressions. Wrapping expressions in <Code>{`""`}</Code> combines them. By default, a file must have at least one match for each expression to be included.
</DialogDescription>
</DialogHeader>
<Table>

View file

@ -14,10 +14,14 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { notFound, redirect } from "next/navigation";
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
import { PendingApprovalCard } from "./components/pendingApproval";
import { SubmitJoinRequest } from "./components/submitJoinRequest";
import { hasEntitlement } from "@sourcebot/shared";
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
import { env } from "@/env.mjs";
import { GcpIapAuth } from "./components/gcpIapAuth";
import { getMemberApprovalRequired } from "@/actions";
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
interface LayoutProps {
children: React.ReactNode,
@ -34,18 +38,11 @@ export default async function Layout({
return notFound();
}
const session = await auth();
const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain);
if (!publicAccessEnabled) {
const session = await auth();
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');
}
}
// If the user is authenticated, we must check if they're a member of the org
if (session) {
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
@ -58,19 +55,43 @@ export default async function Layout({
}
});
// 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) {
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: {
id: session.user.id
orgId: org.id,
requestedById: session.user.id
}
});
// TODO: Organization join requests are only supported in single-tenant mode
if (env.SOURCEBOT_TENANCY_MODE === "single" && user?.pendingApproval) {
return <PendingApprovalCard domain={domain} />
if (hasPendingApproval) {
return <PendingApprovalCard />
} 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

@ -65,7 +65,7 @@ export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCar
<CardTitle>
Organization Name
</CardTitle>
<CardDescription>{`Your organization's visible name within Sourceobot. For example, the name of your company or department.`}</CardDescription>
<CardDescription>{`Your organization's visible name within Sourcebot. For example, the name of your company or department.`}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>

View file

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

View file

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

@ -66,7 +66,7 @@ export const SyntaxReferenceGuide = () => {
<DialogHeader>
<DialogTitle>Syntax Reference Guide</DialogTitle>
<DialogDescription className="text-sm text-foreground">
Queries consist of space-seperated regular expressions. Wrapping expressions in <Code>{`""`}</Code> combines them. By default, a file must have at least one match for each expression to be included.
Queries consist of space-separated regular expressions. Wrapping expressions in <Code>{`""`}</Code> combines them. By default, a file must have at least one match for each expression to be included.
</DialogDescription>
</DialogHeader>
<Table>

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

View file

@ -1,19 +1,14 @@
'use client';
import Image from "next/image";
import { signIn } from "next-auth/react";
import { Fragment, useCallback, useMemo, useState } from "react";
import { useMemo } from "react";
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 { TextSeparator } from "@/app/components/textSeparator";
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
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 { 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 PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
@ -21,17 +16,12 @@ const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
interface LoginFormProps {
callbackUrl?: string;
error?: string;
providers: Array<{ id: string; name: string }>;
providers: AuthProvider[];
context: "login" | "signup";
}
export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormProps) => {
const captureEvent = useCaptureEvent();
const onSignInWithOauth = useCallback((provider: string) => {
signIn(provider, {
redirectTo: callbackUrl ?? "/"
});
}, [callbackUrl]);
const errorMessage = useMemo(() => {
if (!error) {
@ -47,13 +37,6 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
}
}, [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
const getLoginEventName = (providerId: string) => {
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 (
<div className="flex flex-col items-center justify-center w-full">
<div className="mb-6 flex flex-col items-center">
@ -95,38 +83,17 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
{errorMessage}
</div>
)}
<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={() => {
captureEvent(getLoginEventName(provider.id), {});
onSignInWithOauth(provider.id);
}}
/>
);
})}
</div>
] : []),
...(hasMagicLink ? [
<MagicLinkForm key="magic-link" callbackUrl={callbackUrl} />
] : []),
...(hasCredentials ? [
<CredentialsForm key="credentials" callbackUrl={callbackUrl} />
] : [])
]}
<AuthMethodSelector
providers={providers}
callbackUrl={callbackUrl}
context={context}
onProviderClick={handleProviderClick}
securityNoticeClosable={true}
/>
<p className="text-sm text-muted-foreground mt-8">
{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>
)
}
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 {
callbackUrl?: string;
context: "login" | "signup";
}
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => {
const captureEvent = useCaptureEvent();
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
@ -76,7 +77,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
disabled={isLoading}
>
{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>
</form>
</Form>

View file

@ -1,9 +1,11 @@
import { auth } from "@/auth";
import { LoginForm } from "./components/loginForm";
import { redirect } from "next/navigation";
import { getProviders } from "@/auth";
import { Footer } from "@/app/components/footer";
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');
@ -22,24 +24,19 @@ export default async function Login({ searchParams }: LoginProps) {
return redirect("/");
}
const providers = getProviders();
const providerData = 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 }
}
});
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
}
const providers = getAuthProviders();
return (
<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">
<LoginForm
callbackUrl={searchParams.callbackUrl}
error={searchParams.error}
providers={providerData}
providers={providers}
context="login"
/>
</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 { 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";
import type React from "react"
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 { BetweenHorizontalStart, GitBranchIcon, LockIcon } from "lucide-react";
import { hasEntitlement } from "@sourcebot/shared";
import { env } from "@/env.mjs";
import { GcpIapAuth } from "@/app/[domain]/components/gcpIapAuth";
import { headers } from "next/headers";
import { getBaseUrl, createInviteLink } from "@/lib/utils";
interface OnboardingProps {
searchParams?: { step?: string };
}
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();
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: <BetweenHorizontalStart 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 (
<div className="flex flex-col items-center min-h-screen py-12 px-4 sm:px-12 bg-backgroundSecondary relative">
<OnboardHeader
title="Setup your organization"
description="Create a organization for your team to search and share code across your repositories."
step={OnboardingSteps.CreateOrg}
/>
<OrgCreateForm rootDomain={host} />
<LogoutEscapeHatch className="absolute top-0 right-0 p-4 sm:p-12" />
<div className="min-h-screen bg-background flex items-center justify-center p-6">
<div className="w-full max-w-6xl mx-auto">
<div className="overflow-hidden bg-background">
<div className="flex min-h-[700px]">
{/* Left Panel - Progress & Branding */}
<div className="w-2/5 bg-background p-10 border-r border-border">
<div className="h-full flex flex-col">
<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>
);
}

View file

@ -1,35 +1,19 @@
import { auth } from "@/auth";
import { prisma } from "@/prisma";
import { redirect } from "next/navigation";
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
import { getOrgFromDomain } from "@/data/org";
export default async function Page() {
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
}
const session = await auth();
if (!session) {
return redirect("/login");
}
const firstOrg = await prisma.userToOrg.findFirst({
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}`);
return redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`);
}

View file

@ -5,6 +5,8 @@ import { isServiceError } from "@/lib/utils";
import { AcceptInviteCard } from './components/acceptInviteCard';
import { LogoutEscapeHatch } from '../components/logoutEscapeHatch';
import { InviteNotFoundCard } from './components/inviteNotFoundCard';
import { getOrgFromDomain } from '@/data/org';
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
interface RedeemPageProps {
searchParams: {
@ -13,6 +15,11 @@ interface 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;
if (!inviteId) {
return notFound();

View file

@ -1,9 +1,11 @@
import { auth } from "@/auth";
import { LoginForm } from "../login/components/loginForm";
import { redirect } from "next/navigation";
import { getProviders } from "@/auth";
import { Footer } from "@/app/components/footer";
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');
@ -21,24 +23,19 @@ export default async function Signup({ searchParams }: LoginProps) {
return redirect("/");
}
const providers = getProviders();
const providerData = 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 }
}
});
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
if (!org || !org.isOnboarded) {
return redirect("/onboard");
}
const providers = getAuthProviders();
return (
<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">
<LoginForm
callbackUrl={searchParams.callbackUrl}
error={searchParams.error}
providers={providerData}
providers={providers}
context="signup"
/>
</div>

View file

@ -190,7 +190,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// to the client.
session.user = {
...session.user,
// Propogate the userId to the session.
// Propagate the userId to the session.
id: token.userId,
}
return session;

View file

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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
)}
{...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
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>

View file

@ -2,11 +2,18 @@ import 'server-only';
import { prisma } from '@/prisma';
export const getOrgFromDomain = async (domain: string) => {
const org = await prisma.org.findUnique({
where: {
domain: domain
}
});
try {
const org = await prisma.org.findUnique({
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

@ -62,7 +62,7 @@ export const createOnboardingSubscription = async (domain: string) => sew(() =>
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS,
message: "Attemped to create a trial subscription for an organization that already has an active subscription",
message: "Attempted to create a trial subscription for an organization that already has an active subscription",
} satisfies ServiceError;
}

View file

@ -8,7 +8,6 @@ import { RepositoryInfo, SourceRange } from "@/features/search/types";
import { useMemo, useRef } from "react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useVirtualizer } from "@tanstack/react-virtual";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
interface ReferenceListProps {
data: FindRelatedSymbolsResponse;
@ -31,7 +30,6 @@ export const ReferenceList = ({
const { navigateToPath } = useBrowseNavigation();
const captureEvent = useCaptureEvent();
const { prefetchFileSource } = usePrefetchFileSource();
// Virtualization setup
const parentRef = useRef<HTMLDivElement>(null);
@ -120,13 +118,6 @@ export const ReferenceList = ({
highlightRange: match.range,
})
}}
// @note: We prefetch the file source when the user hovers over a file.
// This is to try and mitigate having a loading spinner appear when
// the user clicks on a file to open it.
// @see: /browse/[...path]/page.tsx
onMouseEnter={() => {
prefetchFileSource(file.repository, revisionName, file.fileName);
}}
/>
))}
</div>
@ -144,7 +135,6 @@ interface ReferenceListItemProps {
range: SourceRange;
language: string;
onClick: () => void;
onMouseEnter: () => void;
}
const ReferenceListItem = ({
@ -152,7 +142,6 @@ const ReferenceListItem = ({
range,
language,
onClick,
onMouseEnter,
}: ReferenceListItemProps) => {
const highlightRanges = useMemo(() => [range], [range]);
@ -160,7 +149,6 @@ const ReferenceListItem = ({
<div
className="w-full hover:bg-accent py-1 cursor-pointer"
onClick={onClick}
onMouseEnter={onMouseEnter}
>
<LightweightCodeHighlighter
language={language}

View file

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

View file

@ -7,13 +7,7 @@ import Keycloak from "next-auth/providers/keycloak";
import Gitlab from "next-auth/providers/gitlab";
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
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 { sew } from "@/actions";
import Credentials from "next-auth/providers/credentials";
import type { User as AuthJsUser } from "next-auth";
import { onCreateUser } from "@/lib/authUtils";
@ -173,78 +167,3 @@ export const getSSOProviders = (): Provider[] => {
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'),
// Enterprise Auth
AUTH_EE_ENABLE_JIT_PROVISIONING: booleanSchema.default('false'),
AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(),
AUTH_EE_GITHUB_BASE_URL: z.string().optional(),

View file

@ -12,7 +12,7 @@ const rules = [
"Do NOT provide general feedback, summaries, explanations of changes, or praises for making good additions.",
"Do NOT provide any advice that is not actionable or directly related to the changes.",
"Do NOT provide any comments or reviews on code that you believe is good, correct, or a good addition. Your job is only to identify issues and provide feedback on how to fix them.",
"If a review for a chunk contains different reviews at different line ranges, return a seperate review object for each line range.",
"If a review for a chunk contains different reviews at different line ranges, return a separate review object for each line range.",
"Focus solely on offering specific, objective insights based on the given context and refrain from making broad comments about potential impacts on the system or question intentions behind the changes.",
"Keep comments concise and to the point. Every comment must highlight a specific issue and provide a clear and actionable solution to the developer.",
"If there are no issues found on a line range, do NOT respond with any comments. This includes comments such as \"No issues found\" or \"LGTM\"."

View file

@ -8,7 +8,7 @@ export const generateDiffReviewPrompt = async (diff: sourcebot_diff, context: so
logger.debug("Executing generate_diff_review_prompt");
const prompt = `
You are an expert software engineer that excells at reviewing code changes. Given the input, additional context, and rules defined below, review the code changes and provide a detailed review. The review you provide
You are an expert software engineer that excels at reviewing code changes. Given the input, additional context, and rules defined below, review the code changes and provide a detailed review. The review you provide
must conform to all of the rules defined below. The output format of your review must conform to the output format defined below.
# Input

View file

@ -14,7 +14,6 @@ export const FileTreeItemComponent = ({
isCollapsed = false,
isCollapseChevronVisible = true,
onClick,
onMouseEnter,
parentRef,
}: {
node: FileTreeItem,
@ -23,7 +22,6 @@ export const FileTreeItemComponent = ({
isCollapsed?: boolean,
isCollapseChevronVisible?: boolean,
onClick: () => void,
onMouseEnter: () => void,
parentRef: React.RefObject<HTMLDivElement>,
}) => {
const ref = useRef<HTMLDivElement>(null);
@ -67,7 +65,6 @@ export const FileTreeItemComponent = ({
}
}}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
<div
className="flex flex-row gap-1 cursor-pointer w-4 h-4 flex-shrink-0"

View file

@ -6,7 +6,6 @@ import React, { useCallback, useMemo, useState, useEffect, useRef } from "react"
import { FileTreeItemComponent } from "./fileTreeItemComponent";
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource";
export type FileTreeNode = Omit<RawFileTreeNode, 'children'> & {
@ -14,11 +13,11 @@ export type FileTreeNode = Omit<RawFileTreeNode, 'children'> & {
children: FileTreeNode[];
}
const buildCollapsableTree = (tree: RawFileTreeNode): FileTreeNode => {
const buildCollapsibleTree = (tree: RawFileTreeNode): FileTreeNode => {
return {
...tree,
isCollapsed: true,
children: tree.children.map(buildCollapsableTree),
children: tree.children.map(buildCollapsibleTree),
}
}
@ -40,16 +39,15 @@ interface PureFileTreePanelProps {
}
export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) => {
const [tree, setTree] = useState<FileTreeNode>(buildCollapsableTree(_tree));
const [tree, setTree] = useState<FileTreeNode>(buildCollapsibleTree(_tree));
const scrollAreaRef = useRef<HTMLDivElement>(null);
const { navigateToPath } = useBrowseNavigation();
const { repoName, revisionName } = useBrowseParams();
const { prefetchFileSource } = usePrefetchFileSource();
// @note: When `_tree` changes, it indicates that a new tree has been loaded.
// In that case, we need to rebuild the collapsable tree.
// In that case, we need to rebuild the collapsible tree.
useEffect(() => {
setTree(buildCollapsableTree(_tree));
setTree(buildCollapsibleTree(_tree));
}, [_tree]);
const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => {
@ -89,18 +87,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
}
}, [setIsCollapsed, navigateToPath, repoName, revisionName]);
// @note: We prefetch the file source when the user hovers over a file.
// This is to try and mitigate having a loading spinner appear when
// the user clicks on a file to open it.
// @see: /browse/[...path]/page.tsx
const onNodeMouseEnter = useCallback((node: FileTreeNode) => {
if (node.type !== 'blob') {
return;
}
prefetchFileSource(repoName, revisionName ?? 'HEAD', node.path);
}, [prefetchFileSource, repoName, revisionName]);
const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => {
return (
<>
@ -115,7 +101,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
isCollapsed={node.isCollapsed}
isCollapseChevronVisible={node.type === 'tree'}
onClick={() => onNodeClicked(node)}
onMouseEnter={() => onNodeMouseEnter(node)}
parentRef={scrollAreaRef}
/>
{node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)}
@ -124,7 +109,7 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
})}
</>
);
}, [path, onNodeClicked, onNodeMouseEnter]);
}, [path, onNodeClicked]);
const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]);

View file

@ -1,34 +0,0 @@
'use client';
import { useQueryClient } from "@tanstack/react-query";
import { useDomain } from "./useDomain";
import { unwrapServiceError } from "@/lib/utils";
import { getFileSource } from "@/features/search/fileSourceApi";
import { useDebounceCallback } from "usehooks-ts";
interface UsePrefetchFileSourceProps {
debounceDelay?: number;
staleTime?: number;
}
export const usePrefetchFileSource = ({
debounceDelay = 200,
staleTime = 5 * 60 * 1000, // 5 minutes
}: UsePrefetchFileSourceProps = {}) => {
const queryClient = useQueryClient();
const domain = useDomain();
const prefetchFileSource = useDebounceCallback((repoName: string, revisionName: string, path: string) => {
queryClient.prefetchQuery({
queryKey: ['fileSource', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(getFileSource({
fileName: path,
repository: repoName,
branch: revisionName,
}, domain)),
staleTime,
});
}, debounceDelay);
return { prefetchFileSource };
}

View file

@ -1,36 +0,0 @@
'use client';
import { useQueryClient } from "@tanstack/react-query";
import { useDomain } from "./useDomain";
import { unwrapServiceError } from "@/lib/utils";
import { getFolderContents } from "@/features/fileTree/actions";
import { useDebounceCallback } from "usehooks-ts";
interface UsePrefetchFolderContentsProps {
debounceDelay?: number;
staleTime?: number;
}
export const usePrefetchFolderContents = ({
debounceDelay = 200,
staleTime = 5 * 60 * 1000, // 5 minutes
}: UsePrefetchFolderContentsProps = {}) => {
const queryClient = useQueryClient();
const domain = useDomain();
const prefetchFolderContents = useDebounceCallback((repoName: string, revisionName: string, path: string) => {
queryClient.prefetchQuery({
queryKey: ['tree', repoName, revisionName, path, domain],
queryFn: () => unwrapServiceError(
getFolderContents({
repoName,
revisionName,
path,
}, domain)
),
staleTime,
});
}, debounceDelay);
return { prefetchFolderContents };
}

View file

@ -114,7 +114,7 @@ const syncDeclarativeConfig = async (configPath: string) => {
if (hasPublicAccessEntitlement) {
if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging 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);
}
@ -159,15 +159,32 @@ const pruneOldGuestUser = async () => {
}
const initSingleTenancy = async () => {
await prisma.org.upsert({
where: {
id: SINGLE_TENANT_ORG_ID,
},
update: {},
create: {
name: SINGLE_TENANT_ORG_NAME,
domain: SINGLE_TENANT_ORG_DOMAIN,
id: SINGLE_TENANT_ORG_ID
// Back fill the inviteId if the org has already been created to prevent needing to wipe the db
await prisma.$transaction(async (tx) => {
const org = await tx.org.findUnique({
where: {
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.
const configPath = env.CONFIG_PATH;
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);
// 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 { env } from "@/env.mjs";
import { prisma } from "@/prisma";
import { OrgRole } from "@sourcebot/db";
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { hasEntitlement } from "@sourcebot/shared";
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
import { createAccountRequest } from "@/actions";
import { handleJITProvisioning } from "@/ee/features/sso/sso";
import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
import { createLogger } from "@sourcebot/logger";
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 auditService = getAuditService();
@ -27,7 +28,7 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
id: "undefined",
type: "user"
},
orgId: SINGLE_TENANT_ORG_ID, // TODO(mt)
orgId: SINGLE_TENANT_ORG_ID,
metadata: {
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");
}
// In single-tenant mode, we assign the first user to sign
// up as the owner of the default org.
if (env.SOURCEBOT_TENANCY_MODE === 'single') {
const defaultOrg = await prisma.org.findUnique({
where: {
id: SINGLE_TENANT_ORG_ID,
},
include: {
members: {
where: {
role: {
not: OrgRole.GUEST,
}
const defaultOrg = await prisma.org.findUnique({
where: {
id: SINGLE_TENANT_ORG_ID,
},
include: {
members: {
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) {
await auditService.createAudit({
action: "user.creation_failed",
actor: {
id: user.id,
type: "user"
// If this is the first user to sign up, we make them the 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,
},
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");
}
// 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,
}
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({
action: "user.owner_created",
actor: {
id: user.id,
type: "user"
},
await auditService.createAudit({
action: "user.owner_created",
actor: {
id: user.id,
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,
target: {
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);
}
role: OrgRole.MEMBER,
}
});
}
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({
action: "user.join_requested",
actor: {
id: user.id,
type: "user"
},
orgId: SINGLE_TENANT_ORG_ID,
target: {
id: SINGLE_TENANT_ORG_ID.toString(),
type: "org"
},
});
export const orgHasAvailability = async (domain: string): Promise<boolean> => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
logger.error(`orgHasAvailability: org not found for domain ${domain}`);
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',
NOT_AUTHENTICATED = 'NOT_AUTHENTICATED',
NOT_FOUND = 'NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
ORG_NOT_FOUND = 'ORG_NOT_FOUND',
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND',
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS',
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
INVALID_INVITE = 'INVALID_INVITE',
INVALID_INVITE_LINK = 'INVALID_INVITE_LINK',
INVITE_LINK_NOT_ENABLED = 'INVITE_LINK_NOT_ENABLED',
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',

Some files were not shown because too many files have changed in this diff Show more