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.
+
+
+
+
+
+
+
+## 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)
+
+
+
+
+
+
+
+## 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 (
-
- )
-}
\ 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 (
-
- You've reached the maximum number of seats for your license. Upgrade your plan to invite additional members.
-
-
-
-
- )}
-
-
-
-
-
-
- Invite Team Members
-
- {`Your team is growing! By confirming, you will be inviting ${form.getValues().emails.length} new members to your organization.`}
-
-
-
- {filteredInvites.length === 0 && searchQuery.length > 0 ? "No pending invitations found matching your filters." : "Use the form above to invite new members."}
-
- {filteredRequests.length === 0 && searchQuery.length > 0 ? "No pending requests found matching your filters." : "There are currently no pending requests to join your organization."}
-
-
- {/* 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
-
-
-
-
-
+ {/* Remove the collapsed `border-b` (from TableHeader/TableRow) so it doesn't
+ double up with the box-shadow divider at the top; the shadow is the sole
+ divider and it survives scrolling. */}
+
+
+
+
+
+
+
+ Revoke SCIM Token
+
+ Are you sure you want to revoke {token.name}? Your IdP will no longer be able to provision or deprovision users with this token. This action cannot be undone.
+
+
+
+ Cancel
+ handleRevokeToken(token.name)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Revoke
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx b/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx
new file mode 100644
index 000000000..f6f29487d
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import { useState } from "react"
+import { Sparkles } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { SettingsCard } from "@/app/(app)/settings/components/settingsCard"
+import { UpsellDialog } from "@/features/billing/upsellDialog"
+
+export function ScimUpsellCard() {
+ const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false)
+
+ return (
+ <>
+
+
+
+
+
+
+
+
SCIM provisioning is a paid feature
+
Upgrade to provision and deprovision members automatically from your identity provider.
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/web/src/app/(app)/settings/security/layout.tsx b/packages/web/src/app/(app)/settings/security/layout.tsx
new file mode 100644
index 000000000..b75c5e731
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/security/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function SecuritySettingsLayout({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/packages/web/src/app/(app)/settings/security/page.tsx b/packages/web/src/app/(app)/settings/security/page.tsx
index e48e88c5f..e1d56ea85 100644
--- a/packages/web/src/app/(app)/settings/security/page.tsx
+++ b/packages/web/src/app/(app)/settings/security/page.tsx
@@ -5,16 +5,21 @@ import { CredentialsLoginEnabledSettingsCard } from "./components/credentialsLog
import { EmailCodeLoginEnabledSettingsCard } from "./components/emailCodeLoginEnabledSettingsCard";
import { IdentityProviderSettingsCard } from "./components/identityProviderSettingsCard";
import { IdentityProviderUpsellCard } from "./components/identityProviderUpsellCard";
+import { ScimProvisioningSettings } from "./components/scimProvisioningSettings";
+import { ScimEnabledSettingsCard } from "./components/scimEnabledSettingsCard";
+import { ScimUpsellCard } from "./components/scimUpsellCard";
+import { getScimTokens } from "@/ee/features/scim/actions";
import { UpgradeBadge } from "@/app/(app)/@sidebar/components/upgradeBadge";
import { getProviders, IdentityProvider } from "@/auth";
import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements";
-import { createInviteLink } from "@/lib/utils";
+import { createInviteLink, isServiceError } from "@/lib/utils";
import { authenticatedPage } from "@/middleware/authenticatedPage";
import { OrgRole } from "@sourcebot/db";
import { env, getSMTPConnectionURL, isCredentialsLoginEnabled, isEmailCodeLoginEnabled, isMemberApprovalRequired } from "@sourcebot/shared";
import { SettingsCardGroup } from "../components/settingsCard";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
+import { isScimEnabled } from "@/features/scim/utils";
export default authenticatedPage(async ({ org }) => {
const anonymousAccessEnabled = await isAnonymousAccessEnabled();
@@ -22,6 +27,12 @@ export default authenticatedPage(async ({ org }) => {
const hasSSOEntitlement = await hasEntitlement("sso");
const identityProviders = await getConfiguredIdentityProviders();
+ const hasScimEntitlement = await hasEntitlement("scim");
+ const scimBaseUrl = `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2`;
+ const scimTokensResult = hasScimEntitlement ? await getScimTokens() : [];
+ const scimTokens = isServiceError(scimTokensResult) ? [] : scimTokensResult;
+ const scimEnabled = await isScimEnabled(org)
+
return (
Provision and deprovision members automatically from your identity provider (Okta, Entra). Configure your IdP with the base URL below and a SCIM token.{" "}
+
+ Learn more
+
+
;
export const auditTargetSchema = z.object({
id: z.string(),
- type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat"]),
+ type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat", "scim_token"]),
})
export type AuditTarget = z.infer;
export const auditMetadataSchema = z.object({
message: z.string().optional(),
api_key: z.string().optional(),
+ scim_token: z.string().optional(),
emails: z.string().optional(), // comma separated list of emails
source: z.string().optional(), // request source (e.g., 'mcp') from X-Sourcebot-Client-Source header
})
diff --git a/packages/web/src/ee/features/membership/actions.ts b/packages/web/src/ee/features/membership/actions.ts
new file mode 100644
index 000000000..f848945ac
--- /dev/null
+++ b/packages/web/src/ee/features/membership/actions.ts
@@ -0,0 +1,54 @@
+'use server';
+
+import { sew } from "@/middleware/sew";
+import { ErrorCode } from "@/lib/errorCodes";
+import { ServiceError } from "@/lib/serviceError";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+import { hasEntitlement } from "@/lib/entitlements";
+import { isServiceError } from "@/lib/utils";
+import { setMemberRole } from "@/features/membership/membership.service";
+import { StatusCodes } from "http-status-codes";
+
+const orgManagementNotAvailable = (): ServiceError => ({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
+ message: "Organization management is not available in your current plan",
+});
+
+export const promoteToOwner = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('org-management')) {
+ return orgManagementNotAvailable();
+ }
+
+ const result = await setMemberRole(org.id, memberId, OrgRole.OWNER, {
+ actor: { id: user.id, type: "user" },
+ });
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const demoteToMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('org-management')) {
+ return orgManagementNotAvailable();
+ }
+
+ const result = await setMemberRole(org.id, memberId, OrgRole.MEMBER, {
+ actor: { id: user.id, type: "user" },
+ });
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
diff --git a/packages/web/src/ee/features/scim/actions.ts b/packages/web/src/ee/features/scim/actions.ts
new file mode 100644
index 000000000..0d3d31276
--- /dev/null
+++ b/packages/web/src/ee/features/scim/actions.ts
@@ -0,0 +1,169 @@
+'use server';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import { ErrorCode } from "@/lib/errorCodes";
+import { hasEntitlement } from "@/lib/entitlements";
+import { ServiceError } from "@/lib/serviceError";
+import { sew } from "@/middleware/sew";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+import { env, generateScimToken as generateScimTokenSecret } from "@sourcebot/shared";
+import { StatusCodes } from "http-status-codes";
+
+const scimNotAvailable = (): ServiceError => ({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
+ message: "SCIM provisioning is not available in your current plan",
+});
+
+/**
+ * The base URL an IdP (Okta, Entra) is configured to send SCIM requests to.
+ * Exposed at the clean `/scim/v2` path via a rewrite in `next.config.mjs`.
+ */
+export const getScimBaseUrl = async (): Promise<{ baseUrl: string } | ServiceError> => sew(() =>
+ withAuth(async ({ role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+ return { baseUrl: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2` };
+ })));
+
+/**
+ * Whether SCIM provisioning is currently enabled (toggled on) for the org.
+ * This is the explicit opt-in switch, independent of whether any tokens exist.
+ */
+export const getIsScimEnabled = async (): Promise<{ enabled: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+ return { enabled: org.isScimEnabled };
+ })));
+
+/**
+ * Enables or disables SCIM provisioning for the org. Disabling is a kill switch:
+ * existing tokens stop authenticating and JIT suppression lifts, but tokens are
+ * preserved so provisioning can be resumed by toggling back on.
+ */
+export const setScimEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ await prisma.org.update({
+ where: { id: org.id },
+ data: { isScimEnabled: enabled },
+ });
+
+ await createAudit({
+ action: enabled ? "scim.enabled" : "scim.disabled",
+ actor: { id: user.id, type: "user" },
+ target: { id: org.id.toString(), type: "org" },
+ orgId: org.id,
+ });
+
+ return { success: true };
+ })));
+
+export const generateScimToken = async (name: string): Promise<{ token: string } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ const existing = await prisma.scimToken.findFirst({
+ where: {
+ orgId: org.id,
+ name,
+ },
+ });
+
+ if (existing) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.API_KEY_ALREADY_EXISTS,
+ message: `A SCIM token named "${name}" already exists`,
+ } satisfies ServiceError;
+ }
+
+ const { token, hash } = generateScimTokenSecret();
+ const scimToken = await prisma.scimToken.create({
+ data: {
+ name,
+ hash,
+ orgId: org.id,
+ },
+ });
+
+ await createAudit({
+ action: "scim_token.created",
+ actor: { id: user.id, type: "user" },
+ target: { id: scimToken.hash, type: "scim_token" },
+ orgId: org.id,
+ metadata: { scim_token: name },
+ });
+
+ return { token };
+ })));
+
+export const revokeScimToken = async (name: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ const scimToken = await prisma.scimToken.findFirst({
+ where: {
+ orgId: org.id,
+ name,
+ },
+ });
+
+ if (!scimToken) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.API_KEY_NOT_FOUND,
+ message: `SCIM token "${name}" not found`,
+ } satisfies ServiceError;
+ }
+
+ await prisma.scimToken.delete({
+ where: { hash: scimToken.hash },
+ });
+
+ await createAudit({
+ action: "scim_token.deleted",
+ actor: { id: user.id, type: "user" },
+ target: { id: scimToken.hash, type: "scim_token" },
+ orgId: org.id,
+ metadata: { scim_token: name },
+ });
+
+ return { success: true };
+ })));
+
+export const getScimTokens = async (): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ const tokens = await prisma.scimToken.findMany({
+ where: { orgId: org.id },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return tokens.map((token) => ({
+ name: token.name,
+ createdAt: token.createdAt,
+ lastUsedAt: token.lastUsedAt,
+ }));
+ })));
diff --git a/packages/web/src/ee/features/scim/constants.ts b/packages/web/src/ee/features/scim/constants.ts
new file mode 100644
index 000000000..dae10ec9a
--- /dev/null
+++ b/packages/web/src/ee/features/scim/constants.ts
@@ -0,0 +1,14 @@
+// SCIM 2.0 schema URNs (RFC 7643 / 7644).
+export const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
+export const SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
+export const SCIM_PATCH_OP_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
+export const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
+export const SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
+export const SCIM_RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType";
+export const SCIM_SCHEMA_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema";
+
+export const SCIM_CONTENT_TYPE = "application/scim+json";
+
+// Default and max page sizes for list responses.
+export const SCIM_DEFAULT_COUNT = 100;
+export const SCIM_MAX_COUNT = 200;
diff --git a/packages/web/src/ee/features/scim/mapper.ts b/packages/web/src/ee/features/scim/mapper.ts
new file mode 100644
index 000000000..0db3db5a0
--- /dev/null
+++ b/packages/web/src/ee/features/scim/mapper.ts
@@ -0,0 +1,80 @@
+import { Prisma } from "@sourcebot/db";
+import { env } from "@sourcebot/shared";
+import {
+ SCIM_CONTENT_TYPE,
+ SCIM_ERROR_SCHEMA,
+ SCIM_LIST_RESPONSE_SCHEMA,
+ SCIM_USER_SCHEMA,
+} from "./constants";
+
+// A membership row with its linked user, as returned by the SCIM endpoints.
+export type ScimMembership = Prisma.UserToOrgGetPayload<{ include: { user: true } }>;
+
+const scimUserLocation = (userId: string): string =>
+ `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${userId}`;
+
+/**
+ * Maps a Sourcebot membership + user into a SCIM 2.0 User resource. The SCIM
+ * `id` is the stable `User.id`; `userName` and the primary email are the
+ * user's email; `active` reflects whether the membership is unsuspended.
+ */
+export const toScimUser = (membership: ScimMembership) => {
+ const { user } = membership;
+ const [givenName, ...rest] = (user.name ?? "").split(" ");
+ const familyName = rest.join(" ");
+
+ return {
+ schemas: [SCIM_USER_SCHEMA],
+ id: user.id,
+ ...(membership.scimExternalId ? { externalId: membership.scimExternalId } : {}),
+ userName: user.email ?? undefined,
+ name: user.name ? {
+ formatted: user.name,
+ givenName: givenName || undefined,
+ familyName: familyName || undefined,
+ } : undefined,
+ emails: user.email ? [{ value: user.email, primary: true }] : [],
+ active: membership.suspendedAt == null,
+ meta: {
+ resourceType: "User",
+ created: membership.joinedAt.toISOString(),
+ lastModified: membership.joinedAt.toISOString(),
+ location: scimUserLocation(user.id),
+ },
+ };
+};
+
+/** Wraps a list of SCIM resources in a SCIM ListResponse envelope. */
+export const toScimListResponse = (
+ resources: unknown[],
+ totalResults: number,
+ startIndex: number,
+) => ({
+ schemas: [SCIM_LIST_RESPONSE_SCHEMA],
+ totalResults,
+ startIndex,
+ itemsPerPage: resources.length,
+ Resources: resources,
+});
+
+/** Builds a `Response` with the SCIM content type. */
+export const scimJson = (body: unknown, status: number, headers?: Record): Response =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ "Content-Type": SCIM_CONTENT_TYPE,
+ ...headers,
+ },
+ });
+
+/**
+ * Builds a SCIM error `Response`. Per RFC 7644 the `status` in the body is a
+ * string and must match the HTTP status.
+ */
+export const scimError = (status: number, detail: string, scimType?: string): Response =>
+ scimJson({
+ schemas: [SCIM_ERROR_SCHEMA],
+ status: status.toString(),
+ ...(scimType ? { scimType } : {}),
+ detail,
+ }, status);
diff --git a/packages/web/src/ee/features/scim/schemas.test.ts b/packages/web/src/ee/features/scim/schemas.test.ts
new file mode 100644
index 000000000..aa36271ac
--- /dev/null
+++ b/packages/web/src/ee/features/scim/schemas.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, test } from 'vitest';
+import { parseScimPatchOperations, scimPatchOpSchema } from './schemas';
+
+// Builds a typed Operations array via the schema, mirroring what the route
+// passes into parseScimPatchOperations after validation.
+const ops = (operations: unknown[]): ReturnType['Operations'] =>
+ scimPatchOpSchema.parse({ Operations: operations }).Operations;
+
+describe('parseScimPatchOperations', () => {
+ test('extracts active from a path-based replace (boolean)', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'active', value: false },
+ ]))).toEqual({ active: false });
+ });
+
+ test('coerces a stringified active value', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'active', value: 'false' },
+ ]))).toEqual({ active: false });
+ });
+
+ test('extracts active from the no-path bulk form', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', value: { active: false } },
+ ]))).toEqual({ active: false });
+ });
+
+ test('extracts a name change from displayName', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'displayName', value: 'Jane Doe' },
+ ]))).toEqual({ name: 'Jane Doe' });
+ });
+
+ test('extracts a name change from name.formatted', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'name.formatted', value: 'Jane Doe' },
+ ]))).toEqual({ name: 'Jane Doe' });
+ });
+
+ test('extracts an email change from userName (lowercased)', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'userName', value: 'Jane.New@Corp.COM' },
+ ]))).toEqual({ email: 'jane.new@corp.com' });
+ });
+
+ test('extracts an email change from a filtered emails path', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'emails[type eq "work"].value', value: 'jane@corp.com' },
+ ]))).toEqual({ email: 'jane@corp.com' });
+ });
+
+ test('matches op and path case-insensitively', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'REPLACE', path: 'Active', value: true },
+ ]))).toEqual({ active: true });
+ });
+
+ test('honors `add` operations as well as `replace`', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'add', path: 'displayName', value: 'New Name' },
+ ]))).toEqual({ name: 'New Name' });
+ });
+
+ test('ignores unrecognized operations (e.g. remove)', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'remove', path: 'name.givenName' },
+ ]))).toEqual({});
+ });
+
+ test('ignores unrecognized paths', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'title', value: 'Engineer' },
+ ]))).toEqual({});
+ });
+
+ test('combines name, email, and active across multiple operations', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'name.formatted', value: 'Jane Doe' },
+ { op: 'replace', path: 'userName', value: 'jane@corp.com' },
+ { op: 'replace', path: 'active', value: false },
+ ]))).toEqual({ name: 'Jane Doe', email: 'jane@corp.com', active: false });
+ });
+
+ test('handles a no-path bulk object with multiple attributes', () => {
+ expect(parseScimPatchOperations(ops([
+ {
+ op: 'replace',
+ value: {
+ active: true,
+ userName: 'jane@corp.com',
+ name: { formatted: 'Jane Doe' },
+ },
+ },
+ ]))).toEqual({ name: 'Jane Doe', email: 'jane@corp.com', active: true });
+ });
+
+ test('prefers the primary email from a bulk emails array', () => {
+ expect(parseScimPatchOperations(ops([
+ {
+ op: 'replace',
+ value: {
+ emails: [
+ { value: 'secondary@corp.com', primary: false },
+ { value: 'primary@corp.com', primary: true },
+ ],
+ },
+ },
+ ]))).toEqual({ email: 'primary@corp.com' });
+ });
+
+ test('later operations override earlier ones', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'active', value: true },
+ { op: 'replace', path: 'active', value: false },
+ ]))).toEqual({ active: false });
+ });
+
+ test('returns an empty object when no relevant operations are present', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'locale', value: 'en-US' },
+ { op: 'remove', path: 'title' },
+ ]))).toEqual({});
+ });
+});
diff --git a/packages/web/src/ee/features/scim/schemas.ts b/packages/web/src/ee/features/scim/schemas.ts
new file mode 100644
index 000000000..15cd7c03f
--- /dev/null
+++ b/packages/web/src/ee/features/scim/schemas.ts
@@ -0,0 +1,249 @@
+import { z } from "zod";
+import {
+ SCIM_RESOURCE_TYPE_SCHEMA,
+ SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA,
+ SCIM_USER_SCHEMA,
+} from "./constants";
+
+// ----- Request body schemas (lenient: IdPs send extra attributes) -----
+
+const scimEmailSchema = z.object({
+ value: z.string(),
+ primary: z.boolean().optional(),
+ type: z.string().optional(),
+}).passthrough();
+
+const scimNameSchema = z.object({
+ formatted: z.string().optional(),
+ givenName: z.string().optional(),
+ familyName: z.string().optional(),
+}).passthrough();
+
+export const scimUserCreateSchema = z.object({
+ userName: z.string(),
+ externalId: z.string().optional(),
+ name: scimNameSchema.optional(),
+ emails: z.array(scimEmailSchema).optional(),
+ // `active` may arrive as a boolean or a string ("true"/"false").
+ active: z.union([z.boolean(), z.string()]).optional(),
+ displayName: z.string().optional(),
+}).passthrough();
+export type ScimUserCreate = z.infer;
+
+export const scimUserReplaceSchema = scimUserCreateSchema;
+export type ScimUserReplace = z.infer;
+
+export const scimPatchOpSchema = z.object({
+ schemas: z.array(z.string()).optional(),
+ Operations: z.array(z.object({
+ op: z.string(),
+ path: z.string().optional(),
+ value: z.unknown().optional(),
+ }).passthrough()),
+}).passthrough();
+export type ScimPatchOp = z.infer;
+
+/** Coerces a SCIM `active` value (boolean | "true"/"false" | undefined). */
+export const coerceActive = (value: unknown): boolean | undefined => {
+ if (typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "string") {
+ if (value.toLowerCase() === "true") {
+ return true;
+ }
+ if (value.toLowerCase() === "false") {
+ return false;
+ }
+ }
+ return undefined;
+};
+
+/** Resolves the primary email from a SCIM user payload. */
+export const resolveEmail = (payload: ScimUserCreate): string => {
+ const primary = payload.emails?.find((e) => e.primary)?.value;
+ return (primary ?? payload.emails?.[0]?.value ?? payload.userName).toLowerCase();
+};
+
+/** The subset of attributes Sourcebot persists from a SCIM PatchOp. */
+export interface ScimPatchChanges {
+ name?: string;
+ email?: string;
+ active?: boolean;
+}
+
+// Resolves a display name from a SCIM `name` complex value / `displayName`,
+// mirroring the precedence used elsewhere (formatted, then displayName).
+const resolveNameFromValue = (value: Record): string | undefined => {
+ const name = value.name;
+ const formatted = (name && typeof name === "object" && !Array.isArray(name))
+ ? (name as Record).formatted
+ : undefined;
+ if (typeof formatted === "string") {
+ return formatted;
+ }
+ if (typeof value.displayName === "string") {
+ return value.displayName;
+ }
+ return undefined;
+};
+
+// Resolves the primary email from a SCIM `emails` array / `userName` value.
+const resolveEmailFromValue = (value: Record): string | undefined => {
+ const emails = value.emails;
+ if (Array.isArray(emails)) {
+ const primary = emails.find((e) => e && typeof e === "object" && (e as Record).primary)
+ ?? emails[0];
+ const email = (primary && typeof primary === "object") ? (primary as Record).value : undefined;
+ if (typeof email === "string") {
+ return email.toLowerCase();
+ }
+ }
+ if (typeof value.userName === "string") {
+ return value.userName.toLowerCase();
+ }
+ return undefined;
+};
+
+/**
+ * Reduces a SCIM PatchOp's operations into the subset of changes Sourcebot
+ * persists: display name, email, and active state. Handles both path-based ops
+ * (`{op,path,value}`, e.g. `name.formatted`, `userName`, `active`) and the
+ * no-path bulk form (`{op,value:{...}}`). Operator and attribute names are
+ * matched case-insensitively. Later operations override earlier ones, and any
+ * unrecognized op/path is ignored (lenient, never an error).
+ */
+export const parseScimPatchOperations = (operations: ScimPatchOp["Operations"]): ScimPatchChanges => {
+ const changes: ScimPatchChanges = {};
+
+ for (const operation of operations) {
+ const op = operation.op.toLowerCase();
+ if (op !== "replace" && op !== "add") {
+ continue;
+ }
+
+ const value = operation.value;
+ const path = operation.path?.toLowerCase();
+
+ // No-path bulk form: `value` is an object of attributes to replace.
+ if (path === undefined) {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ const record = value as Record;
+ const active = coerceActive(record.active);
+ if (active !== undefined) {
+ changes.active = active;
+ }
+ const name = resolveNameFromValue(record);
+ if (name !== undefined) {
+ changes.name = name;
+ }
+ const email = resolveEmailFromValue(record);
+ if (email !== undefined) {
+ changes.email = email;
+ }
+ }
+ continue;
+ }
+
+ if (path === "active") {
+ const active = coerceActive(value);
+ if (active !== undefined) {
+ changes.active = active;
+ }
+ } else if (path === "username") {
+ if (typeof value === "string") {
+ changes.email = value.toLowerCase();
+ }
+ } else if (path === "displayname" || path === "name.formatted") {
+ if (typeof value === "string") {
+ changes.name = value;
+ }
+ } else if (path.startsWith("emails")) {
+ // e.g. `emails[type eq "work"].value` → maps to the primary email.
+ if (typeof value === "string") {
+ changes.email = value.toLowerCase();
+ }
+ }
+ }
+
+ return changes;
+};
+
+// ----- Filter parsing -----
+
+export type ScimFilter =
+ | { attribute: "userName" | "externalId"; value: string }
+ | null;
+
+/**
+ * Parses the narrow set of SCIM filters IdPs actually send:
+ * `userName eq "value"` and `externalId eq "value"`. Operator and attribute
+ * are matched case-insensitively. Anything else returns `null`, which callers
+ * treat as "no matching results" rather than an error.
+ */
+export const parseScimFilter = (filter: string | null): ScimFilter => {
+ if (!filter) {
+ return null;
+ }
+ const match = filter.match(/^\s*(userName|externalId)\s+eq\s+"([^"]*)"\s*$/i);
+ if (!match) {
+ return null;
+ }
+ const attribute = match[1].toLowerCase() === "username" ? "userName" : "externalId";
+ return { attribute, value: match[2] };
+};
+
+// ----- Static discovery documents -----
+
+export const serviceProviderConfig = {
+ schemas: [SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA],
+ patch: { supported: true },
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
+ filter: { supported: true, maxResults: 200 },
+ changePassword: { supported: false },
+ sort: { supported: false },
+ etag: { supported: false },
+ authenticationSchemes: [{
+ type: "oauthbearertoken",
+ name: "OAuth Bearer Token",
+ description: "Authentication via the SCIM bearer token generated in Sourcebot settings.",
+ primary: true,
+ }],
+ meta: { resourceType: "ServiceProviderConfig" },
+};
+
+export const userResourceType = {
+ schemas: [SCIM_RESOURCE_TYPE_SCHEMA],
+ id: "User",
+ name: "User",
+ endpoint: "/Users",
+ description: "User Account",
+ schema: SCIM_USER_SCHEMA,
+ meta: { resourceType: "ResourceType" },
+};
+
+export const userSchemaDefinition = {
+ id: SCIM_USER_SCHEMA,
+ name: "User",
+ description: "User Account",
+ attributes: [
+ { name: "userName", type: "string", multiValued: false, required: true, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "server" },
+ { name: "active", type: "boolean", multiValued: false, required: false, mutability: "readWrite", returned: "default" },
+ {
+ name: "name", type: "complex", multiValued: false, required: false, mutability: "readWrite", returned: "default",
+ subAttributes: [
+ { name: "formatted", type: "string", multiValued: false, required: false },
+ { name: "givenName", type: "string", multiValued: false, required: false },
+ { name: "familyName", type: "string", multiValued: false, required: false },
+ ],
+ },
+ {
+ name: "emails", type: "complex", multiValued: true, required: false, mutability: "readWrite", returned: "default",
+ subAttributes: [
+ { name: "value", type: "string", multiValued: false, required: false },
+ { name: "primary", type: "boolean", multiValued: false, required: false },
+ ],
+ },
+ ],
+ meta: { resourceType: "Schema" },
+};
diff --git a/packages/web/src/ee/features/scim/withScimAuth.ts b/packages/web/src/ee/features/scim/withScimAuth.ts
new file mode 100644
index 000000000..c10d5b13d
--- /dev/null
+++ b/packages/web/src/ee/features/scim/withScimAuth.ts
@@ -0,0 +1,77 @@
+import { __unsafePrisma } from "@/prisma";
+import { hasEntitlement } from "@/lib/entitlements";
+import { hashSecret, SCIM_TOKEN_PREFIX, createLogger } from "@sourcebot/shared";
+import { Org, PrismaClient } from "@sourcebot/db";
+import { NextRequest } from "next/server";
+import { scimError } from "./mapper";
+
+const logger = createLogger('scim-auth');
+
+export type ScimAuthContext = {
+ org: Org;
+ prisma: PrismaClient;
+};
+
+/**
+ * Authenticates a SCIM request via its `Authorization: Bearer sbscim_…` token
+ * and runs `fn` with an org-scoped (userless) context. Unlike `withAuth`, this
+ * does NOT resolve a user/role or apply the user-scoped Prisma extension: the
+ * caller is the IdP provisioning integration, acting org-wide. All responses
+ * (including errors) use the SCIM content type and error envelope.
+ */
+export const withScimAuth = async (
+ request: NextRequest,
+ fn: (ctx: ScimAuthContext) => Promise,
+): Promise => {
+ const authorization = request.headers.get("Authorization") ?? undefined;
+ if (!authorization?.startsWith("Bearer ")) {
+ return scimError(401, "Missing or malformed Authorization header");
+ }
+
+ const bearer = authorization.slice("Bearer ".length);
+ if (!bearer.startsWith(SCIM_TOKEN_PREFIX)) {
+ return scimError(401, "Invalid SCIM token");
+ }
+
+ const secret = bearer.slice(SCIM_TOKEN_PREFIX.length);
+ if (!secret) {
+ return scimError(401, "Invalid SCIM token");
+ }
+
+ const scimToken = await __unsafePrisma.scimToken.findUnique({
+ where: { hash: hashSecret(secret) },
+ include: { org: true },
+ });
+ if (!scimToken) {
+ return scimError(401, "Invalid SCIM token");
+ }
+
+ // Enforce the entitlement per-request so a license downgrade disables SCIM
+ // immediately, even with valid tokens still configured in the IdP.
+ if (!await hasEntitlement('scim')) {
+ return scimError(403, "SCIM provisioning is not available in your current plan");
+ }
+
+ // SCIM is an explicit opt-in: a valid token is rejected unless an owner has
+ // toggled provisioning on. Disabling acts as a kill switch that pauses all
+ // provisioning without requiring tokens to be revoked.
+ if (!scimToken.org.isScimEnabled) {
+ return scimError(403, "SCIM provisioning is disabled for this organization");
+ }
+
+ // Best-effort usage tracking; never block the request on it.
+ __unsafePrisma.scimToken.update({
+ where: { hash: scimToken.hash },
+ data: { lastUsedAt: new Date() },
+ }).catch(() => { /* ignore */ });
+
+ try {
+ return await fn({
+ org: scimToken.org,
+ prisma: __unsafePrisma
+ });
+ } catch (error) {
+ logger.error(`Unhandled SCIM error: ${error instanceof Error ? error.message : String(error)}`);
+ return scimError(500, "Internal server error");
+ }
+};
diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts
index 30777536a..ca5b6b686 100644
--- a/packages/web/src/ee/features/sso/sso.ts
+++ b/packages/web/src/ee/features/sso/sso.ts
@@ -1,5 +1,4 @@
import type { IdentityProvider } from "@/auth";
-import { onCreateUser } from "@/lib/authUtils";
import { __unsafePrisma } from "@/prisma";
import { hasEntitlement } from "@/lib/entitlements";
import { createLogger, env, getIdentityProviderConfigs, getTokenFromConfig } from "@sourcebot/shared";
@@ -16,6 +15,7 @@ import Google from "next-auth/providers/google";
import Keycloak from "next-auth/providers/keycloak";
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
import Okta from "next-auth/providers/okta";
+import { onCreateUser } from "@/features/membership/onCreateUser";
const logger = createLogger('web-sso');
diff --git a/packages/web/src/ee/features/userManagement/actions.ts b/packages/web/src/ee/features/userManagement/actions.ts
deleted file mode 100644
index d02b98b4b..000000000
--- a/packages/web/src/ee/features/userManagement/actions.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-'use server';
-
-import { sew } from "@/middleware/sew";
-import { createAudit } from "@/ee/features/audit/audit";
-import { ErrorCode } from "@/lib/errorCodes";
-import { notFound, ServiceError } from "@/lib/serviceError";
-import { withAuth } from "@/middleware/withAuth";
-import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
-import { OrgRole, Prisma } from "@sourcebot/db";
-import { hasEntitlement } from "@/lib/entitlements";
-import { StatusCodes } from "http-status-codes";
-
-const orgManagementNotAvailable = (): ServiceError => ({
- statusCode: StatusCodes.FORBIDDEN,
- errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
- message: "Organization management is not available in your current plan",
-});
-
-export const promoteToOwner = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- if (!await hasEntitlement('org-management')) {
- return orgManagementNotAvailable();
- }
-
- if (memberId === user.id) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: "You are already an owner.",
- } satisfies ServiceError;
- }
-
- const targetMember = await prisma.userToOrg.findUnique({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- });
-
- if (!targetMember) {
- return notFound("Member not found in this organization");
- }
-
- if (targetMember.role === OrgRole.OWNER) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: "This member is already an owner.",
- } satisfies ServiceError;
- }
-
- await prisma.userToOrg.update({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- data: {
- role: "OWNER",
- },
- });
-
- await createAudit({
- action: "org.member_promoted_to_owner",
- actor: { id: user.id, type: "user" },
- target: { id: memberId, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} promoted ${memberId} to owner`,
- },
- });
-
- return { success: true };
- }))
-);
-
-export const demoteToMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- if (!await hasEntitlement('org-management')) {
- return orgManagementNotAvailable();
- }
-
- const guardError = await prisma.$transaction(async (tx) => {
- const targetMember = await tx.userToOrg.findUnique({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- });
-
- if (!targetMember) {
- return notFound("Member not found in this organization");
- }
-
- if (targetMember.role !== OrgRole.OWNER) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: "This member is not an owner.",
- } satisfies ServiceError;
- }
-
- const ownerCount = await tx.userToOrg.count({
- where: {
- orgId: org.id,
- role: OrgRole.OWNER,
- },
- });
-
- if (ownerCount <= 1) {
- return {
- statusCode: StatusCodes.FORBIDDEN,
- errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED,
- message: "Cannot demote the last owner. Promote another member to owner first.",
- } satisfies ServiceError;
- }
-
- await tx.userToOrg.update({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- data: {
- role: "MEMBER",
- },
- });
-
- return null;
- }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
-
- if (guardError) {
- return guardError;
- }
-
- await createAudit({
- action: "org.owner_demoted_to_member",
- actor: { id: user.id, type: "user" },
- target: { id: memberId, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} demoted ${memberId} to member`,
- },
- });
-
- return { success: true };
- }))
-);
diff --git a/packages/web/src/features/billing/actions.ts b/packages/web/src/features/billing/actions.ts
index c9070b219..159b8abf3 100644
--- a/packages/web/src/features/billing/actions.ts
+++ b/packages/web/src/features/billing/actions.ts
@@ -15,6 +15,7 @@ import { captureEvent } from "@/lib/posthog";
import { UpsellSource } from "@/lib/posthogEvents";
import { client } from "./client";
import { z } from "zod";
+import { activeMembershipWhere } from "@/features/membership/utils";
export const activateLicense = async (activationCode: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async ({ org, role, prisma }) =>
@@ -130,6 +131,7 @@ export const createCheckoutSession = async ({
const memberCount = await prisma.userToOrg.count({
where: {
orgId: org.id,
+ ...activeMembershipWhere(),
},
});
const quantity = Math.max(memberCount, 1);
diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts
index e6b052766..ff48b4e98 100644
--- a/packages/web/src/features/billing/servicePing.ts
+++ b/packages/web/src/features/billing/servicePing.ts
@@ -13,6 +13,7 @@ import { client } from "./client";
import { ServicePingRequest } from "./types";
import { ServiceErrorException } from "@/lib/serviceError";
import { getConfiguredLanguageModels } from "@/features/chat/utils.server";
+import { activeMembershipWhere } from "@/features/membership/utils";
const logger = createLogger('service-ping');
@@ -32,40 +33,41 @@ export const syncWithLighthouse = async (orgId: number) => {
const mauCutoff = new Date(now - 30 * DAY_MS);
const [
- userCount,
- repoCount,
+ activeUserCount,
dauCount,
wauCount,
mauCount,
+ repoCount,
] = await Promise.all([
__unsafePrisma.userToOrg.count({
where: {
orgId,
+ ...activeMembershipWhere(),
},
}),
- __unsafePrisma.repo.count({
+ __unsafePrisma.userToOrg.count({
where: {
orgId,
- },
- }),
- __unsafePrisma.user.count({
- where: {
- orgs: { some: { orgId } },
lastActiveAt: { gte: dauCutoff },
},
}),
- __unsafePrisma.user.count({
+ __unsafePrisma.userToOrg.count({
where: {
- orgs: { some: { orgId } },
+ orgId,
lastActiveAt: { gte: wauCutoff },
},
}),
- __unsafePrisma.user.count({
+ __unsafePrisma.userToOrg.count({
where: {
- orgs: { some: { orgId } },
+ orgId,
lastActiveAt: { gte: mauCutoff },
},
}),
+ __unsafePrisma.repo.count({
+ where: {
+ orgId,
+ },
+ }),
]);
const activationCode = license?.activationCode
@@ -78,7 +80,7 @@ export const syncWithLighthouse = async (orgId: number) => {
installId: env.SOURCEBOT_INSTALL_ID,
version: SOURCEBOT_VERSION,
hostname: env.AUTH_URL,
- userCount,
+ userCount: activeUserCount,
repoCount,
dauCount,
wauCount,
@@ -202,4 +204,4 @@ const recordServicePingInDB = async (orgId: number, payload: ServicePingRequest)
// the actual ping from being sent to Lighthouse.
logger.error(`Failed to record service ping in database:\n ${error}`);
}
-};
\ No newline at end of file
+};
diff --git a/packages/web/src/features/billing/types.ts b/packages/web/src/features/billing/types.ts
index 24ed0020c..0be11074b 100644
--- a/packages/web/src/features/billing/types.ts
+++ b/packages/web/src/features/billing/types.ts
@@ -4,6 +4,11 @@ export const servicePingRequestSchema = z.object({
installId: z.string(),
version: z.string(),
hostname: z.string(),
+ /**
+ * The number of billed users: active (non-suspended) members who have been
+ * active in the org at least once. Provisioned members who have never
+ * signed in do not count towards this.
+ */
userCount: z.number().int().nonnegative(),
repoCount: z.number().int().nonnegative(),
dauCount: z.number().int().nonnegative(),
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
index fccc6df8a..9481b387e 100644
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -9,6 +9,7 @@ import { withAuth, withOptionalAuth } from "@/middleware/withAuth";
import { ChatVisibility, Prisma } from "@sourcebot/db";
import { SBChatMessage } from "./types";
import { checkAskEntitlement, isChatSharedWithUser, isOwnerOfChat } from "./utils.server";
+import { activeMembershipWhere } from "../membership/utils";
export const createChat = async ({ source }: { source?: string } = {}) => sew(() =>
withOptionalAuth(async ({ org, user, prisma }) => {
@@ -372,6 +373,7 @@ export const shareChatWithUsers = async ({ chatId, userIds }: { chatId: string,
const memberships = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
+ ...activeMembershipWhere(),
userId: {
in: userIds,
},
diff --git a/packages/web/src/features/membership/actions/accountRequests.ts b/packages/web/src/features/membership/actions/accountRequests.ts
new file mode 100644
index 000000000..913b4d5c4
--- /dev/null
+++ b/packages/web/src/features/membership/actions/accountRequests.ts
@@ -0,0 +1,278 @@
+'use server';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail";
+import { ensureActiveMember } from "@/features/membership/membership.service";
+import { getDefaultMemberRole } from "@/features/membership/utils";
+import { membershipManagedByIdpError } from "@/features/membership/errors";
+import { isScimEnabled } from "@/features/scim/utils";
+import { notAuthenticated, notFound } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { sew } from "@/middleware/sew";
+import { getAuthenticatedUser, withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { render } from "@react-email/components";
+import { OrgRole } from "@sourcebot/db";
+import { env, getSMTPConnectionURL } from "@sourcebot/shared";
+import { createTransport } from "nodemailer";
+import { logger } from "../logger";
+import { __unsafePrisma } from "@/prisma";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+import JoinRequestSubmittedEmail from "@/emails/joinRequestSubmittedEmail";
+
+// 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");
+ }
+
+ // With SCIM enabled the IdP is the source of truth for membership, so
+ // un-provisioned users can't request to join.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ 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 approveAccountRequest = async (requestId: string) => sew(async () =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ // With SCIM enabled the IdP is the source of truth for membership;
+ // approving a request would mint a member it never provisioned.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const failAuditCallback = async (error: string) => {
+ await createAudit({
+ action: "user.join_request_approve_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: requestId,
+ type: "account_join_request"
+ },
+ orgId: org.id,
+ metadata: {
+ message: error,
+ }
+ });
+ }
+
+ const request = await prisma.accountRequest.findUnique({
+ where: {
+ id: requestId,
+ },
+ include: {
+ requestedBy: true,
+ },
+ });
+
+ if (!request || request.orgId !== org.id) {
+ await failAuditCallback("Request not found");
+ return notFound();
+ }
+
+ const addUserToOrgRes = await ensureActiveMember(org.id, request.requestedById, {
+ actor: { id: request.requestedById, type: "user" },
+ role: await getDefaultMemberRole(),
+ });
+ if (isServiceError(addUserToOrgRes)) {
+ await failAuditCallback(addUserToOrgRes.message);
+ return addUserToOrgRes;
+ }
+
+
+ await createAudit({
+ action: "user.join_request_approved",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: org.id,
+ target: {
+ id: requestId,
+ type: "account_join_request"
+ }
+ });
+
+ // Send approval email to the user
+ const smtpConnectionUrl = getSMTPConnectionURL();
+ if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
+ const html = await render(JoinRequestApprovedEmail({
+ baseUrl: env.AUTH_URL,
+ user: {
+ name: request.requestedBy.name ?? undefined,
+ email: request.requestedBy.email,
+ avatarUrl: request.requestedBy.image ?? undefined,
+ },
+ orgName: org.name,
+ }));
+
+ const transport = createTransport(smtpConnectionUrl);
+ const result = await transport.sendMail({
+ to: request.requestedBy.email,
+ from: env.EMAIL_FROM_ADDRESS,
+ subject: `Your request to join ${org.name} has been approved`,
+ html,
+ text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`,
+ });
+
+ const failed = result.rejected.concat(result.pending).filter(Boolean);
+ if (failed.length > 0) {
+ logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`);
+ }
+ } else {
+ logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`);
+ }
+
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const rejectAccountRequest = async (requestId: string) => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const request = await prisma.accountRequest.findUnique({
+ where: {
+ id: requestId,
+ },
+ });
+
+ if (!request || request.orgId !== org.id) {
+ return notFound();
+ }
+
+ await prisma.accountRequest.delete({
+ where: {
+ id: requestId,
+ },
+ });
+
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const getOrgAccountRequests = async () => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const requests = await prisma.accountRequest.findMany({
+ where: {
+ orgId: org.id,
+ },
+ include: {
+ requestedBy: true,
+ },
+ });
+
+ return requests.map((request) => ({
+ id: request.id,
+ email: request.requestedBy.email,
+ createdAt: request.createdAt,
+ name: request.requestedBy.name ?? undefined,
+ image: request.requestedBy.image ?? undefined,
+ }));
+ })));
+
diff --git a/packages/web/src/features/membership/actions/index.ts b/packages/web/src/features/membership/actions/index.ts
new file mode 100644
index 000000000..13ef2b22f
--- /dev/null
+++ b/packages/web/src/features/membership/actions/index.ts
@@ -0,0 +1,3 @@
+export * from './members';
+export * from './invites';
+export * from './accountRequests';
\ No newline at end of file
diff --git a/packages/web/src/features/membership/actions/invites.ts b/packages/web/src/features/membership/actions/invites.ts
new file mode 100644
index 000000000..e6ca6bc07
--- /dev/null
+++ b/packages/web/src/features/membership/actions/invites.ts
@@ -0,0 +1,436 @@
+'use server';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import InviteUserEmail from "@/emails/inviteUserEmail";
+import { ensureActiveMember } from "@/features/membership/membership.service";
+import { getDefaultMemberRole, orgHasAvailability } from "@/features/membership/utils";
+import { membershipManagedByIdpError } from "@/features/membership/errors";
+import { isScimEnabled } from "@/features/scim/utils";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+import { ErrorCode } from "@/lib/errorCodes";
+import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { sew } from "@/middleware/sew";
+import { getAuthenticatedUser, withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { __unsafePrisma } from "@/prisma";
+import { render } from "@react-email/components";
+import { OrgRole } from "@sourcebot/db";
+import { env, getSMTPConnectionURL, isMemberApprovalRequired } from "@sourcebot/shared";
+import { StatusCodes } from "http-status-codes";
+import { createTransport } from "nodemailer";
+import { logger } from "../logger";
+
+export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ // With SCIM enabled the IdP is the source of truth for membership;
+ // invites would add members outside it.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const failAuditCallback = async (error: string) => {
+ await createAudit({
+ action: "user.invite_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: error,
+ emails: emails.join(", ")
+ }
+ });
+ }
+
+ const hasAvailability = await orgHasAvailability(org.id, prisma);
+ if (!hasAvailability) {
+ await createAudit({
+ action: "user.invite_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: "Organization has reached maximum number of seats",
+ emails: emails.join(", ")
+ }
+ });
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
+ message: "The organization has reached the maximum number of seats. Unable to create a new invite",
+ } satisfies ServiceError;
+ }
+
+ // Check for existing invites
+ const existingInvites = await prisma.invite.findMany({
+ where: {
+ recipientEmail: {
+ in: emails
+ },
+ orgId: org.id,
+ }
+ });
+
+ if (existingInvites.length > 0) {
+ await failAuditCallback("A pending invite already exists for one or more of the provided emails");
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_INVITE,
+ message: `A pending invite already exists for one or more of the provided emails.`,
+ } satisfies ServiceError;
+ }
+
+ // Check for members that are already in the org
+ const existingMembers = await prisma.userToOrg.findMany({
+ where: {
+ user: {
+ email: {
+ in: emails,
+ }
+ },
+ orgId: org.id,
+ },
+ });
+
+ if (existingMembers.length > 0) {
+ await failAuditCallback("One or more of the provided emails are already members of this org");
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_INVITE,
+ message: `One or more of the provided emails are already members of this org.`,
+ } satisfies ServiceError;
+ }
+
+ await prisma.invite.createMany({
+ data: emails.map((email) => ({
+ recipientEmail: email,
+ hostUserId: user.id,
+ orgId: org.id,
+ })),
+ skipDuplicates: true,
+ });
+
+ // Send invites to recipients
+ const smtpConnectionUrl = getSMTPConnectionURL();
+ if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
+ await Promise.all(emails.map(async (email) => {
+ const invite = await prisma.invite.findUnique({
+ where: {
+ recipientEmail_orgId: {
+ recipientEmail: email,
+ orgId: org.id,
+ },
+ },
+ include: {
+ org: true,
+ }
+ });
+
+ if (!invite) {
+ return;
+ }
+
+ const recipient = await prisma.user.findUnique({
+ where: {
+ email,
+ },
+ });
+ const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`;
+ const transport = createTransport(smtpConnectionUrl);
+ const html = await render(InviteUserEmail({
+ baseUrl: env.AUTH_URL,
+ host: {
+ name: user.name ?? undefined,
+ email: user.email,
+ avatarUrl: user.image ?? undefined,
+ },
+ recipient: {
+ name: recipient?.name ?? undefined,
+ },
+ orgName: invite.org.name,
+ orgImageUrl: invite.org.imageUrl ?? undefined,
+ inviteLink,
+ }));
+
+ const result = await transport.sendMail({
+ to: email,
+ from: env.EMAIL_FROM_ADDRESS,
+ subject: `Join ${invite.org.name} on Sourcebot`,
+ html,
+ text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`,
+ });
+
+ const failed = result.rejected.concat(result.pending).filter(Boolean);
+ if (failed.length > 0) {
+ logger.error(`Failed to send invite email to ${email}: ${failed}`);
+ }
+ }));
+ } else {
+ logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`);
+ }
+
+ await createAudit({
+ action: "user.invites_created",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ emails: emails.join(", ")
+ }
+ });
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const invite = await prisma.invite.findUnique({
+ where: {
+ id: inviteId,
+ orgId: org.id,
+ },
+ });
+
+ if (!invite) {
+ return notFound();
+ }
+
+ await prisma.invite.delete({
+ where: {
+ id: inviteId,
+ },
+ });
+
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const getOrgInvites = async () => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const invites = await prisma.invite.findMany({
+ where: {
+ orgId: org.id,
+ },
+ });
+
+ return invites.map((invite) => ({
+ id: invite.id,
+ email: invite.recipientEmail,
+ createdAt: invite.createdAt,
+ }));
+ })));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing
+export const joinOrganization = async (inviteLinkId?: string) => 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 orgNotFound();
+ }
+
+ // With SCIM enabled the IdP is the source of truth for membership; joining
+ // via an invite link would bypass it.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ // If member approval is required we must be using a valid invite link
+ if (isMemberApprovalRequired(org)) {
+ 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 ensureActiveMember(org.id, user.id, {
+ actor: { id: user.id, type: "user" },
+ role: await getDefaultMemberRole(),
+ });
+ if (isServiceError(addUserToOrgRes)) {
+ return addUserToOrgRes;
+ }
+
+ return {
+ success: true,
+ }
+});
+
+// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing
+export const redeemInvite = async (inviteId: string): Promise<{ success: boolean; } | ServiceError> => sew(async () => {
+ const authResult = await getAuthenticatedUser();
+ if (!authResult) {
+ return notAuthenticated();
+ }
+
+ const { user } = authResult;
+
+ const invite = await __unsafePrisma.invite.findUnique({
+ where: {
+ id: inviteId,
+ },
+ include: {
+ org: true,
+ }
+ });
+
+ if (!invite) {
+ return notFound();
+ }
+
+ // With SCIM enabled the IdP is the source of truth for membership; accepting
+ // an invite would bypass it.
+ if (await isScimEnabled(invite.org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const failAuditCallback = async (error: string) => {
+ await createAudit({
+ action: "user.invite_accept_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: inviteId,
+ type: "invite"
+ },
+ orgId: invite.org.id,
+ metadata: {
+ message: error
+ }
+ });
+ };
+
+ const hasAvailability = await orgHasAvailability(invite.org.id, __unsafePrisma);
+ 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;
+ }
+
+ // Check if the user is the recipient of the invite
+ if (user.email !== invite.recipientEmail) {
+ await failAuditCallback("User is not the recipient of the invite");
+ return notFound();
+ }
+
+ const addUserToOrgRes = await ensureActiveMember(invite.orgId, user.id, {
+ actor: { id: user.id, type: "user" },
+ role: await getDefaultMemberRole(),
+ });
+ if (isServiceError(addUserToOrgRes)) {
+ await failAuditCallback(addUserToOrgRes.message);
+ return addUserToOrgRes;
+ }
+
+ await createAudit({
+ action: "user.invite_accepted",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: invite.org.id,
+ target: {
+ id: inviteId,
+ type: "invite"
+ }
+ });
+
+ return {
+ success: true,
+ };
+});
+
+
+// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since the invitee is not yet a member
+export const getInviteInfo = async (inviteId: string) => sew(async () => {
+ const authResult = await getAuthenticatedUser();
+ if (!authResult) {
+ return notAuthenticated();
+ }
+
+ const { user } = authResult;
+
+ const invite = await __unsafePrisma.invite.findUnique({
+ where: {
+ id: inviteId,
+ },
+ include: {
+ org: true,
+ host: true,
+ }
+ });
+
+ if (!invite) {
+ return notFound();
+ }
+
+ if (invite.recipientEmail !== user.email) {
+ return notFound();
+ }
+
+ return {
+ id: invite.id,
+ orgName: invite.org.name,
+ orgImageUrl: invite.org.imageUrl ?? undefined,
+ host: {
+ name: invite.host.name ?? undefined,
+ email: invite.host.email,
+ avatarUrl: invite.host.image ?? undefined,
+ },
+ recipient: {
+ name: user.name ?? undefined,
+ email: user.email,
+ }
+ };
+});
diff --git a/packages/web/src/features/membership/actions/members.ts b/packages/web/src/features/membership/actions/members.ts
new file mode 100644
index 000000000..edffc30e1
--- /dev/null
+++ b/packages/web/src/features/membership/actions/members.ts
@@ -0,0 +1,114 @@
+'use server';
+
+import { membershipManagedByIdpError } from "@/features/membership/errors";
+import { removeMember, setMembershipSuspended } from "@/features/membership/membership.service";
+import { isScimEnabled } from "@/features/scim/utils";
+import { ServiceError } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { sew } from "@/middleware/sew";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+
+export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await removeMember(org.id, memberId, {
+ actor: { id: user.id, type: "user" },
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const suspendMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await setMembershipSuspended(org.id, memberId, true, {
+ actor: { id: user.id, type: "user" },
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const reactivateMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await setMembershipSuspended(org.id, memberId, false, {
+ actor: { id: user.id, type: "user" },
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org }) => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await removeMember(org.id, user.id, {
+ actor: { id: user.id, type: "user" },
+ reason: "left",
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return {
+ success: true,
+ }
+ }));
+
+
+export const getOrgMembers = async () => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const members = await prisma.userToOrg.findMany({
+ where: {
+ orgId: org.id,
+ },
+ include: {
+ user: true,
+ },
+ });
+
+ return members.map((member) => ({
+ id: member.userId,
+ email: member.user.email,
+ name: member.user.name ?? undefined,
+ avatarUrl: member.user.image ?? undefined,
+ role: member.role,
+ joinedAt: member.joinedAt,
+ suspendedAt: member.suspendedAt,
+ scimManaged: !!member.scimExternalId,
+ lastActiveAt: member.lastActiveAt,
+ }));
+ })));
diff --git a/packages/web/src/features/membership/components/joinOrganizationCard.tsx b/packages/web/src/features/membership/components/joinOrganizationCard.tsx
new file mode 100644
index 000000000..37f2f0023
--- /dev/null
+++ b/packages/web/src/features/membership/components/joinOrganizationCard.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { SourcebotLogo } from "@/app/components/sourcebotLogo";
+import { useToast } from "@/components/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { joinOrganization } from "@/features/membership/actions";
+import { isServiceError } from "@/lib/utils";
+import { Loader2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { LogoutEscapeHatch } from "../../../app/components/logoutEscapeHatch";
+
+export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) {
+ const [isLoading, setIsLoading] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const handleJoinOrganization = async () => {
+ setIsLoading(true);
+
+ try {
+ const result = await joinOrganization(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 (
+
+
+
+
+
+
+
+
+
+ Welcome to Sourcebot! Click the button below to join this organization.
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/web/src/features/membership/components/managedByScimBadge.tsx b/packages/web/src/features/membership/components/managedByScimBadge.tsx
new file mode 100644
index 000000000..5de93d8f9
--- /dev/null
+++ b/packages/web/src/features/membership/components/managedByScimBadge.tsx
@@ -0,0 +1,32 @@
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { Info } from "lucide-react";
+import { type ReactNode } from "react";
+
+interface ManagedByScimBadgeProps {
+ /** Tooltip explaining the SCIM relationship in the badge's context. */
+ tooltip?: ReactNode;
+}
+
+/**
+ * Marks something governed by SCIM provisioning (a setting that's superseded, or
+ * a member provisioned by the IdP). Pair it with a disabled control where it
+ * marks a setting. The tooltip is context-specific via the `tooltip` prop.
+ */
+export const ManagedByScimBadge = ({
+ tooltip = "Provisioned through your identity provider.",
+}: ManagedByScimBadgeProps) => (
+
+
+
+
+ Managed by SCIM
+
+
+
+
+ {tooltip}
+
+
+
+);
diff --git a/packages/web/src/features/membership/components/managedByScimNotice.tsx b/packages/web/src/features/membership/components/managedByScimNotice.tsx
new file mode 100644
index 000000000..cf8b9022b
--- /dev/null
+++ b/packages/web/src/features/membership/components/managedByScimNotice.tsx
@@ -0,0 +1,15 @@
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Info } from "lucide-react";
+import { type ReactNode } from "react";
+
+/**
+ * Inline notice shown on settings surfaces whose controls are superseded when
+ * SCIM provisioning is enabled (the IdP owns membership). The message is passed
+ * as children so each surface can phrase it for its own controls.
+ */
+export const ManagedByScimNotice = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+);
diff --git a/packages/web/src/features/membership/components/notProvisionedCard.tsx b/packages/web/src/features/membership/components/notProvisionedCard.tsx
new file mode 100644
index 000000000..5f95b5ace
--- /dev/null
+++ b/packages/web/src/features/membership/components/notProvisionedCard.tsx
@@ -0,0 +1,41 @@
+import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
+import { SourcebotLogo } from "@/app/components/sourcebotLogo";
+
+/**
+ * Shown to an authenticated user who is not a member of the org while SCIM
+ * provisioning is enabled. Membership is owned by the IdP, so the usual
+ * join / request-to-join flows don't apply — they must be provisioned upstream.
+ */
+export const NotProvisionedCard = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account not provisioned
+
+
+ Access to this organization is managed by your identity provider. Ask your administrator to provision your account.
+