diff --git a/CHANGELOG.md b/CHANGELOG.md index f4da16c2..68996d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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) - Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365) diff --git a/docs/docs.json b/docs/docs.json index c8b3888e..3af230d7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -52,7 +52,7 @@ "group": "Configuration", "pages": [ { - "group": "Connecting your code", + "group": "Indexing your code", "pages": [ "docs/connections/overview", "docs/connections/github", @@ -72,7 +72,10 @@ "group": "Authentication", "pages": [ "docs/configuration/auth/overview", - "docs/configuration/auth/roles-and-permissions" + "docs/configuration/auth/providers", + "docs/configuration/auth/inviting-members", + "docs/configuration/auth/roles-and-permissions", + "docs/configuration/auth/faq" ] }, "docs/configuration/transactional-emails", diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx index 2a3c0624..f229caaf 100644 --- a/docs/docs/configuration/audit-logs.mdx +++ b/docs/docs/configuration/audit-logs.mdx @@ -125,7 +125,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ | `user.join_requested` | `user` | `org` | | `user.join_request_approve_failed` | `user` | `account_join_request` | | `user.join_request_approved` | `user` | `account_join_request` | -| `user.join_request_removed` | `user` | `account_join_request` | | `user.invite_failed` | `user` | `org` | | `user.invites_created` | `user` | `org` | | `user.invite_accept_failed` | `user` | `invite` | diff --git a/docs/docs/configuration/auth/faq.mdx b/docs/docs/configuration/auth/faq.mdx new file mode 100644 index 00000000..5d37bc66 --- /dev/null +++ b/docs/docs/configuration/auth/faq.mdx @@ -0,0 +1,46 @@ +--- +title: FAQ +--- + +This page covers a range of frequently asked questions about Sourcebot's built-in 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) + + + + 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. + + + + **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. + + + + Please note that IAP bridges are an enterprise feature + 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) + + + + 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. + + + + +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! \ No newline at end of file diff --git a/docs/docs/configuration/auth/inviting-members.mdx b/docs/docs/configuration/auth/inviting-members.mdx new file mode 100644 index 00000000..d67e497b --- /dev/null +++ b/docs/docs/configuration/auth/inviting-members.mdx @@ -0,0 +1,30 @@ +--- +title: Inviting Members +sidebarTitle: Inviting members +--- + +There are various ways to configure how members can join a Sourcebot deployment. + +## Member Approval + +**By default, Sourcebot requires new members to be approved by the owner of the deployment**. This section explains how approvals work and how +to configure this behavior. + +### Configuration +Member approval can be configured by the owner of the deployment by navigating to **Settings -> Members**: + +![Member Approval Toggle](/images/member_approval_toggle.png) + +### Managing Requests + +If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment +until this request is approved by the owner. + +The owner can see and manage all pending join requests by navigating to **Settings -> Members**. + +## Invite link + +If member approval is required, an owner of the deployment can enable an invite link. When enabled, users +can use this invite link to register and be automatically added to the organization without approval: + +![Invite Link Toggle](/images/invite_link_toggle.png) \ No newline at end of file diff --git a/docs/docs/configuration/auth/overview.mdx b/docs/docs/configuration/auth/overview.mdx index 3b11a4a9..732fef35 100644 --- a/docs/docs/configuration/auth/overview.mdx +++ b/docs/docs/configuration/auth/overview.mdx @@ -4,124 +4,23 @@ title: Overview If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable. -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. + + + Configure additional authentication providers for your deployment. + + + Learn how to configure how members join your deployment. + + + Learn more about the different roles and permissions in Sourcebot. + + + Have a question about Sourcebot's auth system? We might have the answers here. + + -![Login Page](/images/login.png) - - -# Approving New Members - -All account registrations after the first account must be approved by the owner. The owner can see all join requests by going into **Settings -> Members**. - -If you have an [enterprise license](/docs/license-key), you can enable [AUTH_EE_ENABLE_JIT_PROVISIONING](/docs/configuration/auth/overview#enterprise-authentication-providers) to -have Sourcebot accounts automatically created and approved on registration. - -You can setup emails to be sent when new join requests are created/approved by configurating [transactional emails](/docs/configuration/transactional-emails) -# Authentication Providers - -To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider. - -## Core Authentication Providers - -### Email / Password ---- -Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`. - -### Email codes ---- -Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables: - -- `AUTH_EMAIL_CODE_LOGIN_ENABLED` -- `SMTP_CONNECTION_URL` -- `EMAIL_FROM_ADDRESS` - - -See [transactional emails](/docs/configuration/transactional-emails) for more details. - -## Enterprise Authentication Providers - -The following authentication providers require an [enterprise license](/docs/license-key) to be enabled. - -By default, a new user registering using these providers must have their join request accepted by the owner of the organization to join. To allow a user to join automatically when -they register for the first time, set the `AUTH_EE_ENABLE_JIT_PROVISIONING` environment variable to `true`. - -### GitHub ---- - -[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github) - -**Required environment variables:** -- `AUTH_EE_GITHUB_CLIENT_ID` -- `AUTH_EE_GITHUB_CLIENT_SECRET` - -Optional environment variables: -- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com) - -### GitLab ---- - -[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab) - -**Required environment variables:** -- `AUTH_EE_GITLAB_CLIENT_ID` -- `AUTH_EE_GITLAB_CLIENT_SECRET` - -Optional environment variables: -- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com) - -### Google ---- - -[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google) - -**Required environment variables:** -- `AUTH_EE_GOOGLE_CLIENT_ID` -- `AUTH_EE_GOOGLE_CLIENT_SECRET` - -### GCP IAP ---- - -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) - -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 diff --git a/docs/docs/configuration/auth/providers.mdx b/docs/docs/configuration/auth/providers.mdx new file mode 100644 index 00000000..ae52ea46 --- /dev/null +++ b/docs/docs/configuration/auth/providers.mdx @@ -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 +--- + +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) + +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` + +--- \ No newline at end of file diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 86df22b4..9378b023 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -41,7 +41,6 @@ The following environment variables allow you to configure your Sourcebot deploy | Variable | Default | Description | | :------- | :------ | :---------- | | `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` |

Enables/disables audit logging

| -| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` |

Enables/disables just-in-time user provisioning for SSO providers.

| | `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` |

The base URL for GitHub Enterprise SSO authentication.

| | `AUTH_EE_GITHUB_CLIENT_ID` | `-` |

The client ID for GitHub Enterprise SSO authentication.

| | `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` |

The client secret for GitHub Enterprise SSO authentication.

| diff --git a/docs/docs/connections/local-repos.mdx b/docs/docs/connections/local-repos.mdx index 58090886..9ba4b0d1 100644 --- a/docs/docs/connections/local-repos.mdx +++ b/docs/docs/connections/local-repos.mdx @@ -5,7 +5,7 @@ icon: folder import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' -Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaing Sourcebot will **not** `git fetch` new revisions. +Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaning Sourcebot will **not** `git fetch` new revisions. ## Getting Started diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/overview.mdx index e095b6d6..5165a2aa 100644 --- a/docs/docs/connections/overview.mdx +++ b/docs/docs/connections/overview.mdx @@ -6,12 +6,24 @@ sidebarTitle: Overview import SupportedPlatforms from '/snippets/platform-support.mdx' import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' -A **connection** in Sourcebot represents a link to a code host (such as GitHub, GitLab, Bitbucket, etc.). Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name. +To index your code with Sourcebot, you must provide a configuration file. When running Sourcebot, this file must be mounted in a volume that is accessible to the container, with its +path specified in the `CONFIG_PATH` environment variable. For example: -A JSON configuration file is used to specify connections. For example: +```bash icon="terminal" Passing in a CONFIG_PATH to Sourcebot +docker run \ + -v $(pwd)/config.json:/data/config.json \ + -e CONFIG_PATH=/data/config.json \ + ... \ # other config + ghcr.io/sourcebot-dev/sourcebot:latest +``` -```json -// Specifies two connections: +## Config Schema + +The configuration file defines a set of **connections**. A connection in Sourcebot represents a link to a code host (such as GitHub, GitLab, Bitbucket, etc.). + +Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name. + +```json wrap icon="code" Example config with two connections { "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", "connections": { @@ -43,16 +55,7 @@ A JSON configuration file is used to specify connections. For example: Configuration files must conform to the [JSON schema](#schema-reference). -When running Sourcebot, this file must be mounted in a volume that is accessible to the container, with its path specified in the `CONFIG_PATH` environment variable. For example: - -```bash -docker run \ - -v $(pwd)/config.json:/data/config.json \ - -e CONFIG_PATH=/data/config.json \ - ... \ # other config - ghcr.io/sourcebot-dev/sourcebot:latest -``` - +## Config Syncing Sourcebot performs syncing in the background. Syncing consists of two steps: 1. Fetch the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host. 2. Re-indexes the repository. @@ -70,10 +73,9 @@ On the home page, you can view the sync status of ongoing jobs: src="https://framerusercontent.com/assets/7YyxK8ctPEy9Rf68X2kIdMI.mp4" > -## 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. diff --git a/docs/docs/overview.mdx b/docs/docs/overview.mdx index 624b044a..35bf8be3 100644 --- a/docs/docs/overview.mdx +++ b/docs/docs/overview.mdx @@ -2,7 +2,7 @@ title: "Overview" --- -Sourcebot is an open-source ([GitHub](https://github.com/sourcebot-dev/sourcebot)), self-hosted code search tool that is purpose built to help teams find and navigate code quickly, at scale. +[Sourcebot]((https://github.com/sourcebot-dev/sourcebot)) is an open-source, self-hosted code search tool. It allows you to search and navigate across millions of lines of code across several code host platforms. @@ -16,10 +16,10 @@ Sourcebot is an open-source ([GitHub](https://github.com/sourcebot-dev/sourcebot - **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more. - - **Self-hosted:** Ships as a single [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot) that can be deployed anywhere. + - **Self-hosted:** Deploy it in minutes using our official [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). All of your data stays on your machine. - **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc. - **Scalable:** Scales to millions of lines of code. - - **Open-source:** Core features are MIT licensed, no vendor lock-in. + - **Open-source:** Core features are MIT licensed. diff --git a/docs/images/invite_link_toggle.png b/docs/images/invite_link_toggle.png new file mode 100644 index 00000000..979033e8 Binary files /dev/null and b/docs/images/invite_link_toggle.png differ diff --git a/docs/images/member_approval_toggle.png b/docs/images/member_approval_toggle.png new file mode 100644 index 00000000..e6c2cfac Binary files /dev/null and b/docs/images/member_approval_toggle.png differ diff --git a/packages/db/prisma/migrations/20250713041019_add_onboarding_revamp_changes/migration.sql b/packages/db/prisma/migrations/20250713041019_add_onboarding_revamp_changes/migration.sql new file mode 100644 index 00000000..4576b33a --- /dev/null +++ b/packages/db/prisma/migrations/20250713041019_add_onboarding_revamp_changes/migration.sql @@ -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"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 29189373..bddc8473 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -165,12 +165,18 @@ model Org { imageUrl String? metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts + memberApprovalRequired Boolean @default(true) + stripeCustomerId String? stripeSubscriptionStatus StripeSubscriptionStatus? stripeLastUpdatedAt DateTime? /// List of pending invites to this organization invites Invite[] + + /// The invite id for this organization + inviteLinkEnabled Boolean @default(false) + inviteLinkId String? audits Audit[] @@ -263,7 +269,6 @@ model User { image String? accounts Account[] orgs UserToOrg[] - pendingApproval Boolean @default(true) accountRequest AccountRequest? /// List of pending invites that the user has created diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 49e7e70f..0381e451 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -110,7 +110,7 @@ export const getPlan = (): Plan => { } export const getSeats = (): number => { - const licenseKey = getLicenseKey(); +const licenseKey = getLicenseKey(); return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; } diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index e9adbc2a..7bdd4a5a 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2,7 +2,7 @@ import { env } from "@/env.mjs"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; +import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; import { CodeHostType, isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma"; import { render } from "@react-email/components"; @@ -28,15 +28,16 @@ import InviteUserEmail from "./emails/inviteUserEmail"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { TenancyMode, ApiKeyPayload } from "./lib/types"; -import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils"; +import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils"; import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; -import { getPlan, getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; +import { getPlan, hasEntitlement } from "@sourcebot/shared"; import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import { createLogger } from "@sourcebot/logger"; import { getAuditService } from "@/ee/features/audit/factory"; +import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; const ajv = new Ajv({ validateFormats: false, @@ -116,35 +117,6 @@ export const withAuth = async (fn: (userId: string, apiKeyHash: string | unde return fn(session.user.id, undefined); } -export const orgHasAvailability = async (domain: string): Promise => { - 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 (userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise, minRequiredRole: OrgRole = OrgRole.MEMBER) => { const org = await prisma.org.findUnique({ where: { @@ -1169,6 +1141,13 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{ }, /* minRequiredRole = */ OrgRole.OWNER) )); +export const getOrgInviteId = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + return org.inviteLinkId; + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + export const getMe = async () => sew(() => withAuth(async (userId) => { const user = await prisma.user.findUnique({ @@ -1208,7 +1187,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean if (isServiceError(user)) { return user; } - + const invite = await prisma.invite.findUnique({ where: { id: inviteId, @@ -1257,73 +1236,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean return notFound(); } - const res = await prisma.$transaction(async (tx) => { - await tx.userToOrg.create({ - data: { - userId: user.id, - orgId: invite.orgId, - role: "MEMBER", - } - }); - - await tx.user.update({ - where: { - id: user.id, - }, - data: { - pendingApproval: false, - } - }); - - await tx.invite.delete({ - where: { - id: invite.id, - } - }); - - // Delete the account request if it exists since we've redeemed an invite - const accountRequest = await tx.accountRequest.findUnique({ - where: { - requestedById_orgId: { - requestedById: user.id, - orgId: invite.orgId, - } - }, - }); - - if (accountRequest) { - logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've redeemed an invite`); - await auditService.createAudit({ - action: "user.join_request_removed", - actor: { - id: user.id, - type: "user" - }, - orgId: invite.org.id, - target: { - id: accountRequest.id, - type: "account_join_request" - } - }); - - await tx.accountRequest.delete({ - where: { - id: accountRequest.id, - } - }); - } - - if (IS_BILLING_ENABLED) { - const result = await incrementOrgSeatCount(invite.orgId, tx); - if (isServiceError(result)) { - throw result; - } - } - }); - - if (isServiceError(res)) { - await failAuditCallback(res.message); - return res; + const addUserToOrgRes = await addUserToOrganization(user.id, invite.orgId); + if (isServiceError(addUserToOrgRes)) { + await failAuditCallback(addUserToOrgRes.message); + return addUserToOrgRes; } await auditService.createAudit({ @@ -1519,19 +1435,6 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro } }); - // TODO: The fact that pendingApproval is set in the user is a bit weird here, since it will prevent approval from working in the multi-tenant case. - // We need to set pendingApproval to be true here though so that if the user tries to sign into the deployment again it will send another request. Without - // this, the user will never be able to request to join the org again. - // TODO(multitenant): Handle this better - await tx.user.update({ - where: { - id: memberId, - }, - data: { - pendingApproval: true, - } - }); - if (IS_BILLING_ENABLED) { const result = await decrementOrgSeatCount(org.id, tx); if (isServiceError(result)) { @@ -1677,14 +1580,6 @@ export const createAccountRequest = async (userId: string, domain: string) => se return notFound("User not found"); } - if (user.pendingApproval == false) { - logger.warn(`User ${userId} isn't pending approval. Skipping account request creation.`); - return { - success: true, - existingRequest: false, - } - } - const org = await prisma.org.findUnique({ where: { domain, @@ -1776,6 +1671,64 @@ export const createAccountRequest = async (userId: string, domain: string) => se } }); +export const getMemberApprovalRequired = async (domain: string): Promise => 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 => sew(async () => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return orgNotFound(); + } + + return org.inviteLinkEnabled; +}); + +export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + await prisma.org.update({ + where: { id: org.id }, + data: { inviteLinkEnabled: enabled }, + }); + + return { + success: true, + }; + }, /* minRequiredRole = */ OrgRole.OWNER) + ) +); + export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () => withAuth(async (userId) => withOrgMembership(userId, domain, async ({ org }) => { @@ -1784,7 +1737,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) = action: "user.join_request_approve_failed", actor: { id: userId, - type: "user" + type: "user" }, target: { id: requestId, @@ -1811,60 +1764,10 @@ export const approveAccountRequest = async (requestId: string, domain: string) = return notFound(); } - const hasAvailability = await orgHasAvailability(domain); - if (!hasAvailability) { - await failAuditCallback("Organization is at max capacity"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "Organization is at max capacity", - } satisfies ServiceError; - } - - const res = await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { - id: request.requestedById, - }, - data: { - pendingApproval: false, - }, - }); - - await tx.userToOrg.create({ - data: { - userId: request.requestedById, - orgId: org.id, - role: "MEMBER", - }, - }); - - await tx.accountRequest.delete({ - where: { - id: requestId, - }, - }); - - const invites = await tx.invite.findMany({ - where: { - recipientEmail: request.requestedBy.email!, - orgId: org.id, - }, - }) - - for (const invite of invites) { - logger.info(`Account request approved. Deleting invite ${invite.id} for ${request.requestedBy.email}`); - await tx.invite.delete({ - where: { - id: invite.id, - }, - }); - } - }); - - if (isServiceError(res)) { - await failAuditCallback(res.message); - return res; + const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id); + if (isServiceError(addUserToOrgRes)) { + await failAuditCallback(addUserToOrgRes.message); + return addUserToOrgRes; } // Send approval email to the user @@ -1936,19 +1839,6 @@ export const rejectAccountRequest = async (requestId: string, domain: string) => }, }); - await auditService.createAudit({ - action: "user.join_request_removed", - actor: { - id: userId, - type: "user" - }, - orgId: org.id, - target: { - id: requestId, - type: "account_join_request" - } - }); - return { success: true, } diff --git a/packages/web/src/app/[domain]/components/onboardGuard.tsx b/packages/web/src/app/[domain]/components/onboardGuard.tsx index a1c5aca3..d9de6514 100644 --- a/packages/web/src/app/[domain]/components/onboardGuard.tsx +++ b/packages/web/src/app/[domain]/components/onboardGuard.tsx @@ -1,7 +1,6 @@ 'use client'; import { Redirect } from "@/app/components/redirect"; -import { useDomain } from "@/hooks/useDomain"; import { usePathname } from "next/navigation"; import { useMemo } from "react"; @@ -10,20 +9,19 @@ interface OnboardGuardProps { } export const OnboardGuard = ({ children }: OnboardGuardProps) => { - const domain = useDomain(); const pathname = usePathname(); const content = useMemo(() => { if (!pathname.endsWith('/onboard')) { return ( ) } else { return children; } - }, [domain, children, pathname]); + }, [children, pathname]); return content; } diff --git a/packages/web/src/app/[domain]/components/pendingApproval.tsx b/packages/web/src/app/[domain]/components/pendingApproval.tsx index 1910a84a..4ffceb20 100644 --- a/packages/web/src/app/[domain]/components/pendingApproval.tsx +++ b/packages/web/src/app/[domain]/components/pendingApproval.tsx @@ -1,15 +1,8 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { HelpCircle } from "lucide-react" import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { auth } from "@/auth" -import { ResubmitAccountRequestButton } from "./resubmitAccountRequestButton" -interface PendingApprovalCardProps { - domain: string -} - -export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps) => { +export const PendingApprovalCard = async () => { const session = await auth() const userId = session?.user?.id @@ -18,42 +11,45 @@ export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps) } return ( -
+
- -
- - - - Pending Approval - - Your request to join the organization is being reviewed - - - - -
- + +
+
+ + +
+
+ + +
-
-
- -
-

Need help or have questions?

- - Submit a support request - -
+ +
+

+ Approval Pending +

+

+ Your request is being reviewed. +

+
+
+ +
+
+
+ + +
+ Awaiting review
- - +
+ +
) diff --git a/packages/web/src/app/[domain]/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx index 242dadad..4f3350f6 100644 --- a/packages/web/src/app/[domain]/components/settingsDropdown.tsx +++ b/packages/web/src/app/[domain]/components/settingsDropdown.tsx @@ -83,8 +83,8 @@ export const SettingsDropdown = ({ {session?.user ? ( -
- +
+ @@ -92,7 +92,7 @@ export const SettingsDropdown = ({ {session.user.name && session.user.name.length > 0 ? session.user.name[0] : 'U'} -

{session.user.email ?? "User"}

+

{session.user.email ?? "User"}

{ diff --git a/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx similarity index 73% rename from packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx rename to packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx index ec4df43e..291e5f50 100644 --- a/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx +++ b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx @@ -6,14 +6,16 @@ import { useState } from "react" import { useToast } from "@/components/hooks/use-toast" import { createAccountRequest } from "@/actions" import { isServiceError } from "@/lib/utils" +import { useRouter } from "next/navigation" -interface ResubmitButtonProps { +interface SubmitButtonProps { domain: string userId: string } -export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonProps) { +export function SubmitAccountRequestButton({ domain, userId }: SubmitButtonProps) { const { toast } = useToast() + const router = useRouter() const [isSubmitting, setIsSubmitting] = useState(false) const handleSubmit = async () => { @@ -28,19 +30,20 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP }) } else { toast({ - title: "Request Resubmitted", - description: "Your request to join the organization has been resubmitted.", + title: "Request Submitted", + description: "Your request to join the organization has been submitted.", variant: "default", }) } + // Refresh the page to trigger layout re-render and show PendingApprovalCard + router.refresh() } else { toast({ - title: "Failed to Resubmit", - description: `There was an error resubmitting your request. Reason: ${result.message}`, + title: "Failed to Submit", + description: `There was an error submitting your request. Reason: ${result.message}`, variant: "destructive", }) } - setIsSubmitting(false) } @@ -57,7 +60,7 @@ export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonP disabled={isSubmitting} > - {isSubmitting ? "Submitting..." : "Resubmit Request"} + {isSubmitting ? "Submitting..." : "Submit Request"} ) diff --git a/packages/web/src/app/[domain]/components/submitJoinRequest.tsx b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx new file mode 100644 index 00000000..b79fbfa5 --- /dev/null +++ b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx @@ -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 ( +
+ + +
+
+ + +
+
+ + + +
+ +
+

+ Request Access +

+

+ Submit a request to join this organization +

+
+
+ +
+
+ +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index bca8bad8..f24accd3 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -14,10 +14,14 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { notFound, redirect } from "next/navigation"; import { getSubscriptionInfo } from "@/ee/features/billing/actions"; import { PendingApprovalCard } from "./components/pendingApproval"; +import { SubmitJoinRequest } from "./components/submitJoinRequest"; import { hasEntitlement } from "@sourcebot/shared"; import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess"; import { env } from "@/env.mjs"; import { GcpIapAuth } from "./components/gcpIapAuth"; +import { getMemberApprovalRequired } from "@/actions"; +import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; interface LayoutProps { children: React.ReactNode, @@ -34,43 +38,60 @@ export default async function Layout({ return notFound(); } + const session = await auth(); const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain); - if (!publicAccessEnabled) { - const session = await auth(); - if (!session) { - const ssoEntitlement = await hasEntitlement("sso"); - if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { - return ; - } else { - redirect('/login'); - } - } - + + // If the user is authenticated, we must check if they're a member of the org + if (session) { const membership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { orgId: org.id, userId: session.user.id } - }, + }, include: { user: true } }); - + + // There's two reasons why a user might not be a member of an org: + // 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them + // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. + // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. if (!membership) { - const user = await prisma.user.findUnique({ + const memberApprovalRequired = await getMemberApprovalRequired(domain); + if (!memberApprovalRequired) { + return ( +
+ + +
+ ) + } else { + const hasPendingApproval = await prisma.accountRequest.findFirst({ where: { - id: session.user.id + orgId: org.id, + requestedById: session.user.id } }); - // TODO: Organization join requests are only supported in single-tenant mode - if (env.SOURCEBOT_TENANCY_MODE === "single" && user?.pendingApproval) { - return + if (hasPendingApproval) { + return } else { - return notFound(); + return } + } + } + } 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 ; + } else { + redirect('/login'); + } } } diff --git a/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx b/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx deleted file mode 100644 index a0df45d2..00000000 --- a/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx deleted file mode 100644 index 00fecdb0..00000000 --- a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx +++ /dev/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(null); - const router = useRouter(); - - const onCreated = useCallback(() => { - router.push(`?step=${nextStep}`); - }, [nextStep, router]); - - const onBack = useCallback(() => { - setSelectedCodeHost(null); - }, []); - - if (!selectedCodeHost) { - return ( - <> - - {securityCardEnabled && } - - ) - } - - if (selectedCodeHost === "github") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "gitlab") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "gitea") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "gerrit") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "bitbucket-cloud") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "bitbucket-server") { - return ( - <> - - - - ) - } - - return null; -} - -interface CodeHostSelectionProps { - onSelect: (codeHost: CodeHostType) => void; -} - -const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => { - const captureEvent = useCaptureEvent(); - - return ( -
- { - onSelect("github"); - captureEvent("wa_onboard_github_selected", {}); - }} - /> - { - onSelect("gitlab"); - captureEvent("wa_onboard_gitlab_selected", {}); - }} - /> - { - onSelect("bitbucket-cloud"); - captureEvent("wa_onboard_bitbucket_cloud_selected", {}); - }} - /> - { - onSelect("bitbucket-server"); - captureEvent("wa_onboard_bitbucket_server_selected", {}); - }} - /> - { - onSelect("gitea"); - captureEvent("wa_onboard_gitea_selected", {}); - }} - /> - { - onSelect("gerrit"); - captureEvent("wa_onboard_gerrit_selected", {}); - }} - /> -
- ) -} diff --git a/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx b/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx deleted file mode 100644 index 2aa9bb9c..00000000 --- a/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx +++ /dev/null @@ -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>({ - 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) => { - 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 ( - -
- - - Email Address - {`Invite members to access your organization's Sourcebot instance.`} - {form.watch('emails').map((_, index) => ( - ( - - - - - - - )} - /> - ))} - {form.formState.errors.emails?.root?.message && ( - {form.formState.errors.emails.root.message} - )} - - - - - - -
- -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/onboardBackButton.tsx b/packages/web/src/app/[domain]/onboard/components/onboardBackButton.tsx deleted file mode 100644 index 25d93615..00000000 --- a/packages/web/src/app/[domain]/onboard/components/onboardBackButton.tsx +++ /dev/null @@ -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 ( -
- -
- ) -} - diff --git a/packages/web/src/app/[domain]/onboard/page.tsx b/packages/web/src/app/[domain]/onboard/page.tsx deleted file mode 100644 index a62770e1..00000000 --- a/packages/web/src/app/[domain]/onboard/page.tsx +++ /dev/null @@ -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 ( -
- {step !== OnboardingSteps.Complete && ( - - )} - {step === OnboardingSteps.ConnectCodeHost && ( - <> - - - - )} - {step === OnboardingSteps.InviteTeam && ( - <> - - - - )} - {step === OnboardingSteps.Checkout && ( - <> - - - )} - {step === OnboardingSteps.Complete && ( - - )} -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index 2c23f3f7..aa984a93 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -12,6 +12,9 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { RequestsList } from "./components/requestsList"; import { OrgRole } from "@prisma/client"; +import { MemberApprovalRequiredToggle } from "@/app/onboard/components/memberApprovalRequiredToggle"; +import { headers } from "next/headers"; +import { getBaseUrl, createInviteLink } from "@/lib/utils"; interface MembersSettingsPageProps { params: { @@ -59,6 +62,11 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa const usedSeats = members.length const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats; + // Get the current URL to construct the full invite link + const headersList = headers(); + const baseUrl = getBaseUrl(headersList); + const inviteLink = createInviteLink(baseUrl, org.inviteLinkId); + return (
@@ -78,6 +86,10 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa )}
+ {userRoleInOrg === OrgRole.OWNER && ( + + )} + 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 ( + <> + + 0 ? [ +
+ {oauthProviders.map((provider) => { + const providerInfo = getAuthProviderInfo(provider.id); + return ( + { + onSignInWithOauth(provider.id); + }} + context={context} + /> + ); + })} +
+ ] : []), + ...(hasMagicLink ? [ + + ] : []), + ...(hasCredentials ? [ + + ] : []) + ]} + /> + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/components/authSecurityNotice.tsx b/packages/web/src/app/components/authSecurityNotice.tsx new file mode 100644 index 00000000..e903383f --- /dev/null +++ b/packages/web/src/app/components/authSecurityNotice.tsx @@ -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 ( +
+ {closable && ( + + )} +

+ + + + + Security Notice: Authentication data is managed by your deployment and is encrypted at rest. Zero data leaves your deployment.{' '} + + Learn more + + +

+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/app/components/dividerSet.tsx b/packages/web/src/app/components/dividerSet.tsx new file mode 100644 index 00000000..1312cdde --- /dev/null +++ b/packages/web/src/app/components/dividerSet.tsx @@ -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 ( + + {child} + {index < elements.length - 1 && } + + ); + }); +}; \ No newline at end of file diff --git a/packages/web/src/app/components/inviteLinkToggle.tsx b/packages/web/src/app/components/inviteLinkToggle.tsx new file mode 100644 index 00000000..feaef814 --- /dev/null +++ b/packages/web/src/app/components/inviteLinkToggle.tsx @@ -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 ( +
+
+
+

+ Enable invite link +

+
+

+ When enabled, team members can use the invite link to join your organization without requiring approval. +

+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ +

+ You can find this link again in the Settings → Members page. +

+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/joinOrganizationButton.tsx b/packages/web/src/app/components/joinOrganizationButton.tsx new file mode 100644 index 00000000..9bed8556 --- /dev/null +++ b/packages/web/src/app/components/joinOrganizationButton.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/packages/web/src/app/components/joinOrganizationCard.tsx b/packages/web/src/app/components/joinOrganizationCard.tsx new file mode 100644 index 00000000..bb7b1d39 --- /dev/null +++ b/packages/web/src/app/components/joinOrganizationCard.tsx @@ -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 ( +
+ + + + + +
+

+ Welcome to Sourcebot! Click the button below to join this organization. +

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/components/providerButton.tsx b/packages/web/src/app/components/providerButton.tsx new file mode 100644 index 00000000..e67789d4 --- /dev/null +++ b/packages/web/src/app/components/providerButton.tsx @@ -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; + 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 ( + + {logo && {name}} + {context === "login" ? `Sign in with ${name}` : `Sign up with ${name}`} + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts new file mode 100644 index 00000000..99bb0256 --- /dev/null +++ b/packages/web/src/app/invite/actions.ts @@ -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, + } + }) +) \ No newline at end of file diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx new file mode 100644 index 00000000..92fa01cf --- /dev/null +++ b/packages/web/src/app/invite/page.tsx @@ -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 ; + } + + 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 ( +
+ + +
+ ); +} + +function WelcomeCard({ inviteLinkId, providers }: { inviteLinkId: string; providers: import("@/lib/authProviders").AuthProvider[] }) { + return ( +
+ + + + + Welcome to Sourcebot + + + +
+

+ You've been invited to join this Sourcebot deployment. Sign up to get started. +

+
+ + +
+
+
+ ); +} diff --git a/packages/web/src/app/login/components/credentialsForm.tsx b/packages/web/src/app/login/components/credentialsForm.tsx index ac9b4bbb..14f5419c 100644 --- a/packages/web/src/app/login/components/credentialsForm.tsx +++ b/packages/web/src/app/login/components/credentialsForm.tsx @@ -14,9 +14,10 @@ import useCaptureEvent from "@/hooks/useCaptureEvent"; interface CredentialsFormProps { callbackUrl?: string; + context: "login" | "signup"; } -export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => { +export const CredentialsForm = ({ callbackUrl, context }: CredentialsFormProps) => { const captureEvent = useCaptureEvent(); const [isLoading, setIsLoading] = useState(false); const form = useForm>({ @@ -80,7 +81,7 @@ export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => { disabled={isLoading} > {isLoading ? : ""} - Sign in with credentials + {context === "login" ? "Sign in with credentials" : "Sign up with credentials"} diff --git a/packages/web/src/app/[domain]/onboard/components/demoCard.tsx b/packages/web/src/app/login/components/demoCard.tsx similarity index 100% rename from packages/web/src/app/[domain]/onboard/components/demoCard.tsx rename to packages/web/src/app/login/components/demoCard.tsx diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx index 721af536..ff0e555c 100644 --- a/packages/web/src/app/login/components/loginForm.tsx +++ b/packages/web/src/app/login/components/loginForm.tsx @@ -1,19 +1,14 @@ 'use client'; -import Image from "next/image"; -import { signIn } from "next-auth/react"; -import { Fragment, useCallback, useMemo, useState } from "react"; +import { useMemo } from "react"; import { Card } from "@/components/ui/card"; -import { cn, getAuthProviderInfo } from "@/lib/utils"; -import { MagicLinkForm } from "./magicLinkForm"; -import { CredentialsForm } from "./credentialsForm"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; -import { TextSeparator } from "@/app/components/textSeparator"; +import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import useCaptureEvent from "@/hooks/useCaptureEvent"; -import DemoCard from "@/app/[domain]/onboard/components/demoCard"; +import DemoCard from "@/app/login/components/demoCard"; import Link from "next/link"; import { env } from "@/env.mjs"; -import { LoadingButton } from "@/components/ui/loading-button"; +import type { AuthProvider } from "@/lib/authProviders"; const TERMS_OF_SERVICE_URL = "https://sourcebot.dev/terms"; const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy"; @@ -21,17 +16,12 @@ const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy"; interface LoginFormProps { callbackUrl?: string; error?: string; - providers: Array<{ id: string; name: string }>; + providers: AuthProvider[]; context: "login" | "signup"; } export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormProps) => { const captureEvent = useCaptureEvent(); - const onSignInWithOauth = useCallback((provider: string) => { - signIn(provider, { - redirectTo: callbackUrl ?? "/" - }); - }, [callbackUrl]); const errorMessage = useMemo(() => { if (!error) { @@ -47,13 +37,6 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP } }, [error]); - // Separate OAuth providers from special auth methods - const oauthProviders = providers.filter(p => - !["credentials", "nodemailer"].includes(p.id) - ); - const hasCredentials = providers.some(p => p.id === "credentials"); - const hasMagicLink = providers.some(p => p.id === "nodemailer"); - // Helper function to get the correct analytics event name const getLoginEventName = (providerId: string) => { switch (providerId) { @@ -74,6 +57,11 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP } }; + // Analytics callback for provider clicks + const handleProviderClick = (providerId: string) => { + captureEvent(getLoginEventName(providerId), {}); + }; + return (
@@ -95,38 +83,17 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP {errorMessage}
)} - 0 ? [ -
- {oauthProviders.map((provider) => { - const providerInfo = getAuthProviderInfo(provider.id); - return ( - { - captureEvent(getLoginEventName(provider.id), {}); - onSignInWithOauth(provider.id); - }} - /> - ); - })} -
- ] : []), - ...(hasMagicLink ? [ - - ] : []), - ...(hasCredentials ? [ - - ] : []) - ]} +

