diff --git a/CHANGELOG.md b/CHANGELOG.md index a451074d7..76d15b138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the ability to configure email code and credentials login from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) - Added a list of configured SSO providers from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) +- [EE] Added a SCIM 2.0 server for automated user provisioning and deprovisioning from identity providers (Okta, Entra). [#1306](https://github.com/sourcebot-dev/sourcebot/pull/1306) ### Fixed - Validated that `SOURCEBOT_ENCRYPTION_KEY` is exactly 32 characters at startup, failing fast with an actionable message instead of a runtime encryption error. [#1305](https://github.com/sourcebot-dev/sourcebot/pull/1305) diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 126baf4c3..3fe693a3e 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1148,6 +1148,11 @@ "MEMBER" ] }, + "suspendedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "createdAt": { "type": "string", "format": "date-time" @@ -1163,6 +1168,7 @@ "name", "email", "role", + "suspendedAt", "createdAt", "lastActivityAt" ] diff --git a/docs/docs.json b/docs/docs.json index 8145088df..76403968e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -113,6 +113,7 @@ "root": "docs/configuration/auth/authentication", "pages": [ "docs/configuration/auth/providers", + "docs/configuration/auth/scim", "docs/configuration/auth/access-settings", "docs/configuration/auth/roles-and-permissions", "docs/configuration/auth/faq" diff --git a/docs/docs/configuration/auth/authentication.mdx b/docs/docs/configuration/auth/authentication.mdx index ad8024d37..a3f717d21 100644 --- a/docs/docs/configuration/auth/authentication.mdx +++ b/docs/docs/configuration/auth/authentication.mdx @@ -13,6 +13,9 @@ Sourcebot's built-in authentication system gates your deployment, and allows adm Learn how to configure how members join your deployment. + + Provision and deprovision organization members from your identity provider. + Learn more about the different roles and permissions in Sourcebot. @@ -33,4 +36,4 @@ A session is guaranteed to remain valid for at least its configured lifetime. Th # Troubleshooting - If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers). -- Still not working? Reach out to us on our [discord](https://discord.gg/HDScTs3ptP) or [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) \ No newline at end of file +- Still not working? Reach out to us on our [discord](https://discord.gg/HDScTs3ptP) or [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) diff --git a/docs/docs/configuration/auth/scim.mdx b/docs/docs/configuration/auth/scim.mdx new file mode 100644 index 000000000..d4891289d --- /dev/null +++ b/docs/docs/configuration/auth/scim.mdx @@ -0,0 +1,111 @@ +--- +title: SCIM +sidebarTitle: SCIM +--- + +import LicenseKeyRequired from '/snippets/license-key-required.mdx' + +SCIM, or _System for Cross-domain Identity Management_ allows for the automation of user provisioning for your Sourcebot organization. + + + SCIM provisioning settings in Sourcebot showing the enable toggle, connector base URL, and SCIM token list + + + + +## Overview + +SCIM provisioning lets your identity provider manage Sourcebot organization membership automatically. When enabled, your identity provider becomes the source of truth for who should have access to your Sourcebot organization. + +Sourcebot supports SCIM 2.0 user provisioning for identity providers such as Okta and Microsoft Entra ID. + +## Configure + +1. Navigate to **Settings -> Security**. +2. Under the "SCIM provisioning" section, toggle the option to enable SCIM. +3. You can now get your **SCIM connector base URL** and generate a **SCIM Bearer auth token**. These values will be needed to configure SCIM in your identity provider. + + +When SCIM provisioning is enabled, Admins will **not** be able to manage users from within Sourcebot as they will be kept up to date through your identity provider. Role assignments can still be managed within Sourcebot. + + +### IdP-specific configuration notes + + + + + Okta does not support SCIM in an OIDC app integration. To work around this, two apps need to be created: + 1. An OIDC app used for SSO. + 2. A SAML provisioning-only app. The SSO portion of the app should not need to be functional. + + [Learn more](https://support.okta.com/help/s/article/configure-scim-for-a-custom-oidc-app). + + + - Follow [these instructions](/docs/configuration/idp#okta) to setup a Okta OIDC app and configure it as a SSO provider in Sourcebot. + - In Okta admin pages, create a SAML 2.0 application. This app will be used for provisioning-only and will not be used for SSO. The sign-on URL and audience URI can be set to the base URL of your deployment. + - In the General tab, click Edit and choose SCIM in the Provisioning section and Save. + - In the Provisioning tab, enter the SCIM Base connector URL from Sourcebot. + - For the Unique identifier field for users section enter **userName** + - For Supported provisioning actions, enable "Push New Users" and "Push Profile Updates" + - For Authentication mode field, choose HTTP Header and enter your SCIM token generated in Sourcebot. You can now test the configuration and save + - Lastly, return to the Provisioning tab in Okta and edit your settings under “To App” to enable the SCIM functionality needed for your Sourcebot application (Create, Update and Deactivate users) + + + Okta provisioning To App settings showing Create Users, Update User Attributes, and Deactivate Users enabled + + + + +## User lifecycle + +Sourcebot represents organization users with three membership states: + +| Sourcebot state | Access | Billing | +| --- | --- | --- | +| Pending | Can access the organization after signing in | Not billed | +| Active | Can access the organization | Billed | +| Suspended | Cannot access the organization | Not billed | + +When a user is provisioned through SCIM, Sourcebot creates or restores their organization membership. New SCIM-provisioned users appear as **Pending** until they sign in and access the organization for the first time. + +When a pending user signs in, Sourcebot moves them to **Active** and they count toward billing. On deployments with a hard seat cap, the user can only become active if a seat is available. + +When your identity provider deactivates a user by sending `active: false`, Sourcebot marks the user as **Suspended**. Suspended users cannot access the organization, and Sourcebot revokes their active sessions, API keys, and OAuth tokens. + +If your identity provider reactivates the user by sending `active: true`, Sourcebot restores their membership. Users who had already become active return to active access; users who had never signed in return to pending. + + +## Roles + +SCIM does not assign Sourcebot [roles](/docs/configuration/auth/roles-and-permissions). Users created through SCIM are added with the **Member** role. + +Owners can promote active members to owner, or demote owners to member, from **Settings -> Members**. Sourcebot prevents changes that would leave the organization without an active owner. + +## Supported attributes + +Sourcebot stores this subset of SCIM user attributes: + +| SCIM attribute | Sourcebot behavior | +| --- | --- | +| `userName` | User email address | +| `emails` | User email address; the primary email is preferred | +| `name.formatted` | Display name | +| `displayName` | Display name fallback | +| `active` | Unsuspended or suspended membership state | +| `externalId` | Stored IdP external identifier | + +Additional attributes may be sent by your identity provider, but Sourcebot ignores attributes it does not use. + +## FAQ + + + + SCIM provisioning should work with most identity providers that support SCIM user provisioning, but it has only been tested with Okta. + + + Sourcebot supports SCIM 2.0. + + + SCIM-created users become billable seats after they sign in and access the organization for the first time. Until then, they appear as pending and do not count toward billing. Suspended users also do not count toward billing. + + diff --git a/docs/images/okta_scim_to_app_provisioning.png b/docs/images/okta_scim_to_app_provisioning.png new file mode 100644 index 000000000..5b5d0f73f Binary files /dev/null and b/docs/images/okta_scim_to_app_provisioning.png differ diff --git a/docs/images/scim_provisioning_settings.png b/docs/images/scim_provisioning_settings.png new file mode 100644 index 000000000..b656812d5 Binary files /dev/null and b/docs/images/scim_provisioning_settings.png differ diff --git a/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql new file mode 100644 index 000000000..22838d192 --- /dev/null +++ b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "isScimEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "UserToOrg" ADD COLUMN "suspendedAt" TIMESTAMP(3), +ADD COLUMN "scimExternalId" TEXT; + +-- CreateTable +CREATE TABLE "ScimToken" ( + "name" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + "orgId" INTEGER NOT NULL, + + CONSTRAINT "ScimToken_pkey" PRIMARY KEY ("hash") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScimToken_hash_key" ON "ScimToken"("hash"); + +-- CreateIndex +CREATE INDEX "ScimToken_orgId_idx" ON "ScimToken"("orgId"); + +-- CreateIndex +CREATE INDEX "UserToOrg_orgId_scimExternalId_idx" ON "UserToOrg"("orgId", "scimExternalId"); + +-- AddForeignKey +ALTER TABLE "ScimToken" ADD CONSTRAINT "ScimToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql new file mode 100644 index 000000000..ccb3d03e9 --- /dev/null +++ b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "UserToOrg" ADD COLUMN "lastActiveAt" TIMESTAMP(3); + +-- Backfill per-membership activity from the global User.lastActiveAt. In a +-- single-tenant deployment a user belongs to exactly one org, so the global +-- timestamp is exactly the per-org timestamp. In multi-tenant deployments this +-- seeds every membership with the user's global last-active time as the best +-- available signal; the per-org value diverges naturally from the next +-- authenticated action onward. Without this, every existing membership would +-- read as "never active" (NULL) until each member's next request. +UPDATE "UserToOrg" AS uto +SET "lastActiveAt" = u."lastActiveAt" +FROM "User" AS u +WHERE uto."userId" = u."id" + AND u."lastActiveAt" IS NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 54444bbe2..4479b2f40 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -272,9 +272,12 @@ model Org { connections Connection[] repos Repo[] apiKeys ApiKey[] + scimTokens ScimToken[] isOnboarded Boolean @default(false) imageUrl String? + isScimEnabled Boolean @default(false) + /// @deprecated This property can be controlled by the environment /// variable `REQUIRE_APPROVAL_NEW_MEMBERS`. To ensure that we use /// the correct setting, use the helper function `isMemberApprovalRequired` @@ -397,7 +400,23 @@ model UserToOrg { role OrgRole @default(MEMBER) + /// When set, the membership is suspended and the user is treated as a + /// non-member for auth purposes (see `getAuthContext`). + suspendedAt DateTime? + + /// The IdP-supplied `externalId` for this membership when provisioned via + /// SCIM. Null for members that joined through invites or self-serve sign-up. + scimExternalId String? + + /// Last time the user performed an authenticated action *in this org*. Unlike + /// `User.lastActiveAt` (which is global to the instance), this is scoped to + /// the membership, so it distinguishes a member who has been active in this + /// org from one who was provisioned but never signed in here. Null means the + /// member has never been active in this org. + lastActiveAt DateTime? + @@id([orgId, userId]) + @@index([orgId, scimExternalId]) } model ApiKey { @@ -414,6 +433,23 @@ model ApiKey { createdById String } +/// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate +/// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is +/// not tied to a user — it acts on behalf of the SCIM integration for the +/// whole org. Only the HMAC hash of the secret is stored. +model ScimToken { + name String + hash String @id @unique + + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@index([orgId]) +} + model Audit { id String @id @default(cuid()) timestamp DateTime @default(now()) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5bb33d146..c299ef1cc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -11,6 +11,7 @@ export const LEGACY_API_KEY_PREFIX = 'sourcebot-'; export const API_KEY_PREFIX = 'sbk_'; export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_'; export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_'; +export const SCIM_TOKEN_PREFIX = 'sbscim_'; /** * Default settings. diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index fbb4be79b..c5b8842be 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { env } from './env.server.js'; import { Token } from '@sourcebot/schemas/v3/shared.type'; import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; -import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js'; +import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX } from './constants.js'; const algorithm = 'aes-256-cbc'; const ivLength = 16; // 16 bytes for CBC @@ -56,6 +56,16 @@ export function generateApiKey(): { key: string; hash: string } { }; } +export function generateScimToken(): { token: string; hash: string } { + const secret = crypto.randomBytes(32).toString('hex'); + const hash = hashSecret(secret); + + return { + token: `${SCIM_TOKEN_PREFIX}${secret}`, + hash, + }; +} + export function generateOAuthToken(): { token: string; hash: string } { const secret = crypto.randomBytes(32).toString('hex'); const hash = hashSecret(secret); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index c03b2f56e..85982c147 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -42,7 +42,8 @@ const ALL_ENTITLEMENTS = [ "org-management", "oauth", "ask", - "mcp" + "mcp", + "scim" ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 6a09b9340..bbe52f31e 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -58,6 +58,7 @@ export { decrypt, hashSecret, generateApiKey, + generateScimToken, generateOAuthToken, generateOAuthRefreshToken, verifySignature, diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 48e01b40c..97d000fb8 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -59,6 +59,13 @@ const nextConfig = { { source: "/api/mcp", destination: "/api/ee/mcp", + }, + // The SCIM 2.0 server lives under /api/ee/scim/v2 (EE-licensed route + // tree) but is exposed at the clean /scim/v2 path that IdPs (Okta, + // Entra) are configured to send provisioning requests to. + { + source: "/scim/v2/:path*", + destination: "/api/ee/scim/v2/:path*", } ]; }, diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 5e5c28682..556d896fc 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -17,7 +17,7 @@ export const MOCK_ORG: Org = { updatedAt: new Date(), isOnboarded: true, imageUrl: null, - metadata: null, + isScimEnabled: false, memberApprovalRequired: false, isCredentialsLoginEnabled: true, isEmailCodeLoginEnabled: false, diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index efd996c1b..c64979173 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,26 +1,20 @@ 'use server'; import { createAudit } from "@/ee/features/audit/audit"; -import { env, getSMTPConnectionURL } from "@sourcebot/shared"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; -import { __unsafePrisma } from "@/prisma"; -import { render } from "@react-email/components"; -import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { sew } from "@/middleware/sew"; import { ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db"; -import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { createLogger, env, generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; -import { createTransport } from "nodemailer"; -import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from "./lib/constants"; -import { RepositoryQuery } from "./lib/types"; -import { getAuthenticatedUser, withAuth, withOptionalAuth } from "./middleware/withAuth"; import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; -import { sew } from "@/middleware/sew"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; +import { RepositoryQuery } from "./lib/types"; +import { withAuth, withOptionalAuth } from "./middleware/withAuth"; const logger = createLogger('web-actions'); @@ -375,110 +369,6 @@ export const getRepoInfoByName = async (repoName: string) => sew(() => } })); -// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth -export const createAccountRequest = async () => sew(async () => { - const authResult = await getAuthenticatedUser(); - if (!authResult) { - return notAuthenticated(); - } - - const { user } = authResult; - - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return notFound("Organization not found"); - } - - const existingRequest = await __unsafePrisma.accountRequest.findUnique({ - where: { - requestedById_orgId: { - requestedById: user.id, - orgId: org.id, - }, - }, - }); - - if (existingRequest) { - logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`); - return { - success: true, - existingRequest: true, - } - } - - if (!existingRequest) { - await __unsafePrisma.accountRequest.create({ - data: { - requestedById: user.id, - orgId: org.id, - }, - }); - - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - // TODO: This is needed because we can't fetch the origin from the request headers when this is called - // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) - const deploymentUrl = env.AUTH_URL; - - const owners = await __unsafePrisma.user.findMany({ - where: { - orgs: { - some: { - orgId: org.id, - role: "OWNER", - }, - }, - }, - }); - - if (owners.length === 0) { - logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`); - } else { - const html = await render(JoinRequestSubmittedEmail({ - baseUrl: deploymentUrl, - requestor: { - name: user.name ?? undefined, - email: user.email, - avatarUrl: user.image ?? undefined, - }, - orgName: org.name, - orgImageUrl: org.imageUrl ?? undefined, - })); - - const ownerEmails = owners - .map((owner) => owner.email) - .filter((email): email is string => email !== null); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: ownerEmails, - from: env.EMAIL_FROM_ADDRESS, - subject: `New account request for ${org.name} on Sourcebot`, - html, - text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`); - } - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); - } - } - - return { - success: true, - existingRequest: false, - } -}); - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index 39edf49df..04ff3caf6 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -3,7 +3,7 @@ import { auth } from "@/auth"; import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; import { HomeView } from "@/hooks/useHomeView"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; diff --git a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx b/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx deleted file mode 100644 index 85398a7db..000000000 --- a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { Clock } from "lucide-react" -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" - - -export function SubmitAccountRequestButton() { - const { toast } = useToast() - const router = useRouter() - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleSubmit = async () => { - setIsSubmitting(true) - const result = await createAccountRequest() - if (!isServiceError(result)) { - if (result.existingRequest) { - toast({ - title: "Request Already Submitted", - description: "Your request to join the organization has already been submitted. Please wait for it to be approved.", - variant: "default", - }) - } else { - toast({ - 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 Submit", - description: `There was an error submitting your request. Reason: ${result.message}`, - variant: "destructive", - }) - } - setIsSubmitting(false) - } - - return ( -
{ - e.preventDefault(); - handleSubmit(); - }}> - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/components/submitJoinRequest.tsx b/packages/web/src/app/(app)/components/submitJoinRequest.tsx deleted file mode 100644 index fdfdc0a20..000000000 --- a/packages/web/src/app/(app)/components/submitJoinRequest.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" -import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import { SubmitAccountRequestButton } from "./submitAccountRequestButton" - -export const SubmitJoinRequest = async () => { - return ( -
- - -
-
- - -
-
- - - -
- -
-

