mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Revamp onboarding flow (#376)
* sign up copy nits * first pass at new onboarding page * wip join onboard logic * refactor auth provider fetch logic * add member approval and invite link flag logic * update join request flow and remove jit logic * onboard guard * nits, onboard role check, invite link enabled check * fix bg color issue in onboarding page * refactor onboard UI * ui nits and more onboarding resource cards * revamp auth docs * change member approval default behavior and updated docs * merge prisma migrations * add id to resource card * feedback * feedback * feedback and fixed build * settings drop down UI nit * ui nits * handle join when max capacity case * add news data for member toggle * refactor for public access case * add iap bridge to onboard logic * fetch member approval req and invite link enabled flag on server * ui nits * fix invite link enable toggle snapping issue * ui nits * styling and ui nits, pass in invite id from server * add mcp resource in onboard step * get invite link in server * fix build issue * refactor docs on config * minor doc nit
This commit is contained in:
parent
1384dd870e
commit
173a56ab64
64 changed files with 1985 additions and 1435 deletions
|
|
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364)
|
- Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364)
|
||||||
|
- Revamped onboarding experience. [#370](https://github.com/sourcebot-dev/sourcebot/pull/376)
|
||||||
- Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377)
|
- Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377)
|
||||||
- Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365)
|
- Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
"group": "Configuration",
|
"group": "Configuration",
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"group": "Connecting your code",
|
"group": "Indexing your code",
|
||||||
"pages": [
|
"pages": [
|
||||||
"docs/connections/overview",
|
"docs/connections/overview",
|
||||||
"docs/connections/github",
|
"docs/connections/github",
|
||||||
|
|
@ -72,7 +72,10 @@
|
||||||
"group": "Authentication",
|
"group": "Authentication",
|
||||||
"pages": [
|
"pages": [
|
||||||
"docs/configuration/auth/overview",
|
"docs/configuration/auth/overview",
|
||||||
"docs/configuration/auth/roles-and-permissions"
|
"docs/configuration/auth/providers",
|
||||||
|
"docs/configuration/auth/inviting-members",
|
||||||
|
"docs/configuration/auth/roles-and-permissions",
|
||||||
|
"docs/configuration/auth/faq"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"docs/configuration/transactional-emails",
|
"docs/configuration/transactional-emails",
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
|
||||||
| `user.join_requested` | `user` | `org` |
|
| `user.join_requested` | `user` | `org` |
|
||||||
| `user.join_request_approve_failed` | `user` | `account_join_request` |
|
| `user.join_request_approve_failed` | `user` | `account_join_request` |
|
||||||
| `user.join_request_approved` | `user` | `account_join_request` |
|
| `user.join_request_approved` | `user` | `account_join_request` |
|
||||||
| `user.join_request_removed` | `user` | `account_join_request` |
|
|
||||||
| `user.invite_failed` | `user` | `org` |
|
| `user.invite_failed` | `user` | `org` |
|
||||||
| `user.invites_created` | `user` | `org` |
|
| `user.invites_created` | `user` | `org` |
|
||||||
| `user.invite_accept_failed` | `user` | `invite` |
|
| `user.invite_accept_failed` | `user` | `invite` |
|
||||||
|
|
|
||||||
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>
|
<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
|
# 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -41,7 +41,6 @@ The following environment variables allow you to configure your Sourcebot deploy
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| :------- | :------ | :---------- |
|
| :------- | :------ | :---------- |
|
||||||
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | <p>Enables/disables audit logging</p> |
|
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | <p>Enables/disables audit logging</p> |
|
||||||
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> |
|
|
||||||
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
|
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
|
||||||
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |
|
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |
|
||||||
| `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` | <p>The client secret for GitHub Enterprise SSO authentication.</p> |
|
| `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` | <p>The client secret for GitHub Enterprise SSO authentication.</p> |
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ icon: folder
|
||||||
|
|
||||||
import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx'
|
import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx'
|
||||||
|
|
||||||
Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaing Sourcebot will **not** `git fetch` new revisions.
|
Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaning Sourcebot will **not** `git fetch` new revisions.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,24 @@ sidebarTitle: Overview
|
||||||
import SupportedPlatforms from '/snippets/platform-support.mdx'
|
import SupportedPlatforms from '/snippets/platform-support.mdx'
|
||||||
import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx'
|
import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx'
|
||||||
|
|
||||||
A **connection** in Sourcebot represents a link to a code host (such as GitHub, GitLab, Bitbucket, etc.). Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name.
|
To index your code with Sourcebot, you must provide a configuration file. When running Sourcebot, this file must be mounted in a volume that is accessible to the container, with its
|
||||||
|
path specified in the `CONFIG_PATH` environment variable. For example:
|
||||||
|
|
||||||
A JSON configuration file is used to specify connections. For example:
|
```bash icon="terminal" Passing in a CONFIG_PATH to Sourcebot
|
||||||
|
docker run \
|
||||||
|
-v $(pwd)/config.json:/data/config.json \
|
||||||
|
-e CONFIG_PATH=/data/config.json \
|
||||||
|
... \ # other config
|
||||||
|
ghcr.io/sourcebot-dev/sourcebot:latest
|
||||||
|
```
|
||||||
|
|
||||||
```json
|
## Config Schema
|
||||||
// Specifies two connections:
|
|
||||||
|
The configuration file defines a set of **connections**. A connection in Sourcebot represents a link to a code host (such as GitHub, GitLab, Bitbucket, etc.).
|
||||||
|
|
||||||
|
Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name.
|
||||||
|
|
||||||
|
```json wrap icon="code" Example config with two connections
|
||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
||||||
"connections": {
|
"connections": {
|
||||||
|
|
@ -43,16 +55,7 @@ A JSON configuration file is used to specify connections. For example:
|
||||||
|
|
||||||
Configuration files must conform to the [JSON schema](#schema-reference).
|
Configuration files must conform to the [JSON schema](#schema-reference).
|
||||||
|
|
||||||
When running Sourcebot, this file must be mounted in a volume that is accessible to the container, with its path specified in the `CONFIG_PATH` environment variable. For example:
|
## Config Syncing
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run \
|
|
||||||
-v $(pwd)/config.json:/data/config.json \
|
|
||||||
-e CONFIG_PATH=/data/config.json \
|
|
||||||
... \ # other config
|
|
||||||
ghcr.io/sourcebot-dev/sourcebot:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Sourcebot performs syncing in the background. Syncing consists of two steps:
|
Sourcebot performs syncing in the background. Syncing consists of two steps:
|
||||||
1. Fetch the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host.
|
1. Fetch the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host.
|
||||||
2. Re-indexes the repository.
|
2. Re-indexes the repository.
|
||||||
|
|
@ -70,10 +73,9 @@ On the home page, you can view the sync status of ongoing jobs:
|
||||||
src="https://framerusercontent.com/assets/7YyxK8ctPEy9Rf68X2kIdMI.mp4"
|
src="https://framerusercontent.com/assets/7YyxK8ctPEy9Rf68X2kIdMI.mp4"
|
||||||
></video>
|
></video>
|
||||||
|
|
||||||
## Getting started
|
## Platform Connection Guides
|
||||||
---
|
|
||||||
|
|
||||||
To get started, pick a platform below and follow the instructions to connect your code.
|
To learn more about how to create a connection for a specific code host, check out the guides below.
|
||||||
|
|
||||||
<SupportedPlatforms />
|
<SupportedPlatforms />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
title: "Overview"
|
title: "Overview"
|
||||||
---
|
---
|
||||||
|
|
||||||
Sourcebot is an open-source ([GitHub](https://github.com/sourcebot-dev/sourcebot)), self-hosted code search tool that is purpose built to help teams find and navigate code quickly, at scale.
|
[Sourcebot]((https://github.com/sourcebot-dev/sourcebot)) is an open-source, self-hosted code search tool. It allows you to search and navigate across millions of lines of code across several code host platforms.
|
||||||
|
|
||||||
<CardGroup>
|
<CardGroup>
|
||||||
<Card title="Deployment guide" icon="server" href="/docs/deployment-guide" horizontal="true">
|
<Card title="Deployment guide" icon="server" href="/docs/deployment-guide" horizontal="true">
|
||||||
|
|
@ -16,10 +16,10 @@ Sourcebot is an open-source ([GitHub](https://github.com/sourcebot-dev/sourcebot
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
<Accordion title="Why Sourcebot?">
|
<Accordion title="Why Sourcebot?">
|
||||||
- **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more.
|
- **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more.
|
||||||
- **Self-hosted:** Ships as a single [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot) that can be deployed anywhere.
|
- **Self-hosted:** Deploy it in minutes using our official [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). All of your data stays on your machine.
|
||||||
- **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc.
|
- **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc.
|
||||||
- **Scalable:** Scales to millions of lines of code.
|
- **Scalable:** Scales to millions of lines of code.
|
||||||
- **Open-source:** Core features are MIT licensed, no vendor lock-in.
|
- **Open-source:** Core features are MIT licensed.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|
||||||
|
|
|
||||||
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 |
|
|
@ -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?
|
imageUrl String?
|
||||||
metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
|
metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
|
||||||
|
|
||||||
|
memberApprovalRequired Boolean @default(true)
|
||||||
|
|
||||||
stripeCustomerId String?
|
stripeCustomerId String?
|
||||||
stripeSubscriptionStatus StripeSubscriptionStatus?
|
stripeSubscriptionStatus StripeSubscriptionStatus?
|
||||||
stripeLastUpdatedAt DateTime?
|
stripeLastUpdatedAt DateTime?
|
||||||
|
|
||||||
/// List of pending invites to this organization
|
/// List of pending invites to this organization
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
||||||
|
/// The invite id for this organization
|
||||||
|
inviteLinkEnabled Boolean @default(false)
|
||||||
|
inviteLinkId String?
|
||||||
|
|
||||||
audits Audit[]
|
audits Audit[]
|
||||||
|
|
||||||
|
|
@ -263,7 +269,6 @@ model User {
|
||||||
image String?
|
image String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
orgs UserToOrg[]
|
orgs UserToOrg[]
|
||||||
pendingApproval Boolean @default(true)
|
|
||||||
accountRequest AccountRequest?
|
accountRequest AccountRequest?
|
||||||
|
|
||||||
/// List of pending invites that the user has created
|
/// List of pending invites that the user has created
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export const getPlan = (): Plan => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSeats = (): number => {
|
export const getSeats = (): number => {
|
||||||
const licenseKey = getLicenseKey();
|
const licenseKey = getLicenseKey();
|
||||||
return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS;
|
return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
|
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
|
||||||
import { CodeHostType, isServiceError } from "@/lib/utils";
|
import { CodeHostType, isServiceError } from "@/lib/utils";
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { render } from "@react-email/components";
|
import { render } from "@react-email/components";
|
||||||
|
|
@ -28,15 +28,16 @@ import InviteUserEmail from "./emails/inviteUserEmail";
|
||||||
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
||||||
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
||||||
import { TenancyMode, ApiKeyPayload } from "./lib/types";
|
import { TenancyMode, ApiKeyPayload } from "./lib/types";
|
||||||
import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils";
|
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
|
||||||
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
|
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
|
||||||
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
|
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
|
||||||
import { getPlan, getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
|
import { getPlan, hasEntitlement } from "@sourcebot/shared";
|
||||||
import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess";
|
import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess";
|
||||||
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
||||||
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { getAuditService } from "@/ee/features/audit/factory";
|
import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
|
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
|
|
@ -116,35 +117,6 @@ export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | unde
|
||||||
return fn(session.user.id, undefined);
|
return fn(session.user.id, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const orgHasAvailability = async (domain: string): Promise<boolean> => {
|
|
||||||
const org = await prisma.org.findUnique({
|
|
||||||
where: {
|
|
||||||
domain,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const members = await prisma.userToOrg.findMany({
|
|
||||||
where: {
|
|
||||||
orgId: org.id,
|
|
||||||
role: {
|
|
||||||
not: OrgRole.GUEST,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxSeats = getSeats();
|
|
||||||
const memberCount = members.length;
|
|
||||||
|
|
||||||
if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const withOrgMembership = async <T>(userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise<T>, minRequiredRole: OrgRole = OrgRole.MEMBER) => {
|
export const withOrgMembership = async <T>(userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise<T>, minRequiredRole: OrgRole = OrgRole.MEMBER) => {
|
||||||
const org = await prisma.org.findUnique({
|
const org = await prisma.org.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -1169,6 +1141,13 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
|
||||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
export const getOrgInviteId = async (domain: string) => sew(() =>
|
||||||
|
withAuth(async (userId) =>
|
||||||
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
return org.inviteLinkId;
|
||||||
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
|
));
|
||||||
|
|
||||||
export const getMe = async () => sew(() =>
|
export const getMe = async () => sew(() =>
|
||||||
withAuth(async (userId) => {
|
withAuth(async (userId) => {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|
@ -1208,7 +1187,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
||||||
if (isServiceError(user)) {
|
if (isServiceError(user)) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invite = await prisma.invite.findUnique({
|
const invite = await prisma.invite.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: inviteId,
|
id: inviteId,
|
||||||
|
|
@ -1257,73 +1236,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await prisma.$transaction(async (tx) => {
|
const addUserToOrgRes = await addUserToOrganization(user.id, invite.orgId);
|
||||||
await tx.userToOrg.create({
|
if (isServiceError(addUserToOrgRes)) {
|
||||||
data: {
|
await failAuditCallback(addUserToOrgRes.message);
|
||||||
userId: user.id,
|
return addUserToOrgRes;
|
||||||
orgId: invite.orgId,
|
|
||||||
role: "MEMBER",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.user.update({
|
|
||||||
where: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
pendingApproval: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.invite.delete({
|
|
||||||
where: {
|
|
||||||
id: invite.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete the account request if it exists since we've redeemed an invite
|
|
||||||
const accountRequest = await tx.accountRequest.findUnique({
|
|
||||||
where: {
|
|
||||||
requestedById_orgId: {
|
|
||||||
requestedById: user.id,
|
|
||||||
orgId: invite.orgId,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (accountRequest) {
|
|
||||||
logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've redeemed an invite`);
|
|
||||||
await auditService.createAudit({
|
|
||||||
action: "user.join_request_removed",
|
|
||||||
actor: {
|
|
||||||
id: user.id,
|
|
||||||
type: "user"
|
|
||||||
},
|
|
||||||
orgId: invite.org.id,
|
|
||||||
target: {
|
|
||||||
id: accountRequest.id,
|
|
||||||
type: "account_join_request"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.accountRequest.delete({
|
|
||||||
where: {
|
|
||||||
id: accountRequest.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IS_BILLING_ENABLED) {
|
|
||||||
const result = await incrementOrgSeatCount(invite.orgId, tx);
|
|
||||||
if (isServiceError(result)) {
|
|
||||||
throw result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
await failAuditCallback(res.message);
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await auditService.createAudit({
|
await auditService.createAudit({
|
||||||
|
|
@ -1519,19 +1435,6 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: The fact that pendingApproval is set in the user is a bit weird here, since it will prevent approval from working in the multi-tenant case.
|
|
||||||
// We need to set pendingApproval to be true here though so that if the user tries to sign into the deployment again it will send another request. Without
|
|
||||||
// this, the user will never be able to request to join the org again.
|
|
||||||
// TODO(multitenant): Handle this better
|
|
||||||
await tx.user.update({
|
|
||||||
where: {
|
|
||||||
id: memberId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
pendingApproval: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (IS_BILLING_ENABLED) {
|
if (IS_BILLING_ENABLED) {
|
||||||
const result = await decrementOrgSeatCount(org.id, tx);
|
const result = await decrementOrgSeatCount(org.id, tx);
|
||||||
if (isServiceError(result)) {
|
if (isServiceError(result)) {
|
||||||
|
|
@ -1677,14 +1580,6 @@ export const createAccountRequest = async (userId: string, domain: string) => se
|
||||||
return notFound("User not found");
|
return notFound("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.pendingApproval == false) {
|
|
||||||
logger.warn(`User ${userId} isn't pending approval. Skipping account request creation.`);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
existingRequest: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const org = await prisma.org.findUnique({
|
const org = await prisma.org.findUnique({
|
||||||
where: {
|
where: {
|
||||||
domain,
|
domain,
|
||||||
|
|
@ -1776,6 +1671,64 @@ export const createAccountRequest = async (userId: string, domain: string) => se
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getMemberApprovalRequired = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
|
||||||
|
const org = await prisma.org.findUnique({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return orgNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return org.memberApprovalRequired;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setMemberApprovalRequired = async (domain: string, required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
|
||||||
|
withAuth(async (userId) =>
|
||||||
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
await prisma.org.update({
|
||||||
|
where: { id: org.id },
|
||||||
|
data: { memberApprovalRequired: required },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getInviteLinkEnabled = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
|
||||||
|
const org = await prisma.org.findUnique({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return orgNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return org.inviteLinkEnabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
|
||||||
|
withAuth(async (userId) =>
|
||||||
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
await prisma.org.update({
|
||||||
|
where: { id: org.id },
|
||||||
|
data: { inviteLinkEnabled: enabled },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () =>
|
export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () =>
|
||||||
withAuth(async (userId) =>
|
withAuth(async (userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
|
@ -1784,7 +1737,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
|
||||||
action: "user.join_request_approve_failed",
|
action: "user.join_request_approve_failed",
|
||||||
actor: {
|
actor: {
|
||||||
id: userId,
|
id: userId,
|
||||||
type: "user"
|
type: "user"
|
||||||
},
|
},
|
||||||
target: {
|
target: {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
|
|
@ -1811,60 +1764,10 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAvailability = await orgHasAvailability(domain);
|
const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id);
|
||||||
if (!hasAvailability) {
|
if (isServiceError(addUserToOrgRes)) {
|
||||||
await failAuditCallback("Organization is at max capacity");
|
await failAuditCallback(addUserToOrgRes.message);
|
||||||
return {
|
return addUserToOrgRes;
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
|
||||||
message: "Organization is at max capacity",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.user.update({
|
|
||||||
where: {
|
|
||||||
id: request.requestedById,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
pendingApproval: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.userToOrg.create({
|
|
||||||
data: {
|
|
||||||
userId: request.requestedById,
|
|
||||||
orgId: org.id,
|
|
||||||
role: "MEMBER",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.accountRequest.delete({
|
|
||||||
where: {
|
|
||||||
id: requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const invites = await tx.invite.findMany({
|
|
||||||
where: {
|
|
||||||
recipientEmail: request.requestedBy.email!,
|
|
||||||
orgId: org.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const invite of invites) {
|
|
||||||
logger.info(`Account request approved. Deleting invite ${invite.id} for ${request.requestedBy.email}`);
|
|
||||||
await tx.invite.delete({
|
|
||||||
where: {
|
|
||||||
id: invite.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
await failAuditCallback(res.message);
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send approval email to the user
|
// Send approval email to the user
|
||||||
|
|
@ -1936,19 +1839,6 @@ export const rejectAccountRequest = async (requestId: string, domain: string) =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await auditService.createAudit({
|
|
||||||
action: "user.join_request_removed",
|
|
||||||
actor: {
|
|
||||||
id: userId,
|
|
||||||
type: "user"
|
|
||||||
},
|
|
||||||
orgId: org.id,
|
|
||||||
target: {
|
|
||||||
id: requestId,
|
|
||||||
type: "account_join_request"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Redirect } from "@/app/components/redirect";
|
import { Redirect } from "@/app/components/redirect";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
|
@ -10,20 +9,19 @@ interface OnboardGuardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardGuard = ({ children }: OnboardGuardProps) => {
|
export const OnboardGuard = ({ children }: OnboardGuardProps) => {
|
||||||
const domain = useDomain();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (!pathname.endsWith('/onboard')) {
|
if (!pathname.endsWith('/onboard')) {
|
||||||
return (
|
return (
|
||||||
<Redirect
|
<Redirect
|
||||||
to={`/${domain}/onboard`}
|
to={`/onboard`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
}, [domain, children, pathname]);
|
}, [children, pathname]);
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,8 @@
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { HelpCircle } from "lucide-react"
|
|
||||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { ResubmitAccountRequestButton } from "./resubmitAccountRequestButton"
|
|
||||||
|
|
||||||
interface PendingApprovalCardProps {
|
export const PendingApprovalCard = async () => {
|
||||||
domain: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps) => {
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
const userId = session?.user?.id
|
const userId = session?.user?.id
|
||||||
|
|
||||||
|
|
@ -18,42 +11,45 @@ export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen py-24 bg-background text-foreground relative">
|
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center p-6">
|
||||||
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
||||||
|
|
||||||
<div className="w-full max-w-md mx-auto">
|
<div className="w-full max-w-md">
|
||||||
<Card className="shadow-xl">
|
<div className="text-center space-y-8">
|
||||||
<CardHeader className="pb-4">
|
<SourcebotLogo
|
||||||
<SourcebotLogo
|
className="h-10 mx-auto"
|
||||||
className="h-16 w-auto mx-auto mb-2"
|
size="large"
|
||||||
size="large"
|
/>
|
||||||
/>
|
|
||||||
<CardTitle className="text-2xl font-bold text-center">Pending Approval</CardTitle>
|
<div className="space-y-6">
|
||||||
<CardDescription className="text-center mt-2">
|
<div className="w-12 h-12 mx-auto bg-[var(--accent)] rounded-full flex items-center justify-center">
|
||||||
Your request to join the organization is being reviewed
|
<svg className="w-6 h-6 text-[var(--accent-foreground)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</CardDescription>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</CardHeader>
|
</svg>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex flex-col items-center space-y-2 mt-4">
|
|
||||||
<ResubmitAccountRequestButton domain={domain} userId={userId} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center">
|
|
||||||
<div className="inline-flex items-center space-x-3 p-3 bg-muted/50 rounded-md">
|
<div className="space-y-2">
|
||||||
<HelpCircle className="h-5 w-5 text-primary" />
|
<h1 className="text-2xl font-semibold text-[var(--foreground)]">
|
||||||
<div className="text-sm text-muted-foreground text-center">
|
Approval Pending
|
||||||
<p>Need help or have questions?</p>
|
</h1>
|
||||||
<a
|
<p className="text-[var(--muted-foreground)] text-base">
|
||||||
href="https://github.com/sourcebot-dev/sourcebot/discussions/categories/support"
|
Your request is being reviewed.
|
||||||
className="text-primary hover:text-primary/80 underline underline-offset-2"
|
</p>
|
||||||
>
|
</div>
|
||||||
Submit a support request
|
</div>
|
||||||
</a>
|
|
||||||
</div>
|
<div className="pt-4">
|
||||||
|
<div className="inline-flex items-center gap-3 px-4 py-2 rounded-full bg-[var(--accent)] text-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="block w-2 h-2 bg-[var(--accent-foreground)] rounded-full opacity-40 animate-pulse"></span>
|
||||||
|
<span className="block w-2 h-2 bg-[var(--accent-foreground)] rounded-full opacity-60 animate-pulse" style={{ animationDelay: '0.15s' }}></span>
|
||||||
|
<span className="block w-2 h-2 bg-[var(--accent-foreground)] rounded-full opacity-80 animate-pulse" style={{ animationDelay: '0.3s' }}></span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[var(--accent-foreground)]">Awaiting review</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ export const SettingsDropdown = ({
|
||||||
<DropdownMenuContent className="w-64">
|
<DropdownMenuContent className="w-64">
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<div className="flex flex-row items-center gap-1 p-2">
|
<div className="flex flex-row items-start gap-3 p-2">
|
||||||
<Avatar>
|
<Avatar className="flex-shrink-0">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={session.user.image ?? ""}
|
src={session.user.image ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|
@ -92,7 +92,7 @@ export const SettingsDropdown = ({
|
||||||
{session.user.name && session.user.name.length > 0 ? session.user.name[0] : 'U'}
|
{session.user.name && session.user.name.length > 0 ? session.user.name[0] : 'U'}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<p className="text-sm font-medium text-ellipsis">{session.user.email ?? "User"}</p>
|
<p className="text-sm font-medium break-all flex-1 leading-relaxed">{session.user.email ?? "User"}</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,16 @@ import { useState } from "react"
|
||||||
import { useToast } from "@/components/hooks/use-toast"
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
import { createAccountRequest } from "@/actions"
|
import { createAccountRequest } from "@/actions"
|
||||||
import { isServiceError } from "@/lib/utils"
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
interface ResubmitButtonProps {
|
interface SubmitButtonProps {
|
||||||
domain: string
|
domain: string
|
||||||
userId: string
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonProps) {
|
export function SubmitAccountRequestButton({ domain, userId }: SubmitButtonProps) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const router = useRouter()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
|
@ -28,19 +30,20 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "Request Resubmitted",
|
title: "Request Submitted",
|
||||||
description: "Your request to join the organization has been resubmitted.",
|
description: "Your request to join the organization has been submitted.",
|
||||||
variant: "default",
|
variant: "default",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Refresh the page to trigger layout re-render and show PendingApprovalCard
|
||||||
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "Failed to Resubmit",
|
title: "Failed to Submit",
|
||||||
description: `There was an error resubmitting your request. Reason: ${result.message}`,
|
description: `There was an error submitting your request. Reason: ${result.message}`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +60,7 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{isSubmitting ? "Submitting..." : "Resubmit Request"}
|
{isSubmitting ? "Submitting..." : "Submit Request"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,14 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||||
import { PendingApprovalCard } from "./components/pendingApproval";
|
import { PendingApprovalCard } from "./components/pendingApproval";
|
||||||
|
import { SubmitJoinRequest } from "./components/submitJoinRequest";
|
||||||
import { hasEntitlement } from "@sourcebot/shared";
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
|
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import { GcpIapAuth } from "./components/gcpIapAuth";
|
import { GcpIapAuth } from "./components/gcpIapAuth";
|
||||||
|
import { getMemberApprovalRequired } from "@/actions";
|
||||||
|
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
|
||||||
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
|
|
@ -34,43 +38,60 @@ export default async function Layout({
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain);
|
const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain);
|
||||||
if (!publicAccessEnabled) {
|
|
||||||
const session = await auth();
|
// If the user is authenticated, we must check if they're a member of the org
|
||||||
if (!session) {
|
if (session) {
|
||||||
const ssoEntitlement = await hasEntitlement("sso");
|
|
||||||
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
|
|
||||||
return <GcpIapAuth callbackUrl={`/${domain}`} />;
|
|
||||||
} else {
|
|
||||||
redirect('/login');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await prisma.userToOrg.findUnique({
|
const membership = await prisma.userToOrg.findUnique({
|
||||||
where: {
|
where: {
|
||||||
orgId_userId: {
|
orgId_userId: {
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
userId: session.user.id
|
userId: session.user.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true
|
user: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// There's two reasons why a user might not be a member of an org:
|
||||||
|
// 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them
|
||||||
|
// the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats.
|
||||||
|
// 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org.
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
const user = await prisma.user.findUnique({
|
const memberApprovalRequired = await getMemberApprovalRequired(domain);
|
||||||
|
if (!memberApprovalRequired) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6">
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
||||||
|
<JoinOrganizationCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const hasPendingApproval = await prisma.accountRequest.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id
|
orgId: org.id,
|
||||||
|
requestedById: session.user.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Organization join requests are only supported in single-tenant mode
|
if (hasPendingApproval) {
|
||||||
if (env.SOURCEBOT_TENANCY_MODE === "single" && user?.pendingApproval) {
|
return <PendingApprovalCard />
|
||||||
return <PendingApprovalCard domain={domain} />
|
|
||||||
} else {
|
} else {
|
||||||
return notFound();
|
return <SubmitJoinRequest domain={domain} />
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the user isn't authenticated and public access isn't enabled, we need to redirect them to the login page.
|
||||||
|
if (!publicAccessEnabled) {
|
||||||
|
const ssoEntitlement = await hasEntitlement("sso");
|
||||||
|
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
|
||||||
|
return <GcpIapAuth callbackUrl={`/${domain}`} />;
|
||||||
|
} else {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -12,6 +12,9 @@ import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
|
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
|
||||||
import { RequestsList } from "./components/requestsList";
|
import { RequestsList } from "./components/requestsList";
|
||||||
import { OrgRole } from "@prisma/client";
|
import { OrgRole } from "@prisma/client";
|
||||||
|
import { MemberApprovalRequiredToggle } from "@/app/onboard/components/memberApprovalRequiredToggle";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { getBaseUrl, createInviteLink } from "@/lib/utils";
|
||||||
|
|
||||||
interface MembersSettingsPageProps {
|
interface MembersSettingsPageProps {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -59,6 +62,11 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
|
||||||
const usedSeats = members.length
|
const usedSeats = members.length
|
||||||
const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats;
|
const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats;
|
||||||
|
|
||||||
|
// Get the current URL to construct the full invite link
|
||||||
|
const headersList = headers();
|
||||||
|
const baseUrl = getBaseUrl(headersList);
|
||||||
|
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
|
|
@ -78,6 +86,10 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{userRoleInOrg === OrgRole.OWNER && (
|
||||||
|
<MemberApprovalRequiredToggle memberApprovalRequired={org.memberApprovalRequired} inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} />
|
||||||
|
)}
|
||||||
|
|
||||||
<InviteMemberCard
|
<InviteMemberCard
|
||||||
currentUserRole={userRoleInOrg}
|
currentUserRole={userRoleInOrg}
|
||||||
isBillingEnabled={IS_BILLING_ENABLED}
|
isBillingEnabled={IS_BILLING_ENABLED}
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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 {
|
interface CredentialsFormProps {
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
|
context: "login" | "signup";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => {
|
export const CredentialsForm = ({ callbackUrl, context }: CredentialsFormProps) => {
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const form = useForm<z.infer<typeof verifyCredentialsRequestSchema>>({
|
const form = useForm<z.infer<typeof verifyCredentialsRequestSchema>>({
|
||||||
|
|
@ -80,7 +81,7 @@ export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => {
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
|
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
|
||||||
Sign in with credentials
|
{context === "login" ? "Sign in with credentials" : "Sign up with credentials"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from "next/image";
|
import { useMemo } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { cn, getAuthProviderInfo } from "@/lib/utils";
|
|
||||||
import { MagicLinkForm } from "./magicLinkForm";
|
|
||||||
import { CredentialsForm } from "./credentialsForm";
|
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
import { TextSeparator } from "@/app/components/textSeparator";
|
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
import DemoCard from "@/app/[domain]/onboard/components/demoCard";
|
import DemoCard from "@/app/login/components/demoCard";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import { LoadingButton } from "@/components/ui/loading-button";
|
import type { AuthProvider } from "@/lib/authProviders";
|
||||||
|
|
||||||
const TERMS_OF_SERVICE_URL = "https://sourcebot.dev/terms";
|
const TERMS_OF_SERVICE_URL = "https://sourcebot.dev/terms";
|
||||||
const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
|
const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
|
||||||
|
|
@ -21,17 +16,12 @@ const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy";
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
providers: Array<{ id: string; name: string }>;
|
providers: AuthProvider[];
|
||||||
context: "login" | "signup";
|
context: "login" | "signup";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormProps) => {
|
export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormProps) => {
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
const onSignInWithOauth = useCallback((provider: string) => {
|
|
||||||
signIn(provider, {
|
|
||||||
redirectTo: callbackUrl ?? "/"
|
|
||||||
});
|
|
||||||
}, [callbackUrl]);
|
|
||||||
|
|
||||||
const errorMessage = useMemo(() => {
|
const errorMessage = useMemo(() => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
|
|
@ -47,13 +37,6 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
// Separate OAuth providers from special auth methods
|
|
||||||
const oauthProviders = providers.filter(p =>
|
|
||||||
!["credentials", "nodemailer"].includes(p.id)
|
|
||||||
);
|
|
||||||
const hasCredentials = providers.some(p => p.id === "credentials");
|
|
||||||
const hasMagicLink = providers.some(p => p.id === "nodemailer");
|
|
||||||
|
|
||||||
// Helper function to get the correct analytics event name
|
// Helper function to get the correct analytics event name
|
||||||
const getLoginEventName = (providerId: string) => {
|
const getLoginEventName = (providerId: string) => {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
|
|
@ -74,6 +57,11 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Analytics callback for provider clicks
|
||||||
|
const handleProviderClick = (providerId: string) => {
|
||||||
|
captureEvent(getLoginEventName(providerId), {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center w-full">
|
<div className="flex flex-col items-center justify-center w-full">
|
||||||
<div className="mb-6 flex flex-col items-center">
|
<div className="mb-6 flex flex-col items-center">
|
||||||
|
|
@ -95,38 +83,17 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DividerSet
|
<AuthMethodSelector
|
||||||
elements={[
|
providers={providers}
|
||||||
...(oauthProviders.length > 0 ? [
|
callbackUrl={callbackUrl}
|
||||||
<div key="oauth-providers" className="w-full space-y-3">
|
context={context}
|
||||||
{oauthProviders.map((provider) => {
|
onProviderClick={handleProviderClick}
|
||||||
const providerInfo = getAuthProviderInfo(provider.id);
|
securityNoticeClosable={true}
|
||||||
return (
|
|
||||||
<ProviderButton
|
|
||||||
key={provider.id}
|
|
||||||
name={providerInfo.displayName}
|
|
||||||
logo={providerInfo.icon}
|
|
||||||
onClick={() => {
|
|
||||||
captureEvent(getLoginEventName(provider.id), {});
|
|
||||||
onSignInWithOauth(provider.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
] : []),
|
|
||||||
...(hasMagicLink ? [
|
|
||||||
<MagicLinkForm key="magic-link" callbackUrl={callbackUrl} />
|
|
||||||
] : []),
|
|
||||||
...(hasCredentials ? [
|
|
||||||
<CredentialsForm key="credentials" callbackUrl={callbackUrl} />
|
|
||||||
] : [])
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground mt-8">
|
<p className="text-sm text-muted-foreground mt-8">
|
||||||
{context === "login" ?
|
{context === "login" ?
|
||||||
<>
|
<>
|
||||||
No account yet? <Link className="underline" href="/signup">Sign up</Link>
|
Don't have an account? <Link className="underline" href="/signup">Sign up</Link>
|
||||||
</>
|
</>
|
||||||
:
|
:
|
||||||
<>
|
<>
|
||||||
|
|
@ -141,43 +108,3 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProviderButton = ({
|
|
||||||
name,
|
|
||||||
logo,
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
logo: { src: string, className?: string } | null;
|
|
||||||
onClick: () => void;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadingButton
|
|
||||||
onClick={() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
className={cn("w-full", className)}
|
|
||||||
variant="outline"
|
|
||||||
loading={isLoading}
|
|
||||||
>
|
|
||||||
{logo && <Image src={logo.src} alt={name} className={cn("w-5 h-5 mr-2", logo.className)} />}
|
|
||||||
Sign in with {name}
|
|
||||||
</LoadingButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => {
|
|
||||||
return elements.map((child, index) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{child}
|
|
||||||
{index < elements.length - 1 && <TextSeparator key={`divider-${index}`} />}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@ const magicLinkSchema = z.object({
|
||||||
|
|
||||||
interface MagicLinkFormProps {
|
interface MagicLinkFormProps {
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
|
context: "login" | "signup";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
|
export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => {
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -76,7 +77,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
|
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
|
||||||
Sign in with login code
|
{context === "login" ? "Sign in with login code" : "Sign up with login code"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { LoginForm } from "./components/loginForm";
|
import { LoginForm } from "./components/loginForm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getProviders } from "@/auth";
|
|
||||||
import { Footer } from "@/app/components/footer";
|
import { Footer } from "@/app/components/footer";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { getAuthProviders } from "@/lib/authProviders";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
|
||||||
const logger = createLogger('login-page');
|
const logger = createLogger('login-page');
|
||||||
|
|
||||||
|
|
@ -22,24 +24,19 @@ export default async function Login({ searchParams }: LoginProps) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = getProviders();
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
const providerData = providers
|
if (!org || !org.isOnboarded) {
|
||||||
.map((provider) => {
|
return redirect("/onboard");
|
||||||
if (typeof provider === "function") {
|
}
|
||||||
const providerInfo = provider()
|
|
||||||
return { id: providerInfo.id, name: providerInfo.name }
|
|
||||||
} else {
|
|
||||||
return { id: provider.id, name: provider.name }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const providers = getAuthProviders();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
||||||
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
callbackUrl={searchParams.callbackUrl}
|
callbackUrl={searchParams.callbackUrl}
|
||||||
error={searchParams.error}
|
error={searchParams.error}
|
||||||
providers={providerData}
|
providers={providers}
|
||||||
context="login"
|
context="login"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 type React from "react"
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { OnboardHeader } from "./components/onboardHeader";
|
|
||||||
import { OnboardingSteps } from "@/lib/constants";
|
|
||||||
import { LogoutEscapeHatch } from "../components/logoutEscapeHatch";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
export default async function Onboarding() {
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { AuthMethodSelector } from "@/app/components/authMethodSelector"
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getAuthProviders } from "@/lib/authProviders";
|
||||||
|
import { MemberApprovalRequiredToggle } from "./components/memberApprovalRequiredToggle";
|
||||||
|
import { CompleteOnboardingButton } from "./components/completeOnboardingButton";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
import { prisma } from "@/prisma";
|
||||||
|
import { OrgRole } from "@sourcebot/db";
|
||||||
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { BetweenHorizonalStart, GitBranchIcon, LockIcon } from "lucide-react";
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { GcpIapAuth } from "@/app/[domain]/components/gcpIapAuth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { getBaseUrl, createInviteLink } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface OnboardingProps {
|
||||||
|
searchParams?: { step?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingStep {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subtitle: React.ReactNode
|
||||||
|
component: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceCard {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
href: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Onboarding({ searchParams }: OnboardingProps) {
|
||||||
|
const providers = getAuthProviders();
|
||||||
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
|
||||||
redirect("/login");
|
if (!org) {
|
||||||
|
return <div>Error loading organization</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = (await headers()).get('host') ?? '';
|
// Get the current URL to construct the full invite link
|
||||||
|
const headersList = headers();
|
||||||
|
const baseUrl = getBaseUrl(headersList);
|
||||||
|
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId);
|
||||||
|
|
||||||
|
if (org && org.isOnboarded) {
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is authenticated but not the owner
|
||||||
|
if (session?.user) {
|
||||||
|
if (org) {
|
||||||
|
const membership = await prisma.userToOrg.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_userId: {
|
||||||
|
orgId: org.id,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || membership.role !== OrgRole.OWNER) {
|
||||||
|
return <NonOwnerOnboardingMessage />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're using an IAP bridge we need to sign them in now and then redirect them back to the onboarding page
|
||||||
|
const ssoEntitlement = await hasEntitlement("sso");
|
||||||
|
if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) {
|
||||||
|
return <GcpIapAuth callbackUrl={`/onboard`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine current step based on URL parameter and authentication state
|
||||||
|
const stepParam = searchParams?.step ? parseInt(searchParams.step) : 0;
|
||||||
|
const currentStep = session?.user ? Math.max(2, stepParam) : Math.max(0, Math.min(stepParam, 1));
|
||||||
|
|
||||||
|
const resourceCards: ResourceCard[] = [
|
||||||
|
{
|
||||||
|
id: "code-host-connections",
|
||||||
|
title: "Code Host Connections",
|
||||||
|
description: "Learn how to index repos across Sourcebot's supported platforms",
|
||||||
|
href: "https://docs.sourcebot.dev/docs/connections/overview",
|
||||||
|
icon: <GitBranchIcon className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "authentication-system",
|
||||||
|
title: "Authentication System",
|
||||||
|
description: "Learn how to setup additional auth providers, invite members, and more",
|
||||||
|
href: "https://docs.sourcebot.dev/docs/configuration/auth",
|
||||||
|
icon: <LockIcon className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mcp-server",
|
||||||
|
title: "MCP Server",
|
||||||
|
description: "Learn how to setup Sourcebot's MCP server to provide code context to your AI agents",
|
||||||
|
href: "https://docs.sourcebot.dev/docs/features/mcp-server",
|
||||||
|
icon: <BetweenHorizonalStart className="w-4 h-4" />,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const steps: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
title: "Welcome to Sourcebot",
|
||||||
|
subtitle: "This onboarding flow will guide you through creating your owner account and configuring your organization.",
|
||||||
|
component: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button asChild className="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground transition-all duration-200 font-medium">
|
||||||
|
<a href="/onboard?step=1">Get Started →</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "owner-signup",
|
||||||
|
title: "Create Owner Account",
|
||||||
|
subtitle: (
|
||||||
|
<>
|
||||||
|
Use your preferred authentication method to create your owner account. To set up additional authentication providers, check out our{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.sourcebot.dev/docs/configuration/auth/overview"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="underline text-[var(--primary)] hover:text-[var(--primary)]/80 transition-colors"
|
||||||
|
>
|
||||||
|
documentation
|
||||||
|
</a>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
component: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AuthMethodSelector
|
||||||
|
providers={providers}
|
||||||
|
callbackUrl="/onboard"
|
||||||
|
context="signup"
|
||||||
|
securityNoticeClosable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "configure-org",
|
||||||
|
title: "Configure Your Organization",
|
||||||
|
subtitle: "Set up your organization's security settings.",
|
||||||
|
component: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<MemberApprovalRequiredToggle memberApprovalRequired={org.memberApprovalRequired} inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} />
|
||||||
|
<Button asChild className="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground transition-all duration-200 font-medium">
|
||||||
|
<a href="/onboard?step=3">Continue →</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "complete",
|
||||||
|
title: "You're All Set!",
|
||||||
|
subtitle: (
|
||||||
|
<>
|
||||||
|
Your Sourcebot deployment is ready. Check out these resources to learn how to get the most out of Sourcebot.
|
||||||
|
<div className="text-center space-y-4 mt-6">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-primary rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
component: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{resourceCards.map((resourceCard) => (
|
||||||
|
<a
|
||||||
|
key={resourceCard.id}
|
||||||
|
href={resourceCard.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="p-4 rounded-lg bg-accent hover:bg-accent/80 border border-border hover:border-primary/20 transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-md bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||||
|
{resourceCard.icon && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{resourceCard.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="font-medium text-foreground text-sm group-hover:text-primary transition-colors">
|
||||||
|
{resourceCard.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs mt-1 leading-4">
|
||||||
|
{resourceCard.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CompleteOnboardingButton />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentStepData = steps[currentStep]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center min-h-screen py-12 px-4 sm:px-12 bg-backgroundSecondary relative">
|
<div className="min-h-screen bg-background flex items-center justify-center p-6">
|
||||||
<OnboardHeader
|
<div className="w-full max-w-6xl mx-auto">
|
||||||
title="Setup your organization"
|
<div className="overflow-hidden bg-background">
|
||||||
description="Create a organization for your team to search and share code across your repositories."
|
<div className="flex min-h-[700px]">
|
||||||
step={OnboardingSteps.CreateOrg}
|
{/* Left Panel - Progress & Branding */}
|
||||||
/>
|
<div className="w-2/5 bg-background p-10 border-r border-border">
|
||||||
<OrgCreateForm rootDomain={host} />
|
<div className="h-full flex flex-col">
|
||||||
<LogoutEscapeHatch className="absolute top-0 right-0 p-4 sm:p-12" />
|
<div className="flex-1">
|
||||||
|
<div className="mb-16">
|
||||||
|
<SourcebotLogo
|
||||||
|
className="w-full h-auto mb-12"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Progress Indicators */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step.id} className="flex items-center group">
|
||||||
|
<div className="flex items-center space-x-4 flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Connecting line */}
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`absolute top-10 left-1/2 transform -translate-x-1/2 w-0.5 h-8 transition-all duration-300 ${
|
||||||
|
index < currentStep ? "bg-primary" : "bg-border"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Circle - positioned above the line with z-index */}
|
||||||
|
<div
|
||||||
|
className={`relative z-10 w-10 h-10 rounded-full border-2 flex items-center justify-center font-semibold text-sm transition-all duration-300 ${
|
||||||
|
index < currentStep
|
||||||
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
|
: index === currentStep
|
||||||
|
? "bg-primary border-primary text-primary-foreground scale-110 shadow-lg"
|
||||||
|
: "bg-background border-border text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index < currentStep ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className={`font-medium text-sm transition-all duration-200 ${
|
||||||
|
index <= currentStep ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{step.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-8 border-t border-border">
|
||||||
|
<p className="text-xs text-muted-foreground leading-5">
|
||||||
|
Need help? Check out our{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.sourcebot.dev/docs/overview"
|
||||||
|
className="text-primary hover:underline font-medium transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
documentation
|
||||||
|
</a>{" "}
|
||||||
|
or{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/sourcebot-dev/sourcebot/discussions"
|
||||||
|
className="text-primary hover:underline font-medium transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
reach out
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Content */}
|
||||||
|
<div className="w-3/5 bg-background p-10">
|
||||||
|
<div className="h-full flex flex-col justify-center max-w-lg mx-auto">
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Step Header */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Step {currentStep + 1} of {steps.length}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-px bg-border"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground leading-tight">
|
||||||
|
{currentStepData.title}
|
||||||
|
</h1>
|
||||||
|
<div className="text-muted-foreground text-base leading-relaxed">
|
||||||
|
{currentStepData.subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<div className="transition-all duration-300 ease-out">
|
||||||
|
{currentStepData.component}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NonOwnerOnboardingMessage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center p-6">
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
||||||
|
<div className="w-full max-w-md mx-auto">
|
||||||
|
<Card className="overflow-hidden shadow-lg border border-border bg-card">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-muted rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">
|
||||||
|
Onboarding In Progress
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-base leading-relaxed">
|
||||||
|
Your Sourcebot deployment is being configured by the organization owner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-accent/50 border border-border">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-5 h-5 mt-0.5 flex-shrink-0">
|
||||||
|
<svg className="w-full h-full text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">
|
||||||
|
Owner Access Required
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Only the organization owner can complete the initial setup and configuration. Once onboarding is complete, you'll be able to access Sourcebot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Need help? Contact your organization owner or check out our{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.sourcebot.dev/docs/overview"
|
||||||
|
className="text-primary hover:text-primary/80 underline transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,19 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { prisma } from "@/prisma";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
|
|
||||||
|
if (!org || !org.isOnboarded) {
|
||||||
|
return redirect("/onboard");
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirect("/login");
|
return redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstOrg = await prisma.userToOrg.findFirst({
|
return redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`);
|
||||||
where: {
|
|
||||||
userId: session.user.id,
|
|
||||||
org: {
|
|
||||||
members: {
|
|
||||||
some: {
|
|
||||||
userId: session.user.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
org: true
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
joinedAt: "asc"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!firstOrg) {
|
|
||||||
return redirect("/onboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect(`/${firstOrg.org.domain}`);
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import { isServiceError } from "@/lib/utils";
|
||||||
import { AcceptInviteCard } from './components/acceptInviteCard';
|
import { AcceptInviteCard } from './components/acceptInviteCard';
|
||||||
import { LogoutEscapeHatch } from '../components/logoutEscapeHatch';
|
import { LogoutEscapeHatch } from '../components/logoutEscapeHatch';
|
||||||
import { InviteNotFoundCard } from './components/inviteNotFoundCard';
|
import { InviteNotFoundCard } from './components/inviteNotFoundCard';
|
||||||
|
import { getOrgFromDomain } from '@/data/org';
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
|
||||||
|
|
||||||
interface RedeemPageProps {
|
interface RedeemPageProps {
|
||||||
searchParams: {
|
searchParams: {
|
||||||
|
|
@ -13,6 +15,11 @@ interface RedeemPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
|
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
|
||||||
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
|
if (!org || !org.isOnboarded) {
|
||||||
|
return redirect("/onboard");
|
||||||
|
}
|
||||||
|
|
||||||
const inviteId = searchParams.invite_id;
|
const inviteId = searchParams.invite_id;
|
||||||
if (!inviteId) {
|
if (!inviteId) {
|
||||||
return notFound();
|
return notFound();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { LoginForm } from "../login/components/loginForm";
|
import { LoginForm } from "../login/components/loginForm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getProviders } from "@/auth";
|
|
||||||
import { Footer } from "@/app/components/footer";
|
import { Footer } from "@/app/components/footer";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { getAuthProviders } from "@/lib/authProviders";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
|
||||||
const logger = createLogger('signup-page');
|
const logger = createLogger('signup-page');
|
||||||
|
|
||||||
|
|
@ -21,24 +23,19 @@ export default async function Signup({ searchParams }: LoginProps) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = getProviders();
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
const providerData = providers
|
if (!org || !org.isOnboarded) {
|
||||||
.map((provider) => {
|
return redirect("/onboard");
|
||||||
if (typeof provider === "function") {
|
}
|
||||||
const providerInfo = provider()
|
|
||||||
return { id: providerInfo.id, name: providerInfo.name }
|
|
||||||
} else {
|
|
||||||
return { id: provider.id, name: provider.name }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const providers = getAuthProviders();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
<div className="flex flex-col min-h-screen bg-backgroundSecondary">
|
||||||
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
<div className="flex-1 flex flex-col items-center p-4 sm:p-12 w-full">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
callbackUrl={searchParams.callbackUrl}
|
callbackUrl={searchParams.callbackUrl}
|
||||||
error={searchParams.error}
|
error={searchParams.error}
|
||||||
providers={providerData}
|
providers={providers}
|
||||||
context="signup"
|
context="signup"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 bg-muted border border-input",
|
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=unchecked]:bg-muted data-[state=checked]:bg-[var(--chart-2)] border border-input data-[state=checked]:border-[var(--chart-2)]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-primary dark:bg-primary shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,18 @@ import 'server-only';
|
||||||
import { prisma } from '@/prisma';
|
import { prisma } from '@/prisma';
|
||||||
|
|
||||||
export const getOrgFromDomain = async (domain: string) => {
|
export const getOrgFromDomain = async (domain: string) => {
|
||||||
const org = await prisma.org.findUnique({
|
try {
|
||||||
where: {
|
const org = await prisma.org.findUnique({
|
||||||
domain: domain
|
where: {
|
||||||
}
|
domain: domain
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return org;
|
return org;
|
||||||
|
} catch (error) {
|
||||||
|
// During build time we won't be able to access the database, so we catch and return null in this case
|
||||||
|
// so that we can statically build pages that hit the DB (ex. to check if the org is onboarded)
|
||||||
|
console.error('Error fetching org from domain:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,8 +105,7 @@ export const createGuestUser = async (domain: string): Promise<ServiceError | bo
|
||||||
create: {
|
create: {
|
||||||
id: SOURCEBOT_GUEST_USER_ID,
|
id: SOURCEBOT_GUEST_USER_ID,
|
||||||
name: "Guest",
|
name: "Guest",
|
||||||
email: SOURCEBOT_GUEST_USER_EMAIL,
|
email: SOURCEBOT_GUEST_USER_EMAIL
|
||||||
pendingApproval: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,7 @@ import Keycloak from "next-auth/providers/keycloak";
|
||||||
import Gitlab from "next-auth/providers/gitlab";
|
import Gitlab from "next-auth/providers/gitlab";
|
||||||
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { notFound, ServiceError } from "@/lib/serviceError";
|
|
||||||
import { OrgRole } from "@sourcebot/db";
|
|
||||||
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import { sew } from "@/actions";
|
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
import type { User as AuthJsUser } from "next-auth";
|
import type { User as AuthJsUser } from "next-auth";
|
||||||
import { onCreateUser } from "@/lib/authUtils";
|
import { onCreateUser } from "@/lib/authUtils";
|
||||||
|
|
@ -172,79 +166,4 @@ export const getSSOProviders = (): Provider[] => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handleJITProvisioning = async (userId: string, domain: string): Promise<ServiceError | boolean> => sew(async () => {
|
|
||||||
const org = await prisma.org.findUnique({
|
|
||||||
where: {
|
|
||||||
domain,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
members: {
|
|
||||||
where: {
|
|
||||||
role: {
|
|
||||||
not: OrgRole.GUEST,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
return notFound(`Org ${domain} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return notFound(`User ${userId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userToOrg = await prisma.userToOrg.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
orgId: org.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userToOrg) {
|
|
||||||
logger.warn(`JIT provisioning skipped for user ${userId} since they're already a member of org ${domain}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seats = getSeats();
|
|
||||||
const memberCount = org.members.length;
|
|
||||||
|
|
||||||
if (seats != SOURCEBOT_UNLIMITED_SEATS && memberCount >= seats) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
|
||||||
message: "Failed to provision user since the organization is at max capacity",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.user.update({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
pendingApproval: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.userToOrg.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
orgId: org.id,
|
|
||||||
role: OrgRole.MEMBER,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
@ -26,8 +26,6 @@ export const env = createEnv({
|
||||||
AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'),
|
AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'),
|
||||||
|
|
||||||
// Enterprise Auth
|
// Enterprise Auth
|
||||||
AUTH_EE_ENABLE_JIT_PROVISIONING: booleanSchema.default('false'),
|
|
||||||
|
|
||||||
AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(),
|
AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(),
|
||||||
AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(),
|
AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||||
AUTH_EE_GITHUB_BASE_URL: z.string().optional(),
|
AUTH_EE_GITHUB_BASE_URL: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ const syncDeclarativeConfig = async (configPath: string) => {
|
||||||
|
|
||||||
if (hasPublicAccessEntitlement) {
|
if (hasPublicAccessEntitlement) {
|
||||||
if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
|
if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
|
||||||
logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging or disable public access.`);
|
logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging (SOURCEBOT_EE_AUDIT_LOGGING_ENABLED) or disable public access.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,15 +159,32 @@ const pruneOldGuestUser = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const initSingleTenancy = async () => {
|
const initSingleTenancy = async () => {
|
||||||
await prisma.org.upsert({
|
// Back fill the inviteId if the org has already been created to prevent needing to wipe the db
|
||||||
where: {
|
await prisma.$transaction(async (tx) => {
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
const org = await tx.org.findUnique({
|
||||||
},
|
where: {
|
||||||
update: {},
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
create: {
|
},
|
||||||
name: SINGLE_TENANT_ORG_NAME,
|
});
|
||||||
domain: SINGLE_TENANT_ORG_DOMAIN,
|
|
||||||
id: SINGLE_TENANT_ORG_ID
|
if (!org) {
|
||||||
|
await tx.org.create({
|
||||||
|
data: {
|
||||||
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
|
name: SINGLE_TENANT_ORG_NAME,
|
||||||
|
domain: SINGLE_TENANT_ORG_DOMAIN,
|
||||||
|
inviteLinkId: crypto.randomUUID(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (!org.inviteLinkId) {
|
||||||
|
await tx.org.update({
|
||||||
|
where: {
|
||||||
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
inviteLinkId: crypto.randomUUID(),
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -186,17 +203,6 @@ const initSingleTenancy = async () => {
|
||||||
// Load any connections defined declaratively in the config file.
|
// Load any connections defined declaratively in the config file.
|
||||||
const configPath = env.CONFIG_PATH;
|
const configPath = env.CONFIG_PATH;
|
||||||
if (configPath) {
|
if (configPath) {
|
||||||
// If we're given a config file, mark the org as onboarded so we don't go through
|
|
||||||
// the UI connection onboarding flow
|
|
||||||
await prisma.org.update({
|
|
||||||
where: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
isOnboarded: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await syncDeclarativeConfig(configPath);
|
await syncDeclarativeConfig(configPath);
|
||||||
|
|
||||||
// watch for changes assuming it is a local file
|
// watch for changes assuming it is a local file
|
||||||
|
|
|
||||||
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 type { User as AuthJsUser } from "next-auth";
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { OrgRole } from "@sourcebot/db";
|
import { OrgRole } from "@sourcebot/db";
|
||||||
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from "@/lib/constants";
|
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
|
||||||
import { hasEntitlement } from "@sourcebot/shared";
|
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
|
||||||
import { createAccountRequest } from "@/actions";
|
|
||||||
import { handleJITProvisioning } from "@/ee/features/sso/sso";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { getAuditService } from "@/ee/features/audit/factory";
|
import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
import { ErrorCode } from "./errorCodes";
|
||||||
|
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||||
|
import { incrementOrgSeatCount } from "@/ee/features/billing/serverUtils";
|
||||||
|
|
||||||
const logger = createLogger('web-auth-utils');
|
const logger = createLogger('web-auth-utils');
|
||||||
const auditService = getAuditService();
|
const auditService = getAuditService();
|
||||||
|
|
@ -27,7 +28,7 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
|
||||||
id: "undefined",
|
id: "undefined",
|
||||||
type: "user"
|
type: "user"
|
||||||
},
|
},
|
||||||
orgId: SINGLE_TENANT_ORG_ID, // TODO(mt)
|
orgId: SINGLE_TENANT_ORG_ID,
|
||||||
metadata: {
|
metadata: {
|
||||||
message: "User ID is undefined on user creation"
|
message: "User ID is undefined on user creation"
|
||||||
}
|
}
|
||||||
|
|
@ -35,158 +36,216 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
|
||||||
throw new Error("User ID is undefined on user creation");
|
throw new Error("User ID is undefined on user creation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// In single-tenant mode, we assign the first user to sign
|
const defaultOrg = await prisma.org.findUnique({
|
||||||
// up as the owner of the default org.
|
where: {
|
||||||
if (env.SOURCEBOT_TENANCY_MODE === 'single') {
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
const defaultOrg = await prisma.org.findUnique({
|
},
|
||||||
where: {
|
include: {
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
members: {
|
||||||
},
|
where: {
|
||||||
include: {
|
role: {
|
||||||
members: {
|
not: OrgRole.GUEST,
|
||||||
where: {
|
|
||||||
role: {
|
|
||||||
not: OrgRole.GUEST,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// We expect the default org to have been created on app initialization
|
||||||
|
if (defaultOrg === null) {
|
||||||
|
await auditService.createAudit({
|
||||||
|
action: "user.creation_failed",
|
||||||
|
actor: {
|
||||||
|
id: user.id,
|
||||||
|
type: "user"
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: user.id,
|
||||||
|
type: "user"
|
||||||
|
},
|
||||||
|
orgId: SINGLE_TENANT_ORG_ID,
|
||||||
|
metadata: {
|
||||||
|
message: "Default org not found on single tenant user creation"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
throw new Error("Default org not found on single tenant user creation");
|
||||||
|
}
|
||||||
|
|
||||||
if (defaultOrg === null) {
|
// If this is the first user to sign up, we make them the owner of the default org.
|
||||||
await auditService.createAudit({
|
const isFirstUser = defaultOrg.members.length === 0;
|
||||||
action: "user.creation_failed",
|
if (isFirstUser) {
|
||||||
actor: {
|
await prisma.$transaction(async (tx) => {
|
||||||
id: user.id,
|
await tx.org.update({
|
||||||
type: "user"
|
where: {
|
||||||
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
},
|
},
|
||||||
target: {
|
data: {
|
||||||
id: user.id,
|
members: {
|
||||||
type: "user"
|
create: {
|
||||||
},
|
role: OrgRole.OWNER,
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
user: {
|
||||||
metadata: {
|
connect: {
|
||||||
message: "Default org not found on single tenant user creation"
|
id: user.id,
|
||||||
}
|
|
||||||
});
|
|
||||||
throw new Error("Default org not found on single tenant user creation");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the first user to sign up will be an owner of the default org.
|
|
||||||
const isFirstUser = defaultOrg.members.length === 0;
|
|
||||||
if (isFirstUser) {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.org.update({
|
|
||||||
where: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
members: {
|
|
||||||
create: {
|
|
||||||
role: OrgRole.OWNER,
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: user.id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
await tx.user.update({
|
|
||||||
where: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
pendingApproval: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await auditService.createAudit({
|
await auditService.createAudit({
|
||||||
action: "user.owner_created",
|
action: "user.owner_created",
|
||||||
actor: {
|
actor: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
type: "user"
|
type: "user"
|
||||||
},
|
},
|
||||||
|
orgId: SINGLE_TENANT_ORG_ID,
|
||||||
|
target: {
|
||||||
|
id: SINGLE_TENANT_ORG_ID.toString(),
|
||||||
|
type: "org"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (!defaultOrg.memberApprovalRequired) {
|
||||||
|
const hasAvailability = await orgHasAvailability(defaultOrg.domain);
|
||||||
|
if (!hasAvailability) {
|
||||||
|
logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userToOrg.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
orgId: SINGLE_TENANT_ORG_ID,
|
||||||
target: {
|
role: OrgRole.MEMBER,
|
||||||
id: SINGLE_TENANT_ORG_ID.toString(),
|
}
|
||||||
type: "org"
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// TODO(auth): handle multi tenant case
|
|
||||||
if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) {
|
|
||||||
const res = await handleJITProvisioning(user.id, SINGLE_TENANT_ORG_DOMAIN);
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
|
|
||||||
await auditService.createAudit({
|
|
||||||
action: "user.jit_provisioning_failed",
|
|
||||||
actor: {
|
|
||||||
id: user.id,
|
|
||||||
type: "user"
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID.toString(),
|
|
||||||
type: "org"
|
|
||||||
},
|
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
metadata: {
|
|
||||||
message: `Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
throw new ServiceErrorException(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
await auditService.createAudit({
|
};
|
||||||
action: "user.jit_provisioned",
|
|
||||||
actor: {
|
|
||||||
id: user.id,
|
|
||||||
type: "user"
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID.toString(),
|
|
||||||
type: "org"
|
|
||||||
},
|
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const res = await createAccountRequest(user.id, SINGLE_TENANT_ORG_DOMAIN);
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
|
|
||||||
await auditService.createAudit({
|
|
||||||
action: "user.join_request_creation_failed",
|
|
||||||
actor: {
|
|
||||||
id: user.id,
|
|
||||||
type: "user"
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID.toString(),
|
|
||||||
type: "org"
|
|
||||||
},
|
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
metadata: {
|
|
||||||
message: res.message
|
|
||||||
}
|
|
||||||
});
|
|
||||||
throw new ServiceErrorException(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
await auditService.createAudit({
|
export const orgHasAvailability = async (domain: string): Promise<boolean> => {
|
||||||
action: "user.join_requested",
|
const org = await prisma.org.findUnique({
|
||||||
actor: {
|
where: {
|
||||||
id: user.id,
|
domain,
|
||||||
type: "user"
|
},
|
||||||
},
|
});
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
target: {
|
if (!org) {
|
||||||
id: SINGLE_TENANT_ORG_ID.toString(),
|
logger.error(`orgHasAvailability: org not found for domain ${domain}`);
|
||||||
type: "org"
|
return false;
|
||||||
},
|
}
|
||||||
});
|
const members = await prisma.userToOrg.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
role: {
|
||||||
|
not: OrgRole.GUEST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxSeats = getSeats();
|
||||||
|
const memberCount = members.length;
|
||||||
|
|
||||||
|
if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) {
|
||||||
|
logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addUserToOrganization = async (userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.error(`addUserToOrganization: user not found for id ${userId}`);
|
||||||
|
return userNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = await prisma.org.findUnique({
|
||||||
|
where: {
|
||||||
|
id: orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
logger.error(`addUserToOrganization: org not found for id ${orgId}`);
|
||||||
|
return orgNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAvailability = await orgHasAvailability(org.domain);
|
||||||
|
if (!hasAvailability) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
||||||
|
message: "Organization is at max capacity",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userToOrg.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
orgId: org.id,
|
||||||
|
role: OrgRole.MEMBER,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (IS_BILLING_ENABLED) {
|
||||||
|
const result = await incrementOrgSeatCount(orgId, tx);
|
||||||
|
if (isServiceError(result)) {
|
||||||
|
throw result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete the account request if it exists since we've added the user to the org
|
||||||
|
const accountRequest = await tx.accountRequest.findUnique({
|
||||||
|
where: {
|
||||||
|
requestedById_orgId: {
|
||||||
|
requestedById: user.id,
|
||||||
|
orgId: orgId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accountRequest) {
|
||||||
|
logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've been added to the org`);
|
||||||
|
await tx.accountRequest.delete({
|
||||||
|
where: {
|
||||||
|
id: accountRequest.id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete any invites that may exist for this user since we've added them to the org
|
||||||
|
const invites = await tx.invite.findMany({
|
||||||
|
where: {
|
||||||
|
recipientEmail: user.email!,
|
||||||
|
orgId: org.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const invite of invites) {
|
||||||
|
logger.info(`Deleting invite ${invite.id} for ${user.email} since they've been added to the org`);
|
||||||
|
await tx.invite.delete({
|
||||||
|
where: {
|
||||||
|
id: invite.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isServiceError(res)) {
|
||||||
|
logger.error(`addUserToOrganization: failed to add user ${userId} to org ${orgId}: ${res.message}`);
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -7,16 +7,19 @@ export enum ErrorCode {
|
||||||
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
|
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
|
||||||
NOT_AUTHENTICATED = 'NOT_AUTHENTICATED',
|
NOT_AUTHENTICATED = 'NOT_AUTHENTICATED',
|
||||||
NOT_FOUND = 'NOT_FOUND',
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||||
|
ORG_NOT_FOUND = 'ORG_NOT_FOUND',
|
||||||
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
|
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
|
||||||
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
|
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
|
||||||
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
|
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
|
||||||
MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND',
|
|
||||||
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
|
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
|
||||||
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
|
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
|
||||||
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
|
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
|
||||||
CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS',
|
CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS',
|
||||||
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
|
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
|
||||||
INVALID_INVITE = 'INVALID_INVITE',
|
INVALID_INVITE = 'INVALID_INVITE',
|
||||||
|
INVALID_INVITE_LINK = 'INVALID_INVITE_LINK',
|
||||||
|
INVITE_LINK_NOT_ENABLED = 'INVITE_LINK_NOT_ENABLED',
|
||||||
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
|
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
|
||||||
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
|
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
|
||||||
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
|
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { NewsItem } from "./types";
|
import { NewsItem } from "./types";
|
||||||
|
|
||||||
export const newsData: NewsItem[] = [
|
export const newsData: NewsItem[] = [
|
||||||
|
{
|
||||||
|
unique_id: "member-approval",
|
||||||
|
header: "Member Approval",
|
||||||
|
sub_header: "We've added a toggle to control whether new users need to be approved.",
|
||||||
|
url: "https://docs.sourcebot.dev/docs/configuration/auth/inviting-members"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
unique_id: "analytics",
|
unique_id: "analytics",
|
||||||
header: "Analytics Dashboard",
|
header: "Analytics Dashboard",
|
||||||
|
|
@ -9,25 +15,25 @@ export const newsData: NewsItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
unique_id: "audit-logs",
|
unique_id: "audit-logs",
|
||||||
header: "Audit logs",
|
header: "Audit Logs",
|
||||||
sub_header: "We've added support for audit logs",
|
sub_header: "We've added support for audit logs",
|
||||||
url: "https://docs.sourcebot.dev/docs/configuration/audit-logs"
|
url: "https://docs.sourcebot.dev/docs/configuration/audit-logs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
unique_id: "file-explorer",
|
unique_id: "file-explorer",
|
||||||
header: "File explorer",
|
header: "File Explorer",
|
||||||
sub_header: "We've added support for a file explorer when browsing files.",
|
sub_header: "We've added support for a file explorer when browsing files.",
|
||||||
url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0"
|
url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
unique_id: "structured-logging",
|
unique_id: "structured-logging",
|
||||||
header: "Structured logging",
|
header: "Structured Logging",
|
||||||
sub_header: "We've added support for structured logging",
|
sub_header: "We've added support for structured logging",
|
||||||
url: "https://docs.sourcebot.dev/docs/configuration/structured-logging"
|
url: "https://docs.sourcebot.dev/docs/configuration/structured-logging"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
unique_id: "code-nav",
|
unique_id: "code-nav",
|
||||||
header: "Code navigation",
|
header: "Code Navigation",
|
||||||
sub_header: "Built in go-to definition and find references",
|
sub_header: "Built in go-to definition and find references",
|
||||||
url: "https://docs.sourcebot.dev/docs/features/code-navigation"
|
url: "https://docs.sourcebot.dev/docs/features/code-navigation"
|
||||||
},
|
},
|
||||||
|
|
@ -39,7 +45,7 @@ export const newsData: NewsItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
unique_id: "search-contexts",
|
unique_id: "search-contexts",
|
||||||
header: "Search contexts",
|
header: "Search Contexts",
|
||||||
sub_header: "Filter searches by groups of repos",
|
sub_header: "Filter searches by groups of repos",
|
||||||
url: "https://docs.sourcebot.dev/docs/features/search/search-contexts"
|
url: "https://docs.sourcebot.dev/docs/features/search/search-contexts"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,22 @@ export const notFound = (message?: string): ServiceError => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const userNotFound = (): ServiceError => {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.NOT_FOUND,
|
||||||
|
errorCode: ErrorCode.USER_NOT_FOUND,
|
||||||
|
message: "User not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orgNotFound = (): ServiceError => {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.NOT_FOUND,
|
||||||
|
errorCode: ErrorCode.ORG_NOT_FOUND,
|
||||||
|
message: "Organization not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const orgDomainExists = (): ServiceError => {
|
export const orgDomainExists = (): ServiceError => {
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.CONFLICT,
|
statusCode: StatusCodes.CONFLICT,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,27 @@ export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base URL from Next.js headers
|
||||||
|
* @param headersList The headers from Next.js headers() function
|
||||||
|
* @returns The base URL (e.g., "https://example.com")
|
||||||
|
*/
|
||||||
|
export const getBaseUrl = (headersList: Headers): string => {
|
||||||
|
const host = headersList.get('host') || 'localhost:3000';
|
||||||
|
const protocol = headersList.get('x-forwarded-proto') || 'http';
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an invite link URL from the base URL and invite ID
|
||||||
|
* @param baseUrl The base URL of the application
|
||||||
|
* @param inviteLinkId The invite link ID
|
||||||
|
* @returns The complete invite link URL or null if no inviteLinkId
|
||||||
|
*/
|
||||||
|
export const createInviteLink = (baseUrl: string, inviteLinkId?: string | null): string | null => {
|
||||||
|
return inviteLinkId ? `${baseUrl}/invite?id=${inviteLinkId}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a list of (potentially undefined) query parameters to a path.
|
* Adds a list of (potentially undefined) query parameters to a path.
|
||||||
*
|
*
|
||||||
|
|
@ -119,7 +140,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => {
|
||||||
id: "microsoft-entra-id",
|
id: "microsoft-entra-id",
|
||||||
name: "Microsoft Entra ID",
|
name: "Microsoft Entra ID",
|
||||||
displayName: "Microsoft Entra ID",
|
displayName: "Microsoft Entra ID",
|
||||||
icon: {
|
icon: {
|
||||||
src: microsoftLogo,
|
src: microsoftLogo,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ export async function middleware(request: NextRequest) {
|
||||||
if (
|
if (
|
||||||
url.pathname.startsWith('/login') ||
|
url.pathname.startsWith('/login') ||
|
||||||
url.pathname.startsWith('/redeem') ||
|
url.pathname.startsWith('/redeem') ||
|
||||||
url.pathname.startsWith('/signup')
|
url.pathname.startsWith('/signup') ||
|
||||||
|
url.pathname.startsWith('/invite') ||
|
||||||
|
url.pathname.startsWith('/onboard')
|
||||||
) {
|
) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue