diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33a02bf53..2e7f6d5c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,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/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..7259bda3e
--- /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 "isActive" BOOLEAN NOT NULL DEFAULT true,
+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/schema.prisma b/packages/db/prisma/schema.prisma
index 54444bbe2..0a336ae3d 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,17 @@ model UserToOrg {
role OrgRole @default(MEMBER)
+ /// SCIM soft-deactivation flag. When false, the membership is suspended by
+ /// the IdP: the user is treated as a non-member for auth purposes (see
+ /// `getAuthContext`) but the row is preserved so the IdP can reactivate it.
+ isActive Boolean @default(true)
+
+ /// 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?
+
@@id([orgId, userId])
+ @@index([orgId, scimExternalId])
}
model ApiKey {
@@ -414,6 +427,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..cd7cb1c4e 100644
--- a/packages/web/src/app/(app)/layout.tsx
+++ b/packages/web/src/app/(app)/layout.tsx
@@ -10,12 +10,14 @@ 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";
@@ -76,26 +78,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 (
-
+
+
+
+
+
+
+ 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.
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
+
+
)
diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts
new file mode 100644
index 000000000..5b6a4ee24
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts
@@ -0,0 +1,10 @@
+import { apiHandler } from '@/lib/apiHandler';
+import { scimJson, toScimListResponse } from '@/ee/features/scim/mapper';
+import { userResourceType } from '@/ee/features/scim/schemas';
+import { withScimAuth } from '@/ee/features/scim/withScimAuth';
+import { NextRequest } from 'next/server';
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const GET = apiHandler(async (request: NextRequest) =>
+ withScimAuth(request, async () =>
+ scimJson(toScimListResponse([userResourceType], 1, 1), 200)));
diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts
new file mode 100644
index 000000000..90cd55eab
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts
@@ -0,0 +1,10 @@
+import { apiHandler } from '@/lib/apiHandler';
+import { scimJson, toScimListResponse } from '@/ee/features/scim/mapper';
+import { userSchemaDefinition } from '@/ee/features/scim/schemas';
+import { withScimAuth } from '@/ee/features/scim/withScimAuth';
+import { NextRequest } from 'next/server';
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const GET = apiHandler(async (request: NextRequest) =>
+ withScimAuth(request, async () =>
+ scimJson(toScimListResponse([userSchemaDefinition], 1, 1), 200)));
diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts
new file mode 100644
index 000000000..6a01235ec
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts
@@ -0,0 +1,9 @@
+import { apiHandler } from '@/lib/apiHandler';
+import { scimJson } from '@/ee/features/scim/mapper';
+import { serviceProviderConfig } from '@/ee/features/scim/schemas';
+import { withScimAuth } from '@/ee/features/scim/withScimAuth';
+import { NextRequest } from 'next/server';
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const GET = apiHandler(async (request: NextRequest) =>
+ withScimAuth(request, async () => scimJson(serviceProviderConfig, 200)));
diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts
new file mode 100644
index 000000000..d05149ec7
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts
@@ -0,0 +1,135 @@
+import { apiHandler } from '@/lib/apiHandler';
+import { removeMember, setMemberActive } from '@/features/membership/membership.service';
+import { scimError, scimJson, toScimUser, type ScimMembership } from '@/ee/features/scim/mapper';
+import {
+ coerceActive,
+ parseScimPatchOperations,
+ resolveEmail,
+ scimPatchOpSchema,
+ scimUserReplaceSchema,
+} from '@/ee/features/scim/schemas';
+import { withScimAuth, type ScimAuthContext } from '@/ee/features/scim/withScimAuth';
+import { isServiceError } from '@/lib/utils';
+import { NextRequest } from 'next/server';
+
+const loadMembership = (prisma: ScimAuthContext['prisma'], orgId: number, userId: string): Promise =>
+ prisma.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ include: { user: true },
+ });
+
+// Applies an active state transition, toggling the membership only when the
+// value actually changes. Returns a SCIM error Response on failure.
+const applyActive = async (orgId: number, userId: string, current: boolean, next: boolean | undefined): Promise => {
+ if (next === undefined || next === current) {
+ return null;
+ }
+ const result = await setMemberActive(orgId, userId, next, {
+ actor: { id: 'scim', type: 'scim_token' },
+ });
+ if (isServiceError(result)) {
+ return scimError(result.statusCode, result.message);
+ }
+ return null;
+};
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const GET = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+ return scimJson(toScimUser(membership), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const PUT = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+
+ const parsed = scimUserReplaceSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return scimError(400, 'Invalid SCIM user payload', 'invalidValue');
+ }
+ const payload = parsed.data;
+
+ const name = payload.name?.formatted ?? payload.displayName ?? undefined;
+ const email = resolveEmail(payload);
+ await prisma.user.update({
+ where: { id },
+ data: { name, email },
+ });
+
+ const activeError = await applyActive(org.id, id, membership.isActive, coerceActive(payload.active));
+ if (activeError) {
+ return activeError;
+ }
+
+ const refreshed = await loadMembership(prisma, org.id, id);
+ return scimJson(toScimUser(refreshed!), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const PATCH = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+
+ const parsed = scimPatchOpSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return scimError(400, 'Invalid SCIM PatchOp payload', 'invalidValue');
+ }
+
+ // Reduce the operations into the attributes we persist (name, email,
+ // active). IdPs send these via path-based ops or a no-path bulk object;
+ // `parseScimPatchOperations` normalizes both. Unrecognized ops/paths are
+ // ignored rather than rejected, per the SCIM lenient-parsing convention.
+ const changes = parseScimPatchOperations(parsed.data.Operations);
+
+ if (changes.name !== undefined || changes.email !== undefined) {
+ await prisma.user.update({
+ where: { id },
+ data: {
+ ...(changes.name !== undefined ? { name: changes.name } : {}),
+ ...(changes.email !== undefined ? { email: changes.email } : {}),
+ },
+ });
+ }
+
+ const activeError = await applyActive(org.id, id, membership.isActive, changes.active);
+ if (activeError) {
+ return activeError;
+ }
+
+ const refreshed = await loadMembership(prisma, org.id, id);
+ return scimJson(toScimUser(refreshed!), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const DELETE = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+ // Per RFC 7644, DELETE removes the resource: hard-delete the membership
+ // (the User row is preserved so re-provisioning reuses the same SCIM id).
+ // Reversible suspension is still available via PATCH/PUT `active: false`.
+ const result = await removeMember(org.id, id, {
+ actor: { id: 'scim', type: 'scim_token' },
+ });
+ if (isServiceError(result)) {
+ return scimError(result.statusCode, result.message);
+ }
+ return new Response(null, { status: 204 });
+ }));
diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts
new file mode 100644
index 000000000..03635a614
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts
@@ -0,0 +1,121 @@
+import { apiHandler } from '@/lib/apiHandler';
+import { addMember, setMemberActive } from '@/features/membership/membership.service';
+import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper';
+import {
+ coerceActive,
+ parseScimFilter,
+ resolveEmail,
+ scimUserCreateSchema,
+} from '@/ee/features/scim/schemas';
+import { withScimAuth } from '@/ee/features/scim/withScimAuth';
+import { ErrorCode } from '@/lib/errorCodes';
+import { isServiceError } from '@/lib/utils';
+import { OrgRole } from '@sourcebot/db';
+import { env } from '@sourcebot/shared';
+import { NextRequest } from 'next/server';
+import { SCIM_DEFAULT_COUNT, SCIM_MAX_COUNT } from '@/ee/features/scim/constants';
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const GET = apiHandler(async (request: NextRequest) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const params = request.nextUrl.searchParams;
+ const filterParam = params.get('filter');
+ const startIndex = Math.max(1, parseInt(params.get('startIndex') ?? '1', 10) || 1);
+ const count = Math.min(SCIM_MAX_COUNT, Math.max(0, parseInt(params.get('count') ?? `${SCIM_DEFAULT_COUNT}`, 10) || SCIM_DEFAULT_COUNT));
+
+ // A filter that's present but unrecognized yields an empty result set
+ // (never a 404/400) so the IdP can decide create-vs-update safely.
+ const filter = parseScimFilter(filterParam);
+ if (filterParam && !filter) {
+ return scimJson(toScimListResponse([], 0, startIndex), 200);
+ }
+
+ const where = {
+ orgId: org.id,
+ ...(filter?.attribute === 'userName' ? { user: { email: { equals: filter.value, mode: 'insensitive' as const } } } : {}),
+ ...(filter?.attribute === 'externalId' ? { scimExternalId: filter.value } : {}),
+ };
+
+ const [total, memberships] = await Promise.all([
+ prisma.userToOrg.count({ where }),
+ prisma.userToOrg.findMany({
+ where,
+ include: { user: true },
+ orderBy: { joinedAt: 'asc' },
+ skip: startIndex - 1,
+ take: count,
+ }),
+ ]);
+
+ return scimJson(toScimListResponse(memberships.map(toScimUser), total, startIndex), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const POST = apiHandler(async (request: NextRequest) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const parsed = scimUserCreateSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return scimError(400, 'Invalid SCIM user payload', 'invalidValue');
+ }
+ const payload = parsed.data;
+ const email = resolveEmail(payload);
+ const name = payload.name?.formatted ?? payload.displayName ?? undefined;
+ const desiredActive = coerceActive(payload.active) ?? true;
+
+ // Find-or-create the user by email.
+ let user = await prisma.user.findUnique({ where: { email } });
+ if (!user) {
+ user = await prisma.user.create({ data: { email, name } });
+ }
+
+ const scimActor = { id: 'scim', type: 'scim_token' } as const;
+ const existing = await prisma.userToOrg.findUnique({
+ where: { orgId_userId: { orgId: org.id, userId: user.id } },
+ });
+
+ // Map the membership state to the SCIM response: an active member is a
+ // conflict, a deactivated member is reactivated (role preserved), and a
+ // brand-new member is created.
+ let httpStatus: number;
+ if (existing?.isActive) {
+ return scimError(409, 'User is already a member of this organization', 'uniqueness');
+ } else if (existing) {
+ const result = await setMemberActive(org.id, user.id, true, {
+ actor: scimActor,
+ scimExternalId: payload.externalId,
+ });
+ if (isServiceError(result)) {
+ const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined;
+ return scimError(result.statusCode, result.message, scimType);
+ }
+ httpStatus = 200;
+ } else {
+ const result = await addMember(org.id, user.id, {
+ actor: scimActor,
+ role: OrgRole.MEMBER,
+ scimExternalId: payload.externalId,
+ });
+ if (isServiceError(result)) {
+ const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined;
+ return scimError(result.statusCode, result.message, scimType);
+ }
+ httpStatus = 201;
+ }
+
+ // IdPs normally provision active and suspend later via PATCH; honor a rare
+ // explicit `active: false` on provisioning.
+ if (!desiredActive) {
+ const deactivated = await setMemberActive(org.id, user.id, false, { actor: scimActor });
+ if (isServiceError(deactivated)) {
+ return scimError(deactivated.statusCode, deactivated.message);
+ }
+ }
+
+ const membership = await prisma.userToOrg.findUniqueOrThrow({
+ where: { orgId_userId: { orgId: org.id, userId: user.id } },
+ include: { user: true },
+ });
+ return scimJson(toScimUser(membership), httpStatus, {
+ Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}`,
+ });
+ }));
diff --git a/packages/web/src/app/components/joinOrganizationButton.tsx b/packages/web/src/app/components/joinOrganizationButton.tsx
deleted file mode 100644
index dda376cb7..000000000
--- a/packages/web/src/app/components/joinOrganizationButton.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { useRouter } from "next/navigation";
-import { useToast } from "@/components/hooks/use-toast";
-import { useState } from "react";
-import { Loader2 } from "lucide-react";
-import { joinOrganization } from "../invite/actions";
-import { isServiceError } from "@/lib/utils";
-
-export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string }) {
- const [isLoading, setIsLoading] = useState(false);
- const router = useRouter();
- const { toast } = useToast();
-
- const handleJoinOrganization = async () => {
- setIsLoading(true);
-
- try {
- const result = await joinOrganization(inviteLinkId);
-
- if (isServiceError(result)) {
- toast({
- title: "Failed to join organization",
- description: result.message,
- variant: "destructive",
- });
- return;
- }
-
- router.refresh();
- } catch (error) {
- console.error("Error joining organization:", error);
- toast({
- title: "Error",
- description: "An unexpected error occurred. Please try again.",
- variant: "destructive",
- });
- } finally {
- setIsLoading(false);
- }
- };
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/packages/web/src/app/components/joinOrganizationCard.tsx b/packages/web/src/app/components/joinOrganizationCard.tsx
deleted file mode 100644
index bb7b1d390..000000000
--- a/packages/web/src/app/components/joinOrganizationCard.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { SourcebotLogo } from "@/app/components/sourcebotLogo";
-import { JoinOrganizationButton } from "./joinOrganizationButton";
-
-export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) {
- return (
-
-
-
-
-
-
-
-
- Welcome to Sourcebot! Click the button below to join this organization.
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/packages/web/src/app/components/logoutEscapeHatch.tsx b/packages/web/src/app/components/logoutEscapeHatch.tsx
index ce7754362..ec317f6e5 100644
--- a/packages/web/src/app/components/logoutEscapeHatch.tsx
+++ b/packages/web/src/app/components/logoutEscapeHatch.tsx
@@ -1,5 +1,7 @@
+"use client";
+
import { LogOutIcon } from "lucide-react";
-import { signOut } from "@/auth";
+import { signOut } from "next-auth/react";
import posthog from "posthog-js";
interface LogoutEscapeHatchProps {
@@ -11,24 +13,20 @@ export const LogoutEscapeHatch = ({
}: LogoutEscapeHatchProps) => {
return (
-
+
+ Log out
+
);
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts
deleted file mode 100644
index 2803c34b9..000000000
--- a/packages/web/src/app/invite/actions.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-"use server";
-
-import { createAudit } from "@/ee/features/audit/audit";
-import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
-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 } from "@/middleware/withAuth";
-import { __unsafePrisma } from "@/prisma";
-import { StatusCodes } from "http-status-codes";
-import { isMemberApprovalRequired } from "@sourcebot/shared";
-
-// 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();
- }
-
-
- // 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 addUserToOrganization(user.id, org.id);
- if (isServiceError(addUserToOrgRes)) {
- return addUserToOrgRes;
- }
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} joined the organization via invite link`,
- },
- });
-
- 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();
- }
-
- 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);
- 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 addUserToOrganization(user.id, invite.orgId);
- 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"
- }
- });
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: invite.org.id,
- metadata: {
- message: `${user.id} joined the organization by accepting invite ${inviteId}`,
- },
- });
-
- 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/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx
index b8173dee0..f00348658 100644
--- a/packages/web/src/app/invite/page.tsx
+++ b/packages/web/src/app/invite/page.tsx
@@ -5,8 +5,9 @@ import { notFound, redirect } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { AuthMethodSelector } from "@/app/components/authMethodSelector";
-import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
-import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
+import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard";
+import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard";
+import { isScimEnabled } from "@/features/scim/utils";
interface InvitePageProps {
searchParams: Promise<{
@@ -45,12 +46,13 @@ export default async function InvitePage(props: InvitePageProps) {
redirect(`/`);
}
+ if (await isScimEnabled(org)) {
+ return ;
+ }
+
// User is logged in but not a member, show join invitation
return (
-
-
-
-
+
);
}
diff --git a/packages/web/src/app/redeem/components/acceptInviteCard.tsx b/packages/web/src/app/redeem/components/acceptInviteCard.tsx
index 295ec38af..9a634205a 100644
--- a/packages/web/src/app/redeem/components/acceptInviteCard.tsx
+++ b/packages/web/src/app/redeem/components/acceptInviteCard.tsx
@@ -9,7 +9,7 @@ import placeholderAvatar from "@/public/placeholder_avatar.png";
import { ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react";
-import { redeemInvite } from "@/app/invite/actions";
+import { redeemInvite } from "@/features/membership/actions";
import { useRouter } from "next/navigation";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils";
diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx
index 8710e27dc..cbb144fa6 100644
--- a/packages/web/src/app/redeem/page.tsx
+++ b/packages/web/src/app/redeem/page.tsx
@@ -1,12 +1,14 @@
import { notFound, redirect } from 'next/navigation';
import { auth } from "@/auth";
-import { getInviteInfo } from "../invite/actions";
+import { getInviteInfo } from "@/features/membership/actions";
import { isServiceError } from "@/lib/utils";
import { AcceptInviteCard } from './components/acceptInviteCard';
import { LogoutEscapeHatch } from '../components/logoutEscapeHatch';
import { InviteNotFoundCard } from './components/inviteNotFoundCard';
import { SINGLE_TENANT_ORG_ID } from '@/lib/constants';
import { __unsafePrisma } from '@/prisma';
+import { isScimEnabled } from '@/features/scim/utils';
+import { NotProvisionedCard } from '@/features/membership/components/notProvisionedCard';
interface RedeemPageProps {
searchParams: Promise<{
@@ -31,6 +33,24 @@ export default async function RedeemPage(props: RedeemPageProps) {
return redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${inviteId}`)}`);
}
+ const membership = await __unsafePrisma.userToOrg.findUnique({
+ where: {
+ orgId_userId: {
+ orgId: org.id,
+ userId: session.user.id
+ }
+ }
+ });
+
+ // If already a member, redirect to the organization
+ if (membership) {
+ redirect(`/`);
+ }
+
+ if (await isScimEnabled(org)) {
+ return
+ }
+
const inviteInfo = await getInviteInfo(inviteId);
return (
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts
index 00fa426df..f22114ce8 100644
--- a/packages/web/src/auth.ts
+++ b/packages/web/src/auth.ts
@@ -15,13 +15,13 @@ import MagicLinkEmail from './emails/magicLinkEmail';
import bcrypt from 'bcryptjs';
import { getEEIdentityProviders } from '@/ee/features/sso/sso';
import { hasEntitlement } from '@/lib/entitlements';
-import { onCreateUser } from '@/lib/authUtils';
import { createAudit } from '@/ee/features/audit/audit';
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
import { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter';
import { getAnonymousId } from '@/lib/anonymousId';
import { captureEvent } from '@/lib/posthog';
import { isEmailCodeLoginEnabled, isCredentialsLoginEnabled } from '@sourcebot/shared'
+import { onCreateUser } from './features/membership/onCreateUser';
export const runtime = 'nodejs';
@@ -143,7 +143,7 @@ export const getProviders = async () => {
sessionVersion: newUser.sessionVersion,
}
- onCreateUser({ user: authJsUser });
+ await onCreateUser({ user: authJsUser });
return authJsUser;
// Otherwise, the user exists, so verify the password.
diff --git a/packages/web/src/ee/features/audit/types.ts b/packages/web/src/ee/features/audit/types.ts
index b936f700b..13c6bcc8c 100644
--- a/packages/web/src/ee/features/audit/types.ts
+++ b/packages/web/src/ee/features/audit/types.ts
@@ -2,19 +2,20 @@ import { z } from "zod";
export const auditActorSchema = z.object({
id: z.string(),
- type: z.enum(["user", "api_key"]),
+ type: z.enum(["user", "api_key", "scim_token"]),
})
export type AuditActor = z.infer;
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..c83d93ad5
--- /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 the membership's `isActive` flag.
+ */
+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.isActive,
+ 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/membership/actions/accountRequests.ts b/packages/web/src/features/membership/actions/accountRequests.ts
new file mode 100644
index 000000000..34d787816
--- /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 { addMember } 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 addMember(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..d2b4bf519
--- /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 { addMember } 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);
+ 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 addMember(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);
+ 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 addMember(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..06ee44827
--- /dev/null
+++ b/packages/web/src/features/membership/actions/members.ts
@@ -0,0 +1,66 @@
+'use server';
+
+import { removeMember } from "@/features/membership/membership.service";
+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 () => {
+ const result = await removeMember(org.id, memberId, {
+ 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 }) => {
+ 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,
+ isActive: member.isActive,
+ scimManaged: !!member.scimExternalId,
+ }));
+ })));
\ No newline at end of file
diff --git a/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx b/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx
new file mode 100644
index 000000000..34396246f
--- /dev/null
+++ b/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx
@@ -0,0 +1,23 @@
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { Info } from "lucide-react";
+
+/**
+ * Marks a member whose membership has been deactivated (`isActive = false`, e.g.
+ * via SCIM). They keep their membership row but can no longer access the org.
+ */
+export const DeactivatedMemberBadge = () => (
+
+
+
+
+ Deactivated
+
+
+
+
+ This member has been deactivated and can no longer access the organization.
+
+
+
+);
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.
+
+
+
+
+
+
+ );
+};
diff --git a/packages/web/src/app/(app)/components/pendingApproval.tsx b/packages/web/src/features/membership/components/pendingApprovalCard.tsx
similarity index 91%
rename from packages/web/src/app/(app)/components/pendingApproval.tsx
rename to packages/web/src/features/membership/components/pendingApprovalCard.tsx
index 4ffceb209..0034a5041 100644
--- a/packages/web/src/app/(app)/components/pendingApproval.tsx
+++ b/packages/web/src/features/membership/components/pendingApprovalCard.tsx
@@ -1,17 +1,9 @@
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
-import { auth } from "@/auth"
export const PendingApprovalCard = async () => {
- const session = await auth()
- const userId = session?.user?.id
-
- if (!userId) {
- return null
- }
-
return (
-
+
diff --git a/packages/web/src/features/membership/components/submitJoinRequestCard.tsx b/packages/web/src/features/membership/components/submitJoinRequestCard.tsx
new file mode 100644
index 000000000..286e12627
--- /dev/null
+++ b/packages/web/src/features/membership/components/submitJoinRequestCard.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
+import { SourcebotLogo } from "@/app/components/sourcebotLogo"
+import { useToast } from "@/components/hooks/use-toast"
+import { LoadingButton } from "@/components/ui/loading-button";
+import { createAccountRequest } from "@/features/membership/actions"
+import { isServiceError } from "@/lib/utils"
+import { Clock } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useState } from "react"
+
+export const SubmitJoinRequestCard = () => {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Request Access
+
+
+ Submit a request to join this organization
+
+
+
+
+
+
+
+ {!isSubmitting && }
+ Submit Request
+
+
+
+
+
+
+ )
+}
diff --git a/packages/web/src/features/membership/errors.ts b/packages/web/src/features/membership/errors.ts
new file mode 100644
index 000000000..9926788f1
--- /dev/null
+++ b/packages/web/src/features/membership/errors.ts
@@ -0,0 +1,32 @@
+import { ErrorCode } from "@/lib/errorCodes";
+import { ServiceError } from "@/lib/serviceError";
+import { StatusCodes } from "http-status-codes";
+
+
+export const seatLimitReached = (): ServiceError => ({
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
+ message: "Organization is at max capacity",
+});
+
+export const lastOwnerError = (reason: "removed" | "left"): ServiceError => ({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED,
+ message: reason === "left"
+ ? "You are the last owner of this organization. Promote another member to owner before leaving."
+ : "Cannot remove the last owner of the organization",
+});
+
+export const lastOwnerDemoteError = (): ServiceError => ({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED,
+ message: "Cannot demote the last owner. Promote another member to owner first.",
+});
+
+// When SCIM is enabled the IdP is the source of truth for membership, so paths
+// that would grant membership outside it (invites, join requests) are disabled.
+export const membershipManagedByIdpError = (): ServiceError => ({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.MEMBERSHIP_MANAGED_BY_IDP,
+ message: "SCIM provisioning is enabled. Membership is managed through your identity provider.",
+});
\ No newline at end of file
diff --git a/packages/web/src/features/membership/logger.ts b/packages/web/src/features/membership/logger.ts
new file mode 100644
index 000000000..f2b56522b
--- /dev/null
+++ b/packages/web/src/features/membership/logger.ts
@@ -0,0 +1,3 @@
+import { createLogger } from "@sourcebot/shared";
+
+export const logger = createLogger('membership');
\ No newline at end of file
diff --git a/packages/web/src/features/membership/membership.service.test.ts b/packages/web/src/features/membership/membership.service.test.ts
new file mode 100644
index 000000000..cc79bf7fb
--- /dev/null
+++ b/packages/web/src/features/membership/membership.service.test.ts
@@ -0,0 +1,334 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { addMember, removeMember, setMemberRole, setMemberActive } from './membership.service';
+import { prisma, MOCK_USER_WITH_ACCOUNTS } from '@/__mocks__/prisma';
+import { OrgRole, type UserToOrg } from '@sourcebot/db';
+import { ErrorCode } from '@/lib/errorCodes';
+import { isServiceError } from '@/lib/utils';
+import type { ServiceError } from '@/lib/serviceError';
+
+const mocks = vi.hoisted(() => ({
+ orgHasAvailability: vi.fn(),
+ syncWithLighthouse: vi.fn(),
+ createAudit: vi.fn(),
+}));
+
+vi.mock('@/prisma', async () => {
+ const actual = await vi.importActual('@/__mocks__/prisma');
+ return { ...actual };
+});
+vi.mock('server-only', () => ({ default: vi.fn() }));
+vi.mock('@/features/membership/utils', () => ({ orgHasAvailability: mocks.orgHasAvailability }));
+vi.mock('@/features/billing/servicePing', () => ({ syncWithLighthouse: mocks.syncWithLighthouse }));
+vi.mock('@/ee/features/audit/audit', () => ({ createAudit: mocks.createAudit }));
+
+const ORG_ID = 1;
+const USER_ID = 'user-1';
+const ACTOR = { id: 'scim', type: 'scim_token' } as const;
+
+const makeMembership = (overrides: Partial = {}): UserToOrg => ({
+ orgId: ORG_ID,
+ userId: USER_ID,
+ role: OrgRole.MEMBER,
+ joinedAt: new Date(),
+ isActive: true,
+ scimExternalId: null,
+ ...overrides,
+});
+
+const mockUser = { ...MOCK_USER_WITH_ACCOUNTS, id: USER_ID, email: 'user@example.com' };
+
+beforeEach(() => {
+ mocks.orgHasAvailability.mockReset().mockResolvedValue(true);
+ mocks.syncWithLighthouse.mockReset().mockResolvedValue(undefined);
+ mocks.createAudit.mockReset().mockResolvedValue(undefined);
+ // Run $transaction callbacks against the same deep mock as the tx client.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (prisma.$transaction as any).mockImplementation(async (cb: any) => cb(prisma));
+});
+
+describe('addMember', () => {
+ test('creates a new active membership when none exists', async () => {
+ const created = makeMembership();
+ prisma.user.findUnique.mockResolvedValue(mockUser);
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+ prisma.userToOrg.create.mockResolvedValue(created);
+
+ const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
+
+ expect(isServiceError(result)).toBe(false);
+ expect(result).toEqual(created);
+ expect(prisma.userToOrg.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ userId: USER_ID, orgId: ORG_ID, role: OrgRole.MEMBER, isActive: true }),
+ }),
+ );
+ expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(ORG_ID);
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_added' }));
+ });
+
+ test('records scimExternalId on create when provided', async () => {
+ prisma.user.findUnique.mockResolvedValue(mockUser);
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+ prisma.userToOrg.create.mockResolvedValue(makeMembership({ scimExternalId: 'ext-1' }));
+
+ await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER, scimExternalId: 'ext-1' });
+
+ expect(prisma.userToOrg.create).toHaveBeenCalledWith(
+ expect.objectContaining({ data: expect.objectContaining({ scimExternalId: 'ext-1' }) }),
+ );
+ });
+
+ test('clears pending invites and account requests on create', async () => {
+ prisma.user.findUnique.mockResolvedValue(mockUser);
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+ prisma.userToOrg.create.mockResolvedValue(makeMembership());
+
+ await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
+
+ expect(prisma.accountRequest.deleteMany).toHaveBeenCalledWith({ where: { requestedById: USER_ID, orgId: ORG_ID } });
+ expect(prisma.invite.deleteMany).toHaveBeenCalledWith({ where: { recipientEmail: mockUser.email, orgId: ORG_ID } });
+ });
+
+ test('is an idempotent no-op when an ACTIVE membership already exists', async () => {
+ const existing = makeMembership({ isActive: true });
+ prisma.user.findUnique.mockResolvedValue(mockUser);
+ prisma.userToOrg.findUnique.mockResolvedValue(existing);
+
+ const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
+
+ expect(result).toEqual(existing);
+ expect(prisma.userToOrg.create).not.toHaveBeenCalled();
+ expect(mocks.createAudit).not.toHaveBeenCalled();
+ });
+
+ test('is a no-op when an INACTIVE membership exists (does not reactivate)', async () => {
+ const existing = makeMembership({ isActive: false });
+ prisma.user.findUnique.mockResolvedValue(mockUser);
+ prisma.userToOrg.findUnique.mockResolvedValue(existing);
+
+ const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
+
+ expect(result).toEqual(existing);
+ expect(isServiceError(result)).toBe(false);
+ expect(prisma.userToOrg.create).not.toHaveBeenCalled();
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ expect(mocks.createAudit).not.toHaveBeenCalled();
+ });
+
+ test('errors when the org is at seat capacity', async () => {
+ prisma.user.findUnique.mockResolvedValue(mockUser);
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+ mocks.orgHasAvailability.mockResolvedValue(false);
+
+ const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.ORG_SEAT_COUNT_REACHED);
+ expect(prisma.userToOrg.create).not.toHaveBeenCalled();
+ });
+
+ test('errors when the user does not exist', async () => {
+ prisma.user.findUnique.mockResolvedValue(null);
+
+ const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
+
+ expect(isServiceError(result)).toBe(true);
+ expect(prisma.userToOrg.create).not.toHaveBeenCalled();
+ });
+});
+
+describe('removeMember', () => {
+ test('deletes the membership and revokes sessions + tokens', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership());
+
+ const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR });
+
+ expect(result).toBeNull();
+ expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: USER_ID }, data: { sessionVersion: { increment: 1 } } });
+ expect(prisma.apiKey.deleteMany).toHaveBeenCalledWith({ where: { createdById: USER_ID, orgId: ORG_ID } });
+ expect(prisma.oAuthToken.deleteMany).toHaveBeenCalled();
+ expect(prisma.userToOrg.delete).toHaveBeenCalledWith({ where: { orgId_userId: { orgId: ORG_ID, userId: USER_ID } } });
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_removed' }));
+ });
+
+ test('errors when the membership does not exist', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+
+ const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect(prisma.userToOrg.delete).not.toHaveBeenCalled();
+ });
+
+ test('blocks removing the last active owner', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true }));
+ prisma.userToOrg.count.mockResolvedValue(1);
+
+ const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED);
+ expect(prisma.userToOrg.delete).not.toHaveBeenCalled();
+ });
+
+ test('allows removing an owner when others remain', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true }));
+ prisma.userToOrg.count.mockResolvedValue(2);
+
+ const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.delete).toHaveBeenCalled();
+ });
+
+ test('reason "left" audits org.member_left', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership());
+
+ const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR, reason: 'left' });
+
+ expect(result).toBeNull();
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_left' }));
+ });
+});
+
+describe('setMemberRole', () => {
+ test('promotes a member to owner and audits it', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.MEMBER }));
+
+ const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.OWNER, { actor: ACTOR });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.update).toHaveBeenCalledWith(
+ expect.objectContaining({ data: { role: OrgRole.OWNER } }),
+ );
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_promoted_to_owner' }));
+ });
+
+ test('demotes an owner to member when other owners remain', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true }));
+ prisma.userToOrg.count.mockResolvedValue(2);
+
+ const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR });
+
+ expect(result).toBeNull();
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.owner_demoted_to_member' }));
+ });
+
+ test('blocks demoting the last active owner', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true }));
+ prisma.userToOrg.count.mockResolvedValue(1);
+
+ const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED);
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ });
+
+ test('is a no-op when the role is unchanged', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.MEMBER }));
+
+ const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ expect(mocks.createAudit).not.toHaveBeenCalled();
+ });
+
+ test('errors when the membership does not exist', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+
+ const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.OWNER, { actor: ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ });
+});
+
+describe('setMemberActive', () => {
+ describe('deactivate', () => {
+ test('deactivates an active member and revokes access', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true }));
+
+ const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR });
+
+ expect(result).toBeNull();
+ expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: USER_ID }, data: { sessionVersion: { increment: 1 } } });
+ expect(prisma.apiKey.deleteMany).toHaveBeenCalledWith({ where: { createdById: USER_ID, orgId: ORG_ID } });
+ expect(prisma.oAuthToken.deleteMany).toHaveBeenCalled();
+ expect(prisma.userToOrg.update).toHaveBeenCalledWith(
+ expect.objectContaining({ data: { isActive: false } }),
+ );
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_deactivated' }));
+ });
+
+ test('is a no-op when already inactive', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false }));
+
+ const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ expect(mocks.createAudit).not.toHaveBeenCalled();
+ });
+
+ test('errors when the membership does not exist', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+
+ const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('reactivate', () => {
+ test('reactivates an inactive member when a seat is available', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false }));
+ mocks.orgHasAvailability.mockResolvedValue(true);
+
+ const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.update).toHaveBeenCalledWith(
+ expect.objectContaining({ data: expect.objectContaining({ isActive: true, scimExternalId: 'ext-1' }) }),
+ );
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_reactivated' }));
+ });
+
+ test('errors when the org is at seat capacity', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false }));
+ mocks.orgHasAvailability.mockResolvedValue(false);
+
+ const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.ORG_SEAT_COUNT_REACHED);
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ });
+
+ test('is a no-op when already active (no audit, no seat check)', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'ext-1' }));
+
+ const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ expect(mocks.orgHasAvailability).not.toHaveBeenCalled();
+ expect(mocks.createAudit).not.toHaveBeenCalled();
+ });
+
+ test('refreshes externalId when already active and it changed', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'old' }));
+
+ const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'new' });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.update).toHaveBeenCalledWith(
+ expect.objectContaining({ data: { scimExternalId: 'new' } }),
+ );
+ expect(mocks.createAudit).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/web/src/features/membership/membership.service.ts b/packages/web/src/features/membership/membership.service.ts
new file mode 100644
index 000000000..0303cef68
--- /dev/null
+++ b/packages/web/src/features/membership/membership.service.ts
@@ -0,0 +1,332 @@
+import 'server-only';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import { type AuditActor } from "@/ee/features/audit/types";
+import { syncWithLighthouse } from "@/features/billing/servicePing";
+import { orgHasAvailability } from "@/features/membership/utils";
+import { notFound, type ServiceError } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { __unsafePrisma as prisma } from "@/prisma";
+import { OrgRole, Prisma, type UserToOrg } from "@sourcebot/db";
+import { lastOwnerDemoteError, lastOwnerError, seatLimitReached } from "./errors";
+
+export interface AddMemberOptions {
+ actor: AuditActor;
+ role: OrgRole;
+ scimExternalId?: string;
+}
+
+/**
+ * Ensures a membership exists for the user in the org. Idempotent: if a
+ * membership already exists (active or inactive) it is returned unchanged — this
+ * does NOT reactivate a deactivated membership (that's `setMemberActive`'s job)
+ * or change its role. On create, enforces the seat cap and clears any pending
+ * invites / account requests for the user.
+ *
+ * Note: a returned membership may be pre-existing and inactive, so a successful
+ * result does not by itself imply the user is active.
+ */
+export const addMember = async (
+ orgId: number,
+ userId: string,
+ options: AddMemberOptions,
+): Promise => {
+ const { actor, role, scimExternalId } = options;
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+ if (!user) {
+ return notFound("User not found");
+ }
+
+ const existing = await prisma.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ });
+ if (existing) {
+ return existing;
+ }
+
+ if (!(await orgHasAvailability(orgId))) {
+ return seatLimitReached();
+ }
+
+ const membership = await prisma.$transaction(async (tx) => {
+ const created = await tx.userToOrg.create({
+ data: {
+ userId,
+ orgId,
+ role,
+ isActive: true,
+ ...(scimExternalId ? { scimExternalId } : {}),
+ },
+ });
+
+ await tx.accountRequest.deleteMany({
+ where: { requestedById: userId, orgId },
+ });
+ await tx.invite.deleteMany({
+ where: { recipientEmail: user.email, orgId },
+ });
+
+ return created;
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
+
+ await syncWithLighthouse(orgId).catch(() => { /* best effort */ });
+ await createAudit({
+ action: "org.member_added",
+ actor,
+ target: { id: userId, type: "user" },
+ orgId,
+ });
+
+ return membership;
+};
+
+
+export interface RemoveMemberOptions {
+ actor: AuditActor;
+ reason?: "removed" | "left";
+}
+
+/**
+ * Hard-removes a membership (deletes the join row, preserving the `User`).
+ * Bumps `sessionVersion` and revokes the user's API keys + OAuth tokens.
+ */
+export const removeMember = async (
+ orgId: number,
+ userId: string,
+ options: RemoveMemberOptions,
+): Promise => {
+ const { actor, reason = "removed" } = options;
+
+ const result = await prisma.$transaction(async (tx) => {
+ const target = await tx.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ });
+ if (!target) {
+ return notFound("Member not found in this organization");
+ }
+
+ if (target.role === OrgRole.OWNER && target.isActive) {
+ if ((await countActiveOwners(tx, orgId)) <= 1) {
+ return lastOwnerError(reason);
+ }
+ }
+
+ await revokeAllUserAuthCredentials(tx, userId, orgId);
+
+ await tx.userToOrg.delete({
+ where: { orgId_userId: { orgId, userId } },
+ });
+
+ return null;
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
+
+ if (!isServiceError(result)) {
+ await syncWithLighthouse(orgId).catch(() => { /* best effort */ });
+ await createAudit({
+ action: reason === "left" ? "org.member_left" : "org.member_removed",
+ actor,
+ target: { id: userId, type: "user" },
+ orgId,
+ });
+ }
+
+ return result;
+};
+
+
+export interface SetMemberRoleOptions {
+ actor: AuditActor;
+}
+
+/**
+ * Changes a member's role (no-op when unchanged). No session/token revocation:
+ * role is resolved from the DB on every request, so a change takes effect on the
+ * member's next request. Seats are unaffected, so no lighthouse sync.
+ */
+export const setMemberRole = async (
+ orgId: number,
+ userId: string,
+ role: OrgRole,
+ options: SetMemberRoleOptions,
+): Promise => {
+ const { actor } = options;
+
+ let didChange = false;
+
+ const result = await prisma.$transaction(async (tx) => {
+ const target = await tx.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ });
+ if (!target) {
+ return notFound("Member not found in this organization");
+ }
+
+ if (target.role === role) {
+ return null;
+ }
+
+ const isDemotionFromOwner = target.role === OrgRole.OWNER && role !== OrgRole.OWNER;
+ if (isDemotionFromOwner && target.isActive) {
+ if ((await countActiveOwners(tx, orgId)) <= 1) {
+ return lastOwnerDemoteError();
+ }
+ }
+
+ await tx.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: { role },
+ });
+ didChange = true;
+
+ return null;
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
+
+ if (!isServiceError(result) && didChange) {
+ await createAudit({
+ action: role === OrgRole.OWNER ? "org.member_promoted_to_owner" : "org.owner_demoted_to_member",
+ actor,
+ target: { id: userId, type: "user" },
+ orgId,
+ });
+ }
+
+ return result;
+};
+
+export interface SetMemberActiveOptions {
+ actor: AuditActor;
+ scimExternalId?: string;
+}
+
+/**
+ * Suspends (`active: false`) or restores (`active: true`) a membership without
+ * deleting it. Deactivation bumps `sessionVersion` + revokes tokens; reactivation
+ * re-checks the seat cap. A no-op when already in the requested state.
+ */
+export const setMemberActive = async (
+ orgId: number,
+ userId: string,
+ active: boolean,
+ options: SetMemberActiveOptions,
+): Promise => {
+ const { actor, scimExternalId } = options;
+
+ if (!active) {
+ let didChange = false;
+
+ const result = await prisma.$transaction(async (tx) => {
+ const target = await tx.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ });
+ if (!target) {
+ return notFound("Member not found in this organization");
+ }
+ if (!target.isActive) {
+ return null;
+ }
+
+ await revokeAllUserAuthCredentials(tx, userId, orgId);
+
+ await tx.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: { isActive: false },
+ });
+ didChange = true;
+
+ return null;
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
+
+ if (!isServiceError(result) && didChange) {
+ await syncWithLighthouse(orgId).catch(() => { /* best effort */ });
+ await createAudit({
+ action: "org.member_deactivated",
+ actor,
+ target: { id: userId, type: "user" },
+ orgId,
+ });
+ }
+
+ return result;
+ }
+
+ const target = await prisma.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ });
+ if (!target) {
+ return notFound("Member not found in this organization");
+ }
+
+ if (target.isActive) {
+ if (scimExternalId && target.scimExternalId !== scimExternalId) {
+ await prisma.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: { scimExternalId },
+ });
+ }
+ return null;
+ }
+
+ if (!(await orgHasAvailability(orgId))) {
+ return seatLimitReached();
+ }
+
+ await prisma.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: {
+ isActive: true,
+ ...(scimExternalId ? { scimExternalId } : {}),
+ },
+ });
+
+ await syncWithLighthouse(orgId).catch(() => { /* best effort */ });
+ await createAudit({
+ action: "org.member_reactivated",
+ actor,
+ target: { id: userId, type: "user" },
+ orgId,
+ });
+
+ return null;
+};
+
+const countActiveOwners = (tx: Prisma.TransactionClient, orgId: number): Promise =>
+ tx.userToOrg.count({
+ where: { orgId, role: OrgRole.OWNER, isActive: true },
+ });
+
+const revokeAllUserAuthCredentials = async (
+ prisma: Prisma.TransactionClient,
+ userId: string,
+ orgId: number,
+): Promise => {
+ // JWT token
+ await prisma.user.update({
+ where: { id: userId },
+ data: { sessionVersion: { increment: 1 } },
+ });
+
+ // API Keys
+ await prisma.apiKey.deleteMany({
+ where: {
+ createdById: userId,
+ orgId,
+ }
+ });
+
+ // OAuth tokens
+ await prisma.oAuthToken.deleteMany({
+ where: {
+ userId
+ }
+ });
+ await prisma.oAuthRefreshToken.deleteMany({
+ where: {
+ userId
+ }
+ });
+ await prisma.oAuthAuthorizationCode.deleteMany({
+ where: {
+ userId
+ }
+ });
+};
diff --git a/packages/web/src/features/membership/onCreateUser.ts b/packages/web/src/features/membership/onCreateUser.ts
new file mode 100644
index 000000000..1244b1ca8
--- /dev/null
+++ b/packages/web/src/features/membership/onCreateUser.ts
@@ -0,0 +1,120 @@
+import type { User as AuthJsUser } from "next-auth";
+import { __unsafePrisma } from "@/prisma";
+import { OrgRole } from "@sourcebot/db";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+import { isMemberApprovalRequired } from "@sourcebot/shared";
+import { createAudit } from "@/ee/features/audit/audit";
+import { isScimEnabled } from "@/features/scim/utils";
+import { getDefaultMemberRole } from "@/features/membership/utils";
+import { isServiceError } from "@/lib/utils";
+import { addMember } from "@/features/membership/membership.service";
+import { logger } from "./logger";
+import { captureEvent } from "@/lib/posthog";
+
+/**
+ * Auth-layer hook invoked whenever a new user account is created (NextAuth's
+ * `createUser` event, the credentials provider, and SSO). Handles org onboarding:
+ * the first user bootstraps the org as OWNER, subsequent users auto-join as
+ * members in open self-serve mode, and auto-join is suppressed when member
+ * approval is required or SCIM is enabled (the IdP is then the source of truth).
+ * Membership writes go through the membership service.
+ */
+export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
+ if (!user.id) {
+ logger.error("User ID is undefined on user creation");
+ await createAudit({
+ action: "user.creation_failed",
+ actor: {
+ id: "undefined",
+ type: "user"
+ },
+ target: {
+ id: "undefined",
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ metadata: {
+ message: "User ID is undefined on user creation"
+ }
+ });
+ throw new Error("User ID is undefined on user creation");
+ }
+
+ const defaultOrg = await __unsafePrisma.org.findUnique({
+ where: {
+ id: SINGLE_TENANT_ORG_ID,
+ },
+ include: {
+ members: true,
+ }
+ });
+
+ if (defaultOrg === null) {
+ await createAudit({
+ action: "user.creation_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ metadata: {
+ message: "Default org not found on single tenant user creation"
+ }
+ });
+ throw new Error("Default org not found on single tenant user creation");
+ }
+
+ // @note when creating a user, there are two cases for when
+ // we should be adding them to the single tenant organization.
+ //
+ // 1. The organization is empty. In this case, we add the
+ // user as a member with the OWNER role.
+ const isFirstUser = defaultOrg.members.length === 0;
+ if (isFirstUser) {
+ const result = await addMember(SINGLE_TENANT_ORG_ID, user.id, {
+ actor: { id: user.id, type: "user" },
+ role: OrgRole.OWNER,
+ });
+ if (isServiceError(result)) {
+ throw new Error(`Failed to bootstrap initial owner for user ${user.id}: ${result.message}`);
+ }
+
+ await createAudit({
+ action: "user.owner_created",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ target: {
+ id: SINGLE_TENANT_ORG_ID.toString(),
+ type: "org"
+ }
+ });
+ }
+
+ // 2. Otherwise, if both member approvals is disabled &&
+ // scim is disabled, then we add the user as a member with
+ // whatever the default role is.
+ else if (
+ !isMemberApprovalRequired(defaultOrg) &&
+ !(await isScimEnabled(defaultOrg))
+ ) {
+ const result = await addMember(SINGLE_TENANT_ORG_ID, user.id, {
+ actor: { id: user.id, type: "user" },
+ role: await getDefaultMemberRole(),
+ });
+ if (isServiceError(result)) {
+ logger.warn(`onCreateUser: user ${user.id} was not auto-joined to org ${SINGLE_TENANT_ORG_ID}: ${result.message}`);
+ return;
+ }
+ }
+
+ // Dynamic import to avoid circular dependency:
+ // authUtils -> posthog -> auth -> authUtils
+ await captureEvent('wa_user_created', { userId: user.id });
+};
\ No newline at end of file
diff --git a/packages/web/src/features/membership/utils.ts b/packages/web/src/features/membership/utils.ts
new file mode 100644
index 000000000..f431f3b95
--- /dev/null
+++ b/packages/web/src/features/membership/utils.ts
@@ -0,0 +1,43 @@
+import { __unsafePrisma } from "@/prisma";
+import { hasEntitlement } from "@/lib/entitlements";
+import { createLogger, getSeatCap } from "@sourcebot/shared";
+import { OrgRole } from "@sourcebot/db";
+
+const logger = createLogger("membership-utils");
+
+/**
+ * Resolves the role a user receives when joining the org via invite,
+ * account-request approval, or interactive-login auto-join. On paid plans (the
+ * `org-management` entitlement) joiners are MEMBERs; on free plans there is no
+ * role distinction, so they join as OWNER.
+ */
+export const getDefaultMemberRole = async (): Promise =>
+ (await hasEntitlement("org-management")) ? OrgRole.MEMBER : OrgRole.OWNER;
+
+/**
+ * Checks to see if the given organization has seat availability. Seat
+ * availability is determined by the `seats` parameter in the offline license
+ * key, if available.
+ */
+export const orgHasAvailability = async (orgId: number): Promise => {
+ const seatCap = getSeatCap();
+
+ // SCIM-deactivated members don't consume a seat, so they free up capacity
+ // for new provisions while their membership row is preserved.
+ const activeUserCount = await __unsafePrisma.userToOrg.count({
+ where: {
+ orgId,
+ isActive: true,
+ },
+ });
+
+ if (
+ seatCap &&
+ activeUserCount >= seatCap
+ ) {
+ logger.error(`orgHasAvailability: org ${orgId} has reached max capacity`);
+ return false;
+ }
+
+ return true;
+};
diff --git a/packages/web/src/features/scim/utils.ts b/packages/web/src/features/scim/utils.ts
new file mode 100644
index 000000000..a9c0fc680
--- /dev/null
+++ b/packages/web/src/features/scim/utils.ts
@@ -0,0 +1,10 @@
+import { __unsafePrisma } from "@/prisma";
+import { hasEntitlement } from "@/lib/entitlements";
+import { Org } from "@sourcebot/db";
+
+export const isScimEnabled = async (org: Org): Promise => {
+ if (!await hasEntitlement('scim')) {
+ return false;
+ }
+ return org?.isScimEnabled ?? false;
+};
diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts
deleted file mode 100644
index 8a6bc334f..000000000
--- a/packages/web/src/features/userManagement/actions.ts
+++ /dev/null
@@ -1,579 +0,0 @@
-'use server';
-
-import { createAudit } from "@/ee/features/audit/audit";
-import { syncWithLighthouse } from "@/features/billing/servicePing";
-import InviteUserEmail from "@/emails/inviteUserEmail";
-import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail";
-import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
-import { ErrorCode } from "@/lib/errorCodes";
-import { notFound, 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 { render } from "@react-email/components";
-import { OrgRole, Prisma, PrismaClient } from "@sourcebot/db";
-import { createLogger, env, getSMTPConnectionURL } from "@sourcebot/shared";
-import { StatusCodes } from "http-status-codes";
-import { createTransport } from "nodemailer";
-
-const logger = createLogger('user-management');
-
-export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const guardError = await _removeUserFromOrg(prisma, {
- orgId: org.id,
- userId: memberId,
- lastOwnerMessage: "Cannot remove the last owner of the organization.",
- });
-
- if (guardError) {
- return guardError;
- }
-
- await createAudit({
- action: "org.member_removed",
- actor: { id: user.id, type: "user" },
- target: { id: memberId, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} removed ${memberId} from the organization`,
- },
- });
-
- return { success: true };
- }))
-);
-
-export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, prisma }) => {
- const guardError = await _removeUserFromOrg(prisma, {
- orgId: org.id,
- userId: user.id,
- lastOwnerMessage: "You are the last owner of this organization. Promote another member to owner before leaving.",
- });
-
- if (guardError) {
- return guardError;
- }
-
- await createAudit({
- action: "org.member_left",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} left the organization`,
- },
- });
-
- return {
- success: true,
- }
- }));
-
-
-const _removeUserFromOrg = async (
- prisma: PrismaClient,
- { orgId, userId, lastOwnerMessage }: { orgId: number; userId: string; lastOwnerMessage: string },
-): Promise => {
- const result = await prisma.$transaction(async (tx) => {
- const target = await tx.userToOrg.findUnique({
- where: {
- orgId_userId: {
- orgId,
- userId,
- }
- }
- });
-
- if (!target) {
- return notFound("Member not found in this organization");
- }
-
- if (target.role === OrgRole.OWNER) {
- const ownerCount = await tx.userToOrg.count({
- where: {
- orgId,
- role: OrgRole.OWNER,
- },
- });
-
- if (ownerCount <= 1) {
- return {
- statusCode: StatusCodes.FORBIDDEN,
- errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED,
- message: lastOwnerMessage,
- } satisfies ServiceError;
- }
- }
-
- await invalidateAllSessionsForUser(tx, userId);
- await revokeUserOAuthTokens(tx, userId);
- await revokeUserApiKeysInOrg(tx, userId, orgId);
-
- await tx.userToOrg.delete({
- where: {
- orgId_userId: {
- orgId,
- userId,
- }
- }
- });
-
- return null;
- }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
-
- // Sync with lighthouse s.t., the subscription
- // quantity will update immediately.
- if (!isServiceError(result)) {
- await syncWithLighthouse(orgId).catch(() => { /* ignore error */ });
- }
-
- return result;
-};
-
-
-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 approveAccountRequest = async (requestId: string) => sew(async () =>
- withAuth(async ({ org, user, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- 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 addUserToOrganization(request.requestedById, org.id);
- 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"
- }
- });
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: request.requestedById, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} approved join request ${requestId} for ${request.requestedById}`,
- },
- });
-
- // 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 createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ org, user, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- 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);
- 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 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,
- }));
- })));
-
-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,
- }));
- })));
-
-
-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,
- }));
- })));
-
-/**
- * Invalidates every active JWT cookie for the given user by incrementing
- * their `sessionVersion`. The next request from any of their active
- * sessions will compare the cookie's baked-in version against the
- * (now-bumped) value on the User row, fail, and be treated as logged out.
- */
-const invalidateAllSessionsForUser = async (
- prisma: Prisma.TransactionClient,
- userId: string,
-): Promise => {
- await prisma.user.update({
- where: { id: userId },
- data: { sessionVersion: { increment: 1 } },
- });
-};
-
-const revokeUserApiKeysInOrg = async (
- prisma: Prisma.TransactionClient,
- userId: string,
- orgId: number,
-): Promise => {
- await prisma.apiKey.deleteMany({
- where: {
- createdById: userId,
- orgId,
- }
- });
-};
-
-const revokeUserOAuthTokens = async (
- prisma: Prisma.TransactionClient,
- userId: string,
-): Promise => {
- await prisma.oAuthToken.deleteMany({
- where: {
- userId
- }
- });
- await prisma.oAuthRefreshToken.deleteMany({
- where: {
- userId
- }
- });
- await prisma.oAuthAuthorizationCode.deleteMany({
- where: {
- userId
- }
- });
-};
-
diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts
deleted file mode 100644
index ef0dd6551..000000000
--- a/packages/web/src/lib/authUtils.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-import type { User as AuthJsUser } from "next-auth";
-import { __unsafePrisma } from "@/prisma";
-import { OrgRole } from "@sourcebot/db";
-import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
-import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
-import { createLogger, getSeatCap } from "@sourcebot/shared";
-import { createAudit } from "@/ee/features/audit/audit";
-import { StatusCodes } from "http-status-codes";
-import { ErrorCode } from "./errorCodes";
-import { syncWithLighthouse } from "@/features/billing/servicePing";
-import { hasEntitlement } from "./entitlements";
-
-const logger = createLogger('web-auth-utils');
-
-export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
- if (!user.id) {
- logger.error("User ID is undefined on user creation");
- await createAudit({
- action: "user.creation_failed",
- actor: {
- id: "undefined",
- type: "user"
- },
- target: {
- id: "undefined",
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: "User ID is undefined on user creation"
- }
- });
- throw new Error("User ID is undefined on user creation");
- }
-
- const defaultOrg = await __unsafePrisma.org.findUnique({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- include: {
- members: true,
- }
- });
-
- if (defaultOrg === null) {
- await createAudit({
- action: "user.creation_failed",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: user.id,
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: "Default org not found on single tenant user creation"
- }
- });
- throw new Error("Default org not found on single tenant user creation");
- }
-
- // First user to sign up bootstraps the org as its OWNER. This is how a
- // fresh deployment gets its initial admin without manual setup.
- const isFirstUser = defaultOrg.members.length === 0;
- if (isFirstUser) {
- await __unsafePrisma.$transaction(async (tx) => {
- await tx.org.update({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- data: {
- members: {
- create: {
- role: OrgRole.OWNER,
- user: {
- connect: {
- id: user.id,
- }
- }
- }
- }
- }
- });
- });
-
- await createAudit({
- action: "user.owner_created",
- actor: {
- id: user.id,
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- target: {
- id: SINGLE_TENANT_ORG_ID.toString(),
- type: "org"
- }
- });
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: `${user.id} joined the organization as the initial owner`,
- },
- });
- }
-
- // Subsequent users auto-join only when the org is in open self-serve
- // mode. Their role depends on the `org-management` entitlement: on paid
- // plans they join as MEMBER, on free they join as OWNER (no role
- // distinction exists without the entitlement). If memberApprovalRequired
- // is true, the user is left without a membership and must submit an
- // AccountRequest for an owner to approve via addUserToOrganization.
- else if (!defaultOrg.memberApprovalRequired) {
- // Don't exceed the licensed seat count. The user row still exists;
- // they just aren't attached to the org until a seat frees up.
- const hasAvailability = await orgHasAvailability(defaultOrg.id);
- if (!hasAvailability) {
- logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`);
- return;
- }
-
- const hasOrgManagement = await hasEntitlement("org-management");
-
- await __unsafePrisma.userToOrg.create({
- data: {
- userId: user.id,
- orgId: SINGLE_TENANT_ORG_ID,
- role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER,
- }
- });
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: `${user.id} joined the organization (member approval not required)`,
- },
- });
- }
-
- // Dynamic import to avoid circular dependency:
- // authUtils -> posthog -> auth -> authUtils
- const { captureEvent } = await import("@/lib/posthog");
- await captureEvent('wa_user_created', { userId: user.id });
-
- // Sync with lighthouse s.t., the subscription
- // quantity will update immediately.
- await syncWithLighthouse(defaultOrg.id).catch(() => { /* ignore error */ });
-};
-
-
-/**
- * Checks to see if the given organization has seat availability.
- * Seat availability is determined by the `seats` parameter in
- * the offline license key, if available.
- */
-export const orgHasAvailability = async (orgId: number): Promise => {
- const org = await __unsafePrisma.org.findUniqueOrThrow({
- where: {
- id: orgId,
- },
- include: {
- members: true,
- }
- });
-
- const seatCap = getSeatCap();
- const memberCount = org.members.length;
-
- if (
- seatCap &&
- memberCount >= seatCap
- ) {
- logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`);
- return false;
- }
-
- return true;
-}
-
-export const addUserToOrganization = async (userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
- const user = await __unsafePrisma.user.findUnique({
- where: {
- id: userId,
- },
- });
-
- if (!user) {
- logger.error(`addUserToOrganization: user not found for id ${userId}`);
- return userNotFound();
- }
-
- const org = await __unsafePrisma.org.findUnique({
- where: {
- id: orgId,
- },
- });
-
- if (!org) {
- logger.error(`addUserToOrganization: org not found for id ${orgId}`);
- return orgNotFound();
- }
-
- const hasAvailability = await orgHasAvailability(org.id);
- if (!hasAvailability) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
- message: "Organization is at max capacity",
- } satisfies ServiceError;
- }
-
- const hasOrgManagement = await hasEntitlement('org-management');
-
- await __unsafePrisma.$transaction(async (tx) => {
- // Upsert rather than create: the user may already be a member from the
- // self-serve auto-join in onCreateUser, in which case this call is
- // just here to trigger the AccountRequest / Invite cleanup below.
- await tx.userToOrg.upsert({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: user.id,
- },
- },
- create: {
- userId: user.id,
- orgId: org.id,
- role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER,
- },
- update: {},
- });
-
- // Delete the account request if it exists since we've added the user to the org
- const accountRequest = await tx.accountRequest.findUnique({
- where: {
- requestedById_orgId: {
- requestedById: user.id,
- orgId: orgId,
- }
- },
- });
-
- if (accountRequest) {
- logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've been added to the org`);
- await tx.accountRequest.delete({
- where: {
- id: accountRequest.id,
- }
- });
- }
-
- // Delete any invites that may exist for this user since we've added them to the org
- const invites = await tx.invite.findMany({
- where: {
- recipientEmail: user.email,
- orgId: org.id,
- },
- })
-
- for (const invite of invites) {
- logger.info(`Deleting invite ${invite.id} for ${user.email} since they've been added to the org`);
- await tx.invite.delete({
- where: {
- id: invite.id,
- },
- });
- }
- });
-
- // Sync with lighthouse s.t., the subscription
- // quantity will update immediately.
- await syncWithLighthouse(org.id).catch(() => { /* ignore error */ });
-
- return {
- success: true,
- }
-};
\ No newline at end of file
diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts
index 2cea6d4ac..6f20a5e5e 100644
--- a/packages/web/src/lib/errorCodes.ts
+++ b/packages/web/src/lib/errorCodes.ts
@@ -44,4 +44,5 @@ export enum ErrorCode {
EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED = 'EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED',
MEMBER_APPROVAL_CONTROLLED_BY_ENV = 'MEMBER_APPROVAL_CONTROLLED_BY_ENV',
ANONYMOUS_ACCESS_CONTROLLED_BY_ENV = 'ANONYMOUS_ACCESS_CONTROLLED_BY_ENV',
+ MEMBERSHIP_MANAGED_BY_IDP = 'MEMBERSHIP_MANAGED_BY_IDP',
}
diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts
index 3ad283254..c172de94a 100644
--- a/packages/web/src/lib/posthogEvents.ts
+++ b/packages/web/src/lib/posthogEvents.ts
@@ -10,6 +10,7 @@ export type UpsellSource =
'license_settings' |
'mcp_settings' |
'sso_settings' |
+ 'scim_settings' |
'chat_connectors';
export type SourcebotWebClientSource = 'sourcebot-web-client';
diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts
index bc0586615..da6975c18 100644
--- a/packages/web/src/middleware/withAuth.test.ts
+++ b/packages/web/src/middleware/withAuth.test.ts
@@ -319,6 +319,8 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
@@ -349,6 +351,8 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
@@ -405,6 +409,71 @@ describe('getAuthContext', () => {
});
});
+ test('should not grant a role when the membership is SCIM-deactivated (isActive: false), even though the membership row exists', async () => {
+ const userId = 'test-user-id';
+ prisma.user.findUnique.mockResolvedValue({
+ ...MOCK_USER_WITH_ACCOUNTS,
+ id: userId,
+ });
+ prisma.org.findUnique.mockResolvedValue({
+ ...MOCK_ORG,
+ });
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: userId,
+ orgId: MOCK_ORG.id,
+ isActive: false,
+ scimExternalId: null,
+ role: OrgRole.OWNER,
+ });
+
+ setMockSession(createMockSession({ user: { id: userId } }));
+ const authContext = await getAuthContext();
+ expect(authContext).toStrictEqual({
+ user: {
+ ...MOCK_USER_WITH_ACCOUNTS,
+ id: userId,
+ },
+ org: MOCK_ORG,
+ prisma: undefined,
+ });
+ });
+
+ test('should not grant a role to a SCIM-deactivated member authenticating via API key (API-key auth bypasses the JWT sessionVersion logout, so this gate is what denies them)', async () => {
+ const userId = 'test-user-id';
+ prisma.user.findUnique.mockResolvedValue({
+ ...MOCK_USER_WITH_ACCOUNTS,
+ id: userId,
+ });
+ prisma.org.findUnique.mockResolvedValue({
+ ...MOCK_ORG,
+ });
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: userId,
+ orgId: MOCK_ORG.id,
+ isActive: false,
+ scimExternalId: null,
+ role: OrgRole.MEMBER,
+ });
+ prisma.apiKey.findUnique.mockResolvedValue({
+ ...MOCK_API_KEY,
+ hash: 'apikey',
+ createdById: userId,
+ });
+ setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
+
+ const authContext = await getAuthContext();
+ expect(authContext).toStrictEqual({
+ user: {
+ ...MOCK_USER_WITH_ACCOUNTS,
+ id: userId,
+ },
+ org: MOCK_ORG,
+ prisma: undefined,
+ });
+ });
+
describe('DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS', () => {
test('should return a 403 service error when flag is enabled and a non-owner authenticates via api key', async () => {
mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true';
@@ -415,6 +484,8 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId });
@@ -437,6 +508,8 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId });
@@ -460,6 +533,8 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
setMockSession(createMockSession({ user: { id: userId } }));
@@ -493,6 +568,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
vi.mocked(userScopedPrismaClientExtension).mockResolvedValue(extension as never);
@@ -522,6 +599,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -552,6 +631,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -582,6 +663,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -617,6 +700,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -652,6 +737,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -687,6 +774,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -722,6 +811,8 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
setMockSession(null);
@@ -749,6 +840,61 @@ describe('withAuth', () => {
expect(cb).not.toHaveBeenCalled();
expect(result).toStrictEqual(notAuthenticated());
});
+
+ test('should return a service error when the membership is SCIM-deactivated (isActive: false), even with a valid session', async () => {
+ const userId = 'test-user-id';
+ prisma.user.findUnique.mockResolvedValue({
+ ...MOCK_USER_WITH_ACCOUNTS,
+ id: userId,
+ });
+ prisma.org.findUnique.mockResolvedValue({
+ ...MOCK_ORG,
+ });
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: userId,
+ orgId: MOCK_ORG.id,
+ isActive: false,
+ scimExternalId: null,
+ role: OrgRole.OWNER,
+ });
+ setMockSession(createMockSession({ user: { id: userId } }));
+
+ const cb = vi.fn();
+ const result = await withAuth(cb);
+ expect(cb).not.toHaveBeenCalled();
+ expect(result).toStrictEqual(notAuthenticated());
+ });
+
+ test('should deny a SCIM-deactivated member authenticating via API key', async () => {
+ const userId = 'test-user-id';
+ prisma.user.findUnique.mockResolvedValue({
+ ...MOCK_USER_WITH_ACCOUNTS,
+ id: userId,
+ });
+ prisma.org.findUnique.mockResolvedValue({
+ ...MOCK_ORG,
+ });
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: userId,
+ orgId: MOCK_ORG.id,
+ isActive: false,
+ scimExternalId: null,
+ role: OrgRole.MEMBER,
+ });
+ prisma.apiKey.findUnique.mockResolvedValue({
+ ...MOCK_API_KEY,
+ hash: 'apikey',
+ createdById: userId,
+ });
+ setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
+
+ const cb = vi.fn();
+ const result = await withAuth(cb);
+ expect(cb).not.toHaveBeenCalled();
+ expect(result).toStrictEqual(notAuthenticated());
+ });
});
describe('withOptionalAuth', () => {
@@ -765,6 +911,8 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -795,6 +943,8 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -825,6 +975,8 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -860,6 +1012,8 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -895,6 +1049,8 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -930,6 +1086,8 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -965,6 +1123,8 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ isActive: true,
+ scimExternalId: null,
role: OrgRole.MEMBER,
});
setMockSession(null);
diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts
index 0e930fa63..649d39e5b 100644
--- a/packages/web/src/middleware/withAuth.ts
+++ b/packages/web/src/middleware/withAuth.ts
@@ -85,7 +85,10 @@ export const getAuthContext = async (): Promise