{context === "login" ? <> - No account yet? Sign up + Don't have an account? Sign up : <> @@ -141,43 +108,3 @@ export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormP

) } - -const ProviderButton = ({ - name, - logo, - onClick, - className, -}: { - name: string; - logo: { src: string, className?: string } | null; - onClick: () => void; - className?: string; -}) => { - const [isLoading, setIsLoading] = useState(false); - - return ( - { - setIsLoading(true); - onClick(); - }} - className={cn("w-full", className)} - variant="outline" - loading={isLoading} - > - {logo && {name}} - Sign in with {name} - - ) -} - -const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => { - return elements.map((child, index) => { - return ( - - {child} - {index < elements.length - 1 && } - - ) - }) -} diff --git a/packages/web/src/app/login/components/magicLinkForm.tsx b/packages/web/src/app/login/components/magicLinkForm.tsx index 32775203..d731436a 100644 --- a/packages/web/src/app/login/components/magicLinkForm.tsx +++ b/packages/web/src/app/login/components/magicLinkForm.tsx @@ -18,9 +18,10 @@ const magicLinkSchema = z.object({ interface MagicLinkFormProps { callbackUrl?: string; + context: "login" | "signup"; } -export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => { +export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => { const captureEvent = useCaptureEvent(); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -76,7 +77,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => { disabled={isLoading} > {isLoading ? : ""} - Sign in with login code + {context === "login" ? "Sign in with login code" : "Sign up with login code"} diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx index a948a464..48e5031a 100644 --- a/packages/web/src/app/login/page.tsx +++ b/packages/web/src/app/login/page.tsx @@ -1,9 +1,11 @@ import { auth } from "@/auth"; import { LoginForm } from "./components/loginForm"; import { redirect } from "next/navigation"; -import { getProviders } from "@/auth"; import { Footer } from "@/app/components/footer"; import { createLogger } from "@sourcebot/logger"; +import { getAuthProviders } from "@/lib/authProviders"; +import { getOrgFromDomain } from "@/data/org"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; const logger = createLogger('login-page'); @@ -22,24 +24,19 @@ export default async function Login({ searchParams }: LoginProps) { return redirect("/"); } - const providers = getProviders(); - const providerData = providers - .map((provider) => { - if (typeof provider === "function") { - const providerInfo = provider() - return { id: providerInfo.id, name: providerInfo.name } - } else { - return { id: provider.id, name: provider.name } - } - }); + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (!org || !org.isOnboarded) { + return redirect("/onboard"); + } + const providers = getAuthProviders(); return (
diff --git a/packages/web/src/app/onboard/components/completeOnboardingButton.tsx b/packages/web/src/app/onboard/components/completeOnboardingButton.tsx new file mode 100644 index 00000000..ef44e0f3 --- /dev/null +++ b/packages/web/src/app/onboard/components/completeOnboardingButton.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/memberApprovalRequiredToggle.tsx b/packages/web/src/app/onboard/components/memberApprovalRequiredToggle.tsx new file mode 100644 index 00000000..325f6ba8 --- /dev/null +++ b/packages/web/src/app/onboard/components/memberApprovalRequiredToggle.tsx @@ -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 ( +
+
+
+
+

+ Require approval for new members +

+
+

+ When enabled, new users will need approval from an organization owner before they can access your deployment.{" "} + + Learn More + +

+
+
+
+ +
+
+
+ +
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/onboardHeader.tsx b/packages/web/src/app/onboard/components/onboardHeader.tsx deleted file mode 100644 index 17281d76..00000000 --- a/packages/web/src/app/onboard/components/onboardHeader.tsx +++ /dev/null @@ -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 ( -
- -

- {title} -

-

- {description} -

-
- {steps.map((step, index) => ( -
- ))} -
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/orgCreateForm.tsx b/packages/web/src/app/onboard/components/orgCreateForm.tsx deleted file mode 100644 index 371db309..00000000 --- a/packages/web/src/app/onboard/components/orgCreateForm.tsx +++ /dev/null @@ -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>({ - resolver: zodResolver(onboardingFormSchema), - defaultValues: { - name: "", - domain: "", - } - }); - - const onSubmit = useCallback(async (data: z.infer) => { - 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) => { - const name = e.target.value - const domain = name.toLowerCase().replace(/[^a-zA-Z\s]/g, "").replace(/\s+/g, "-") - form.setValue("domain", domain) - } - - return ( - -
- - ( - - Organization Name - {`Your organization's visible name within Sourcebot. For example, the name of your company or department.`} - - { - field.onChange(e) - handleNameChange(e) - }} - /> - - - - )} - /> - ( - - Organization URL - {`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`} - -
-
{rootDomain}/
- -
-
- -
- )} - /> - - - -
- ) -} diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index 9389a794..fb7677b5 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -1,28 +1,410 @@ -import { OrgCreateForm } from "./components/orgCreateForm"; -import { auth } from "@/auth"; -import { redirect } from "next/navigation"; -import { OnboardHeader } from "./components/onboardHeader"; -import { OnboardingSteps } from "@/lib/constants"; -import { LogoutEscapeHatch } from "../components/logoutEscapeHatch"; -import { headers } from "next/headers"; +import type React from "react" -export default async function Onboarding() { +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { AuthMethodSelector } from "@/app/components/authMethodSelector" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { auth } from "@/auth"; +import { getAuthProviders } from "@/lib/authProviders"; +import { MemberApprovalRequiredToggle } from "./components/memberApprovalRequiredToggle"; +import { CompleteOnboardingButton } from "./components/completeOnboardingButton"; +import { getOrgFromDomain } from "@/data/org"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { prisma } from "@/prisma"; +import { OrgRole } from "@sourcebot/db"; +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; +import { redirect } from "next/navigation"; +import { 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(); - if (!session) { - redirect("/login"); + + if (!org) { + return
Error loading organization
; } - 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 ; + } + } + } + + // 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 ; + } + + // 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: , + }, + { + 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: , + }, + { + 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: , + } + ] + + 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: ( +
+ +
+ ), + }, + { + 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{" "} + + documentation + . + + ), + component: ( +
+ +
+ ), + }, + { + id: "configure-org", + title: "Configure Your Organization", + subtitle: "Set up your organization's security settings.", + component: ( +
+ + +
+ ), + }, + { + 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. +
+
+ + + +
+
+ + ), + component: ( + + ), + }, + ] + + const currentStepData = steps[currentStep] return ( -
- - - +
+
+
+
+ {/* Left Panel - Progress & Branding */} +
+
+
+
+ +
+ + {/* Step Progress Indicators */} +
+ {steps.map((step, index) => ( +
+
+
+ {/* Connecting line */} + {index < steps.length - 1 && ( +
+ )} + {/* Circle - positioned above the line with z-index */} +
+ {index < currentStep ? ( + + + + ) : ( + {index + 1} + )} +
+
+
+
+ {step.title} +
+
+
+
+ ))} +
+
+ + {/* Footer */} +
+

+ Need help? Check out our{" "} + + documentation + {" "} + or{" "} + + reach out + + . +

+
+
+
+ + {/* Right Panel - Content */} +
+
+
+ {/* Step Header */} +
+
+
+ Step {currentStep + 1} of {steps.length} +
+
+
+
+

+ {currentStepData.title} +

+
+ {currentStepData.subtitle} +
+
+
+ + {/* Step Content */} +
+ {currentStepData.component} +
+
+
+
+
+
+
+
+ ) +} + +function NonOwnerOnboardingMessage() { + return ( +
+ +
+ + +
+
+ + + + +
+ +
+

+ Onboarding In Progress +

+

+ Your Sourcebot deployment is being configured by the organization owner. +

+
+ +
+
+
+ + + +
+
+

+ Owner Access Required +

+

+ Only the organization owner can complete the initial setup and configuration. Once onboarding is complete, you'll be able to access Sourcebot. +

+
+
+
+ +
+
+ Need help? Contact your organization owner or check out our{" "} + + documentation + + . +
+
+
+
+
+
); } diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 86b0aa9b..d576e0af 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,35 +1,19 @@ import { auth } from "@/auth"; -import { prisma } from "@/prisma"; import { redirect } from "next/navigation"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { getOrgFromDomain } from "@/data/org"; export default async function Page() { + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + + if (!org || !org.isOnboarded) { + return redirect("/onboard"); + } + const session = await auth(); if (!session) { return redirect("/login"); } - const firstOrg = await prisma.userToOrg.findFirst({ - where: { - userId: session.user.id, - org: { - members: { - some: { - userId: session.user.id, - } - } - } - }, - include: { - org: true - }, - orderBy: { - joinedAt: "asc" - } - }); - - if (!firstOrg) { - return redirect("/onboard"); - } - - return redirect(`/${firstOrg.org.domain}`); + return redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`); } \ No newline at end of file diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index 34550222..3513e5b2 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -5,6 +5,8 @@ import { isServiceError } from "@/lib/utils"; import { AcceptInviteCard } from './components/acceptInviteCard'; import { LogoutEscapeHatch } from '../components/logoutEscapeHatch'; import { InviteNotFoundCard } from './components/inviteNotFoundCard'; +import { getOrgFromDomain } from '@/data/org'; +import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants'; interface RedeemPageProps { searchParams: { @@ -13,6 +15,11 @@ interface RedeemPageProps { } export default async function RedeemPage({ searchParams }: RedeemPageProps) { + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (!org || !org.isOnboarded) { + return redirect("/onboard"); + } + const inviteId = searchParams.invite_id; if (!inviteId) { return notFound(); diff --git a/packages/web/src/app/signup/page.tsx b/packages/web/src/app/signup/page.tsx index 7c59899c..cabc47a0 100644 --- a/packages/web/src/app/signup/page.tsx +++ b/packages/web/src/app/signup/page.tsx @@ -1,9 +1,11 @@ import { auth } from "@/auth"; import { LoginForm } from "../login/components/loginForm"; import { redirect } from "next/navigation"; -import { getProviders } from "@/auth"; import { Footer } from "@/app/components/footer"; import { createLogger } from "@sourcebot/logger"; +import { getAuthProviders } from "@/lib/authProviders"; +import { getOrgFromDomain } from "@/data/org"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; const logger = createLogger('signup-page'); @@ -21,24 +23,19 @@ export default async function Signup({ searchParams }: LoginProps) { return redirect("/"); } - const providers = getProviders(); - const providerData = providers - .map((provider) => { - if (typeof provider === "function") { - const providerInfo = provider() - return { id: providerInfo.id, name: providerInfo.name } - } else { - return { id: provider.id, name: provider.name } - } - }); + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (!org || !org.isOnboarded) { + return redirect("/onboard"); + } + const providers = getAuthProviders(); return (
diff --git a/packages/web/src/components/ui/switch.tsx b/packages/web/src/components/ui/switch.tsx index b4ce08a1..6723fb2e 100644 --- a/packages/web/src/components/ui/switch.tsx +++ b/packages/web/src/components/ui/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( diff --git a/packages/web/src/data/org.ts b/packages/web/src/data/org.ts index 2d57fbc8..f942fe7c 100644 --- a/packages/web/src/data/org.ts +++ b/packages/web/src/data/org.ts @@ -2,11 +2,18 @@ import 'server-only'; import { prisma } from '@/prisma'; export const getOrgFromDomain = async (domain: string) => { - const org = await prisma.org.findUnique({ - where: { - domain: domain - } - }); + try { + const org = await prisma.org.findUnique({ + where: { + domain: domain + } + }); - return org; + return org; + } catch (error) { + // During build time we won't be able to access the database, so we catch and return null in this case + // so that we can statically build pages that hit the DB (ex. to check if the org is onboarded) + console.error('Error fetching org from domain:', error); + return null; + } } \ No newline at end of file diff --git a/packages/web/src/ee/features/publicAccess/publicAccess.tsx b/packages/web/src/ee/features/publicAccess/publicAccess.tsx index 9adee76d..3ad65e27 100644 --- a/packages/web/src/ee/features/publicAccess/publicAccess.tsx +++ b/packages/web/src/ee/features/publicAccess/publicAccess.tsx @@ -105,8 +105,7 @@ export const createGuestUser = async (domain: string): Promise { } return providers; -} - -export const handleJITProvisioning = async (userId: string, domain: string): Promise => 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; -}); \ No newline at end of file +} \ No newline at end of file diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index fd94c9c0..4ba3f3c1 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -26,8 +26,6 @@ export const env = createEnv({ AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'), // Enterprise Auth - AUTH_EE_ENABLE_JIT_PROVISIONING: booleanSchema.default('false'), - AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(), AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(), AUTH_EE_GITHUB_BASE_URL: z.string().optional(), diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 975fa83d..02b74485 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -114,7 +114,7 @@ const syncDeclarativeConfig = async (configPath: string) => { if (hasPublicAccessEntitlement) { if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') { - logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging or disable public access.`); + logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging (SOURCEBOT_EE_AUDIT_LOGGING_ENABLED) or disable public access.`); process.exit(1); } @@ -159,15 +159,32 @@ const pruneOldGuestUser = async () => { } const initSingleTenancy = async () => { - await prisma.org.upsert({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - update: {}, - create: { - name: SINGLE_TENANT_ORG_NAME, - domain: SINGLE_TENANT_ORG_DOMAIN, - id: SINGLE_TENANT_ORG_ID + // Back fill the inviteId if the org has already been created to prevent needing to wipe the db + await prisma.$transaction(async (tx) => { + const org = await tx.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + }); + + if (!org) { + await tx.org.create({ + data: { + id: SINGLE_TENANT_ORG_ID, + name: SINGLE_TENANT_ORG_NAME, + domain: SINGLE_TENANT_ORG_DOMAIN, + inviteLinkId: crypto.randomUUID(), + } + }); + } else if (!org.inviteLinkId) { + await tx.org.update({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + data: { + inviteLinkId: crypto.randomUUID(), + } + }); } }); @@ -186,17 +203,6 @@ const initSingleTenancy = async () => { // Load any connections defined declaratively in the config file. const configPath = env.CONFIG_PATH; if (configPath) { - // If we're given a config file, mark the org as onboarded so we don't go through - // the UI connection onboarding flow - await prisma.org.update({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - data: { - isOnboarded: true, - } - }); - await syncDeclarativeConfig(configPath); // watch for changes assuming it is a local file diff --git a/packages/web/src/lib/authProviders.ts b/packages/web/src/lib/authProviders.ts new file mode 100644 index 00000000..ca2a6697 --- /dev/null +++ b/packages/web/src/lib/authProviders.ts @@ -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 }; + } + }); +}; \ No newline at end of file diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index 2a158c8d..e67f5c2a 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -1,15 +1,16 @@ import type { User as AuthJsUser } from "next-auth"; -import { env } from "@/env.mjs"; import { prisma } from "@/prisma"; import { OrgRole } from "@sourcebot/db"; -import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID } from "@/lib/constants"; -import { hasEntitlement } from "@sourcebot/shared"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { isServiceError } from "@/lib/utils"; -import { ServiceErrorException } from "@/lib/serviceError"; -import { createAccountRequest } from "@/actions"; -import { handleJITProvisioning } from "@/ee/features/sso/sso"; +import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError"; import { createLogger } from "@sourcebot/logger"; import { getAuditService } from "@/ee/features/audit/factory"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "./errorCodes"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; +import { incrementOrgSeatCount } from "@/ee/features/billing/serverUtils"; const logger = createLogger('web-auth-utils'); const auditService = getAuditService(); @@ -27,7 +28,7 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { id: "undefined", type: "user" }, - orgId: SINGLE_TENANT_ORG_ID, // TODO(mt) + orgId: SINGLE_TENANT_ORG_ID, metadata: { message: "User ID is undefined on user creation" } @@ -35,158 +36,216 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { throw new Error("User ID is undefined on user creation"); } - // In single-tenant mode, we assign the first user to sign - // up as the owner of the default org. - if (env.SOURCEBOT_TENANCY_MODE === 'single') { - const defaultOrg = await prisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - include: { - members: { - where: { - role: { - not: OrgRole.GUEST, - } + const defaultOrg = await prisma.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + include: { + members: { + where: { + role: { + not: OrgRole.GUEST, } - }, + } + }, + } + }); + + // We expect the default org to have been created on app initialization + if (defaultOrg === null) { + await auditService.createAudit({ + action: "user.creation_failed", + actor: { + id: user.id, + type: "user" + }, + target: { + id: user.id, + type: "user" + }, + orgId: SINGLE_TENANT_ORG_ID, + metadata: { + message: "Default org not found on single tenant user creation" } }); + throw new Error("Default org not found on single tenant user creation"); + } - if (defaultOrg === null) { - await auditService.createAudit({ - action: "user.creation_failed", - actor: { - id: user.id, - type: "user" + // If this is the first user to sign up, we make them the owner of the default org. + const isFirstUser = defaultOrg.members.length === 0; + if (isFirstUser) { + await prisma.$transaction(async (tx) => { + await tx.org.update({ + where: { + id: SINGLE_TENANT_ORG_ID, }, - target: { - id: user.id, - type: "user" - }, - orgId: SINGLE_TENANT_ORG_ID, - metadata: { - message: "Default org not found on single tenant user creation" - } - }); - throw new Error("Default org not found on single tenant user creation"); - } - - // Only the first user to sign up will be an owner of the default org. - const isFirstUser = defaultOrg.members.length === 0; - if (isFirstUser) { - await prisma.$transaction(async (tx) => { - await tx.org.update({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - data: { - members: { - create: { - role: OrgRole.OWNER, - user: { - connect: { - id: user.id, - } + data: { + members: { + create: { + role: OrgRole.OWNER, + user: { + connect: { + id: user.id, } } } } - }); - - await tx.user.update({ - where: { - id: user.id, - }, - data: { - pendingApproval: false, - } - }); + } }); + }); - await auditService.createAudit({ - action: "user.owner_created", - actor: { - id: user.id, - type: "user" - }, + await auditService.createAudit({ + action: "user.owner_created", + actor: { + id: user.id, + type: "user" + }, + orgId: SINGLE_TENANT_ORG_ID, + target: { + id: SINGLE_TENANT_ORG_ID.toString(), + type: "org" + } + }); + } else if (!defaultOrg.memberApprovalRequired) { + const hasAvailability = await orgHasAvailability(defaultOrg.domain); + if (!hasAvailability) { + logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`); + return; + } + + await prisma.userToOrg.create({ + data: { + userId: user.id, orgId: SINGLE_TENANT_ORG_ID, - target: { - id: SINGLE_TENANT_ORG_ID.toString(), - type: "org" - } - }); - } else { - // TODO(auth): handle multi tenant case - if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) { - const res = await handleJITProvisioning(user.id, SINGLE_TENANT_ORG_DOMAIN); - if (isServiceError(res)) { - logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); - await auditService.createAudit({ - action: "user.jit_provisioning_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: SINGLE_TENANT_ORG_ID.toString(), - type: "org" - }, - orgId: SINGLE_TENANT_ORG_ID, - metadata: { - message: `Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}` - } - }); - throw new ServiceErrorException(res); - } + role: OrgRole.MEMBER, + } + }); + } - await auditService.createAudit({ - action: "user.jit_provisioned", - actor: { - id: user.id, - type: "user" - }, - target: { - id: SINGLE_TENANT_ORG_ID.toString(), - type: "org" - }, - orgId: SINGLE_TENANT_ORG_ID, - }); - } else { - const res = await createAccountRequest(user.id, SINGLE_TENANT_ORG_DOMAIN); - if (isServiceError(res)) { - logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`); - await auditService.createAudit({ - action: "user.join_request_creation_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: SINGLE_TENANT_ORG_ID.toString(), - type: "org" - }, - orgId: SINGLE_TENANT_ORG_ID, - metadata: { - message: res.message - } - }); - throw new ServiceErrorException(res); - } +}; - await auditService.createAudit({ - action: "user.join_requested", - actor: { - id: user.id, - type: "user" - }, - orgId: SINGLE_TENANT_ORG_ID, - target: { - id: SINGLE_TENANT_ORG_ID.toString(), - type: "org" - }, - }); +export const orgHasAvailability = async (domain: string): Promise => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + logger.error(`orgHasAvailability: org not found for domain ${domain}`); + return false; + } + const members = await prisma.userToOrg.findMany({ + where: { + orgId: org.id, + role: { + not: OrgRole.GUEST, + }, + }, + }); + + const maxSeats = getSeats(); + const memberCount = members.length; + + if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) { + logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`); + return false; + } + + return true; +} + +export const addUserToOrganization = async (userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + logger.error(`addUserToOrganization: user not found for id ${userId}`); + return userNotFound(); + } + + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org) { + logger.error(`addUserToOrganization: org not found for id ${orgId}`); + return orgNotFound(); + } + + const hasAvailability = await orgHasAvailability(org.domain); + if (!hasAvailability) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", + } satisfies ServiceError; + } + + const res = await prisma.$transaction(async (tx) => { + await tx.userToOrg.create({ + data: { + userId: user.id, + orgId: org.id, + role: OrgRole.MEMBER, + } + }); + + if (IS_BILLING_ENABLED) { + const result = await incrementOrgSeatCount(orgId, tx); + if (isServiceError(result)) { + throw result; } } + + // Delete the account request if it exists since we've added the user to the org + const accountRequest = await tx.accountRequest.findUnique({ + where: { + requestedById_orgId: { + requestedById: user.id, + orgId: orgId, + } + }, + }); + + if (accountRequest) { + logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've been added to the org`); + await tx.accountRequest.delete({ + where: { + id: accountRequest.id, + } + }); + } + + // Delete any invites that may exist for this user since we've added them to the org + const invites = await tx.invite.findMany({ + where: { + recipientEmail: user.email!, + orgId: org.id, + }, + }) + + for (const invite of invites) { + logger.info(`Deleting invite ${invite.id} for ${user.email} since they've been added to the org`); + await tx.invite.delete({ + where: { + id: invite.id, + }, + }); + } + }); + + if (isServiceError(res)) { + logger.error(`addUserToOrganization: failed to add user ${userId} to org ${orgId}: ${res.message}`); + return res; } -}; \ No newline at end of file + + return { + success: true, + } +}; \ No newline at end of file diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 85cc2d08..da2c0bd0 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -7,16 +7,19 @@ export enum ErrorCode { INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY', NOT_AUTHENTICATED = 'NOT_AUTHENTICATED', NOT_FOUND = 'NOT_FOUND', + USER_NOT_FOUND = 'USER_NOT_FOUND', + ORG_NOT_FOUND = 'ORG_NOT_FOUND', CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED', ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS', ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION', - MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND', INVALID_CREDENTIALS = 'INVALID_CREDENTIALS', INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED', CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS', OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG', INVALID_INVITE = 'INVALID_INVITE', + INVALID_INVITE_LINK = 'INVALID_INVITE_LINK', + INVITE_LINK_NOT_ENABLED = 'INVITE_LINK_NOT_ENABLED', STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR', SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS', SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts index 7eb20e5c..79f533c5 100644 --- a/packages/web/src/lib/newsData.ts +++ b/packages/web/src/lib/newsData.ts @@ -1,6 +1,12 @@ import { NewsItem } from "./types"; 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", header: "Analytics Dashboard", @@ -9,25 +15,25 @@ export const newsData: NewsItem[] = [ }, { unique_id: "audit-logs", - header: "Audit logs", + header: "Audit Logs", sub_header: "We've added support for audit logs", url: "https://docs.sourcebot.dev/docs/configuration/audit-logs" }, { unique_id: "file-explorer", - header: "File explorer", + header: "File Explorer", 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" }, { unique_id: "structured-logging", - header: "Structured logging", + header: "Structured Logging", sub_header: "We've added support for structured logging", url: "https://docs.sourcebot.dev/docs/configuration/structured-logging" }, { unique_id: "code-nav", - header: "Code navigation", + header: "Code Navigation", sub_header: "Built in go-to definition and find references", url: "https://docs.sourcebot.dev/docs/features/code-navigation" }, @@ -39,7 +45,7 @@ export const newsData: NewsItem[] = [ }, { unique_id: "search-contexts", - header: "Search contexts", + header: "Search Contexts", sub_header: "Filter searches by groups of repos", url: "https://docs.sourcebot.dev/docs/features/search/search-contexts" } diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index 71132673..051672e1 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -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 => { return { statusCode: StatusCodes.CONFLICT, diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 3526217a..1a3ff68c 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -19,6 +19,27 @@ export function cn(...inputs: ClassValue[]) { 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. * @@ -119,7 +140,7 @@ export const getAuthProviderInfo = (providerId: string): AuthProviderInfo => { id: "microsoft-entra-id", name: "Microsoft Entra ID", displayName: "Microsoft Entra ID", - icon: { + icon: { src: microsoftLogo, }, }; diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index b373ff2f..5e4c5f1c 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -13,7 +13,9 @@ export async function middleware(request: NextRequest) { if ( url.pathname.startsWith('/login') || url.pathname.startsWith('/redeem') || - url.pathname.startsWith('/signup') + url.pathname.startsWith('/signup') || + url.pathname.startsWith('/invite') || + url.pathname.startsWith('/onboard') ) { return NextResponse.next(); }