- Request Access -

-

- Submit a request to join this organization -

-
-
- -
-
- -
-
-
-
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index ec355a56d..5120f6bcc 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -10,17 +10,20 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, OPTIONAL_PROVID import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { notFound, redirect } from "next/navigation"; -import { PendingApprovalCard } from "./components/pendingApproval"; -import { SubmitJoinRequest } from "./components/submitJoinRequest"; +import { PendingApprovalCard } from "../../features/membership/components/pendingApprovalCard"; +import { SubmitJoinRequestCard } from "../../features/membership/components/submitJoinRequestCard"; +import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard"; +import { isScimEnabled } from "@/features/scim/utils"; import { env, getOfflineLicenseMetadata, SOURCEBOT_VERSION, isMemberApprovalRequired } from "@sourcebot/shared"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; import { GcpIapAuth } from "./components/gcpIapAuth"; -import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; +import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { GitHubStarToast } from "./components/githubStarToast"; import { getLinkedAccounts } from "@/ee/features/sso/actions"; import { BannerSlot } from "./components/banners/bannerSlot"; import { BannerHeightObserver } from "./components/banners/bannerHeightObserver"; +import { activeOrPendingMembershipWhere } from "@/features/membership/utils"; import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api"; import { OrgRole } from "@sourcebot/db"; import { ServiceErrorException } from "@/lib/serviceError"; @@ -63,8 +66,9 @@ export default async function Layout(props: LayoutProps) { where: { orgId_userId: { orgId: org.id, - userId: session.user.id - } + userId: session.user.id, + }, + ...activeOrPendingMembershipWhere(), }, include: { user: true @@ -76,26 +80,25 @@ export default async function Layout(props: LayoutProps) { // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. if (!membership) { + if (await isScimEnabled(org)) { + return ; + } + if (!isMemberApprovalRequired(org)) { - return ( -
- - -
- ) - } else { - const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ - where: { - orgId: org.id, - requestedById: session.user.id - } - }); - - if (hasPendingApproval) { - return - } else { - return + return ; + } + + const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ + where: { + orgId: org.id, + requestedById: session.user.id } + }); + + if (hasPendingApproval) { + return + } else { + return } } diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx new file mode 100644 index 000000000..cdf17facf --- /dev/null +++ b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function AccountAskAgentSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/analytics/layout.tsx b/packages/web/src/app/(app)/settings/analytics/layout.tsx new file mode 100644 index 000000000..ec8f6d691 --- /dev/null +++ b/packages/web/src/app/(app)/settings/analytics/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function AnalyticsSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx index dfc24fa63..7fdba3f2b 100644 --- a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx +++ b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx @@ -2,11 +2,12 @@ import { notFound } from "next/navigation"; import { OrgRole } from "@sourcebot/db"; import { env } from "@sourcebot/shared"; import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { SettingsContainer } from "../components/settingsContainer"; export default authenticatedPage<{ children: React.ReactNode }>(async ({ role }, { children }) => { if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && role !== OrgRole.OWNER) { return notFound(); } - return <>{children}; + return {children}; }); diff --git a/packages/web/src/app/(app)/settings/components/settingsCard.tsx b/packages/web/src/app/(app)/settings/components/settingsCard.tsx index 6649e002b..a9c267506 100644 --- a/packages/web/src/app/(app)/settings/components/settingsCard.tsx +++ b/packages/web/src/app/(app)/settings/components/settingsCard.tsx @@ -30,15 +30,19 @@ interface BasicSettingsCardProps { description?: string; children: ReactNode; footer?: ReactNode; + badge?: ReactNode; className?: string; } -export function BasicSettingsCard({ name, description, children, footer, className }: BasicSettingsCardProps) { +export function BasicSettingsCard({ name, description, children, footer, badge, className }: BasicSettingsCardProps) { return (
-

{name}

+
+

{name}

+ {badge} +
{description && (

{description}

)} diff --git a/packages/web/src/app/(app)/settings/components/settingsContainer.tsx b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx new file mode 100644 index 000000000..26ec413d1 --- /dev/null +++ b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +type SettingsContainerVariant = "centered" | "full"; + +interface SettingsContainerProps { + children: React.ReactNode; + variant?: SettingsContainerVariant; +} + +export const SettingsContainer = ({ children, variant = "centered" }: SettingsContainerProps) => { + const isFull = variant === "full"; + return ( +
+
+
{children}
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/settings/connections/layout.tsx b/packages/web/src/app/(app)/settings/connections/layout.tsx index ec521d14e..c89c498e6 100644 --- a/packages/web/src/app/(app)/settings/connections/layout.tsx +++ b/packages/web/src/app/(app)/settings/connections/layout.tsx @@ -1,6 +1,7 @@ import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; +import { SettingsContainer } from "../components/settingsContainer"; export default authenticatedPage<{ children: React.ReactNode }>(async (_auth, { children }) => { - return <>{children}; + return {children}; }, { minRole: OrgRole.OWNER, redirectTo: '/settings' }); diff --git a/packages/web/src/app/(app)/settings/general/layout.tsx b/packages/web/src/app/(app)/settings/general/layout.tsx new file mode 100644 index 000000000..e5b1afe41 --- /dev/null +++ b/packages/web/src/app/(app)/settings/general/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function GeneralSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index cf640ac11..422785ced 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { isServiceError } from "@/lib/utils"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; import { env } from "@sourcebot/shared"; @@ -33,13 +33,7 @@ export default async function SettingsLayout( } return ( -
-
-
-
{children}
-
-
-
+ <>{children} ) } diff --git a/packages/web/src/app/(app)/settings/license/layout.tsx b/packages/web/src/app/(app)/settings/license/layout.tsx new file mode 100644 index 000000000..24d60263c --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function LicenseSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index 419422ee8..83ba0a7d2 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -15,6 +15,7 @@ import { getAllInvoices } from "@/ee/features/lighthouse/actions"; import { syncWithLighthouse } from "@/features/billing/servicePing"; import { isServiceError } from "@/lib/utils"; import { getYearlyTermStatus } from "./types"; +import { activeMembershipWhere } from "@/features/membership/utils"; type LicensePageProps = { searchParams?: Promise>; @@ -49,7 +50,12 @@ export default authenticatedPage(async ({ prisma, org }, props : await prisma.license.findUnique({ where: { orgId: org.id } }); const yearlyTermStatus = getYearlyTermStatus(license); - const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } }); + const currentActiveUserCount = await prisma.userToOrg.count({ + where: { + orgId: org.id, + ...activeMembershipWhere(), + }, + }); const invoicesResult = license ? await getAllInvoices() : null; const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : []; @@ -97,7 +103,7 @@ export default authenticatedPage(async ({ prisma, org }, props && !isOnlineLicenseInactive && yearlyTermStatus && ( )} diff --git a/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx new file mode 100644 index 000000000..7f5a6d9ee --- /dev/null +++ b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function LinkedAccountsSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/mcp/layout.tsx b/packages/web/src/app/(app)/settings/mcp/layout.tsx new file mode 100644 index 000000000..8ffa6bb7a --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcp/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function McpSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx deleted file mode 100644 index b81c16996..000000000 --- a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx +++ /dev/null @@ -1,193 +0,0 @@ -'use client'; - -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { useCallback, useState } from "react"; -import { z } from "zod"; -import { PlusCircleIcon, Loader2, AlertTriangle } from "lucide-react"; -import { OrgRole } from "@prisma/client"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { createInvites } from "@/features/userManagement/actions"; -import { isServiceError } from "@/lib/utils"; -import { useToast } from "@/components/hooks/use-toast"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -export const inviteMemberFormSchema = z.object({ - emails: z.array(z.object({ - email: z.string().email() - })) - .refine((emails) => { - const emailSet = new Set(emails.map(e => e.email.toLowerCase())); - return emailSet.size === emails.length; - }, "Duplicate email addresses are not allowed") -}); - -interface InviteMemberCardProps { - currentUserRole: OrgRole; - seatsAvailable: boolean; -} - -export const InviteMemberCard = ({ currentUserRole, seatsAvailable }: InviteMemberCardProps) => { - const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - 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 onSubmit = useCallback((data: z.infer) => { - setIsLoading(true); - createInvites(data.emails.map(e => e.email)) - .then((res) => { - if (isServiceError(res)) { - toast({ - description: `❌ Failed to invite members. Reason: ${res.message}` - }); - captureEvent('wa_invite_member_card_invite_fail', { - errorCode: res.errorCode, - num_emails: data.emails.length, - }); - } else { - form.reset(); - router.push(`?tab=invites`); - toast({ - description: `✅ Successfully invited ${data.emails.length} members` - }); - captureEvent('wa_invite_member_card_invite_success', { - num_emails: data.emails.length, - }); - } - }) - .finally(() => { - setIsLoading(false); - }); - }, [form, toast, router, captureEvent]); - - const isDisabled = !seatsAvailable || currentUserRole !== OrgRole.OWNER || isLoading; - - return ( - <> - - - Invite Member - Invite new members to your organization. - - {!seatsAvailable && ( -
-
- -
-

- Maximum seats reached -

-

- You've reached the maximum number of seats for your license. Upgrade your plan to invite additional members. -

-
-
-
- )} -
- setIsInviteDialogOpen(true))}> - - Email Address - {form.watch('emails').map((_, index) => ( - ( - - - - - - - )} - /> - ))} - {form.formState.errors.emails?.root?.message && ( - {form.formState.errors.emails.root.message} - )} - - - - - -
- -
- - - - Invite Team Members - - {`Your team is growing! By confirming, you will be inviting ${form.getValues().emails.length} new members to your organization.`} - - -
-
- {form.getValues().emails.map(({ email }, index) => ( -

- {email} -

- ))} -
-
- - captureEvent('wa_invite_member_card_invite_cancel', { - num_emails: form.getValues().emails.length, - })}>Cancel - onSubmit(form.getValues())} - > - Invite - - -
-
- - ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx deleted file mode 100644 index b5ebb4883..000000000 --- a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx +++ /dev/null @@ -1,214 +0,0 @@ -'use client'; - -import { OrgRole } from "@sourcebot/db"; -import { useToast } from "@/components/hooks/use-toast"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { createPathWithQueryParams, isServiceError } from "@/lib/utils"; -import { UserAvatar } from "@/components/userAvatar"; -import { Copy, MoreVertical, Search } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { cancelInvite } from "@/features/userManagement/actions"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -interface Invite { - id: string; - email: string; - createdAt: Date; -} - -interface InviteListProps { - invites: Invite[] - currentUserRole: OrgRole -} - -export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { - const [searchQuery, setSearchQuery] = useState("") - const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") - const [isCancelInviteDialogOpen, setIsCancelInviteDialogOpen] = useState(false) - const [inviteToCancel, setInviteToCancel] = useState(null) - const { toast } = useToast(); - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const filteredInvites = useMemo(() => { - return invites - .filter((invite) => { - const searchLower = searchQuery.toLowerCase(); - const matchesSearch = - invite.email.toLowerCase().includes(searchLower); - return matchesSearch; - }) - .sort((a, b) => { - return dateSort === "newest" - ? b.createdAt.getTime() - a.createdAt.getTime() - : a.createdAt.getTime() - b.createdAt.getTime() - }); - }, [invites, searchQuery, dateSort]); - - const onCancelInvite = useCallback((inviteId: string) => { - cancelInvite(inviteId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to cancel invite. Reason: ${response.message}` - }) - captureEvent('wa_invites_list_cancel_invite_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Invite cancelled successfully.` - }) - captureEvent('wa_invites_list_cancel_invite_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - return ( -
-
-
- - setSearchQuery(e.target.value)} - /> -
- - -
- -
-
- {invites.length === 0 || (filteredInvites.length === 0 && searchQuery.length > 0) ? ( -
-

No Pending Invitations Found

-

- {filteredInvites.length === 0 && searchQuery.length > 0 ? "No pending invitations found matching your filters." : "Use the form above to invite new members."} -

-
- ) : ( - filteredInvites.map((invite) => ( -
-
- -
-
{invite.email}
-
-
-
- - - - - - - { - navigator.clipboard.writeText(invite.email) - .then(() => { - toast({ - description: `✅ Email copied to clipboard.` - }) - captureEvent('wa_invites_list_copy_email_success', {}) - }) - .catch(() => { - toast({ - description: `❌ Failed to copy email.` - }) - captureEvent('wa_invites_list_copy_email_fail', {}) - }) - }} - > - Copy email - - {currentUserRole === OrgRole.OWNER && ( - { - setIsCancelInviteDialogOpen(true); - setInviteToCancel(invite); - }} - > - Cancel invite - - )} - - -
-
- )) - )} -
-
- - - - Cancel Invite - - Are you sure you want to cancel this invite for {inviteToCancel?.email}? - - - - - Back - - { - onCancelInvite(inviteToCancel?.id ?? ""); - }} - > - Cancel - - - - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/members/components/membersList.tsx b/packages/web/src/app/(app)/settings/members/components/membersList.tsx deleted file mode 100644 index 9976f11de..000000000 --- a/packages/web/src/app/(app)/settings/members/components/membersList.tsx +++ /dev/null @@ -1,430 +0,0 @@ -'use client'; - -import { Input } from "@/components/ui/input"; -import { Search, MoreVertical } from "lucide-react"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; -import { useCallback, useMemo, useState } from "react"; -import { OrgRole } from "@prisma/client"; -import { UserAvatar } from "@/components/userAvatar"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions"; -import { leaveOrg, removeMemberFromOrg } from "@/features/userManagement/actions"; -import { isServiceError } from "@/lib/utils"; -import { useToast } from "@/components/hooks/use-toast"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -import Link from "next/link"; - -type Member = { - id: string - email: string - name?: string - role: OrgRole - joinedAt: Date - avatarUrl?: string -} - -export interface MembersListProps { - members: Member[], - currentUserId: string, - currentUserRole: OrgRole, - orgName: string, - hasOrgManagement: boolean, -} - -const ROLES_AND_PERMISSIONS_DOCS_LINK = "https://docs.sourcebot.dev/docs/configuration/auth/roles-and-permissions" - -export const MembersList = ({ members, currentUserId, currentUserRole, orgName, hasOrgManagement }: MembersListProps) => { - const [searchQuery, setSearchQuery] = useState("") - const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all") - const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") - const [memberToRemove, setMemberToRemove] = useState(null) - const [memberToPromote, setMemberToPromote] = useState(null) - const [memberToDemote, setMemberToDemote] = useState(null) - const { toast } = useToast() - const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false) - const [isPromoteDialogOpen, setIsPromoteDialogOpen] = useState(false) - const [isDemoteDialogOpen, setIsDemoteDialogOpen] = useState(false) - const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false) - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const ownerCount = useMemo(() => members.filter(m => m.role === OrgRole.OWNER).length, [members]); - - const filteredMembers = useMemo(() => { - return members - .filter((member) => { - const searchLower = searchQuery.toLowerCase(); - const matchesSearch = - member.name?.toLowerCase().includes(searchLower) || member.email.toLowerCase().includes(searchLower); - const matchesRole = roleFilter === "all" || member.role === roleFilter; - return matchesSearch && matchesRole; - }) - .sort((a, b) => { - return dateSort === "newest" - ? b.joinedAt.getTime() - a.joinedAt.getTime() - : a.joinedAt.getTime() - b.joinedAt.getTime() - }); - }, [members, searchQuery, roleFilter, dateSort]); - - const onRemoveMember = useCallback((memberId: string) => { - removeMemberFromOrg(memberId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to remove member. Reason: ${response.message}` - }) - captureEvent('wa_members_list_remove_member_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Member removed successfully.` - }) - captureEvent('wa_members_list_remove_member_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onPromoteToOwner = useCallback((memberId: string) => { - promoteToOwner(memberId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to promote member. Reason: ${response.message}` - }) - captureEvent('wa_members_list_promote_to_owner_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Member promoted to owner.` - }) - captureEvent('wa_members_list_promote_to_owner_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onDemoteToMember = useCallback((memberId: string) => { - demoteToMember(memberId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to demote owner. Reason: ${response.message}` - }) - captureEvent('wa_members_list_demote_to_member_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Owner demoted to member.` - }) - captureEvent('wa_members_list_demote_to_member_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onLeaveOrg = useCallback(() => { - leaveOrg() - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to leave organization. Reason: ${response.message}` - }) - captureEvent('wa_members_list_leave_org_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ You have left the organization.` - }) - captureEvent('wa_members_list_leave_org_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - return ( -
-
-
-
- - setSearchQuery(e.target.value)} - /> -
- - - - -
- -
-
- {filteredMembers.length === 0 ? ( -
-

No Members Found

-

- No members found matching your filters. -

-
- ) : ( - filteredMembers.map((member) => ( -
-
- -
-
{member.name}
-
{member.email}
-
-
-
- {member.role.toLowerCase()} - - - - - - { - navigator.clipboard.writeText(member.email) - .then(() => { - toast({ - description: `✅ Email copied to clipboard.` - }) - }) - .catch(() => { - toast({ - description: `❌ Failed to copy email.` - }) - }) - }} - > - Copy email - - {member.id !== currentUserId && currentUserRole === OrgRole.OWNER && member.role !== OrgRole.OWNER && ( - - - - { - setMemberToPromote(member); - setIsPromoteDialogOpen(true); - }} - > - Promote to owner - - - - {!hasOrgManagement && ( - - Upgrade your plan to manage roles. Learn more - - )} - - )} - {currentUserRole === OrgRole.OWNER && member.role === OrgRole.OWNER && ( - - - - { - setMemberToDemote(member); - setIsDemoteDialogOpen(true); - }} - > - Demote to member - - - - {(ownerCount <= 1 || !hasOrgManagement) && ( - - {!hasOrgManagement - ? <>Upgrade your plan to manage roles. Learn more - : "Cannot demote the last owner. Promote another member to owner first." - } - - )} - - )} - {member.id !== currentUserId && currentUserRole === OrgRole.OWNER && ( - { - setMemberToRemove(member); - setIsRemoveDialogOpen(true); - }} - > - Remove - - )} - {member.id === currentUserId && ( - - - - { - setIsLeaveOrgDialogOpen(true); - }} - > - Leave organization - - - - {currentUserRole === OrgRole.OWNER && ownerCount <= 1 && ( - - You are the last owner. Promote another member to owner before leaving. - - )} - - )} - - -
-
- )) - )} -
-
- - - - Remove Member - - {`Are you sure you want to remove ${memberToRemove?.name ?? memberToRemove?.email}?`} - - - - Cancel - { - onRemoveMember(memberToRemove?.id ?? ""); - }} - > - Remove - - - - - - - - Promote to Owner - - {`Are you sure you want to promote ${memberToPromote?.name ?? memberToPromote?.email} to owner? They will have full administrative access.`} - - - - Cancel - { - onPromoteToOwner(memberToPromote?.id ?? ""); - }} - > - Promote - - - - - - - - Demote to Member - - {memberToDemote?.id === currentUserId - ? `Are you sure you want to step down as owner? You will lose administrative access.` - : `Are you sure you want to demote ${memberToDemote?.name ?? memberToDemote?.email} from owner to member? They will lose administrative access.` - } - - - - Cancel - { - onDemoteToMember(memberToDemote?.id ?? ""); - }} - > - Demote - - - - - - - - Leave Organization - - {`Are you sure you want to leave the organization?`} - - - - Cancel - - Leave - - - - -
-
- ) -} diff --git a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx deleted file mode 100644 index 2412f0eb7..000000000 --- a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx +++ /dev/null @@ -1,232 +0,0 @@ -'use client'; - -import { OrgRole } from "@sourcebot/db"; -import { useToast } from "@/components/hooks/use-toast"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { isServiceError } from "@/lib/utils"; -import { UserAvatar } from "@/components/userAvatar"; -import { CheckCircle, Search, XCircle } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { rejectAccountRequest, approveAccountRequest } from "@/features/userManagement/actions"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; - -interface Request { - id: string; - email: string; - createdAt: Date; - name?: string; - image?: string; -} - -interface RequestsListProps { - requests: Request[] - currentUserRole: OrgRole -} - -export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) => { - const [searchQuery, setSearchQuery] = useState("") - const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") - const [isApproveRequestDialogOpen, setIsApproveRequestDialogOpen] = useState(false) - const [isRejectRequestDialogOpen, setIsRejectRequestDialogOpen] = useState(false) - const [requestToAction, setRequestToAction] = useState(null) - const { toast } = useToast(); - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const filteredRequests = useMemo(() => { - return requests - .filter((request) => { - const searchLower = searchQuery.toLowerCase(); - const matchesSearch = - request.email.toLowerCase().includes(searchLower) || - (request.name?.toLowerCase().includes(searchLower) || false); - return matchesSearch; - }) - .sort((a, b) => { - return dateSort === "newest" - ? b.createdAt.getTime() - a.createdAt.getTime() - : a.createdAt.getTime() - b.createdAt.getTime() - }); - }, [requests, searchQuery, dateSort]); - - const onApproveRequest = useCallback((requestId: string) => { - approveAccountRequest(requestId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to approve request. Reason: ${response.message}` - }) - captureEvent('wa_requests_list_approve_request_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Request approved successfully.` - }) - captureEvent('wa_requests_list_approve_request_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - const onRejectRequest = useCallback((requestId: string) => { - rejectAccountRequest(requestId) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to reject request.` - }) - captureEvent('wa_requests_list_reject_request_fail', { - errorCode: response.errorCode, - }) - } else { - toast({ - description: `✅ Request rejected successfully.` - }) - captureEvent('wa_requests_list_reject_request_success', {}) - router.refresh(); - } - }); - }, [toast, router, captureEvent]); - - return ( -
-
-
- - setSearchQuery(e.target.value)} - /> -
- - -
- -
-
- {requests.length === 0 || (filteredRequests.length === 0 && searchQuery.length > 0) ? ( -
-

No Pending Requests Found

-

- {filteredRequests.length === 0 && searchQuery.length > 0 ? "No pending requests found matching your filters." : "There are currently no pending requests to join your organization."} -

-
- ) : ( - filteredRequests.map((request) => ( -
-
- -
-
{request.name || request.email}
-
{request.email}
-
-
-
- {currentUserRole === OrgRole.OWNER && ( - <> - - - - )} -
-
- )) - )} -
-
- - {/* Approve Request Dialog */} - - - - Approve Request - - Are you sure you want to approve the request from {requestToAction?.email}? They will be added as a member to your organization. - - - - - Back - - { - onApproveRequest(requestToAction?.id ?? ""); - }} - > - Approve - - - - - - {/* Reject Request Dialog */} - - - - Reject Request - - Are you sure you want to reject the request from {requestToAction?.email}? - - - - - Back - - { - onRejectRequest(requestToAction?.id ?? ""); - }} - > - Reject - - - - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx new file mode 100644 index 000000000..bac881928 --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/components/hooks/use-toast"; +import { createInvites } from "@/features/membership/actions"; +import { isServiceError } from "@/lib/utils"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface InviteMembersDialogProps { + className?: string; +} + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const parseEmails = (value: string) => { + return value + .split(",") + .map((email) => email.trim()) + .filter(Boolean); +}; + +const inviteMembersFormSchema = z.object({ + emails: z.string().trim().superRefine((value, ctx) => { + const emails = parseEmails(value); + + if (emails.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter at least one email address.", + }); + return; + } + + const invalidEmail = emails.find((email) => !emailPattern.test(email)); + if (invalidEmail) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${invalidEmail} is not a valid email address.`, + }); + return; + } + + const normalizedEmails = emails.map((email) => email.toLowerCase()); + if (new Set(normalizedEmails).size !== normalizedEmails.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Duplicate email addresses are not allowed.", + }); + } + }), +}); + +type InviteMembersFormValues = z.infer; + +export const InviteMembersDialog = ({ className }: InviteMembersDialogProps) => { + const [open, setOpen] = useState(false); + const [shouldFocusEmails, setShouldFocusEmails] = useState(false); + const emailsTextareaRef = useRef(null); + const router = useRouter(); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + const form = useForm({ + resolver: zodResolver(inviteMembersFormSchema), + defaultValues: { + emails: "", + }, + }); + + const isSubmitting = form.formState.isSubmitting; + const emailsValue = form.watch("emails"); + const emails = parseEmails(emailsValue); + const emailsRegistration = form.register("emails"); + + const focusEmailsField = () => { + form.setFocus("emails"); + window.setTimeout(() => { + emailsTextareaRef.current?.focus({ preventScroll: true }); + }, 0); + }; + + useEffect(() => { + if (!shouldFocusEmails || isSubmitting) { + return; + } + + focusEmailsField(); + setShouldFocusEmails(false); + // `form` is intentionally omitted here. The effect should run when the + // failed submit settles and the textarea is no longer disabled. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldFocusEmails, isSubmitting]); + + const handleOpenChange = (nextOpen: boolean) => { + if (isSubmitting) { + return; + } + + if (!nextOpen && emailsValue.trim().length > 0) { + captureEvent("wa_invite_member_card_invite_cancel", { + num_emails: emails.length, + }); + } + + if (!nextOpen) { + form.reset(); + } + + setOpen(nextOpen); + }; + + const onSubmit = async (values: InviteMembersFormValues) => { + const emails = parseEmails(values.emails); + try { + const result = await createInvites(emails); + if (isServiceError(result)) { + form.setError("emails", { + type: "server", + message: result.message, + }, { + shouldFocus: true, + }); + setShouldFocusEmails(true); + toast({ description: `Failed to send invites. Reason: ${result.message}` }); + captureEvent("wa_invite_member_card_invite_fail", { + errorCode: result.errorCode, + num_emails: emails.length, + }); + return; + } + + toast({ description: `Successfully sent ${emails.length} invite${emails.length === 1 ? "" : "s"}.` }); + captureEvent("wa_invite_member_card_invite_success", { + num_emails: emails.length, + }); + form.reset(); + setOpen(false); + router.refresh(); + } catch { + form.setError("emails", { + type: "server", + message: "Something went wrong while sending invites.", + }, { + shouldFocus: true, + }); + setShouldFocusEmails(true); + toast({ description: "Failed to send invites." }); + } + }; + + const onInvalidSubmit = () => { + setShouldFocusEmails(true); + }; + + const isSendDisabled = isSubmitting || emails.length === 0; + + return ( + + + + + Invite members + + Invite new members to your organization. + + +
+ + ( + + Email + +