mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-17 23:05:21 +00:00
Merge branch 'sourcebot-dev:main' into main
This commit is contained in:
commit
14fac64c14
105 changed files with 2481 additions and 1825 deletions
17
.github/workflows/changelog-reminder.yml
vendored
Normal file
17
.github/workflows/changelog-reminder.yml
vendored
Normal 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
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
@ -229,7 +239,7 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou
|
|||
|
||||
### Added
|
||||
|
||||
- Added `maxTrigramCount` to the config to control the maximum allowable of trigrams per document.
|
||||
- Added `maxTrigramCount` to the config to control the maximum allowable of trigrams per document.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
@ -287,7 +297,7 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou
|
|||
- Added config option `settings.maxFileSize` to control the maximum file size zoekt will index. ([#118](https://github.com/sourcebot-dev/sourcebot/pull/118))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
- Fixed syntax highlighting for zoekt query language. ([#115](https://github.com/sourcebot-dev/sourcebot/pull/115))
|
||||
- Fixed issue with Gerrit repo fetching not paginating. ([#114](https://github.com/sourcebot-dev/sourcebot/pull/114))
|
||||
- Fixed visual issues with filter panel. ([#105](https://github.com/sourcebot-dev/sourcebot/pull/105))
|
||||
|
|
@ -339,13 +349,13 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou
|
|||
### Added
|
||||
|
||||
- Added `DOMAIN_SUB_PATH` environment variable to allow overriding the default domain subpath. ([#74](https://github.com/sourcebot-dev/sourcebot/pull/74))
|
||||
- Added option `all` to the GitLab index schema, allowing for indexing all projects in a self-hosted GitLab instance. ([#84](https://github.com/sourcebot-dev/sourcebot/pull/84))
|
||||
- Added option `all` to the GitLab index schema, allowing for indexing all projects in a self-hosted GitLab instance. ([#84](https://github.com/sourcebot-dev/sourcebot/pull/84))
|
||||
|
||||
## [2.4.3] - 2024-11-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Bumped NodeJS version to v20. ([#78](https://github.com/sourcebot-dev/sourcebot/pull/78))
|
||||
- Bumped NodeJS version to v20. ([#78](https://github.com/sourcebot-dev/sourcebot/pull/78))
|
||||
|
||||
## [2.4.2] - 2024-11-14
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -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
6
_typos.toml
Normal 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"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
46
docs/docs/configuration/auth/faq.mdx
Normal file
46
docs/docs/configuration/auth/faq.mdx
Normal 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!
|
||||
30
docs/docs/configuration/auth/inviting-members.mdx
Normal file
30
docs/docs/configuration/auth/inviting-members.mdx
Normal 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**:
|
||||
|
||||

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

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

|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
|
|
|||
105
docs/docs/configuration/auth/providers.mdx
Normal file
105
docs/docs/configuration/auth/providers.mdx
Normal 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`
|
||||
|
||||
---
|
||||
|
|
@ -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> |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||

|
||||
|
||||
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 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 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">
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
BIN
docs/images/github_pat_scopes_fine_grained.png
Normal file
BIN
docs/images/github_pat_scopes_fine_grained.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/images/invite_link_toggle.png
Normal file
BIN
docs/images/invite_link_toggle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
BIN
docs/images/member_approval_toggle.png
Normal file
BIN
docs/images/member_approval_toggle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
|
|
@ -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": [
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -165,12 +165,18 @@ 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?
|
||||
|
||||
/// List of pending invites to this organization
|
||||
invites Invite[]
|
||||
|
||||
/// The invite id for this organization
|
||||
inviteLinkEnabled Boolean @default(false)
|
||||
inviteLinkId String?
|
||||
|
||||
audits Audit[]
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
[
|
||||
|
|
@ -2258,4 +2258,4 @@ const schema = {
|
|||
},
|
||||
"additionalProperties": false
|
||||
} as const;
|
||||
export { schema as indexSchema };
|
||||
export { schema as indexSchema };
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export const getPlan = (): Plan => {
|
|||
}
|
||||
|
||||
export const getSeats = (): number => {
|
||||
const licenseKey = getLicenseKey();
|
||||
const licenseKey = getLicenseKey();
|
||||
return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
@ -1208,7 +1187,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
|||
if (isServiceError(user)) {
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
|
|
@ -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 }) => {
|
||||
|
|
@ -1784,7 +1737,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
|
|||
action: "user.join_request_approve_failed",
|
||||
actor: {
|
||||
id: userId,
|
||||
type: "user"
|
||||
type: "user"
|
||||
},
|
||||
target: {
|
||||
id: requestId,
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
194
packages/web/src/app/[domain]/browse/hooks/utils.test.ts
Normal file
194
packages/web/src/app/[domain]/browse/hooks/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
43
packages/web/src/app/[domain]/browse/hooks/utils.ts
Normal file
43
packages/web/src/app/[domain]/browse/hooks/utils.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col items-center space-y-2 mt-4">
|
||||
<ResubmitAccountRequestButton domain={domain} userId={userId} />
|
||||
|
||||
<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(--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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,43 +38,60 @@ 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: {
|
||||
orgId: org.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// There's two reasons why a user might not be a member of an org:
|
||||
// 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them
|
||||
// the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats.
|
||||
// 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org.
|
||||
if (!membership) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 >
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
77
packages/web/src/app/components/authMethodSelector.tsx
Normal file
77
packages/web/src/app/components/authMethodSelector.tsx
Normal 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} />
|
||||
] : [])
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
98
packages/web/src/app/components/authSecurityNotice.tsx
Normal file
98
packages/web/src/app/components/authSecurityNotice.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
packages/web/src/app/components/dividerSet.tsx
Normal file
13
packages/web/src/app/components/dividerSet.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
};
|
||||
130
packages/web/src/app/components/inviteLinkToggle.tsx
Normal file
130
packages/web/src/app/components/inviteLinkToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
packages/web/src/app/components/joinOrganizationButton.tsx
Normal file
55
packages/web/src/app/components/joinOrganizationButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
packages/web/src/app/components/joinOrganizationCard.tsx
Normal file
23
packages/web/src/app/components/joinOrganizationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
packages/web/src/app/components/providerButton.tsx
Normal file
45
packages/web/src/app/components/providerButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
52
packages/web/src/app/invite/actions.ts
Normal file
52
packages/web/src/app/invite/actions.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
)
|
||||
86
packages/web/src/app/invite/page.tsx
Normal file
86
packages/web/src/app/invite/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -172,79 +166,4 @@ 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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\"."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
18
packages/web/src/lib/authProviders.ts
Normal file
18
packages/web/src/lib/authProviders.ts
Normal 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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
@ -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
Loading…
Reference in a new issue