From e0b9f028bcd9afaafe1b78b3fe75d572ca370d3e Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 12 Jun 2026 17:29:52 -0700 Subject: [PATCH 01/17] feat: add SCIM 2.0 user provisioning (EE) Adds a SCIM 2.0 server so an IdP (Okta, Entra) can provision, update, and deprovision org members. Users-only scope; deprovisioning soft-deactivates the membership (forces logout + revokes tokens) rather than deleting it, and JIT auto-join is suppressed when SCIM is enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../migration.sql | 26 ++ packages/db/prisma/schema.prisma | 28 ++ packages/shared/src/constants.ts | 1 + packages/shared/src/crypto.ts | 12 +- packages/shared/src/entitlements.ts | 3 +- packages/shared/src/index.server.ts | 1 + packages/web/next.config.mjs | 7 + .../components/scimProvisioningSettings.tsx | 263 ++++++++++++++++++ .../security/components/scimUpsellCard.tsx | 39 +++ .../src/app/(app)/settings/security/page.tsx | 33 ++- .../ee/scim/v2/ResourceTypes/route.ts | 10 + .../api/(server)/ee/scim/v2/Schemas/route.ts | 10 + .../ee/scim/v2/ServiceProviderConfig/route.ts | 9 + .../(server)/ee/scim/v2/Users/[id]/route.ts | 135 +++++++++ .../api/(server)/ee/scim/v2/Users/route.ts | 110 ++++++++ packages/web/src/ee/features/audit/types.ts | 5 +- packages/web/src/ee/features/scim/actions.ts | 129 +++++++++ .../web/src/ee/features/scim/constants.ts | 14 + packages/web/src/ee/features/scim/mapper.ts | 80 ++++++ .../web/src/ee/features/scim/membership.ts | 119 ++++++++ packages/web/src/ee/features/scim/schemas.ts | 145 ++++++++++ .../web/src/ee/features/scim/withScimAuth.ts | 76 +++++ .../src/features/userManagement/actions.ts | 51 +--- .../userManagement/membershipMutations.ts | 58 ++++ packages/web/src/lib/authUtils.ts | 37 ++- packages/web/src/lib/posthogEvents.ts | 1 + packages/web/src/middleware/withAuth.test.ts | 40 +++ packages/web/src/middleware/withAuth.ts | 5 +- 29 files changed, 1383 insertions(+), 65 deletions(-) create mode 100644 packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql create mode 100644 packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx create mode 100644 packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts create mode 100644 packages/web/src/ee/features/scim/actions.ts create mode 100644 packages/web/src/ee/features/scim/constants.ts create mode 100644 packages/web/src/ee/features/scim/mapper.ts create mode 100644 packages/web/src/ee/features/scim/membership.ts create mode 100644 packages/web/src/ee/features/scim/schemas.ts create mode 100644 packages/web/src/ee/features/scim/withScimAuth.ts create mode 100644 packages/web/src/features/userManagement/membershipMutations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cff95faf2..799b6cef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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/20260612235524_add_scim_users_support/migration.sql b/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql new file mode 100644 index 000000000..fc818fcee --- /dev/null +++ b/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql @@ -0,0 +1,26 @@ +-- 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 ae0a53840..ebf430426 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -272,6 +272,7 @@ model Org { connections Connection[] repos Repo[] apiKeys ApiKey[] + scimTokens ScimToken[] isOnboarded Boolean @default(false) imageUrl String? @@ -387,7 +388,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 { @@ -404,6 +415,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 bcfdac6cd..e3c42ddcc 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -40,7 +40,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 0c8f281a4..6147cdd08 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -56,6 +56,7 @@ export { decrypt, hashSecret, generateApiKey, + generateScimToken, generateOAuthToken, generateOAuthRefreshToken, verifySignature, diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index c34c126d0..3ba30c0ef 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -55,6 +55,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/app/(app)/settings/security/components/scimProvisioningSettings.tsx b/packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx new file mode 100644 index 000000000..58204f930 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { generateScimToken, revokeScimToken } from "@/ee/features/scim/actions"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { isServiceError } from "@/lib/utils"; +import { Copy, Check, AlertTriangle, Loader2, KeyRound, Plus, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useToast } from "@/components/hooks/use-toast"; +import { formatDistanceToNow } from "date-fns"; +import { useRouter } from "next/navigation"; + +interface ScimToken { + name: string; + createdAt: Date; + lastUsedAt: Date | null; +} + +interface ScimProvisioningSettingsProps { + baseUrl: string; + tokens: ScimToken[]; +} + +export function ScimProvisioningSettings({ baseUrl, tokens }: ScimProvisioningSettingsProps) { + const { toast } = useToast(); + const router = useRouter(); + + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newTokenName, setNewTokenName] = useState(""); + const [isCreatingToken, setIsCreatingToken] = useState(false); + const [newlyCreatedToken, setNewlyCreatedToken] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + const [baseUrlCopied, setBaseUrlCopied] = useState(false); + + const handleCopyBaseUrl = () => { + navigator.clipboard.writeText(baseUrl) + .then(() => { + setBaseUrlCopied(true); + setTimeout(() => setBaseUrlCopied(false), 2000); + }) + .catch(() => { + toast({ title: "Error", description: "Failed to copy base URL", variant: "destructive" }); + }); + }; + + const handleCreateToken = async () => { + if (!newTokenName.trim()) { + toast({ title: "Error", description: "Token name cannot be empty", variant: "destructive" }); + return; + } + + setIsCreatingToken(true); + try { + const result = await generateScimToken(newTokenName.trim()); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to create SCIM token: ${result.message}`, variant: "destructive" }); + return; + } + setNewlyCreatedToken(result.token); + router.refresh(); + } catch (error) { + console.error(error); + toast({ title: "Error", description: `Failed to create SCIM token: ${error}`, variant: "destructive" }); + } finally { + setIsCreatingToken(false); + } + }; + + const handleCopyToken = () => { + if (!newlyCreatedToken) { + return; + } + navigator.clipboard.writeText(newlyCreatedToken) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + toast({ title: "Error", description: "Failed to copy token to clipboard", variant: "destructive" }); + }); + }; + + const handleCloseDialog = () => { + setIsCreateDialogOpen(false); + setNewTokenName(""); + setNewlyCreatedToken(null); + setCopySuccess(false); + }; + + const handleRevokeToken = async (name: string) => { + const result = await revokeScimToken(name); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to revoke SCIM token: ${result.message}`, variant: "destructive" }); + return; + } + router.refresh(); + toast({ description: "SCIM token revoked" }); + }; + + const sortedTokens = useMemo( + () => [...tokens].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), + [tokens] + ); + + return ( +
+
+ SCIM connector base URL +
+
+ {baseUrl} +
+ +
+
+ +
+
+ + {tokens.length} SCIM token{tokens.length !== 1 ? "s" : ""} + + + + + + + + + {newlyCreatedToken ? 'Your New SCIM Token' : 'Create SCIM Token'} + + + {newlyCreatedToken ? ( +
+
+ +

+ This is the only time you'll see this token. Copy it now and paste it into your IdP. +

+
+ +
+
+ {newlyCreatedToken} +
+ +
+
+ ) : ( +
+ setNewTokenName(e.target.value)} + placeholder="Enter a name for your SCIM token" + className="mb-2" + /> +
+ )} + + + {newlyCreatedToken ? ( + + ) : ( + <> + + + + )} + +
+
+
+ + {sortedTokens.length === 0 ? ( +
+ No SCIM tokens yet. +
+ ) : ( +
+ {sortedTokens.map((token) => ( +
+
+ +
+
+ {token.name} + + Created {formatDistanceToNow(token.createdAt, { addSuffix: true })} + {" · "} + {token.lastUsedAt + ? `last used ${formatDistanceToNow(token.lastUsedAt, { addSuffix: true })}` + : "never used" + } + +
+ + + + + + + Revoke SCIM Token + + Are you sure you want to revoke {token.name}? Your IdP will no longer be able to provision or deprovision users with this token. This action cannot be undone. + + + + Cancel + handleRevokeToken(token.name)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Revoke + + + + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx b/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx new file mode 100644 index 000000000..f6f29487d --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx @@ -0,0 +1,39 @@ +"use client" + +import { useState } from "react" +import { Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" +import { SettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { UpsellDialog } from "@/features/billing/upsellDialog" + +export function ScimUpsellCard() { + const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false) + + return ( + <> + +
+
+
+ +
+
+

SCIM provisioning is a paid feature

+

Upgrade to provision and deprovision members automatically from your identity provider.

+
+
+ +
+
+ + + + ) +} diff --git a/packages/web/src/app/(app)/settings/security/page.tsx b/packages/web/src/app/(app)/settings/security/page.tsx index e48e88c5f..06a7dae5f 100644 --- a/packages/web/src/app/(app)/settings/security/page.tsx +++ b/packages/web/src/app/(app)/settings/security/page.tsx @@ -5,10 +5,13 @@ import { CredentialsLoginEnabledSettingsCard } from "./components/credentialsLog import { EmailCodeLoginEnabledSettingsCard } from "./components/emailCodeLoginEnabledSettingsCard"; import { IdentityProviderSettingsCard } from "./components/identityProviderSettingsCard"; import { IdentityProviderUpsellCard } from "./components/identityProviderUpsellCard"; +import { ScimProvisioningSettings } from "./components/scimProvisioningSettings"; +import { ScimUpsellCard } from "./components/scimUpsellCard"; +import { getScimTokens } from "@/ee/features/scim/actions"; import { UpgradeBadge } from "@/app/(app)/@sidebar/components/upgradeBadge"; import { getProviders, IdentityProvider } from "@/auth"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; -import { createInviteLink } from "@/lib/utils"; +import { createInviteLink, isServiceError } from "@/lib/utils"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; import { env, getSMTPConnectionURL, isCredentialsLoginEnabled, isEmailCodeLoginEnabled, isMemberApprovalRequired } from "@sourcebot/shared"; @@ -22,6 +25,11 @@ export default authenticatedPage(async ({ org }) => { const hasSSOEntitlement = await hasEntitlement("sso"); const identityProviders = await getConfiguredIdentityProviders(); + const hasScimEntitlement = await hasEntitlement("scim"); + const scimBaseUrl = `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2`; + const scimTokensResult = hasScimEntitlement ? await getScimTokens() : []; + const scimTokens = isServiceError(scimTokensResult) ? [] : scimTokensResult; + return (
@@ -107,6 +115,29 @@ export default authenticatedPage(async ({ org }) => { )} + +
+
+

SCIM Provisioning

+ {!hasScimEntitlement && } +
+

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 + +

+
+ + {!hasScimEntitlement ? ( + + ) : ( + + )}
) 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..9511577c9 --- /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 { deactivateScimMember, reactivateScimMember } from '@/ee/features/scim/membership'; +import { scimError, scimJson, toScimUser, type ScimMembership } from '@/ee/features/scim/mapper'; +import { + coerceActive, + 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, running the deactivate/reactivate helper +// 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 = next + ? await reactivateScimMember(orgId, userId) + : await deactivateScimMember(orgId, userId); + 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'); + } + + // Extract the desired `active` value. IdPs send it two ways: + // { op: "replace", path: "active", value: false } + // { op: "replace", value: { active: false } } + // `op` is case-insensitive. Other operations are ignored (lenient). + let nextActive: boolean | undefined; + for (const operation of parsed.data.Operations) { + const op = operation.op.toLowerCase(); + if (op !== 'replace' && op !== 'add') { + continue; + } + if (operation.path === 'active') { + nextActive = coerceActive(operation.value); + } else if (!operation.path && operation.value && typeof operation.value === 'object') { + const maybe = (operation.value as Record).active; + if (maybe !== undefined) { + nextActive = coerceActive(maybe); + } + } + } + + const activeError = await applyActive(org.id, id, membership.isActive, nextActive); + 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`); + } + // DELETE is treated as deactivation, not a hard delete, so the IdP can + // reactivate later and we preserve the user's data/history. + const result = await deactivateScimMember(org.id, id); + 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..77cdf6b73 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts @@ -0,0 +1,110 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { orgHasAvailability } from '@/lib/authUtils'; +import { reactivateScimMember } from '@/ee/features/scim/membership'; +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 { 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 isActive = coerceActive(payload.active) ?? true; + + // Find-or-create the user by email. We deliberately bypass `onCreateUser` + // (its JIT/bootstrap logic is for interactive login, not provisioning). + let user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + user = await prisma.user.create({ data: { email, name } }); + } + + const existing = await prisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId: org.id, userId: user.id } }, + include: { user: true }, + }); + + if (existing) { + if (existing.isActive) { + return scimError(409, 'User is already a member of this organization', 'uniqueness'); + } + // Re-provisioning a previously deactivated user → reactivate. + const result = await reactivateScimMember(org.id, user.id, payload.externalId); + if (isServiceError(result)) { + return scimError(result.statusCode, result.message); + } + const refreshed = await prisma.userToOrg.findUniqueOrThrow({ + where: { orgId_userId: { orgId: org.id, userId: user.id } }, + include: { user: true }, + }); + return scimJson(toScimUser(refreshed), 200, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + } + + // New membership: enforce the seat cap before creating. + if (isActive && !(await orgHasAvailability(org.id))) { + return scimError(400, 'Organization seat limit reached', 'tooMany'); + } + + const membership = await prisma.userToOrg.create({ + data: { + userId: user.id, + orgId: org.id, + role: OrgRole.MEMBER, + isActive, + scimExternalId: payload.externalId, + }, + include: { user: true }, + }); + + return scimJson(toScimUser(membership), 201, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + })); 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/scim/actions.ts b/packages/web/src/ee/features/scim/actions.ts new file mode 100644 index 000000000..657c33e6f --- /dev/null +++ b/packages/web/src/ee/features/scim/actions.ts @@ -0,0 +1,129 @@ +'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` }; + }))); + +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/membership.ts b/packages/web/src/ee/features/scim/membership.ts new file mode 100644 index 000000000..d6018f581 --- /dev/null +++ b/packages/web/src/ee/features/scim/membership.ts @@ -0,0 +1,119 @@ +import { createAudit } from "@/ee/features/audit/audit"; +import { orgHasAvailability } from "@/lib/authUtils"; +import { ErrorCode } from "@/lib/errorCodes"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { __unsafePrisma } from "@/prisma"; +import { syncWithLighthouse } from "@/features/billing/servicePing"; +import { + invalidateAllSessionsForUser, + revokeUserApiKeysInOrg, + revokeUserOAuthTokens, +} from "@/features/userManagement/membershipMutations"; +import { OrgRole, Prisma } from "@sourcebot/db"; +import { StatusCodes } from "http-status-codes"; + +/** + * SCIM soft-deactivation. Mirrors `_removeUserFromOrg` but, instead of deleting + * the membership, sets `isActive = false` so the IdP can later reactivate it. + * Bumps `sessionVersion` (forcing logout on next request) and revokes the + * user's API keys + OAuth tokens so a deactivated user has no path back in. + */ +export const deactivateScimMember = async (orgId: number, userId: string): Promise => { + const result = await __unsafePrisma.$transaction(async (tx) => { + const target = await tx.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + + if (!target) { + return notFound("Member not found in this organization"); + } + + // Refuse to deactivate the last active owner — doing so would lock + // everyone out of org administration. + if (target.role === OrgRole.OWNER && target.isActive) { + const activeOwnerCount = await tx.userToOrg.count({ + where: { orgId, role: OrgRole.OWNER, isActive: true }, + }); + + if (activeOwnerCount <= 1) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED, + message: "Cannot deactivate the last owner of the organization.", + } satisfies ServiceError; + } + } + + await invalidateAllSessionsForUser(tx, userId); + await revokeUserOAuthTokens(tx, userId); + await revokeUserApiKeysInOrg(tx, userId, orgId); + + await tx.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { isActive: false }, + }); + + return null; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + if (!isServiceError(result)) { + await syncWithLighthouse(orgId).catch(() => { /* ignore error */ }); + await createAudit({ + action: "org.member_deactivated", + actor: { id: "scim", type: "scim_token" }, + target: { id: userId, type: "user" }, + orgId, + }); + } + + return result; +}; + +/** + * SCIM reactivation: flips `isActive` back to true. Re-checks seat availability + * first, since deactivated users free their seat and it may have been filled. + * Optionally updates the stored IdP `externalId`. + */ +export const reactivateScimMember = async ( + orgId: number, + userId: string, + scimExternalId?: string, +): Promise => { + const target = await __unsafePrisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + + if (!target) { + return notFound("Member not found in this organization"); + } + + if (!target.isActive) { + const hasAvailability = await orgHasAvailability(orgId); + if (!hasAvailability) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", + } satisfies ServiceError; + } + } + + await __unsafePrisma.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { + isActive: true, + ...(scimExternalId ? { scimExternalId } : {}), + }, + }); + + await syncWithLighthouse(orgId).catch(() => { /* ignore error */ }); + await createAudit({ + action: "org.member_reactivated", + actor: { id: "scim", type: "scim_token" }, + target: { id: userId, type: "user" }, + orgId, + }); + + return null; +}; 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..dee7351b8 --- /dev/null +++ b/packages/web/src/ee/features/scim/schemas.ts @@ -0,0 +1,145 @@ +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(); +}; + +// ----- 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..0cbbee44f --- /dev/null +++ b/packages/web/src/ee/features/scim/withScimAuth.ts @@ -0,0 +1,76 @@ +import { __unsafePrisma } from "@/prisma"; +import { hasEntitlement } from "@/lib/entitlements"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +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; + // SCIM acts on behalf of the IdP integration for the whole org — there is + // no user, so we use the unscoped client rather than the user-scoped one. + 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) }, + }); + 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"); + } + + const org = await __unsafePrisma.org.findUnique({ + where: { id: SINGLE_TENANT_ORG_ID }, + }); + if (!org) { + return scimError(500, "Organization not found"); + } + + // 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, 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/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts index 859617f91..8427c1e59 100644 --- a/packages/web/src/features/userManagement/actions.ts +++ b/packages/web/src/features/userManagement/actions.ts @@ -5,6 +5,7 @@ import { syncWithLighthouse } from "@/features/billing/servicePing"; import InviteUserEmail from "@/emails/inviteUserEmail"; import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; +import { invalidateAllSessionsForUser, revokeUserApiKeysInOrg, revokeUserOAuthTokens } from "./membershipMutations"; import { ErrorCode } from "@/lib/errorCodes"; import { notFound, ServiceError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; @@ -486,6 +487,7 @@ export const getOrgMembers = async () => sew(() => avatarUrl: member.user.image ?? undefined, role: member.role, joinedAt: member.joinedAt, + isActive: member.isActive, })); }))); @@ -527,53 +529,4 @@ export const getOrgAccountRequests = async () => sew(() => })); }))); -/** - * 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/features/userManagement/membershipMutations.ts b/packages/web/src/features/userManagement/membershipMutations.ts new file mode 100644 index 000000000..3b55f5bed --- /dev/null +++ b/packages/web/src/features/userManagement/membershipMutations.ts @@ -0,0 +1,58 @@ +import { Prisma } from "@sourcebot/db"; + +/** + * Low-level membership mutation helpers shared between user-management server + * actions and SCIM provisioning. These are plain functions (not server + * actions) so they can be imported by both `actions.ts` and the SCIM feature; + * they must NOT live in a `'use server'` module. + */ + +/** + * 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. + */ +export const invalidateAllSessionsForUser = async ( + prisma: Prisma.TransactionClient, + userId: string, +): Promise => { + await prisma.user.update({ + where: { id: userId }, + data: { sessionVersion: { increment: 1 } }, + }); +}; + +export const revokeUserApiKeysInOrg = async ( + prisma: Prisma.TransactionClient, + userId: string, + orgId: number, +): Promise => { + await prisma.apiKey.deleteMany({ + where: { + createdById: userId, + orgId, + } + }); +}; + +export 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 index 9215cabb7..da9a0dda8 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -12,6 +12,20 @@ import { hasEntitlement } from "./entitlements"; const logger = createLogger('web-auth-utils'); +/** + * SCIM is "enabled" for an org once it has at least one SCIM token configured + * (and the entitlement is present). When enabled, the IdP directory is the + * source of truth for membership, so interactive-login JIT auto-join is + * suppressed — users must be provisioned via SCIM. + */ +export const isScimEnabled = async (orgId: number): Promise => { + if (!await hasEntitlement('scim')) { + return false; + } + const tokenCount = await __unsafePrisma.scimToken.count({ where: { orgId } }); + return tokenCount > 0; +}; + export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { if (!user.id) { logger.error("User ID is undefined on user creation"); @@ -115,7 +129,11 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { // 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) { + // + // When SCIM is enabled, auto-join is suppressed entirely: the IdP is the + // source of truth, so a login for a user the IdP hasn't provisioned creates + // the User row but no membership (they're denied until SCIM provisions them). + else if (!defaultOrg.memberApprovalRequired && !(await isScimEnabled(SINGLE_TENANT_ORG_ID))) { // 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); @@ -162,23 +180,22 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { * the offline license key, if available. */ export const orgHasAvailability = async (orgId: number): Promise => { - const org = await __unsafePrisma.org.findUniqueOrThrow({ + 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 memberCount = await __unsafePrisma.userToOrg.count({ where: { - id: orgId, + orgId, + isActive: true, }, - 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`); + logger.error(`orgHasAvailability: org ${orgId} has reached max capacity`); return false; } 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..6fe06a4f1 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, }); @@ -415,6 +419,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 +443,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 +468,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 +503,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 +534,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 +566,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 +598,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 +635,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 +672,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 +709,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 +746,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); setMockSession(null); @@ -765,6 +791,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 +823,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 +855,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 +892,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 +929,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 +966,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 +1003,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 Date: Mon, 15 Jun 2026 16:33:38 -0700 Subject: [PATCH 02/17] derive org from scim token --- packages/web/src/ee/features/scim/withScimAuth.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/web/src/ee/features/scim/withScimAuth.ts b/packages/web/src/ee/features/scim/withScimAuth.ts index 0cbbee44f..4f25f0b19 100644 --- a/packages/web/src/ee/features/scim/withScimAuth.ts +++ b/packages/web/src/ee/features/scim/withScimAuth.ts @@ -1,6 +1,5 @@ import { __unsafePrisma } from "@/prisma"; import { hasEntitlement } from "@/lib/entitlements"; -import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { hashSecret, SCIM_TOKEN_PREFIX, createLogger } from "@sourcebot/shared"; import { Org, PrismaClient } from "@sourcebot/db"; import { NextRequest } from "next/server"; @@ -10,8 +9,6 @@ const logger = createLogger('scim-auth'); export type ScimAuthContext = { org: Org; - // SCIM acts on behalf of the IdP integration for the whole org — there is - // no user, so we use the unscoped client rather than the user-scoped one. prisma: PrismaClient; }; @@ -43,6 +40,7 @@ export const withScimAuth = async ( const scimToken = await __unsafePrisma.scimToken.findUnique({ where: { hash: hashSecret(secret) }, + include: { org: true }, }); if (!scimToken) { return scimError(401, "Invalid SCIM token"); @@ -54,12 +52,6 @@ export const withScimAuth = async ( return scimError(403, "SCIM provisioning is not available in your current plan"); } - const org = await __unsafePrisma.org.findUnique({ - where: { id: SINGLE_TENANT_ORG_ID }, - }); - if (!org) { - return scimError(500, "Organization not found"); - } // Best-effort usage tracking; never block the request on it. __unsafePrisma.scimToken.update({ @@ -68,7 +60,10 @@ export const withScimAuth = async ( }).catch(() => { /* ignore */ }); try { - return await fn({ org, prisma: __unsafePrisma }); + 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"); From ed5bc429fd0236c203c7a6dbbfbd564a65a323ba Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 15 Jun 2026 16:57:47 -0700 Subject: [PATCH 03/17] await onCreateUser call --- packages/web/src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 3240824e5..c69911329 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -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. From 564895750751d397ae6045453a3f304b476a875c Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 16 Jun 2026 10:09:41 -0700 Subject: [PATCH 04/17] nit --- packages/web/src/features/scim/utils.ts | 10 ++++++++++ packages/web/src/lib/authUtils.ts | 22 ++++++---------------- 2 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/features/scim/utils.ts diff --git a/packages/web/src/features/scim/utils.ts b/packages/web/src/features/scim/utils.ts new file mode 100644 index 000000000..f117cd846 --- /dev/null +++ b/packages/web/src/features/scim/utils.ts @@ -0,0 +1,10 @@ +import { __unsafePrisma } from "@/prisma"; +import { hasEntitlement } from "@/lib/entitlements"; + +export const isScimEnabled = async (orgId: number): Promise => { + if (!await hasEntitlement('scim')) { + return false; + } + const tokenCount = await __unsafePrisma.scimToken.count({ where: { orgId } }); + return tokenCount > 0; +}; diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index da9a0dda8..d5747d0c0 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -3,29 +3,16 @@ 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 { createLogger, getSeatCap, isMemberApprovalRequired } 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"; +import { isScimEnabled } from "@/features/scim/utils"; const logger = createLogger('web-auth-utils'); -/** - * SCIM is "enabled" for an org once it has at least one SCIM token configured - * (and the entitlement is present). When enabled, the IdP directory is the - * source of truth for membership, so interactive-login JIT auto-join is - * suppressed — users must be provisioned via SCIM. - */ -export const isScimEnabled = async (orgId: number): Promise => { - if (!await hasEntitlement('scim')) { - return false; - } - const tokenCount = await __unsafePrisma.scimToken.count({ where: { orgId } }); - return tokenCount > 0; -}; - export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { if (!user.id) { logger.error("User ID is undefined on user creation"); @@ -133,7 +120,10 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { // When SCIM is enabled, auto-join is suppressed entirely: the IdP is the // source of truth, so a login for a user the IdP hasn't provisioned creates // the User row but no membership (they're denied until SCIM provisions them). - else if (!defaultOrg.memberApprovalRequired && !(await isScimEnabled(SINGLE_TENANT_ORG_ID))) { + else if ( + !isMemberApprovalRequired(defaultOrg) && + !(await isScimEnabled(defaultOrg.id)) + ) { // 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); From 199c2bd3016560e15892f4066d05a7e2fe5feac3 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 20 Jun 2026 14:28:42 -0700 Subject: [PATCH 05/17] refactor --- .../migration.sql | 3 + packages/db/prisma/schema.prisma | 2 + packages/web/src/__mocks__/prisma.ts | 2 +- packages/web/src/actions.ts | 122 +--- .../components/defaultSidebar/index.tsx | 2 +- .../components/submitAccountRequestButton.tsx | 62 -- .../(app)/components/submitJoinRequest.tsx | 43 -- packages/web/src/app/(app)/layout.tsx | 43 +- .../settings/components/settingsCard.tsx | 8 +- .../web/src/app/(app)/settings/layout.tsx | 2 +- .../members/components/inviteMemberCard.tsx | 2 +- .../members/components/invitesList.tsx | 2 +- .../members/components/membersList.tsx | 33 +- .../members/components/requestsList.tsx | 2 +- .../src/app/(app)/settings/members/page.tsx | 22 +- .../inviteLinkEnabledSettingsCard.tsx | 11 +- .../memberApprovalRequiredSettingsCard.tsx | 6 +- .../components/scimEnabledSettingsCard.tsx | 117 ++++ .../src/app/(app)/settings/security/page.tsx | 14 +- .../(server)/ee/scim/v2/Users/[id]/route.ts | 56 +- .../api/(server)/ee/scim/v2/Users/route.ts | 71 ++- .../app/components/joinOrganizationButton.tsx | 54 -- .../app/components/joinOrganizationCard.tsx | 23 - .../src/app/components/logoutEscapeHatch.tsx | 26 +- packages/web/src/app/invite/actions.ts | 206 ------- packages/web/src/app/invite/page.tsx | 14 +- .../redeem/components/acceptInviteCard.tsx | 2 +- packages/web/src/app/redeem/page.tsx | 22 +- packages/web/src/auth.ts | 2 +- .../web/src/ee/features/membership/actions.ts | 54 ++ packages/web/src/ee/features/scim/actions.ts | 40 ++ .../web/src/ee/features/scim/membership.ts | 119 ---- .../web/src/ee/features/scim/schemas.test.ts | 124 ++++ packages/web/src/ee/features/scim/schemas.ts | 104 ++++ .../web/src/ee/features/scim/withScimAuth.ts | 6 + packages/web/src/ee/features/sso/sso.ts | 2 +- .../src/ee/features/userManagement/actions.ts | 156 ----- .../membership/actions/accountRequests.ts | 278 +++++++++ .../src/features/membership/actions/index.ts | 3 + .../features/membership/actions/invites.ts | 436 ++++++++++++++ .../features/membership/actions/members.ts | 66 +++ .../components/deactivatedMemberBadge.tsx | 23 + .../components/joinOrganizationCard.tsx | 72 +++ .../components/managedByScimBadge.tsx | 32 ++ .../components/managedByScimNotice.tsx | 15 + .../components/notProvisionedCard.tsx | 41 ++ .../components/pendingApprovalCard.tsx} | 10 +- .../components/submitJoinRequestCard.tsx | 93 +++ .../web/src/features/membership/errors.ts | 32 ++ .../web/src/features/membership/logger.ts | 3 + .../membership/membership.service.test.ts | 334 +++++++++++ .../features/membership/membership.service.ts | 332 +++++++++++ .../src/features/membership/onCreateUser.ts | 120 ++++ packages/web/src/features/membership/utils.ts | 43 ++ packages/web/src/features/scim/utils.ts | 6 +- .../src/features/userManagement/actions.ts | 532 ------------------ .../userManagement/membershipMutations.ts | 58 -- packages/web/src/lib/authUtils.ts | 292 ---------- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/middleware/withAuth.test.ts | 120 ++++ 60 files changed, 2714 insertions(+), 1807 deletions(-) rename packages/db/prisma/migrations/{20260612235524_add_scim_users_support => 20260619214548_add_scim_users_support}/migration.sql (89%) delete mode 100644 packages/web/src/app/(app)/components/submitAccountRequestButton.tsx delete mode 100644 packages/web/src/app/(app)/components/submitJoinRequest.tsx create mode 100644 packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx delete mode 100644 packages/web/src/app/components/joinOrganizationButton.tsx delete mode 100644 packages/web/src/app/components/joinOrganizationCard.tsx delete mode 100644 packages/web/src/app/invite/actions.ts create mode 100644 packages/web/src/ee/features/membership/actions.ts delete mode 100644 packages/web/src/ee/features/scim/membership.ts create mode 100644 packages/web/src/ee/features/scim/schemas.test.ts delete mode 100644 packages/web/src/ee/features/userManagement/actions.ts create mode 100644 packages/web/src/features/membership/actions/accountRequests.ts create mode 100644 packages/web/src/features/membership/actions/index.ts create mode 100644 packages/web/src/features/membership/actions/invites.ts create mode 100644 packages/web/src/features/membership/actions/members.ts create mode 100644 packages/web/src/features/membership/components/deactivatedMemberBadge.tsx create mode 100644 packages/web/src/features/membership/components/joinOrganizationCard.tsx create mode 100644 packages/web/src/features/membership/components/managedByScimBadge.tsx create mode 100644 packages/web/src/features/membership/components/managedByScimNotice.tsx create mode 100644 packages/web/src/features/membership/components/notProvisionedCard.tsx rename packages/web/src/{app/(app)/components/pendingApproval.tsx => features/membership/components/pendingApprovalCard.tsx} (91%) create mode 100644 packages/web/src/features/membership/components/submitJoinRequestCard.tsx create mode 100644 packages/web/src/features/membership/errors.ts create mode 100644 packages/web/src/features/membership/logger.ts create mode 100644 packages/web/src/features/membership/membership.service.test.ts create mode 100644 packages/web/src/features/membership/membership.service.ts create mode 100644 packages/web/src/features/membership/onCreateUser.ts create mode 100644 packages/web/src/features/membership/utils.ts delete mode 100644 packages/web/src/features/userManagement/actions.ts delete mode 100644 packages/web/src/features/userManagement/membershipMutations.ts delete mode 100644 packages/web/src/lib/authUtils.ts diff --git a/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql similarity index 89% rename from packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql rename to packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql index fc818fcee..7259bda3e 100644 --- a/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql +++ b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql @@ -1,3 +1,6 @@ +-- 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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f7659150d..0a336ae3d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -276,6 +276,8 @@ model Org { 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` diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 5e5c28682..556d896fc 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -17,7 +17,7 @@ export const MOCK_ORG: Org = { updatedAt: new Date(), isOnboarded: true, imageUrl: null, - metadata: null, + isScimEnabled: false, memberApprovalRequired: false, isCredentialsLoginEnabled: true, isEmailCodeLoginEnabled: false, diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index efd996c1b..c64979173 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,26 +1,20 @@ 'use server'; import { createAudit } from "@/ee/features/audit/audit"; -import { env, getSMTPConnectionURL } from "@sourcebot/shared"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; -import { __unsafePrisma } from "@/prisma"; -import { render } from "@react-email/components"; -import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { sew } from "@/middleware/sew"; import { ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db"; -import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { createLogger, env, generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; -import { createTransport } from "nodemailer"; -import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from "./lib/constants"; -import { RepositoryQuery } from "./lib/types"; -import { getAuthenticatedUser, withAuth, withOptionalAuth } from "./middleware/withAuth"; import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; -import { sew } from "@/middleware/sew"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; +import { RepositoryQuery } from "./lib/types"; +import { withAuth, withOptionalAuth } from "./middleware/withAuth"; const logger = createLogger('web-actions'); @@ -375,110 +369,6 @@ export const getRepoInfoByName = async (repoName: string) => sew(() => } })); -// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth -export const createAccountRequest = async () => sew(async () => { - const authResult = await getAuthenticatedUser(); - if (!authResult) { - return notAuthenticated(); - } - - const { user } = authResult; - - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return notFound("Organization not found"); - } - - const existingRequest = await __unsafePrisma.accountRequest.findUnique({ - where: { - requestedById_orgId: { - requestedById: user.id, - orgId: org.id, - }, - }, - }); - - if (existingRequest) { - logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`); - return { - success: true, - existingRequest: true, - } - } - - if (!existingRequest) { - await __unsafePrisma.accountRequest.create({ - data: { - requestedById: user.id, - orgId: org.id, - }, - }); - - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - // TODO: This is needed because we can't fetch the origin from the request headers when this is called - // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) - const deploymentUrl = env.AUTH_URL; - - const owners = await __unsafePrisma.user.findMany({ - where: { - orgs: { - some: { - orgId: org.id, - role: "OWNER", - }, - }, - }, - }); - - if (owners.length === 0) { - logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`); - } else { - const html = await render(JoinRequestSubmittedEmail({ - baseUrl: deploymentUrl, - requestor: { - name: user.name ?? undefined, - email: user.email, - avatarUrl: user.image ?? undefined, - }, - orgName: org.name, - orgImageUrl: org.imageUrl ?? undefined, - })); - - const ownerEmails = owners - .map((owner) => owner.email) - .filter((email): email is string => email !== null); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: ownerEmails, - from: env.EMAIL_FROM_ADDRESS, - subject: `New account request for ${org.name} on Sourcebot`, - html, - text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`); - } - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); - } - } - - return { - success: true, - existingRequest: false, - } -}); - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index 39edf49df..04ff3caf6 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -3,7 +3,7 @@ import { auth } from "@/auth"; import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; import { HomeView } from "@/hooks/useHomeView"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; diff --git a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx b/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx deleted file mode 100644 index 85398a7db..000000000 --- a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { Clock } from "lucide-react" -import { useState } from "react" -import { useToast } from "@/components/hooks/use-toast" -import { createAccountRequest } from "@/actions" -import { isServiceError } from "@/lib/utils" -import { useRouter } from "next/navigation" - - -export function SubmitAccountRequestButton() { - const { toast } = useToast() - const router = useRouter() - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleSubmit = async () => { - setIsSubmitting(true) - const result = await createAccountRequest() - if (!isServiceError(result)) { - if (result.existingRequest) { - toast({ - title: "Request Already Submitted", - description: "Your request to join the organization has already been submitted. Please wait for it to be approved.", - variant: "default", - }) - } else { - toast({ - title: "Request Submitted", - description: "Your request to join the organization has been submitted.", - variant: "default", - }) - } - // Refresh the page to trigger layout re-render and show PendingApprovalCard - router.refresh() - } else { - toast({ - title: "Failed to Submit", - description: `There was an error submitting your request. Reason: ${result.message}`, - variant: "destructive", - }) - } - setIsSubmitting(false) - } - - return ( -
{ - e.preventDefault(); - handleSubmit(); - }}> - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/components/submitJoinRequest.tsx b/packages/web/src/app/(app)/components/submitJoinRequest.tsx deleted file mode 100644 index fdfdc0a20..000000000 --- a/packages/web/src/app/(app)/components/submitJoinRequest.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" -import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import { SubmitAccountRequestButton } from "./submitAccountRequestButton" - -export const SubmitJoinRequest = async () => { - return ( -
- - -
-
- - -
-
- - - -
- -
-

- Request Access -

-

- Submit a request to join this organization -

-
-
- -
-
- -
-
-
-
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index ec355a56d..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 ( -
- - -
- ) - } else { - const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ - where: { - orgId: org.id, - requestedById: session.user.id - } - }); - - if (hasPendingApproval) { - return - } else { - return + return ; + } + + const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ + where: { + orgId: org.id, + requestedById: session.user.id } + }); + + if (hasPendingApproval) { + return + } else { + return } } diff --git a/packages/web/src/app/(app)/settings/components/settingsCard.tsx b/packages/web/src/app/(app)/settings/components/settingsCard.tsx index 6649e002b..a9c267506 100644 --- a/packages/web/src/app/(app)/settings/components/settingsCard.tsx +++ b/packages/web/src/app/(app)/settings/components/settingsCard.tsx @@ -30,15 +30,19 @@ interface BasicSettingsCardProps { description?: string; children: ReactNode; footer?: ReactNode; + badge?: ReactNode; className?: string; } -export function BasicSettingsCard({ name, description, children, footer, className }: BasicSettingsCardProps) { +export function BasicSettingsCard({ name, description, children, footer, badge, className }: BasicSettingsCardProps) { return (
-

{name}

+
+

{name}

+ {badge} +
{description && (

{description}

)} diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index cf640ac11..442f9a546 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { isServiceError } from "@/lib/utils"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; import { env } from "@sourcebot/shared"; diff --git a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx index b81c16996..9d1eff8c1 100644 --- a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx @@ -11,7 +11,7 @@ import { z } from "zod"; import { PlusCircleIcon, Loader2, AlertTriangle } from "lucide-react"; import { OrgRole } from "@prisma/client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { createInvites } from "@/features/userManagement/actions"; +import { createInvites } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; diff --git a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx index b5ebb4883..6492bd2f1 100644 --- a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx +++ b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx @@ -11,7 +11,7 @@ import { createPathWithQueryParams, isServiceError } from "@/lib/utils"; import { UserAvatar } from "@/components/userAvatar"; import { Copy, MoreVertical, Search } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; -import { cancelInvite } from "@/features/userManagement/actions"; +import { cancelInvite } from "@/features/membership/actions"; import { useRouter } from "next/navigation"; import useCaptureEvent from "@/hooks/useCaptureEvent"; interface Invite { diff --git a/packages/web/src/app/(app)/settings/members/components/membersList.tsx b/packages/web/src/app/(app)/settings/members/components/membersList.tsx index 9976f11de..eb64970d2 100644 --- a/packages/web/src/app/(app)/settings/members/components/membersList.tsx +++ b/packages/web/src/app/(app)/settings/members/components/membersList.tsx @@ -9,14 +9,16 @@ import { useCallback, useMemo, useState } from "react"; import { OrgRole } from "@prisma/client"; import { UserAvatar } from "@/components/userAvatar"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions"; -import { leaveOrg, removeMemberFromOrg } from "@/features/userManagement/actions"; +import { promoteToOwner, demoteToMember } from "@/ee/features/membership/actions"; +import { leaveOrg, removeMemberFromOrg } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useRouter } from "next/navigation"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import Link from "next/link"; +import { ManagedByScimBadge } from "@/features/membership/components/managedByScimBadge"; +import { DeactivatedMemberBadge } from "@/features/membership/components/deactivatedMemberBadge"; type Member = { id: string @@ -25,19 +27,20 @@ type Member = { role: OrgRole joinedAt: Date avatarUrl?: string + scimManaged?: boolean + isActive?: boolean } export interface MembersListProps { members: Member[], currentUserId: string, currentUserRole: OrgRole, - orgName: string, hasOrgManagement: boolean, } const ROLES_AND_PERMISSIONS_DOCS_LINK = "https://docs.sourcebot.dev/docs/configuration/auth/roles-and-permissions" -export const MembersList = ({ members, currentUserId, currentUserRole, orgName, hasOrgManagement }: MembersListProps) => { +export const MembersList = ({ members, currentUserId, currentUserRole, hasOrgManagement }: MembersListProps) => { const [searchQuery, setSearchQuery] = useState("") const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all") const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") @@ -64,6 +67,12 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName, return matchesSearch && matchesRole; }) .sort((a, b) => { + // Deactivated members sink to the bottom, regardless of date sort. + const aActive = a.isActive !== false; + const bActive = b.isActive !== false; + if (aActive !== bActive) { + return aActive ? -1 : 1; + } return dateSort === "newest" ? b.joinedAt.getTime() - a.joinedAt.getTime() : a.joinedAt.getTime() - b.joinedAt.getTime() @@ -197,18 +206,22 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName,
) : ( filteredMembers.map((member) => ( -
-
+
+
-
-
{member.name}
-
{member.email}
+
+
+ {member.name} + {member.isActive === false && } + {member.scimManaged && } +
+
{member.email}
-
+
{member.role.toLowerCase()} diff --git a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx index 2412f0eb7..4fb066880 100644 --- a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx +++ b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx @@ -10,7 +10,7 @@ import { isServiceError } from "@/lib/utils"; import { UserAvatar } from "@/components/userAvatar"; import { CheckCircle, Search, XCircle } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; -import { rejectAccountRequest, approveAccountRequest } from "@/features/userManagement/actions"; +import { rejectAccountRequest, approveAccountRequest } from "@/features/membership/actions"; import { useRouter } from "next/navigation"; import useCaptureEvent from "@/hooks/useCaptureEvent"; diff --git a/packages/web/src/app/(app)/settings/members/page.tsx b/packages/web/src/app/(app)/settings/members/page.tsx index d991b5df6..33762d41d 100644 --- a/packages/web/src/app/(app)/settings/members/page.tsx +++ b/packages/web/src/app/(app)/settings/members/page.tsx @@ -1,5 +1,5 @@ import { MembersList } from "./components/membersList"; -import { getOrgMembers, getOrgInvites, getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgInvites, getOrgMembers, getOrgAccountRequests} from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { InviteMemberCard } from "./components/inviteMemberCard"; import { Tabs, TabsContent } from "@/components/ui/tabs"; @@ -12,7 +12,9 @@ import { OrgRole } from "@sourcebot/db"; import { NotificationDot } from "../../components/notificationDot"; import { Badge } from "@/components/ui/badge"; import { authenticatedPage } from "@/middleware/authenticatedPage"; -import { orgHasAvailability } from "@/lib/authUtils"; +import { orgHasAvailability } from "@/features/membership/utils"; +import { isScimEnabled } from "@/features/scim/utils"; +import { ManagedByScimNotice } from "@/features/membership/components/managedByScimNotice"; import { getSeatCap } from "@sourcebot/shared"; type MembersSettingsPageProps = { @@ -47,6 +49,7 @@ export default authenticatedPage(async ({ org, role, u const hasAvailability = await orgHasAvailability(org.id); const seatCap = getSeatCap(); + const scimEnabled = await isScimEnabled(org); return (
@@ -67,10 +70,16 @@ export default authenticatedPage(async ({ org, role, u )}
- + {scimEnabled ? ( + + SCIM provisioning is enabled. Members are provisioned through your identity provider. + + ) : ( + + )}
@@ -128,7 +137,6 @@ export default authenticatedPage(async ({ org, role, u members={members} currentUserId={user.id} currentUserRole={role} - orgName={org.name} hasOrgManagement={await hasEntitlement('org-management')} /> diff --git a/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx index 863fbe182..b43be38a7 100644 --- a/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx +++ b/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx @@ -9,13 +9,15 @@ import { useToast } from "@/components/hooks/use-toast" import { setInviteLinkEnabled } from "@/app/(app)/settings/security/actions" import { cn, isServiceError } from "@/lib/utils" import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { ManagedByScimBadge } from "@/features/membership/components/managedByScimBadge" interface InviteLinkEnabledSettingsCardProps { inviteLinkEnabled: boolean inviteLink: string | null + scimManaged?: boolean } -export function InviteLinkEnabledSettingsCard({ inviteLinkEnabled, inviteLink }: InviteLinkEnabledSettingsCardProps) { +export function InviteLinkEnabledSettingsCard({ inviteLinkEnabled, inviteLink, scimManaged = false }: InviteLinkEnabledSettingsCardProps) { const [enabled, setEnabled] = useState(inviteLinkEnabled) const [isLoading, setIsLoading] = useState(false) const [copied, setCopied] = useState(false) @@ -70,7 +72,8 @@ export function InviteLinkEnabledSettingsCard({ inviteLinkEnabled, inviteLink }: : undefined} + footer={scimManaged ? undefined : (
- } + )} > ) diff --git a/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx index 3fca0e003..cd797df78 100644 --- a/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx +++ b/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx @@ -6,13 +6,16 @@ import { setMemberApprovalRequired } from "@/app/(app)/settings/security/actions import { isServiceError } from "@/lib/utils" import { useToast } from "@/components/hooks/use-toast" import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { ManagedByScimBadge } from "@/features/membership/components/managedByScimBadge" interface MemberApprovalRequiredSettingsCardProps { memberApprovalRequired: boolean + scimManaged?: boolean } export const MemberApprovalRequiredSettingsCard = ({ memberApprovalRequired, + scimManaged = false, }: MemberApprovalRequiredSettingsCardProps) => { const [enabled, setEnabled] = useState(memberApprovalRequired) const [isLoading, setIsLoading] = useState(false) @@ -49,11 +52,12 @@ export const MemberApprovalRequiredSettingsCard = ({ : undefined} > ) diff --git a/packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx new file mode 100644 index 000000000..688ae6d9e --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { setScimEnabled } from "@/ee/features/scim/actions" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" +import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { useRouter } from "next/navigation" + +interface ScimEnabledSettingsCardProps { + isScimEnabled: boolean +} + +export const ScimEnabledSettingsCard = ({ + isScimEnabled, +}: ScimEnabledSettingsCardProps) => { + const [enabled, setEnabled] = useState(isScimEnabled) + const [isLoading, setIsLoading] = useState(false) + // The toggle value awaiting confirmation; null when no dialog is open. + const [pendingChange, setPendingChange] = useState(null) + const { toast } = useToast() + const router = useRouter() + + // Both directions change how membership is governed, so confirm either way. + const handleToggle = (checked: boolean) => { + setPendingChange(checked) + } + + const applyToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setScimEnabled(checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message, + variant: "destructive", + }) + return + } + + setEnabled(checked) + router.refresh() + } catch (error) { + console.error("Error updating SCIM provisioning setting:", error) + toast({ + title: "Error", + description: "Failed to update SCIM provisioning setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( + <> + + + + + { + if (!open) { + setPendingChange(null) + } + }} + > + + + + {pendingChange === false ? "Disable SCIM provisioning?" : "Enable SCIM provisioning?"} + + + {pendingChange === false + ? "Members will no longer be synced from your identity provider. Existing members keep their access. Your SCIM tokens aren't revoked, but they'll stop working until you re-enable SCIM." + : "Your identity provider will become the source of truth for membership. While SCIM is enabled, invite links, member approval, and join requests are disabled. Members can only be added or removed through your IdP."} + + + + Cancel + { + if (pendingChange !== null) { + void applyToggle(pendingChange) + } + }} + disabled={isLoading} + > + {pendingChange === false ? "Disable" : "Enable"} + + + + + + ) +} diff --git a/packages/web/src/app/(app)/settings/security/page.tsx b/packages/web/src/app/(app)/settings/security/page.tsx index 06a7dae5f..e1d56ea85 100644 --- a/packages/web/src/app/(app)/settings/security/page.tsx +++ b/packages/web/src/app/(app)/settings/security/page.tsx @@ -6,6 +6,7 @@ import { EmailCodeLoginEnabledSettingsCard } from "./components/emailCodeLoginEn import { IdentityProviderSettingsCard } from "./components/identityProviderSettingsCard"; import { IdentityProviderUpsellCard } from "./components/identityProviderUpsellCard"; import { ScimProvisioningSettings } from "./components/scimProvisioningSettings"; +import { ScimEnabledSettingsCard } from "./components/scimEnabledSettingsCard"; import { ScimUpsellCard } from "./components/scimUpsellCard"; import { getScimTokens } from "@/ee/features/scim/actions"; import { UpgradeBadge } from "@/app/(app)/@sidebar/components/upgradeBadge"; @@ -18,6 +19,7 @@ import { env, getSMTPConnectionURL, isCredentialsLoginEnabled, isEmailCodeLoginE import { SettingsCardGroup } from "../components/settingsCard"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Info } from "lucide-react"; +import { isScimEnabled } from "@/features/scim/utils"; export default authenticatedPage(async ({ org }) => { const anonymousAccessEnabled = await isAnonymousAccessEnabled(); @@ -29,6 +31,7 @@ export default authenticatedPage(async ({ org }) => { const scimBaseUrl = `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2`; const scimTokensResult = hasScimEntitlement ? await getScimTokens() : []; const scimTokens = isServiceError(scimTokensResult) ? [] : scimTokensResult; + const scimEnabled = await isScimEnabled(org) return ( @@ -53,9 +56,11 @@ export default authenticatedPage(async ({ org }) => { { {!hasScimEntitlement ? ( ) : ( - + <> + + + + {scimEnabled && ( + + )} + )}
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 index 9511577c9..d05149ec7 100644 --- 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 @@ -1,8 +1,9 @@ import { apiHandler } from '@/lib/apiHandler'; -import { deactivateScimMember, reactivateScimMember } from '@/ee/features/scim/membership'; +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, @@ -17,15 +18,15 @@ const loadMembership = (prisma: ScimAuthContext['prisma'], orgId: number, userId include: { user: true }, }); -// Applies an active state transition, running the deactivate/reactivate helper -// only when the value actually changes. Returns a SCIM error Response on failure. +// 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 = next - ? await reactivateScimMember(orgId, userId) - : await deactivateScimMember(orgId, userId); + const result = await setMemberActive(orgId, userId, next, { + actor: { id: 'scim', type: 'scim_token' }, + }); if (isServiceError(result)) { return scimError(result.statusCode, result.message); } @@ -88,27 +89,23 @@ export const PATCH = apiHandler(async (request: NextRequest, { params }: { param return scimError(400, 'Invalid SCIM PatchOp payload', 'invalidValue'); } - // Extract the desired `active` value. IdPs send it two ways: - // { op: "replace", path: "active", value: false } - // { op: "replace", value: { active: false } } - // `op` is case-insensitive. Other operations are ignored (lenient). - let nextActive: boolean | undefined; - for (const operation of parsed.data.Operations) { - const op = operation.op.toLowerCase(); - if (op !== 'replace' && op !== 'add') { - continue; - } - if (operation.path === 'active') { - nextActive = coerceActive(operation.value); - } else if (!operation.path && operation.value && typeof operation.value === 'object') { - const maybe = (operation.value as Record).active; - if (maybe !== undefined) { - nextActive = coerceActive(maybe); - } - } + // 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, nextActive); + const activeError = await applyActive(org.id, id, membership.isActive, changes.active); if (activeError) { return activeError; } @@ -125,9 +122,12 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: { para if (!membership) { return scimError(404, `User ${id} not found`); } - // DELETE is treated as deactivation, not a hard delete, so the IdP can - // reactivate later and we preserve the user's data/history. - const result = await deactivateScimMember(org.id, id); + // 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); } 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 index 77cdf6b73..03635a614 100644 --- 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 @@ -1,6 +1,5 @@ import { apiHandler } from '@/lib/apiHandler'; -import { orgHasAvailability } from '@/lib/authUtils'; -import { reactivateScimMember } from '@/ee/features/scim/membership'; +import { addMember, setMemberActive } from '@/features/membership/membership.service'; import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper'; import { coerceActive, @@ -9,6 +8,7 @@ import { 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'; @@ -60,51 +60,62 @@ export const POST = apiHandler(async (request: NextRequest) => const payload = parsed.data; const email = resolveEmail(payload); const name = payload.name?.formatted ?? payload.displayName ?? undefined; - const isActive = coerceActive(payload.active) ?? true; + const desiredActive = coerceActive(payload.active) ?? true; - // Find-or-create the user by email. We deliberately bypass `onCreateUser` - // (its JIT/bootstrap logic is for interactive login, not provisioning). + // 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 } }, - include: { user: true }, }); - if (existing) { - if (existing.isActive) { - return scimError(409, 'User is already a member of this organization', 'uniqueness'); - } - // Re-provisioning a previously deactivated user → reactivate. - const result = await reactivateScimMember(org.id, user.id, payload.externalId); + // 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)) { - return scimError(result.statusCode, result.message); + const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined; + return scimError(result.statusCode, result.message, scimType); } - const refreshed = await prisma.userToOrg.findUniqueOrThrow({ - where: { orgId_userId: { orgId: org.id, userId: user.id } }, - include: { user: true }, + httpStatus = 200; + } else { + const result = await addMember(org.id, user.id, { + actor: scimActor, + role: OrgRole.MEMBER, + scimExternalId: payload.externalId, }); - return scimJson(toScimUser(refreshed), 200, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + if (isServiceError(result)) { + const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined; + return scimError(result.statusCode, result.message, scimType); + } + httpStatus = 201; } - // New membership: enforce the seat cap before creating. - if (isActive && !(await orgHasAvailability(org.id))) { - return scimError(400, 'Organization seat limit reached', 'tooMany'); + // 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.create({ - data: { - userId: user.id, - orgId: org.id, - role: OrgRole.MEMBER, - isActive, - scimExternalId: payload.externalId, - }, + const membership = await prisma.userToOrg.findUniqueOrThrow({ + where: { orgId_userId: { orgId: org.id, userId: user.id } }, include: { user: true }, }); - - return scimJson(toScimUser(membership), 201, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + 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 (
-
{ - "use server"; - await signOut({ + -
+ + 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 4486a4add..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'; 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 index 657c33e6f..0d3d31276 100644 --- a/packages/web/src/ee/features/scim/actions.ts +++ b/packages/web/src/ee/features/scim/actions.ts @@ -30,6 +30,46 @@ export const getScimBaseUrl = async (): Promise<{ baseUrl: string } | ServiceErr 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 () => { diff --git a/packages/web/src/ee/features/scim/membership.ts b/packages/web/src/ee/features/scim/membership.ts deleted file mode 100644 index d6018f581..000000000 --- a/packages/web/src/ee/features/scim/membership.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createAudit } from "@/ee/features/audit/audit"; -import { orgHasAvailability } from "@/lib/authUtils"; -import { ErrorCode } from "@/lib/errorCodes"; -import { notFound, ServiceError } from "@/lib/serviceError"; -import { isServiceError } from "@/lib/utils"; -import { __unsafePrisma } from "@/prisma"; -import { syncWithLighthouse } from "@/features/billing/servicePing"; -import { - invalidateAllSessionsForUser, - revokeUserApiKeysInOrg, - revokeUserOAuthTokens, -} from "@/features/userManagement/membershipMutations"; -import { OrgRole, Prisma } from "@sourcebot/db"; -import { StatusCodes } from "http-status-codes"; - -/** - * SCIM soft-deactivation. Mirrors `_removeUserFromOrg` but, instead of deleting - * the membership, sets `isActive = false` so the IdP can later reactivate it. - * Bumps `sessionVersion` (forcing logout on next request) and revokes the - * user's API keys + OAuth tokens so a deactivated user has no path back in. - */ -export const deactivateScimMember = async (orgId: number, userId: string): Promise => { - const result = await __unsafePrisma.$transaction(async (tx) => { - const target = await tx.userToOrg.findUnique({ - where: { orgId_userId: { orgId, userId } }, - }); - - if (!target) { - return notFound("Member not found in this organization"); - } - - // Refuse to deactivate the last active owner — doing so would lock - // everyone out of org administration. - if (target.role === OrgRole.OWNER && target.isActive) { - const activeOwnerCount = await tx.userToOrg.count({ - where: { orgId, role: OrgRole.OWNER, isActive: true }, - }); - - if (activeOwnerCount <= 1) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED, - message: "Cannot deactivate the last owner of the organization.", - } satisfies ServiceError; - } - } - - await invalidateAllSessionsForUser(tx, userId); - await revokeUserOAuthTokens(tx, userId); - await revokeUserApiKeysInOrg(tx, userId, orgId); - - await tx.userToOrg.update({ - where: { orgId_userId: { orgId, userId } }, - data: { isActive: false }, - }); - - return null; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); - - if (!isServiceError(result)) { - await syncWithLighthouse(orgId).catch(() => { /* ignore error */ }); - await createAudit({ - action: "org.member_deactivated", - actor: { id: "scim", type: "scim_token" }, - target: { id: userId, type: "user" }, - orgId, - }); - } - - return result; -}; - -/** - * SCIM reactivation: flips `isActive` back to true. Re-checks seat availability - * first, since deactivated users free their seat and it may have been filled. - * Optionally updates the stored IdP `externalId`. - */ -export const reactivateScimMember = async ( - orgId: number, - userId: string, - scimExternalId?: string, -): Promise => { - const target = await __unsafePrisma.userToOrg.findUnique({ - where: { orgId_userId: { orgId, userId } }, - }); - - if (!target) { - return notFound("Member not found in this organization"); - } - - if (!target.isActive) { - const hasAvailability = await orgHasAvailability(orgId); - if (!hasAvailability) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "Organization is at max capacity", - } satisfies ServiceError; - } - } - - await __unsafePrisma.userToOrg.update({ - where: { orgId_userId: { orgId, userId } }, - data: { - isActive: true, - ...(scimExternalId ? { scimExternalId } : {}), - }, - }); - - await syncWithLighthouse(orgId).catch(() => { /* ignore error */ }); - await createAudit({ - action: "org.member_reactivated", - actor: { id: "scim", type: "scim_token" }, - target: { id: userId, type: "user" }, - orgId, - }); - - return null; -}; 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 index dee7351b8..15cd7c03f 100644 --- a/packages/web/src/ee/features/scim/schemas.ts +++ b/packages/web/src/ee/features/scim/schemas.ts @@ -65,6 +65,110 @@ export const resolveEmail = (payload: ScimUserCreate): string => { 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 = diff --git a/packages/web/src/ee/features/scim/withScimAuth.ts b/packages/web/src/ee/features/scim/withScimAuth.ts index 4f25f0b19..c10d5b13d 100644 --- a/packages/web/src/ee/features/scim/withScimAuth.ts +++ b/packages/web/src/ee/features/scim/withScimAuth.ts @@ -52,6 +52,12 @@ export const withScimAuth = async ( 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({ 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 index f117cd846..a9c0fc680 100644 --- a/packages/web/src/features/scim/utils.ts +++ b/packages/web/src/features/scim/utils.ts @@ -1,10 +1,10 @@ import { __unsafePrisma } from "@/prisma"; import { hasEntitlement } from "@/lib/entitlements"; +import { Org } from "@sourcebot/db"; -export const isScimEnabled = async (orgId: number): Promise => { +export const isScimEnabled = async (org: Org): Promise => { if (!await hasEntitlement('scim')) { return false; } - const tokenCount = await __unsafePrisma.scimToken.count({ where: { orgId } }); - return tokenCount > 0; + 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 fb00de77e..000000000 --- a/packages/web/src/features/userManagement/actions.ts +++ /dev/null @@ -1,532 +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 { invalidateAllSessionsForUser, revokeUserApiKeysInOrg, revokeUserOAuthTokens } from "./membershipMutations"; -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, - isActive: member.isActive, - })); - }))); - -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, - })); - }))); - - diff --git a/packages/web/src/features/userManagement/membershipMutations.ts b/packages/web/src/features/userManagement/membershipMutations.ts deleted file mode 100644 index 3b55f5bed..000000000 --- a/packages/web/src/features/userManagement/membershipMutations.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Prisma } from "@sourcebot/db"; - -/** - * Low-level membership mutation helpers shared between user-management server - * actions and SCIM provisioning. These are plain functions (not server - * actions) so they can be imported by both `actions.ts` and the SCIM feature; - * they must NOT live in a `'use server'` module. - */ - -/** - * 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. - */ -export const invalidateAllSessionsForUser = async ( - prisma: Prisma.TransactionClient, - userId: string, -): Promise => { - await prisma.user.update({ - where: { id: userId }, - data: { sessionVersion: { increment: 1 } }, - }); -}; - -export const revokeUserApiKeysInOrg = async ( - prisma: Prisma.TransactionClient, - userId: string, - orgId: number, -): Promise => { - await prisma.apiKey.deleteMany({ - where: { - createdById: userId, - orgId, - } - }); -}; - -export 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 dfe03d6c0..000000000 --- a/packages/web/src/lib/authUtils.ts +++ /dev/null @@ -1,292 +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, isMemberApprovalRequired } 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"; -import { isScimEnabled } from "@/features/scim/utils"; - -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. - // - // When SCIM is enabled, auto-join is suppressed entirely: the IdP is the - // source of truth, so a login for a user the IdP hasn't provisioned creates - // the User row but no membership (they're denied until SCIM provisions them). - else if ( - !isMemberApprovalRequired(defaultOrg) && - !(await isScimEnabled(defaultOrg.id)) - ) { - // 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 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 memberCount = await __unsafePrisma.userToOrg.count({ - where: { - orgId, - isActive: true, - }, - }); - - if ( - seatCap && - memberCount >= seatCap - ) { - logger.error(`orgHasAvailability: org ${orgId} 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/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 6fe06a4f1..da6975c18 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -409,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'; @@ -775,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', () => { From d50d008bb55f2a65a03e3795028456a627486a8d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 23 Jun 2026 11:57:29 -0700 Subject: [PATCH 06/17] Add isActive to db fetches where necessary --- docs/api-reference/sourcebot-public.openapi.json | 4 ++++ packages/web/src/app/(app)/layout.tsx | 11 ++++++----- packages/web/src/app/(app)/settings/license/page.tsx | 9 +++++++-- .../src/app/api/(server)/ee/askmcp/callback/route.ts | 1 + .../(server)/ee/chat/[chatId]/searchMembers/route.ts | 1 + packages/web/src/app/api/(server)/ee/users/route.ts | 1 + packages/web/src/app/invite/page.tsx | 3 ++- packages/web/src/app/onboard/page.tsx | 3 ++- packages/web/src/app/redeem/page.tsx | 3 ++- packages/web/src/features/billing/actions.ts | 1 + packages/web/src/features/billing/servicePing.ts | 1 + packages/web/src/features/chat/actions.ts | 1 + packages/web/src/openapi/publicApiSchemas.ts | 1 + 13 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 126baf4c3..72920c987 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1148,6 +1148,9 @@ "MEMBER" ] }, + "isActive": { + "type": "boolean" + }, "createdAt": { "type": "string", "format": "date-time" @@ -1163,6 +1166,7 @@ "name", "email", "role", + "isActive", "createdAt", "lastActivityAt" ] diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index cd7cb1c4e..0deb0a65d 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -61,12 +61,13 @@ export default async function Layout(props: LayoutProps) { // If the user is authenticated, we must check if they're a member of the org if (session) { - const membership = await __unsafePrisma.userToOrg.findUnique({ + const activeMembership = await __unsafePrisma.userToOrg.findUnique({ where: { orgId_userId: { orgId: org.id, - userId: session.user.id - } + userId: session.user.id, + }, + isActive: true, }, include: { user: true @@ -77,7 +78,7 @@ export default async function Layout(props: LayoutProps) { // 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. - if (!membership) { + if (!activeMembership) { if (await isScimEnabled(org)) { return ; } @@ -100,7 +101,7 @@ export default async function Layout(props: LayoutProps) { } } - role = membership.role; + role = activeMembership.role; } else { // If the user isn't authenticated and anonymous access isn't enabled, we need to redirect them to the login page. if (!anonymousAccessEnabled) { diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index 419422ee8..95c73d02f 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -49,7 +49,12 @@ export default authenticatedPage(async ({ prisma, org }, props : await prisma.license.findUnique({ where: { orgId: org.id } }); const yearlyTermStatus = getYearlyTermStatus(license); - const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } }); + const currentActiveUserCount = await prisma.userToOrg.count({ + where: { + orgId: org.id, + isActive: true, + }, + }); const invoicesResult = license ? await getAllInvoices() : null; const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : []; @@ -97,7 +102,7 @@ export default authenticatedPage(async ({ prisma, org }, props && !isOnlineLicenseInactive && yearlyTermStatus && ( )} diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index 4d7879d0c..762242893 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -153,6 +153,7 @@ export const GET = apiHandler(async (request: NextRequest) => { orgId: userServer.server.orgId, userId: session.user.id, }, + isActive: true, }, }); diff --git a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts index 871db72d0..60866f727 100644 --- a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts +++ b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts @@ -111,6 +111,7 @@ export const GET = apiHandler(async ( { email: { contains: query, mode: 'insensitive' } }, ], }, + isActive: true, }, include: { user: true, diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts index 58ed5738f..d00d3e83f 100644 --- a/packages/web/src/app/api/(server)/ee/users/route.ts +++ b/packages/web/src/app/api/(server)/ee/users/route.ts @@ -56,6 +56,7 @@ export const GET = apiHandler(async () => { name: membership.user.name, email: membership.user.email, role: membership.role, + isActive: membership.isActive, createdAt: membership.user.createdAt, lastActivityAt: lastActivity?.timestamp ?? null, }; diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx index f00348658..a6ee74465 100644 --- a/packages/web/src/app/invite/page.tsx +++ b/packages/web/src/app/invite/page.tsx @@ -37,7 +37,8 @@ export default async function InvitePage(props: InvitePageProps) { orgId_userId: { orgId: org.id, userId: session.user.id - } + }, + isActive: true, } }); diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index ab6dd7ff6..faf5539f3 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -50,7 +50,8 @@ export default async function Onboarding(props: OnboardingProps) { orgId_userId: { orgId: org.id, userId: session.user.id - } + }, + isActive: true, } }); diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index cbb144fa6..c5315471a 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -38,7 +38,8 @@ export default async function RedeemPage(props: RedeemPageProps) { orgId_userId: { orgId: org.id, userId: session.user.id - } + }, + isActive: true, } }); diff --git a/packages/web/src/features/billing/actions.ts b/packages/web/src/features/billing/actions.ts index c9070b219..aa40c8612 100644 --- a/packages/web/src/features/billing/actions.ts +++ b/packages/web/src/features/billing/actions.ts @@ -130,6 +130,7 @@ export const createCheckoutSession = async ({ const memberCount = await prisma.userToOrg.count({ where: { orgId: org.id, + isActive: true, }, }); const quantity = Math.max(memberCount, 1); diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index e6b052766..d2522a6cb 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -41,6 +41,7 @@ export const syncWithLighthouse = async (orgId: number) => { __unsafePrisma.userToOrg.count({ where: { orgId, + isActive: true, }, }), __unsafePrisma.repo.count({ diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index fccc6df8a..4c6e8cd21 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -375,6 +375,7 @@ export const shareChatWithUsers = async ({ chatId, userIds }: { chatId: string, userId: { in: userIds, }, + isActive: true, }, }); diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index 3b7efba31..c99100d44 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -77,6 +77,7 @@ export const publicEeUserListItemSchema = z.object({ name: z.string().nullable(), email: z.string(), role: z.enum(['OWNER', 'MEMBER']), + isActive: z.boolean(), createdAt: z.string().datetime(), lastActivityAt: z.string().datetime().nullable(), }).openapi('PublicEeUserListItem'); From 260b7894556aec435b5d5e3dfe86cbf8d439f23b Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 23 Jun 2026 12:01:13 -0700 Subject: [PATCH 07/17] ensureActiveMember --- .../src/app/(app)/settings/members/page.tsx | 4 +- .../api/(server)/ee/scim/v2/Users/route.ts | 4 +- .../membership/actions/accountRequests.ts | 4 +- .../features/membership/actions/invites.ts | 10 +- .../membership/membership.service.test.ts | 53 +++++--- .../features/membership/membership.service.ts | 121 ++++++++++-------- .../src/features/membership/onCreateUser.ts | 6 +- packages/web/src/features/membership/utils.ts | 7 +- 8 files changed, 119 insertions(+), 90 deletions(-) diff --git a/packages/web/src/app/(app)/settings/members/page.tsx b/packages/web/src/app/(app)/settings/members/page.tsx index 33762d41d..693b96606 100644 --- a/packages/web/src/app/(app)/settings/members/page.tsx +++ b/packages/web/src/app/(app)/settings/members/page.tsx @@ -23,7 +23,7 @@ type MembersSettingsPageProps = { }> } -export default authenticatedPage(async ({ org, role, user }, props) => { +export default authenticatedPage(async ({ org, role, user, prisma }, props) => { const searchParams = await props.searchParams; const { @@ -47,7 +47,7 @@ export default authenticatedPage(async ({ org, role, u const currentTab = tab || "members"; - const hasAvailability = await orgHasAvailability(org.id); + const hasAvailability = await orgHasAvailability(org.id, prisma); const seatCap = getSeatCap(); const scimEnabled = await isScimEnabled(org); 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 index 03635a614..d3cf5424a 100644 --- 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 @@ -1,5 +1,5 @@ import { apiHandler } from '@/lib/apiHandler'; -import { addMember, setMemberActive } from '@/features/membership/membership.service'; +import { ensureActiveMember, setMemberActive } from '@/features/membership/membership.service'; import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper'; import { coerceActive, @@ -90,7 +90,7 @@ export const POST = apiHandler(async (request: NextRequest) => } httpStatus = 200; } else { - const result = await addMember(org.id, user.id, { + const result = await ensureActiveMember(org.id, user.id, { actor: scimActor, role: OrgRole.MEMBER, scimExternalId: payload.externalId, diff --git a/packages/web/src/features/membership/actions/accountRequests.ts b/packages/web/src/features/membership/actions/accountRequests.ts index 34d787816..913b4d5c4 100644 --- a/packages/web/src/features/membership/actions/accountRequests.ts +++ b/packages/web/src/features/membership/actions/accountRequests.ts @@ -2,7 +2,7 @@ import { createAudit } from "@/ee/features/audit/audit"; import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail"; -import { addMember } from "@/features/membership/membership.service"; +import { ensureActiveMember } from "@/features/membership/membership.service"; import { getDefaultMemberRole } from "@/features/membership/utils"; import { membershipManagedByIdpError } from "@/features/membership/errors"; import { isScimEnabled } from "@/features/scim/utils"; @@ -171,7 +171,7 @@ export const approveAccountRequest = async (requestId: string) => sew(async () = return notFound(); } - const addUserToOrgRes = await addMember(org.id, request.requestedById, { + const addUserToOrgRes = await ensureActiveMember(org.id, request.requestedById, { actor: { id: request.requestedById, type: "user" }, role: await getDefaultMemberRole(), }); diff --git a/packages/web/src/features/membership/actions/invites.ts b/packages/web/src/features/membership/actions/invites.ts index d2b4bf519..e6ca6bc07 100644 --- a/packages/web/src/features/membership/actions/invites.ts +++ b/packages/web/src/features/membership/actions/invites.ts @@ -2,7 +2,7 @@ import { createAudit } from "@/ee/features/audit/audit"; import InviteUserEmail from "@/emails/inviteUserEmail"; -import { addMember } from "@/features/membership/membership.service"; +import { ensureActiveMember } from "@/features/membership/membership.service"; import { getDefaultMemberRole, orgHasAvailability } from "@/features/membership/utils"; import { membershipManagedByIdpError } from "@/features/membership/errors"; import { isScimEnabled } from "@/features/scim/utils"; @@ -49,7 +49,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea }); } - const hasAvailability = await orgHasAvailability(org.id); + const hasAvailability = await orgHasAvailability(org.id, prisma); if (!hasAvailability) { await createAudit({ action: "user.invite_failed", @@ -289,7 +289,7 @@ export const joinOrganization = async (inviteLinkId?: string) => sew(async () => } } - const addUserToOrgRes = await addMember(org.id, user.id, { + const addUserToOrgRes = await ensureActiveMember(org.id, user.id, { actor: { id: user.id, type: "user" }, role: await getDefaultMemberRole(), }); @@ -348,7 +348,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean }); }; - const hasAvailability = await orgHasAvailability(invite.org.id); + const hasAvailability = await orgHasAvailability(invite.org.id, __unsafePrisma); if (!hasAvailability) { await failAuditCallback("Organization is at max capacity"); return { @@ -364,7 +364,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean return notFound(); } - const addUserToOrgRes = await addMember(invite.orgId, user.id, { + const addUserToOrgRes = await ensureActiveMember(invite.orgId, user.id, { actor: { id: user.id, type: "user" }, role: await getDefaultMemberRole(), }); diff --git a/packages/web/src/features/membership/membership.service.test.ts b/packages/web/src/features/membership/membership.service.test.ts index cc79bf7fb..08690dc24 100644 --- a/packages/web/src/features/membership/membership.service.test.ts +++ b/packages/web/src/features/membership/membership.service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { addMember, removeMember, setMemberRole, setMemberActive } from './membership.service'; +import { ensureActiveMember, 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'; @@ -46,14 +46,14 @@ beforeEach(() => { (prisma.$transaction as any).mockImplementation(async (cb: any) => cb(prisma)); }); -describe('addMember', () => { +describe('ensureActiveMember', () => { 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 }); + const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); expect(isServiceError(result)).toBe(false); expect(result).toEqual(created); @@ -71,7 +71,7 @@ describe('addMember', () => { 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' }); + await ensureActiveMember(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' }) }), @@ -83,7 +83,7 @@ describe('addMember', () => { prisma.userToOrg.findUnique.mockResolvedValue(null); prisma.userToOrg.create.mockResolvedValue(makeMembership()); - await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + await ensureActiveMember(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 } }); @@ -94,25 +94,30 @@ describe('addMember', () => { prisma.user.findUnique.mockResolvedValue(mockUser); prisma.userToOrg.findUnique.mockResolvedValue(existing); - const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + const result = await ensureActiveMember(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 () => { + test('reactivates an INACTIVE membership (delegates to setMemberActive)', async () => { const existing = makeMembership({ isActive: false }); + const reactivated = makeMembership({ isActive: true }); prisma.user.findUnique.mockResolvedValue(mockUser); prisma.userToOrg.findUnique.mockResolvedValue(existing); + prisma.userToOrg.update.mockResolvedValue(reactivated); + mocks.orgHasAvailability.mockResolvedValue(true); - const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); - expect(result).toEqual(existing); expect(isServiceError(result)).toBe(false); + expect(result).toEqual(reactivated); expect(prisma.userToOrg.create).not.toHaveBeenCalled(); - expect(prisma.userToOrg.update).not.toHaveBeenCalled(); - expect(mocks.createAudit).not.toHaveBeenCalled(); + expect(prisma.userToOrg.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ isActive: true }) }), + ); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_reactivated' })); }); test('errors when the org is at seat capacity', async () => { @@ -120,7 +125,7 @@ describe('addMember', () => { prisma.userToOrg.findUnique.mockResolvedValue(null); mocks.orgHasAvailability.mockResolvedValue(false); - const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + const result = await ensureActiveMember(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); @@ -130,7 +135,7 @@ describe('addMember', () => { 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 }); + const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); expect(isServiceError(result)).toBe(true); expect(prisma.userToOrg.create).not.toHaveBeenCalled(); @@ -248,11 +253,13 @@ describe('setMemberRole', () => { describe('setMemberActive', () => { describe('deactivate', () => { test('deactivates an active member and revokes access', async () => { + const deactivated = makeMembership({ isActive: false }); prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true })); + prisma.userToOrg.update.mockResolvedValue(deactivated); const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR }); - expect(result).toBeNull(); + expect(result).toEqual(deactivated); 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(); @@ -263,11 +270,12 @@ describe('setMemberActive', () => { }); test('is a no-op when already inactive', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false })); + const existing = makeMembership({ isActive: false }); + prisma.userToOrg.findUnique.mockResolvedValue(existing); const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR }); - expect(result).toBeNull(); + expect(result).toEqual(existing); expect(prisma.userToOrg.update).not.toHaveBeenCalled(); expect(prisma.user.update).not.toHaveBeenCalled(); expect(mocks.createAudit).not.toHaveBeenCalled(); @@ -285,12 +293,14 @@ describe('setMemberActive', () => { describe('reactivate', () => { test('reactivates an inactive member when a seat is available', async () => { + const reactivated = makeMembership({ isActive: true, scimExternalId: 'ext-1' }); prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false })); + prisma.userToOrg.update.mockResolvedValue(reactivated); mocks.orgHasAvailability.mockResolvedValue(true); const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' }); - expect(result).toBeNull(); + expect(result).toEqual(reactivated); expect(prisma.userToOrg.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ isActive: true, scimExternalId: 'ext-1' }) }), ); @@ -309,22 +319,25 @@ describe('setMemberActive', () => { }); 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 existing = makeMembership({ isActive: true, scimExternalId: 'ext-1' }); + prisma.userToOrg.findUnique.mockResolvedValue(existing); const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' }); - expect(result).toBeNull(); + expect(result).toEqual(existing); 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 () => { + const refreshed = makeMembership({ isActive: true, scimExternalId: 'new' }); prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'old' })); + prisma.userToOrg.update.mockResolvedValue(refreshed); const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'new' }); - expect(result).toBeNull(); + expect(result).toEqual(refreshed); expect(prisma.userToOrg.update).toHaveBeenCalledWith( expect.objectContaining({ data: { scimExternalId: 'new' } }), ); diff --git a/packages/web/src/features/membership/membership.service.ts b/packages/web/src/features/membership/membership.service.ts index 0303cef68..a84fa60bd 100644 --- a/packages/web/src/features/membership/membership.service.ts +++ b/packages/web/src/features/membership/membership.service.ts @@ -10,26 +10,22 @@ import { __unsafePrisma as prisma } from "@/prisma"; import { OrgRole, Prisma, type UserToOrg } from "@sourcebot/db"; import { lastOwnerDemoteError, lastOwnerError, seatLimitReached } from "./errors"; -export interface AddMemberOptions { +export interface EnsureActiveMemberOptions { 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 + * Ensures the user has an active membership. Active: returned unchanged. + * Inactive: reactivated (re-checks the seat cap). Otherwise: created. + * `role` only applies on create. Enforces the seat cap and clears 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 ( +export const ensureActiveMember = async ( orgId: number, userId: string, - options: AddMemberOptions, + options: EnsureActiveMemberOptions, ): Promise => { const { actor, role, scimExternalId } = options; const user = await prisma.user.findUnique({ where: { id: userId } }); @@ -40,15 +36,22 @@ export const addMember = async ( const existing = await prisma.userToOrg.findUnique({ where: { orgId_userId: { orgId, userId } }, }); - if (existing) { + + if (existing && existing.isActive) { return existing; } - - if (!(await orgHasAvailability(orgId))) { - return seatLimitReached(); + if (existing && !existing.isActive) { + return setMemberActive(orgId, userId, true, { + actor, + scimExternalId + }); } const membership = await prisma.$transaction(async (tx) => { + if (!(await orgHasAvailability(orgId, tx))) { + return seatLimitReached(); + } + const created = await tx.userToOrg.create({ data: { userId, @@ -69,6 +72,10 @@ export const addMember = async ( return created; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + if (isServiceError(membership)) { + return membership; + } + await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); await createAudit({ action: "org.member_added", @@ -208,32 +215,32 @@ export const setMemberActive = async ( userId: string, active: boolean, options: SetMemberActiveOptions, -): Promise => { +): Promise => { const { actor, scimExternalId } = options; + // Case: deactivating a member if (!active) { let didChange = false; const result = await prisma.$transaction(async (tx) => { - const target = await tx.userToOrg.findUnique({ + let 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; + return target; } await revokeAllUserAuthCredentials(tx, userId, orgId); - await tx.userToOrg.update({ + target = await tx.userToOrg.update({ where: { orgId_userId: { orgId, userId } }, data: { isActive: false }, }); didChange = true; - - return null; + return target; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); if (!isServiceError(result) && didChange) { @@ -247,46 +254,56 @@ export const setMemberActive = async ( } return result; - } - const target = await prisma.userToOrg.findUnique({ - where: { orgId_userId: { orgId, userId } }, - }); - if (!target) { - return notFound("Member not found in this organization"); - } + // Case: reactivating a member + } else { + let didChange = false; - if (target.isActive) { - if (scimExternalId && target.scimExternalId !== scimExternalId) { - await prisma.userToOrg.update({ + const result = await prisma.$transaction(async (tx) => { + let target = await tx.userToOrg.findUnique({ where: { orgId_userId: { orgId, userId } }, - data: { scimExternalId }, }); - } - return null; - } + if (!target) { + return notFound("Member not found in this organization"); + } - if (!(await orgHasAvailability(orgId))) { - return seatLimitReached(); - } + if (target.isActive) { + if (scimExternalId && target.scimExternalId !== scimExternalId) { + target = await tx.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { scimExternalId }, + }); + } + return target; + } - await prisma.userToOrg.update({ - where: { orgId_userId: { orgId, userId } }, - data: { - isActive: true, - ...(scimExternalId ? { scimExternalId } : {}), - }, - }); + if (!(await orgHasAvailability(orgId, tx))) { + return seatLimitReached(); + } - await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); - await createAudit({ - action: "org.member_reactivated", - actor, - target: { id: userId, type: "user" }, - orgId, - }); + target = await tx.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { + isActive: true, + ...(scimExternalId ? { scimExternalId } : {}), + }, + }); + didChange = true; + return target; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + if (!isServiceError(result) && didChange) { + await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); + await createAudit({ + action: "org.member_reactivated", + actor, + target: { id: userId, type: "user" }, + orgId, + }); + } - return null; + return result; + } }; const countActiveOwners = (tx: Prisma.TransactionClient, orgId: number): Promise => diff --git a/packages/web/src/features/membership/onCreateUser.ts b/packages/web/src/features/membership/onCreateUser.ts index 1244b1ca8..869eb73f6 100644 --- a/packages/web/src/features/membership/onCreateUser.ts +++ b/packages/web/src/features/membership/onCreateUser.ts @@ -7,7 +7,7 @@ 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 { ensureActiveMember } from "@/features/membership/membership.service"; import { logger } from "./logger"; import { captureEvent } from "@/lib/posthog"; @@ -75,7 +75,7 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { // 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, { + const result = await ensureActiveMember(SINGLE_TENANT_ORG_ID, user.id, { actor: { id: user.id, type: "user" }, role: OrgRole.OWNER, }); @@ -104,7 +104,7 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { !isMemberApprovalRequired(defaultOrg) && !(await isScimEnabled(defaultOrg)) ) { - const result = await addMember(SINGLE_TENANT_ORG_ID, user.id, { + const result = await ensureActiveMember(SINGLE_TENANT_ORG_ID, user.id, { actor: { id: user.id, type: "user" }, role: await getDefaultMemberRole(), }); diff --git a/packages/web/src/features/membership/utils.ts b/packages/web/src/features/membership/utils.ts index f431f3b95..0a01eada8 100644 --- a/packages/web/src/features/membership/utils.ts +++ b/packages/web/src/features/membership/utils.ts @@ -1,7 +1,6 @@ -import { __unsafePrisma } from "@/prisma"; import { hasEntitlement } from "@/lib/entitlements"; import { createLogger, getSeatCap } from "@sourcebot/shared"; -import { OrgRole } from "@sourcebot/db"; +import { OrgRole, Prisma } from "@sourcebot/db"; const logger = createLogger("membership-utils"); @@ -19,12 +18,12 @@ export const getDefaultMemberRole = async (): Promise => * availability is determined by the `seats` parameter in the offline license * key, if available. */ -export const orgHasAvailability = async (orgId: number): Promise => { +export const orgHasAvailability = async (orgId: number, tx: Prisma.TransactionClient): 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({ + const activeUserCount = await tx.userToOrg.count({ where: { orgId, isActive: true, From e06991ceed64b1b51f16532de4050c20042b7190 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 24 Jun 2026 16:37:40 -0700 Subject: [PATCH 08/17] feat(web): track per-org member activity and bill only active-seen members Add a per-membership lastActiveAt to UserToOrg, stamped in getAuthContext alongside the existing global User.lastActiveAt. The migration backfills it from User.lastActiveAt so existing members are not reset to "never active". Switch the service ping's DAU/WAU/MAU and billed user count onto the per-org timestamp. billedUserCount now counts only active (non-suspended) members who have been active in the org at least once, so provisioned-but-never-signed-in members no longer consume a seat. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 15 +++++++++ packages/db/prisma/schema.prisma | 7 +++++ .../web/src/features/billing/servicePing.ts | 31 +++++++++++-------- packages/web/src/features/billing/types.ts | 5 +++ packages/web/src/middleware/withAuth.test.ts | 30 ++++++++++++++++-- packages/web/src/middleware/withAuth.ts | 29 ++++++++++++++++- 6 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql diff --git a/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql new file mode 100644 index 000000000..ccb3d03e9 --- /dev/null +++ b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "UserToOrg" ADD COLUMN "lastActiveAt" TIMESTAMP(3); + +-- Backfill per-membership activity from the global User.lastActiveAt. In a +-- single-tenant deployment a user belongs to exactly one org, so the global +-- timestamp is exactly the per-org timestamp. In multi-tenant deployments this +-- seeds every membership with the user's global last-active time as the best +-- available signal; the per-org value diverges naturally from the next +-- authenticated action onward. Without this, every existing membership would +-- read as "never active" (NULL) until each member's next request. +UPDATE "UserToOrg" AS uto +SET "lastActiveAt" = u."lastActiveAt" +FROM "User" AS u +WHERE uto."userId" = u."id" + AND u."lastActiveAt" IS NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0a336ae3d..8c38f3b43 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -409,6 +409,13 @@ model UserToOrg { /// SCIM. Null for members that joined through invites or self-serve sign-up. scimExternalId String? + /// Last time the user performed an authenticated action *in this org*. Unlike + /// `User.lastActiveAt` (which is global to the instance), this is scoped to + /// the membership, so it distinguishes a member who has been active in this + /// org from one who was provisioned but never signed in here. Null means the + /// member has never been active in this org. + lastActiveAt DateTime? + @@id([orgId, userId]) @@index([orgId, scimExternalId]) } diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index d2522a6cb..617f128ab 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -32,41 +32,46 @@ export const syncWithLighthouse = async (orgId: number) => { const mauCutoff = new Date(now - 30 * DAY_MS); const [ - userCount, - repoCount, + billedUserCount, dauCount, wauCount, mauCount, + repoCount, ] = await Promise.all([ + // A member is billed only if their membership is active (not suspended) + // AND they have been active in this org at least once. A member who was + // provisioned (e.g. via SCIM or an invite) but never signed in here has + // a null `lastActiveAt` and does not consume a seat. __unsafePrisma.userToOrg.count({ where: { orgId, isActive: true, + lastActiveAt: { not: null }, }, }), - __unsafePrisma.repo.count({ + __unsafePrisma.userToOrg.count({ where: { orgId, - }, - }), - __unsafePrisma.user.count({ - where: { - orgs: { some: { orgId } }, lastActiveAt: { gte: dauCutoff }, }, }), - __unsafePrisma.user.count({ + __unsafePrisma.userToOrg.count({ where: { - orgs: { some: { orgId } }, + orgId, lastActiveAt: { gte: wauCutoff }, }, }), - __unsafePrisma.user.count({ + __unsafePrisma.userToOrg.count({ where: { - orgs: { some: { orgId } }, + orgId, lastActiveAt: { gte: mauCutoff }, }, }), + __unsafePrisma.repo.count({ + where: { + orgId, + }, + }), ]); const activationCode = license?.activationCode @@ -79,7 +84,7 @@ export const syncWithLighthouse = async (orgId: number) => { installId: env.SOURCEBOT_INSTALL_ID, version: SOURCEBOT_VERSION, hostname: env.AUTH_URL, - userCount, + userCount: billedUserCount, repoCount, dauCount, wauCount, diff --git a/packages/web/src/features/billing/types.ts b/packages/web/src/features/billing/types.ts index 24ed0020c..0be11074b 100644 --- a/packages/web/src/features/billing/types.ts +++ b/packages/web/src/features/billing/types.ts @@ -4,6 +4,11 @@ export const servicePingRequestSchema = z.object({ installId: z.string(), version: z.string(), hostname: z.string(), + /** + * The number of billed users: active (non-suspended) members who have been + * active in the org at least once. Provisioned members who have never + * signed in do not count towards this. + */ userCount: z.number().int().nonnegative(), repoCount: z.number().int().nonnegative(), dauCount: z.number().int().nonnegative(), diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index da6975c18..d36394ee4 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -86,9 +86,11 @@ beforeEach(() => { mocks.headers.mockResolvedValue(new Headers()); mocks.hasEntitlement.mockReturnValue(false); mocks.isAnonymousAccessAvailable.mockReturnValue(false); - // getAuthContext fires `prisma.user.update().catch(...)` to bump lastActiveAt; - // without a default, the reset mock returns undefined and the .catch chain throws. + // getAuthContext fires `prisma.user.update().catch(...)` and + // `prisma.userToOrg.update().catch(...)` to bump lastActiveAt; without a + // default, the reset mock returns undefined and the .catch chain throws. prisma.user.update.mockResolvedValue(MOCK_USER_WITH_ACCOUNTS); + prisma.userToOrg.update.mockResolvedValue({}); // Reset env flags between tests Object.keys(mocks.env).forEach(key => delete mocks.env[key]); }); @@ -321,6 +323,7 @@ describe('getAuthContext', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); @@ -353,6 +356,7 @@ describe('getAuthContext', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); @@ -424,6 +428,7 @@ describe('getAuthContext', () => { orgId: MOCK_ORG.id, isActive: false, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); @@ -454,6 +459,7 @@ describe('getAuthContext', () => { orgId: MOCK_ORG.id, isActive: false, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -486,6 +492,7 @@ describe('getAuthContext', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); @@ -510,6 +517,7 @@ describe('getAuthContext', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); @@ -535,6 +543,7 @@ describe('getAuthContext', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); setMockSession(createMockSession({ user: { id: userId } })); @@ -570,6 +579,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); vi.mocked(userScopedPrismaClientExtension).mockResolvedValue(extension as never); @@ -601,6 +611,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -633,6 +644,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -665,6 +677,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -702,6 +715,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -739,6 +753,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -776,6 +791,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -813,6 +829,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); setMockSession(null); @@ -856,6 +873,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: false, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); setMockSession(createMockSession({ user: { id: userId } })); @@ -881,6 +899,7 @@ describe('withAuth', () => { orgId: MOCK_ORG.id, isActive: false, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -913,6 +932,7 @@ describe('withOptionalAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -945,6 +965,7 @@ describe('withOptionalAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -977,6 +998,7 @@ describe('withOptionalAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -1014,6 +1036,7 @@ describe('withOptionalAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -1051,6 +1074,7 @@ describe('withOptionalAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -1088,6 +1112,7 @@ describe('withOptionalAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -1125,6 +1150,7 @@ describe('withOptionalAuth', () => { orgId: MOCK_ORG.id, isActive: true, scimExternalId: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); setMockSession(null); diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 649d39e5b..40898e868 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -1,6 +1,6 @@ import { __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma"; import { hashSecret, OAUTH_ACCESS_TOKEN_PREFIX, API_KEY_PREFIX, LEGACY_API_KEY_PREFIX, env } from "@sourcebot/shared"; -import { ApiKey, Org, OrgRole, PrismaClient, UserWithAccounts } from "@sourcebot/db"; +import { ApiKey, Org, OrgRole, PrismaClient, UserToOrg, UserWithAccounts } from "@sourcebot/db"; import { headers } from "next/headers"; import { auth } from "../auth"; import { notAuthenticated, notFound, ServiceError } from "../lib/serviceError"; @@ -108,6 +108,10 @@ export const getAuthContext = async (): Promise { .catch(() => { /* updaing the lastActiveAt is best effort. */ }); }; +const updateMembershipLastActiveAt = (membership: UserToOrg) => { + const now = Date.now(); + if ( + membership.lastActiveAt && + (now - membership.lastActiveAt.getTime()) < LAST_ACTIVE_AT_THRESHOLD_MS + ) { + return; + } + + // Fired without a await to avoid blocking. + void __unsafePrisma.userToOrg + .update({ + where: { + orgId_userId: { + orgId: membership.orgId, + userId: membership.userId, + }, + }, + data: { lastActiveAt: new Date(now) }, + }) + .catch(() => { /* updating the lastActiveAt is best effort. */ }); +}; + type AuthSource = 'session' | 'oauth' | 'api_key'; export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, source: AuthSource } | undefined> => { From b9d36a395e00bfa45252f6133893bdfef9d582fe Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 25 Jun 2026 14:03:04 -0700 Subject: [PATCH 09/17] members table v2 --- .../(app)/settings/accountAskAgent/layout.tsx | 6 + .../app/(app)/settings/analytics/layout.tsx | 6 + .../src/app/(app)/settings/apiKeys/layout.tsx | 3 +- .../settings/components/settingsContainer.tsx | 20 + .../app/(app)/settings/connections/layout.tsx | 3 +- .../src/app/(app)/settings/general/layout.tsx | 6 + .../web/src/app/(app)/settings/layout.tsx | 8 +- .../src/app/(app)/settings/license/layout.tsx | 6 + .../(app)/settings/linked-accounts/layout.tsx | 6 + .../web/src/app/(app)/settings/mcp/layout.tsx | 6 + .../members/components/inviteMemberCard.tsx | 193 ------- .../members/components/invitesList.tsx | 214 -------- .../members/components/membersList.tsx | 443 ----------------- .../members/components/requestsList.tsx | 232 --------- .../src/app/(app)/settings/members/layout.tsx | 11 + .../settings/members/membersFilterSelect.tsx | 37 ++ .../(app)/settings/members/membersTable.tsx | 469 ++++++++++++++++++ .../settings/members/membersTableActions.tsx | 383 ++++++++++++++ .../settings/members/membersTableView.tsx | 67 +++ .../src/app/(app)/settings/members/page.tsx | 175 ++----- .../app/(app)/settings/security/layout.tsx | 6 + .../settings/workspaceAskAgent/layout.tsx | 6 + packages/web/src/components/ui/table.tsx | 6 +- .../features/membership/actions/members.ts | 52 +- packages/web/src/middleware/withAuth.test.ts | 10 +- 25 files changed, 1131 insertions(+), 1243 deletions(-) create mode 100644 packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/analytics/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/components/settingsContainer.tsx create mode 100644 packages/web/src/app/(app)/settings/general/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/license/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/linked-accounts/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/mcp/layout.tsx delete mode 100644 packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx delete mode 100644 packages/web/src/app/(app)/settings/members/components/invitesList.tsx delete mode 100644 packages/web/src/app/(app)/settings/members/components/membersList.tsx delete mode 100644 packages/web/src/app/(app)/settings/members/components/requestsList.tsx create mode 100644 packages/web/src/app/(app)/settings/members/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/members/membersFilterSelect.tsx create mode 100644 packages/web/src/app/(app)/settings/members/membersTable.tsx create mode 100644 packages/web/src/app/(app)/settings/members/membersTableActions.tsx create mode 100644 packages/web/src/app/(app)/settings/members/membersTableView.tsx create mode 100644 packages/web/src/app/(app)/settings/security/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/workspaceAskAgent/layout.tsx diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx new file mode 100644 index 000000000..cdf17facf --- /dev/null +++ b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function AccountAskAgentSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/analytics/layout.tsx b/packages/web/src/app/(app)/settings/analytics/layout.tsx new file mode 100644 index 000000000..ec8f6d691 --- /dev/null +++ b/packages/web/src/app/(app)/settings/analytics/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function AnalyticsSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx index dfc24fa63..7fdba3f2b 100644 --- a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx +++ b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx @@ -2,11 +2,12 @@ import { notFound } from "next/navigation"; import { OrgRole } from "@sourcebot/db"; import { env } from "@sourcebot/shared"; import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { SettingsContainer } from "../components/settingsContainer"; export default authenticatedPage<{ children: React.ReactNode }>(async ({ role }, { children }) => { if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && role !== OrgRole.OWNER) { return notFound(); } - return <>{children}; + return {children}; }); diff --git a/packages/web/src/app/(app)/settings/components/settingsContainer.tsx b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx new file mode 100644 index 000000000..26ec413d1 --- /dev/null +++ b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +type SettingsContainerVariant = "centered" | "full"; + +interface SettingsContainerProps { + children: React.ReactNode; + variant?: SettingsContainerVariant; +} + +export const SettingsContainer = ({ children, variant = "centered" }: SettingsContainerProps) => { + const isFull = variant === "full"; + return ( +
+
+
{children}
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/settings/connections/layout.tsx b/packages/web/src/app/(app)/settings/connections/layout.tsx index ec521d14e..c89c498e6 100644 --- a/packages/web/src/app/(app)/settings/connections/layout.tsx +++ b/packages/web/src/app/(app)/settings/connections/layout.tsx @@ -1,6 +1,7 @@ import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; +import { SettingsContainer } from "../components/settingsContainer"; export default authenticatedPage<{ children: React.ReactNode }>(async (_auth, { children }) => { - return <>{children}; + return {children}; }, { minRole: OrgRole.OWNER, redirectTo: '/settings' }); diff --git a/packages/web/src/app/(app)/settings/general/layout.tsx b/packages/web/src/app/(app)/settings/general/layout.tsx new file mode 100644 index 000000000..e5b1afe41 --- /dev/null +++ b/packages/web/src/app/(app)/settings/general/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function GeneralSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 442f9a546..422785ced 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -33,13 +33,7 @@ export default async function SettingsLayout( } return ( -
-
-
-
{children}
-
-
-
+ <>{children} ) } diff --git a/packages/web/src/app/(app)/settings/license/layout.tsx b/packages/web/src/app/(app)/settings/license/layout.tsx new file mode 100644 index 000000000..24d60263c --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function LicenseSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx new file mode 100644 index 000000000..7f5a6d9ee --- /dev/null +++ b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function LinkedAccountsSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/mcp/layout.tsx b/packages/web/src/app/(app)/settings/mcp/layout.tsx new file mode 100644 index 000000000..8ffa6bb7a --- /dev/null +++ b/packages/web/src/app/(app)/settings/mcp/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function McpSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx deleted file mode 100644 index 9d1eff8c1..000000000 --- a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx +++ /dev/null @@ -1,193 +0,0 @@ -'use client'; - -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { useCallback, useState } from "react"; -import { z } from "zod"; -import { PlusCircleIcon, Loader2, AlertTriangle } from "lucide-react"; -import { OrgRole } from "@prisma/client"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { createInvites } from "@/features/membership/actions"; -import { isServiceError } from "@/lib/utils"; -import { useToast } from "@/components/hooks/use-toast"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -export const inviteMemberFormSchema = z.object({ - emails: z.array(z.object({ - email: z.string().email() - })) - .refine((emails) => { - const emailSet = new Set(emails.map(e => e.email.toLowerCase())); - return emailSet.size === emails.length; - }, "Duplicate email addresses are not allowed") -}); - -interface InviteMemberCardProps { - currentUserRole: OrgRole; - seatsAvailable: boolean; -} - -export const InviteMemberCard = ({ currentUserRole, seatsAvailable }: InviteMemberCardProps) => { - const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const { toast } = useToast(); - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const form = useForm>({ - resolver: zodResolver(inviteMemberFormSchema), - defaultValues: { - emails: [{ email: "" }] - }, - }); - - const addEmailField = useCallback(() => { - const emails = form.getValues().emails; - form.setValue('emails', [...emails, { email: "" }]); - }, [form]); - - const onSubmit = useCallback((data: z.infer) => { - setIsLoading(true); - createInvites(data.emails.map(e => e.email)) - .then((res) => { - if (isServiceError(res)) { - toast({ - description: `❌ Failed to invite members. Reason: ${res.message}` - }); - captureEvent('wa_invite_member_card_invite_fail', { - errorCode: res.errorCode, - num_emails: data.emails.length, - }); - } else { - form.reset(); - router.push(`?tab=invites`); - toast({ - description: `✅ Successfully invited ${data.emails.length} members` - }); - captureEvent('wa_invite_member_card_invite_success', { - num_emails: data.emails.length, - }); - } - }) - .finally(() => { - setIsLoading(false); - }); - }, [form, toast, router, captureEvent]); - - const isDisabled = !seatsAvailable || currentUserRole !== OrgRole.OWNER || isLoading; - - return ( - <> - - - Invite Member - Invite new members to your organization. - - {!seatsAvailable && ( -
-
- -
-

- Maximum seats reached -

-

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

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

- {email} -

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

No Pending Invitations Found

-

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

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

No Members Found

-

- No members found matching your filters. -

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

No Pending Requests Found

-

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

-
- ) : ( - filteredRequests.map((request) => ( -
-
- -
-
{request.name || request.email}
-
{request.email}
-
-
-
- {currentUserRole === OrgRole.OWNER && ( - <> - - - - )} -
-
- )) - )} -
-
- - {/* Approve Request Dialog */} - - - - Approve Request - - Are you sure you want to approve the request from {requestToAction?.email}? They will be added as a member to your organization. - - - - - Back - - { - onApproveRequest(requestToAction?.id ?? ""); - }} - > - Approve - - - - - - {/* Reject Request Dialog */} - - - - Reject Request - - Are you sure you want to reject the request from {requestToAction?.email}? - - - - - Back - - { - onRejectRequest(requestToAction?.id ?? ""); - }} - > - Reject - - - - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/members/layout.tsx b/packages/web/src/app/(app)/settings/members/layout.tsx new file mode 100644 index 000000000..aabceb26b --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/layout.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +/** + * Members uses the full-width variant of `SettingsContainer` rather than the + * default centered column: the member table is dense and benefits from the full + * viewport width. + */ +export default function MembersSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/members/membersFilterSelect.tsx b/packages/web/src/app/(app)/settings/members/membersFilterSelect.tsx new file mode 100644 index 000000000..eeb0adda0 --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/membersFilterSelect.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { type MemberFilter } from "./membersTable"; + +const FILTER_OPTIONS: { value: MemberFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "owners", label: "Owners" }, + { value: "members", label: "Members" }, + { value: "active", label: "Active" }, + { value: "pending", label: "Pending" }, + { value: "suspended", label: "Suspended" }, + { value: "invited", label: "Invited" }, + { value: "requests", label: "Requests" }, +]; + +interface MembersFilterSelectProps { + value: MemberFilter; + onValueChange: (value: MemberFilter) => void; +} + +export const MembersFilterSelect = ({ value, onValueChange }: MembersFilterSelectProps) => { + return ( + + ); +}; diff --git a/packages/web/src/app/(app)/settings/members/membersTable.tsx b/packages/web/src/app/(app)/settings/members/membersTable.tsx new file mode 100644 index 000000000..237812b01 --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/membersTable.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + type Column, + type ColumnDef, + type OnChangeFn, + type RowData, + type SortingFn, + type SortingState, + flexRender, + functionalUpdate, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + className?: string; + } +} +import { ArrowDown, ArrowUp } from "lucide-react"; +import { OrgRole } from "@sourcebot/db"; +import { UserAvatar } from "@/components/userAvatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { DisplayDate } from "@/app/(app)/components/DisplayDate"; +import { NotificationDot } from "@/app/(app)/components/notificationDot"; +import { cn } from "@/lib/utils"; +import { MembersTableActions, type MembersTableActionsProps } from "./membersTableActions"; + +// Matches the `lastActiveAt` write throttle in withAuth: a member seen within +// this window is treated as currently online. +const ONLINE_THRESHOLD_MS = 5 * 60 * 1000; + +// --- Input shapes (mirrors the membership action DTOs) --------------------- + +export type Member = { + id: string; + email: string; + name?: string; + avatarUrl?: string; + role: OrgRole; + joinedAt: Date; + isActive: boolean; + scimManaged: boolean; + lastActiveAt?: Date | null; +}; + +export type Invite = { + id: string; + email: string; + createdAt: Date; +}; + +export type Request = { + id: string; + email: string; + createdAt: Date; + name?: string; + image?: string; +}; + +// --- Unified row model ----------------------------------------------------- + +type Section = "active" | "pending" | "suspended" | "invited" | "requests"; + +type MemberRow = Member & { kind: "member"; section: Extract }; +type InviteRow = Invite & { kind: "invite"; section: "invited" }; +type RequestRow = Request & { kind: "request"; section: "requests" }; +export type TableRowData = MemberRow | InviteRow | RequestRow; +export type MemberFilter = "all" | "owners" | "members" | Section; + +const SECTIONS: { id: Section; label: string }[] = [ + { id: "active", label: "Active" }, + { id: "pending", label: "Pending" }, + { id: "suspended", label: "Suspended" }, + { id: "invited", label: "Invited" }, + { id: "requests", label: "Requests" }, +]; + +const COLUMN_WIDTHS = ["auto", "120px", "120px", "120px", "64px"]; + +const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", +}); + +const getDisplayName = (row: TableRowData) => { + if (row.kind === "invite") { + return row.email; + } + return row.name ?? row.email; +}; + +const getStatusLabel = (row: TableRowData) => { + if (row.kind === "member") { + return row.role.toLowerCase(); + } + return row.kind === "invite" ? "invited" : "requested"; +}; + +const getJoinedTime = (row: TableRowData) => { + return (row.kind === "member" ? row.joinedAt : row.createdAt).getTime(); +}; + +const getLastSeenTime = (row: TableRowData) => { + if (row.kind !== "member" || row.lastActiveAt == null) { + return Number.NEGATIVE_INFINITY; + } + return row.lastActiveAt.getTime(); +}; + +const rowMatchesFilter = (row: TableRowData, filter: MemberFilter) => { + if (filter === "all") { + return true; + } + if (filter === "owners") { + return row.kind === "member" && row.role === OrgRole.OWNER; + } + if (filter === "members") { + return row.kind === "member" && row.role === OrgRole.MEMBER; + } + return row.section === filter; +}; + +const rowMatchesSearch = (row: TableRowData, searchQuery: string) => { + const query = searchQuery.trim().toLowerCase(); + if (query.length === 0) { + return true; + } + + return getDisplayName(row).toLowerCase().includes(query) || row.email.toLowerCase().includes(query); +}; + +const compareByName = (a: TableRowData, b: TableRowData) => { + return collator.compare(getDisplayName(a), getDisplayName(b)) || collator.compare(a.email, b.email); +}; + +const sortByName: SortingFn = (rowA, rowB) => { + return compareByName(rowA.original, rowB.original); +}; + +const sortByStatus: SortingFn = (rowA, rowB) => { + return collator.compare(getStatusLabel(rowA.original), getStatusLabel(rowB.original)) + || compareByName(rowA.original, rowB.original); +}; + +const sortByJoined: SortingFn = (rowA, rowB) => { + return getJoinedTime(rowA.original) - getJoinedTime(rowB.original) + || compareByName(rowA.original, rowB.original); +}; + +const sortByLastSeen: SortingFn = (rowA, rowB) => { + return getLastSeenTime(rowA.original) - getLastSeenTime(rowB.original) + || compareByName(rowA.original, rowB.original); +}; + +const SortableHeader = ({ + column, + children, +}: { + column: Column; + children: string; +}) => { + const sortDirection = column.getIsSorted(); + const Icon = sortDirection === "asc" ? ArrowDown : sortDirection === "desc" ? ArrowUp : ArrowDown; + + return ( + + ); +}; + +const ColumnWidths = () => ( + + {COLUMN_WIDTHS.map((width, index) => ( + + ))} + +); + +/** + * Derives a member's section. Mirrors the `billedUserCount` query so the table + * and the bill stay in lockstep: + * - suspended: membership is deactivated (`isActive === false`) + * - pending: active but never signed in to this org (`lastActiveAt == null`) + * - active: active and has signed in at least once + */ +const getMemberSection = (member: Member): MemberRow["section"] => { + if (!member.isActive) { + return "suspended"; + } + if (member.lastActiveAt == null) { + return "pending"; + } + return "active"; +}; + +const getColumns = (actionContext: Omit): ColumnDef[] => [ + { + id: "identity", + accessorFn: getDisplayName, + sortingFn: sortByName, + header: ({ column }) => Name, + cell: ({ row }) => { + const r = row.original; + const name = getDisplayName(r); + const imageUrl = r.kind === "member" ? r.avatarUrl : r.kind === "request" ? r.image : undefined; + return ( +
+ +
+
+ {name} +
+
{r.email}
+
+
+ ); + }, + }, + { + id: "status", + accessorFn: getStatusLabel, + meta: { className: "whitespace-nowrap" }, + sortingFn: sortByStatus, + header: ({ column }) => Status, + cell: ({ row }) => { + const r = row.original; + if (r.kind === "member") { + return {r.role.toLowerCase()}; + } + return {r.kind === "invite" ? "Invited" : "Requested"}; + }, + }, + { + id: "joined", + accessorFn: getJoinedTime, + meta: { className: "whitespace-nowrap" }, + sortingFn: sortByJoined, + header: ({ column }) => Joined, + cell: ({ row }) => { + const r = row.original; + const date = r.kind === "member" ? r.joinedAt : r.createdAt; + return ; + }, + }, + { + id: "lastActive", + accessorFn: getLastSeenTime, + meta: { className: "whitespace-nowrap" }, + sortingFn: sortByLastSeen, + header: ({ column }) => Last seen, + cell: ({ row }) => { + const r = row.original; + if (r.kind !== "member") { + return ; + } + if (!r.lastActiveAt) { + return Never; + } + if (Date.now() - r.lastActiveAt.getTime() < ONLINE_THRESHOLD_MS) { + return ( +
+ + Online +
+ ); + } + return ; + }, + }, + { + id: "actions", + meta: { className: "text-right" }, + enableSorting: false, + header: "", + cell: ({ row }) => ( + + ), + }, +]; + +interface MembersTableProps { + members: Member[]; + invites: Invite[]; + requests: Request[]; + filter: MemberFilter; + searchQuery: string; + onClearFilters: () => void; + currentUserId: string; + hasOrgManagement: boolean; + scimEnabled: boolean; +} + +export const MembersTable = ({ + members, + invites, + requests, + filter, + searchQuery, + onClearFilters, + currentUserId, + hasOrgManagement, + scimEnabled, +}: MembersTableProps) => { + const scrollContainerRef = useRef(null); + const scrollTopBeforeSortRef = useRef(null); + const [sorting, setSorting] = useState([ + { + id: "identity", + desc: false, + }, + ]); + + const data = useMemo(() => { + const memberRows: TableRowData[] = members.map((m) => ({ + ...m, + kind: "member", + section: getMemberSection(m), + })); + const inviteRows: TableRowData[] = invites.map((i) => ({ ...i, kind: "invite", section: "invited" })); + const requestRows: TableRowData[] = requests.map((r) => ({ ...r, kind: "request", section: "requests" })); + return [...memberRows, ...inviteRows, ...requestRows]; + }, [members, invites, requests]); + + const filteredData = useMemo(() => { + return data.filter((row) => rowMatchesFilter(row, filter) && rowMatchesSearch(row, searchQuery)); + }, [data, filter, searchQuery]); + + const activeOwnerCount = useMemo(() => { + return members.filter((member) => member.isActive && member.role === OrgRole.OWNER).length; + }, [members]); + + const columns = useMemo(() => getColumns({ + currentUserId, + activeOwnerCount, + hasOrgManagement, + scimEnabled, + }), [activeOwnerCount, currentUserId, hasOrgManagement, scimEnabled]); + + const handleSortingChange: OnChangeFn = (updater) => { + scrollTopBeforeSortRef.current = scrollContainerRef.current?.scrollTop ?? null; + setSorting((previous) => functionalUpdate(updater, previous)); + }; + + useLayoutEffect(() => { + if (scrollTopBeforeSortRef.current == null || !scrollContainerRef.current) { + return; + } + + scrollContainerRef.current.scrollTop = scrollTopBeforeSortRef.current; + scrollTopBeforeSortRef.current = null; + }, [sorting]); + + useLayoutEffect(() => { + if (!scrollContainerRef.current) { + return; + } + + scrollContainerRef.current.scrollTop = 0; + }, [filter, searchQuery]); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + }, + onSortingChange: handleSortingChange, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + const rows = table.getRowModel().rows; + const visibleSections = SECTIONS.map((section) => ({ + ...section, + rows: rows.filter((row) => row.original.section === section.id), + })).filter((section) => section.rows.length > 0); + + return ( +
+
+ {/* Remove the collapsed `border-b` (from TableHeader/TableRow) so it doesn't + double up with the box-shadow divider at the top; the shadow is the sole + divider and it survives scrolling. */} + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + +
+ {rows.length === 0 ? ( + + + + + +
+ No rows match this filter. + +
+
+
+
+
+ ) : visibleSections.map((section) => ( + + + + + +
+ {section.label} + {section.rows.length} +
+
+
+ {section.rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} +
+
+ ))} +
+
+ ); +}; diff --git a/packages/web/src/app/(app)/settings/members/membersTableActions.tsx b/packages/web/src/app/(app)/settings/members/membersTableActions.tsx new file mode 100644 index 000000000..41da4d274 --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/membersTableActions.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { useState } from "react"; +import { MoreVertical } from "lucide-react"; +import { OrgRole } from "@sourcebot/db"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useToast } from "@/components/hooks/use-toast"; +import { + approveAccountRequest, + cancelInvite, + leaveOrg, + reactivateMember, + rejectAccountRequest, + removeMemberFromOrg, + suspendMember, +} from "@/features/membership/actions"; +import { demoteToMember, promoteToOwner } from "@/ee/features/membership/actions"; +import { type ServiceError } from "@/lib/serviceError"; +import { createPathWithQueryParams, isServiceError } from "@/lib/utils"; +import { type TableRowData } from "./membersTable"; + +type MemberAction = + | "promote" + | "demote" + | "suspend" + | "reactivate" + | "remove" + | "leave" + | "cancelInvite" + | "approveRequest" + | "rejectRequest"; + +export interface MembersTableActionsProps { + row: TableRowData; + currentUserId: string; + activeOwnerCount: number; + hasOrgManagement: boolean; + scimEnabled: boolean; +} + +const getDisplayName = (row: TableRowData) => { + if (row.kind === "invite") { + return row.email; + } + return row.name ?? row.email; +}; + +const getActionLabel = (action: MemberAction) => { + switch (action) { + case "promote": + return "Promote"; + case "demote": + return "Demote"; + case "suspend": + return "Suspend"; + case "reactivate": + return "Reactivate"; + case "remove": + return "Remove"; + case "leave": + return "Leave"; + case "cancelInvite": + return "Cancel invite"; + case "approveRequest": + return "Approve"; + case "rejectRequest": + return "Reject"; + } +}; + +const getDialogCopy = (action: MemberAction, row: TableRowData) => { + const name = getDisplayName(row); + + switch (action) { + case "promote": + return { + title: "Promote to Owner", + description: `Are you sure you want to promote ${name} to owner? They will have full administrative access.`, + }; + case "demote": + return { + title: "Demote to Member", + description: `Are you sure you want to demote ${name} from owner to member? They will lose administrative access.`, + }; + case "suspend": + return { + title: "Suspend Member", + description: `Are you sure you want to suspend ${name}? They will lose access to this organization.`, + }; + case "reactivate": + return { + title: "Reactivate Member", + description: `Are you sure you want to reactivate ${name}? They will regain access to this organization.`, + }; + case "remove": + return { + title: "Remove Member", + description: `Are you sure you want to permanently remove ${name} from this organization?`, + }; + case "leave": + return { + title: "Leave Organization", + description: "Are you sure you want to leave this organization?", + }; + case "cancelInvite": + return { + title: "Cancel Invite", + description: `Are you sure you want to cancel the invite for ${row.email}?`, + }; + case "approveRequest": + return { + title: "Approve Request", + description: `Are you sure you want to approve the request from ${row.email}? They will be added as a member to your organization.`, + }; + case "rejectRequest": + return { + title: "Reject Request", + description: `Are you sure you want to reject the request from ${row.email}?`, + }; + } +}; + +const isDestructiveAction = (action: MemberAction) => { + return ["demote", "suspend", "remove", "leave", "cancelInvite", "rejectRequest"].includes(action); +}; + +export const MembersTableActions = ({ + row, + currentUserId, + activeOwnerCount, + hasOrgManagement, + scimEnabled, +}: MembersTableActionsProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [pendingAction, setPendingAction] = useState(null); + const [confirmingAction, setConfirmingAction] = useState(null); + const isCurrentUser = row.kind === "member" && row.id === currentUserId; + const isLastActiveOwner = row.kind === "member" + && row.isActive + && row.role === OrgRole.OWNER + && activeOwnerCount <= 1; + const scimDisabledTitle = scimEnabled ? "SCIM provisioning is enabled" : undefined; + + const copyEmail = async () => { + try { + await navigator.clipboard.writeText(row.email); + toast({ description: "Email copied to clipboard." }); + } catch { + toast({ description: "Failed to copy email." }); + } + }; + + const copyInviteLink = async () => { + if (row.kind !== "invite") { + return; + } + + try { + const url = createPathWithQueryParams(`${window.location.origin}/redeem?invite_id=${row.id}`); + await navigator.clipboard.writeText(url); + toast({ description: "Invite link copied to clipboard." }); + } catch { + toast({ description: "Failed to copy invite link." }); + } + }; + + const runConfirmedAction = async () => { + if (confirmingAction == null) { + return; + } + + const action = confirmingAction; + setPendingAction(action); + + let result: { success: boolean } | ServiceError; + try { + result = await (async () => { + switch (action) { + case "promote": + return promoteToOwner(row.id); + case "demote": + return demoteToMember(row.id); + case "suspend": + return suspendMember(row.id); + case "reactivate": + return reactivateMember(row.id); + case "remove": + return removeMemberFromOrg(row.id); + case "leave": + return leaveOrg(); + case "cancelInvite": + return cancelInvite(row.id); + case "approveRequest": + return approveAccountRequest(row.id); + case "rejectRequest": + return rejectAccountRequest(row.id); + } + })(); + } catch { + toast({ description: `Failed to ${getActionLabel(action).toLowerCase()}.` }); + return; + } finally { + setPendingAction(null); + } + + if (isServiceError(result)) { + toast({ + description: `Failed to ${getActionLabel(action).toLowerCase()}. Reason: ${result.message}`, + }); + return; + } + + toast({ description: `${getActionLabel(action)} successful.` }); + setConfirmingAction(null); + router.refresh(); + }; + + const dialogCopy = confirmingAction == null ? null : getDialogCopy(confirmingAction, row); + const confirmButtonClassName = confirmingAction != null && isDestructiveAction(confirmingAction) + ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" + : undefined; + + return ( + <> + + + + + + + Copy email + + {row.kind === "invite" && ( + <> + + Copy invite link + + setConfirmingAction("cancelInvite")} + > + Cancel invite + + + )} + {row.kind === "request" && ( + <> + setConfirmingAction("approveRequest")} + > + Approve request + + setConfirmingAction("rejectRequest")} + > + Reject request + + + )} + {row.kind === "member" && ( + <> + {row.role === OrgRole.MEMBER && ( + setConfirmingAction("promote")} + > + Promote to owner + + )} + {row.role === OrgRole.OWNER && ( + setConfirmingAction("demote")} + > + Demote to member + + )} + {row.isActive ? ( + isCurrentUser ? ( + setConfirmingAction("leave")} + > + Leave organization + + ) : ( + setConfirmingAction("suspend")} + > + Suspend + + ) + ) : ( + <> + setConfirmingAction("reactivate")} + > + Reactivate + + setConfirmingAction("remove")} + > + Remove + + + )} + + )} + + + { + if (!open) { + setConfirmingAction(null); + } + }}> + + + {dialogCopy?.title} + {dialogCopy?.description} + + + Cancel + { + event.preventDefault(); + runConfirmedAction(); + }} + > + {confirmingAction == null ? "Confirm" : getActionLabel(confirmingAction)} + + + + + + ); +}; diff --git a/packages/web/src/app/(app)/settings/members/membersTableView.tsx b/packages/web/src/app/(app)/settings/members/membersTableView.tsx new file mode 100644 index 000000000..8ff90e808 --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/membersTableView.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { MembersFilterSelect } from "./membersFilterSelect"; +import { + MembersTable, + type Invite, + type Member, + type MemberFilter, + type Request, +} from "./membersTable"; + +interface MembersTableViewProps { + members: Member[]; + invites: Invite[]; + requests: Request[]; + currentUserId: string; + hasOrgManagement: boolean; + scimEnabled: boolean; +} + +export const MembersTableView = ({ + members, + invites, + requests, + currentUserId, + hasOrgManagement, + scimEnabled, +}: MembersTableViewProps) => { + const [filter, setFilter] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + + const clearFilters = () => { + setFilter("all"); + setSearchQuery(""); + }; + + return ( +
+
+
+ + setSearchQuery(event.target.value)} + placeholder="Search by name or email" + className="pl-9" + /> +
+ +
+ +
+ ); +}; diff --git a/packages/web/src/app/(app)/settings/members/page.tsx b/packages/web/src/app/(app)/settings/members/page.tsx index 693b96606..75cca18df 100644 --- a/packages/web/src/app/(app)/settings/members/page.tsx +++ b/packages/web/src/app/(app)/settings/members/page.tsx @@ -1,167 +1,50 @@ -import { MembersList } from "./components/membersList"; -import { getOrgInvites, getOrgMembers, getOrgAccountRequests} from "@/features/membership/actions"; +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { OrgRole } from "@sourcebot/db"; +import { getOrgMembers, getOrgInvites, getOrgAccountRequests } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; -import { InviteMemberCard } from "./components/inviteMemberCard"; -import { Tabs, TabsContent } from "@/components/ui/tabs"; -import { TabSwitcher } from "@/components/ui/tab-switcher"; -import { InvitesList } from "./components/invitesList"; import { ServiceErrorException } from "@/lib/serviceError"; -import { hasEntitlement } from "@/lib/entitlements"; -import { RequestsList } from "./components/requestsList"; -import { OrgRole } from "@sourcebot/db"; -import { NotificationDot } from "../../components/notificationDot"; -import { Badge } from "@/components/ui/badge"; -import { authenticatedPage } from "@/middleware/authenticatedPage"; -import { orgHasAvailability } from "@/features/membership/utils"; +import { MembersTableView } from "./membersTableView"; import { isScimEnabled } from "@/features/scim/utils"; -import { ManagedByScimNotice } from "@/features/membership/components/managedByScimNotice"; -import { getSeatCap } from "@sourcebot/shared"; - -type MembersSettingsPageProps = { - searchParams: Promise<{ - tab?: string - }> -} - -export default authenticatedPage(async ({ org, role, user, prisma }, props) => { - const searchParams = await props.searchParams; +import { hasEntitlement } from "@/lib/entitlements"; - const { - tab - } = searchParams; +export default authenticatedPage(async ({ org, user }) => { + const [members, invites, requests, scimEnabled, hasOrgManagement] = await Promise.all([ + getOrgMembers(), + getOrgInvites(), + getOrgAccountRequests(), + isScimEnabled(org), + hasEntitlement("org-management"), + ]); - const members = await getOrgMembers(); if (isServiceError(members)) { throw new ServiceErrorException(members); } - - const invites = await getOrgInvites(); if (isServiceError(invites)) { throw new ServiceErrorException(invites); } - - const requests = await getOrgAccountRequests(); if (isServiceError(requests)) { throw new ServiceErrorException(requests); } - const currentTab = tab || "members"; - - const hasAvailability = await orgHasAvailability(org.id, prisma); - const seatCap = getSeatCap(); - const scimEnabled = await isScimEnabled(org); - return ( -
-
-
-

Members

-

Invite and manage members of your organization.

-
- {seatCap && ( -
-
- {members.length} - of - {seatCap} - seats used -
-
- )} +
+
+

Members

+

+ Invite and manage members of your organization. +

- - {scimEnabled ? ( - - SCIM provisioning is enabled. Members are provisioned through your identity provider. - - ) : ( - - )} - - -
- - Team Members - - {members.length} - -
- ), - value: "members" - }, - ...(role === OrgRole.OWNER ? [ - { - label: ( -
- {requests.length > 0 && ( - - )} - Pending Requests - {requests.length > 0 && ( - - {requests.length} - - )} -
- ), - value: "requests" - }, - { - label: ( -
- Pending Invites - {invites.length > 0 && ( - - {invites.length} - - )} -
- ), - value: "invites" - }, - ] : []), - ]} - currentTab={currentTab} - /> -
- - - - - {role === OrgRole.OWNER && ( - <> - - - - - - - - - )} - +
- ) + ); }, { minRole: OrgRole.OWNER, redirectTo: '/settings' -}); +}) diff --git a/packages/web/src/app/(app)/settings/security/layout.tsx b/packages/web/src/app/(app)/settings/security/layout.tsx new file mode 100644 index 000000000..b75c5e731 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function SecuritySettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/layout.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/layout.tsx new file mode 100644 index 000000000..52e7de90a --- /dev/null +++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function WorkspaceAskAgentLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/components/ui/table.tsx b/packages/web/src/components/ui/table.tsx index 32165557a..55debe634 100644 --- a/packages/web/src/components/ui/table.tsx +++ b/packages/web/src/components/ui/table.tsx @@ -4,9 +4,9 @@ import { cn } from "@/lib/utils" const Table = React.forwardRef< HTMLTableElement, - React.HTMLAttributes & { wrapperClassName?: string } ->(({ className, wrapperClassName, ...props }, ref) => ( -
+ React.HTMLAttributes & { wrapperClassName?: string; wrapperRef?: React.Ref } +>(({ className, wrapperClassName, wrapperRef, ...props }, ref) => ( +
=> sew(() => withAuth(async ({ user, org, role }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + const result = await removeMember(org.id, memberId, { actor: { id: user.id, type: "user" }, }); @@ -23,9 +29,50 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success: })) ); +export const suspendMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ user, org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + + const result = await setMemberActive(org.id, memberId, false, { + actor: { id: user.id, type: "user" }, + }); + + if (isServiceError(result)) { + return result; + } + + return { success: true }; + })) +); + +export const reactivateMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ user, org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + + const result = await setMemberActive(org.id, memberId, true, { + actor: { id: user.id, type: "user" }, + }); + + if (isServiceError(result)) { + return result; + } + + return { success: true }; + })) +); export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() => withAuth(async ({ user, org }) => { + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + const result = await removeMember(org.id, user.id, { actor: { id: user.id, type: "user" }, reason: "left", @@ -62,5 +109,6 @@ export const getOrgMembers = async () => sew(() => joinedAt: member.joinedAt, isActive: member.isActive, scimManaged: !!member.scimExternalId, + lastActiveAt: member.lastActiveAt, })); - }))); \ No newline at end of file + }))); diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index d36394ee4..bb759476d 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -90,7 +90,15 @@ beforeEach(() => { // `prisma.userToOrg.update().catch(...)` to bump lastActiveAt; without a // default, the reset mock returns undefined and the .catch chain throws. prisma.user.update.mockResolvedValue(MOCK_USER_WITH_ACCOUNTS); - prisma.userToOrg.update.mockResolvedValue({}); + prisma.userToOrg.update.mockResolvedValue({ + orgId: MOCK_ORG.id, + userId: 'test-user-id', + joinedAt: new Date(), + role: OrgRole.MEMBER, + isActive: true, + scimExternalId: null, + lastActiveAt: new Date(), + }); // Reset env flags between tests Object.keys(mocks.env).forEach(key => delete mocks.env[key]); }); From 12355dfe646bbf3997ed6bb74326f9ea62566519 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 25 Jun 2026 15:39:30 -0700 Subject: [PATCH 10/17] change isActive to suspendedAt --- .../sourcebot-public.openapi.json | 8 +- .../migration.sql | 2 +- packages/db/prisma/schema.prisma | 7 +- packages/web/src/app/(app)/layout.tsx | 3 +- .../src/app/(app)/settings/license/page.tsx | 3 +- .../(app)/settings/members/membersTable.tsx | 10 +- .../settings/members/membersTableActions.tsx | 5 +- .../api/(server)/ee/askmcp/callback/route.ts | 3 +- .../ee/chat/[chatId]/searchMembers/route.ts | 3 +- .../(server)/ee/scim/v2/Users/[id]/route.ts | 8 +- .../api/(server)/ee/scim/v2/Users/route.ts | 14 +- .../src/app/api/(server)/ee/users/route.ts | 3 +- packages/web/src/app/invite/page.tsx | 3 +- packages/web/src/app/onboard/page.tsx | 3 +- packages/web/src/app/redeem/page.tsx | 3 +- packages/web/src/ee/features/scim/mapper.ts | 4 +- packages/web/src/features/billing/actions.ts | 3 +- .../web/src/features/billing/servicePing.ts | 14 +- packages/web/src/features/chat/actions.ts | 3 +- .../features/membership/actions/members.ts | 8 +- .../components/deactivatedMemberBadge.tsx | 23 --- .../membership/membership.service.test.ts | 83 +++++----- .../features/membership/membership.service.ts | 61 ++++---- packages/web/src/features/membership/utils.ts | 35 ++++- packages/web/src/middleware/withAuth.test.ts | 144 +++++++++++++----- packages/web/src/middleware/withAuth.ts | 35 +++-- packages/web/src/openapi/publicApiSchemas.ts | 3 +- 27 files changed, 292 insertions(+), 202 deletions(-) delete mode 100644 packages/web/src/features/membership/components/deactivatedMemberBadge.tsx diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 72920c987..3fe693a3e 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1148,8 +1148,10 @@ "MEMBER" ] }, - "isActive": { - "type": "boolean" + "suspendedAt": { + "type": "string", + "nullable": true, + "format": "date-time" }, "createdAt": { "type": "string", @@ -1166,7 +1168,7 @@ "name", "email", "role", - "isActive", + "suspendedAt", "createdAt", "lastActivityAt" ] 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 index 7259bda3e..22838d192 100644 --- a/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql +++ b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql @@ -2,7 +2,7 @@ ALTER TABLE "Org" ADD COLUMN "isScimEnabled" BOOLEAN NOT NULL DEFAULT false; -- AlterTable -ALTER TABLE "UserToOrg" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true, +ALTER TABLE "UserToOrg" ADD COLUMN "suspendedAt" TIMESTAMP(3), ADD COLUMN "scimExternalId" TEXT; -- CreateTable diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8c38f3b43..4479b2f40 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -400,10 +400,9 @@ 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) + /// When set, the membership is suspended and the user is treated as a + /// non-member for auth purposes (see `getAuthContext`). + suspendedAt DateTime? /// The IdP-supplied `externalId` for this membership when provisioned via /// SCIM. Null for members that joined through invites or self-serve sign-up. diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 0deb0a65d..5d03f8061 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -23,6 +23,7 @@ import { GitHubStarToast } from "./components/githubStarToast"; import { getLinkedAccounts } from "@/ee/features/sso/actions"; import { BannerSlot } from "./components/banners/bannerSlot"; import { BannerHeightObserver } from "./components/banners/bannerHeightObserver"; +import { unsuspendedMembershipWhere } from "@/features/membership/utils"; import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api"; import { OrgRole } from "@sourcebot/db"; import { ServiceErrorException } from "@/lib/serviceError"; @@ -67,7 +68,7 @@ export default async function Layout(props: LayoutProps) { orgId: org.id, userId: session.user.id, }, - isActive: true, + ...unsuspendedMembershipWhere(), }, include: { user: true diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index 95c73d02f..83ba0a7d2 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -15,6 +15,7 @@ import { getAllInvoices } from "@/ee/features/lighthouse/actions"; import { syncWithLighthouse } from "@/features/billing/servicePing"; import { isServiceError } from "@/lib/utils"; import { getYearlyTermStatus } from "./types"; +import { activeMembershipWhere } from "@/features/membership/utils"; type LicensePageProps = { searchParams?: Promise>; @@ -52,7 +53,7 @@ export default authenticatedPage(async ({ prisma, org }, props const currentActiveUserCount = await prisma.userToOrg.count({ where: { orgId: org.id, - isActive: true, + ...activeMembershipWhere(), }, }); diff --git a/packages/web/src/app/(app)/settings/members/membersTable.tsx b/packages/web/src/app/(app)/settings/members/membersTable.tsx index 237812b01..c190b90d1 100644 --- a/packages/web/src/app/(app)/settings/members/membersTable.tsx +++ b/packages/web/src/app/(app)/settings/members/membersTable.tsx @@ -45,7 +45,7 @@ export type Member = { avatarUrl?: string; role: OrgRole; joinedAt: Date; - isActive: boolean; + suspendedAt?: Date | null; scimManaged: boolean; lastActiveAt?: Date | null; }; @@ -199,12 +199,12 @@ const ColumnWidths = () => ( /** * Derives a member's section. Mirrors the `billedUserCount` query so the table * and the bill stay in lockstep: - * - suspended: membership is deactivated (`isActive === false`) - * - pending: active but never signed in to this org (`lastActiveAt == null`) + * - suspended: membership is suspended (`suspendedAt != null`) + * - pending: unsuspended but never signed in to this org (`lastActiveAt == null`) * - active: active and has signed in at least once */ const getMemberSection = (member: Member): MemberRow["section"] => { - if (!member.isActive) { + if (member.suspendedAt != null) { return "suspended"; } if (member.lastActiveAt == null) { @@ -349,7 +349,7 @@ export const MembersTable = ({ }, [data, filter, searchQuery]); const activeOwnerCount = useMemo(() => { - return members.filter((member) => member.isActive && member.role === OrgRole.OWNER).length; + return members.filter((member) => member.suspendedAt == null && member.role === OrgRole.OWNER).length; }, [members]); const columns = useMemo(() => getColumns({ diff --git a/packages/web/src/app/(app)/settings/members/membersTableActions.tsx b/packages/web/src/app/(app)/settings/members/membersTableActions.tsx index 41da4d274..4606050db 100644 --- a/packages/web/src/app/(app)/settings/members/membersTableActions.tsx +++ b/packages/web/src/app/(app)/settings/members/membersTableActions.tsx @@ -153,8 +153,9 @@ export const MembersTableActions = ({ const [pendingAction, setPendingAction] = useState(null); const [confirmingAction, setConfirmingAction] = useState(null); const isCurrentUser = row.kind === "member" && row.id === currentUserId; + const isSuspended = row.kind === "member" && row.suspendedAt != null; const isLastActiveOwner = row.kind === "member" - && row.isActive + && !isSuspended && row.role === OrgRole.OWNER && activeOwnerCount <= 1; const scimDisabledTitle = scimEnabled ? "SCIM provisioning is enabled" : undefined; @@ -309,7 +310,7 @@ export const MembersTableActions = ({ Demote to member )} - {row.isActive ? ( + {!isSuspended ? ( isCurrentUser ? ( { orgId: userServer.server.orgId, userId: session.user.id, }, - isActive: true, + ...unsuspendedMembershipWhere(), }, }); diff --git a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts index 60866f727..e6216743c 100644 --- a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts +++ b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts @@ -8,6 +8,7 @@ import { hasEntitlement } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { NextRequest } from "next/server"; import { z } from "zod"; +import { activeMembershipWhere } from "@/features/membership/utils"; const searchMembersQueryParamsSchema = z.object({ query: z.string().default(''), @@ -102,6 +103,7 @@ export const GET = apiHandler(async ( const members = await prisma.userToOrg.findMany({ where: { orgId: org.id, + ...activeMembershipWhere(), userId: { notIn: Array.from(excludeUserIds), }, @@ -111,7 +113,6 @@ export const GET = apiHandler(async ( { email: { contains: query, mode: 'insensitive' } }, ], }, - isActive: true, }, include: { user: true, 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 index d05149ec7..25ade6e9f 100644 --- 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 @@ -1,5 +1,5 @@ import { apiHandler } from '@/lib/apiHandler'; -import { removeMember, setMemberActive } from '@/features/membership/membership.service'; +import { removeMember, setMembershipSuspended } from '@/features/membership/membership.service'; import { scimError, scimJson, toScimUser, type ScimMembership } from '@/ee/features/scim/mapper'; import { coerceActive, @@ -24,7 +24,7 @@ const applyActive = async (orgId: number, userId: string, current: boolean, next if (next === undefined || next === current) { return null; } - const result = await setMemberActive(orgId, userId, next, { + const result = await setMembershipSuspended(orgId, userId, !next, { actor: { id: 'scim', type: 'scim_token' }, }); if (isServiceError(result)) { @@ -66,7 +66,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: { params: data: { name, email }, }); - const activeError = await applyActive(org.id, id, membership.isActive, coerceActive(payload.active)); + const activeError = await applyActive(org.id, id, membership.suspendedAt == null, coerceActive(payload.active)); if (activeError) { return activeError; } @@ -105,7 +105,7 @@ export const PATCH = apiHandler(async (request: NextRequest, { params }: { param }); } - const activeError = await applyActive(org.id, id, membership.isActive, changes.active); + const activeError = await applyActive(org.id, id, membership.suspendedAt == null, changes.active); if (activeError) { return activeError; } 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 index d3cf5424a..064848166 100644 --- 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 @@ -1,5 +1,5 @@ import { apiHandler } from '@/lib/apiHandler'; -import { ensureActiveMember, setMemberActive } from '@/features/membership/membership.service'; +import { ensureActiveMember, setMembershipSuspended } from '@/features/membership/membership.service'; import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper'; import { coerceActive, @@ -73,14 +73,14 @@ export const POST = apiHandler(async (request: NextRequest) => 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. + // Map the membership state to the SCIM response: an unsuspended member + // is a conflict, a suspended member is reactivated (role preserved), and + // a brand-new member is created. let httpStatus: number; - if (existing?.isActive) { + if (existing && existing.suspendedAt == null) { return scimError(409, 'User is already a member of this organization', 'uniqueness'); } else if (existing) { - const result = await setMemberActive(org.id, user.id, true, { + const result = await setMembershipSuspended(org.id, user.id, false, { actor: scimActor, scimExternalId: payload.externalId, }); @@ -105,7 +105,7 @@ export const POST = apiHandler(async (request: NextRequest) => // 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 }); + const deactivated = await setMembershipSuspended(org.id, user.id, true, { actor: scimActor }); if (isServiceError(deactivated)) { return scimError(deactivated.statusCode, deactivated.message); } diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts index d00d3e83f..ac1cea3de 100644 --- a/packages/web/src/app/api/(server)/ee/users/route.ts +++ b/packages/web/src/app/api/(server)/ee/users/route.ts @@ -56,7 +56,7 @@ export const GET = apiHandler(async () => { name: membership.user.name, email: membership.user.email, role: membership.role, - isActive: membership.isActive, + suspendedAt: membership.suspendedAt, createdAt: membership.user.createdAt, lastActivityAt: lastActivity?.timestamp ?? null, }; @@ -91,4 +91,3 @@ export const GET = apiHandler(async () => { return Response.json(result); }); - diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx index a6ee74465..78df01490 100644 --- a/packages/web/src/app/invite/page.tsx +++ b/packages/web/src/app/invite/page.tsx @@ -8,6 +8,7 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard"; import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard"; import { isScimEnabled } from "@/features/scim/utils"; +import { unsuspendedMembershipWhere } from "@/features/membership/utils"; interface InvitePageProps { searchParams: Promise<{ @@ -38,7 +39,7 @@ export default async function InvitePage(props: InvitePageProps) { orgId: org.id, userId: session.user.id }, - isActive: true, + ...unsuspendedMembershipWhere(), } }); diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index faf5539f3..cb0184c8e 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -16,6 +16,7 @@ import { redirect } from "next/navigation"; import { env } from "@sourcebot/shared"; import { hasEntitlement, isValidLicenseActive } from "@/lib/entitlements"; import { GcpIapAuth } from "@/app/(app)/components/gcpIapAuth"; +import { unsuspendedMembershipWhere } from "@/features/membership/utils"; interface OnboardingProps { searchParams?: Promise<{ step?: string }>; @@ -51,7 +52,7 @@ export default async function Onboarding(props: OnboardingProps) { orgId: org.id, userId: session.user.id }, - isActive: true, + ...unsuspendedMembershipWhere(), } }); diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index c5315471a..a851e4de0 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -9,6 +9,7 @@ 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'; +import { unsuspendedMembershipWhere } from '@/features/membership/utils'; interface RedeemPageProps { searchParams: Promise<{ @@ -39,7 +40,7 @@ export default async function RedeemPage(props: RedeemPageProps) { orgId: org.id, userId: session.user.id }, - isActive: true, + ...unsuspendedMembershipWhere(), } }); diff --git a/packages/web/src/ee/features/scim/mapper.ts b/packages/web/src/ee/features/scim/mapper.ts index c83d93ad5..0db3db5a0 100644 --- a/packages/web/src/ee/features/scim/mapper.ts +++ b/packages/web/src/ee/features/scim/mapper.ts @@ -16,7 +16,7 @@ const scimUserLocation = (userId: string): string => /** * 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. + * user's email; `active` reflects whether the membership is unsuspended. */ export const toScimUser = (membership: ScimMembership) => { const { user } = membership; @@ -34,7 +34,7 @@ export const toScimUser = (membership: ScimMembership) => { familyName: familyName || undefined, } : undefined, emails: user.email ? [{ value: user.email, primary: true }] : [], - active: membership.isActive, + active: membership.suspendedAt == null, meta: { resourceType: "User", created: membership.joinedAt.toISOString(), diff --git a/packages/web/src/features/billing/actions.ts b/packages/web/src/features/billing/actions.ts index aa40c8612..159b8abf3 100644 --- a/packages/web/src/features/billing/actions.ts +++ b/packages/web/src/features/billing/actions.ts @@ -15,6 +15,7 @@ import { captureEvent } from "@/lib/posthog"; import { UpsellSource } from "@/lib/posthogEvents"; import { client } from "./client"; import { z } from "zod"; +import { activeMembershipWhere } from "@/features/membership/utils"; export const activateLicense = async (activationCode: string): Promise<{ success: boolean } | ServiceError> => sew(() => withAuth(async ({ org, role, prisma }) => @@ -130,7 +131,7 @@ export const createCheckoutSession = async ({ const memberCount = await prisma.userToOrg.count({ where: { orgId: org.id, - isActive: true, + ...activeMembershipWhere(), }, }); const quantity = Math.max(memberCount, 1); diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index 617f128ab..ff48b4e98 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -13,6 +13,7 @@ import { client } from "./client"; import { ServicePingRequest } from "./types"; import { ServiceErrorException } from "@/lib/serviceError"; import { getConfiguredLanguageModels } from "@/features/chat/utils.server"; +import { activeMembershipWhere } from "@/features/membership/utils"; const logger = createLogger('service-ping'); @@ -32,21 +33,16 @@ export const syncWithLighthouse = async (orgId: number) => { const mauCutoff = new Date(now - 30 * DAY_MS); const [ - billedUserCount, + activeUserCount, dauCount, wauCount, mauCount, repoCount, ] = await Promise.all([ - // A member is billed only if their membership is active (not suspended) - // AND they have been active in this org at least once. A member who was - // provisioned (e.g. via SCIM or an invite) but never signed in here has - // a null `lastActiveAt` and does not consume a seat. __unsafePrisma.userToOrg.count({ where: { orgId, - isActive: true, - lastActiveAt: { not: null }, + ...activeMembershipWhere(), }, }), __unsafePrisma.userToOrg.count({ @@ -84,7 +80,7 @@ export const syncWithLighthouse = async (orgId: number) => { installId: env.SOURCEBOT_INSTALL_ID, version: SOURCEBOT_VERSION, hostname: env.AUTH_URL, - userCount: billedUserCount, + userCount: activeUserCount, repoCount, dauCount, wauCount, @@ -208,4 +204,4 @@ const recordServicePingInDB = async (orgId: number, payload: ServicePingRequest) // the actual ping from being sent to Lighthouse. logger.error(`Failed to record service ping in database:\n ${error}`); } -}; \ No newline at end of file +}; diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index 4c6e8cd21..9481b387e 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -9,6 +9,7 @@ import { withAuth, withOptionalAuth } from "@/middleware/withAuth"; import { ChatVisibility, Prisma } from "@sourcebot/db"; import { SBChatMessage } from "./types"; import { checkAskEntitlement, isChatSharedWithUser, isOwnerOfChat } from "./utils.server"; +import { activeMembershipWhere } from "../membership/utils"; export const createChat = async ({ source }: { source?: string } = {}) => sew(() => withOptionalAuth(async ({ org, user, prisma }) => { @@ -372,10 +373,10 @@ export const shareChatWithUsers = async ({ chatId, userIds }: { chatId: string, const memberships = await prisma.userToOrg.findMany({ where: { orgId: org.id, + ...activeMembershipWhere(), userId: { in: userIds, }, - isActive: true, }, }); diff --git a/packages/web/src/features/membership/actions/members.ts b/packages/web/src/features/membership/actions/members.ts index 2e5e1d6d7..edffc30e1 100644 --- a/packages/web/src/features/membership/actions/members.ts +++ b/packages/web/src/features/membership/actions/members.ts @@ -1,7 +1,7 @@ 'use server'; import { membershipManagedByIdpError } from "@/features/membership/errors"; -import { removeMember, setMemberActive } from "@/features/membership/membership.service"; +import { removeMember, setMembershipSuspended } from "@/features/membership/membership.service"; import { isScimEnabled } from "@/features/scim/utils"; import { ServiceError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; @@ -36,7 +36,7 @@ export const suspendMember = async (memberId: string): Promise<{ success: boolea return membershipManagedByIdpError(); } - const result = await setMemberActive(org.id, memberId, false, { + const result = await setMembershipSuspended(org.id, memberId, true, { actor: { id: user.id, type: "user" }, }); @@ -55,7 +55,7 @@ export const reactivateMember = async (memberId: string): Promise<{ success: boo return membershipManagedByIdpError(); } - const result = await setMemberActive(org.id, memberId, true, { + const result = await setMembershipSuspended(org.id, memberId, false, { actor: { id: user.id, type: "user" }, }); @@ -107,7 +107,7 @@ export const getOrgMembers = async () => sew(() => avatarUrl: member.user.image ?? undefined, role: member.role, joinedAt: member.joinedAt, - isActive: member.isActive, + suspendedAt: member.suspendedAt, scimManaged: !!member.scimExternalId, lastActiveAt: member.lastActiveAt, })); diff --git a/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx b/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx deleted file mode 100644 index 34396246f..000000000 --- a/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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/membership.service.test.ts b/packages/web/src/features/membership/membership.service.test.ts index 08690dc24..74d77d870 100644 --- a/packages/web/src/features/membership/membership.service.test.ts +++ b/packages/web/src/features/membership/membership.service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { ensureActiveMember, removeMember, setMemberRole, setMemberActive } from './membership.service'; +import { ensureActiveMember, removeMember, setMemberRole, setMembershipSuspended } from './membership.service'; import { prisma, MOCK_USER_WITH_ACCOUNTS } from '@/__mocks__/prisma'; import { OrgRole, type UserToOrg } from '@sourcebot/db'; import { ErrorCode } from '@/lib/errorCodes'; @@ -17,21 +17,26 @@ vi.mock('@/prisma', async () => { return { ...actual }; }); vi.mock('server-only', () => ({ default: vi.fn() })); -vi.mock('@/features/membership/utils', () => ({ orgHasAvailability: mocks.orgHasAvailability })); +vi.mock('@/features/membership/utils', () => ({ + orgHasAvailability: mocks.orgHasAvailability, + unsuspendedMembershipWhere: () => ({ suspendedAt: null }), +})); 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 SUSPENDED_AT = new Date('2026-01-01T00:00:00.000Z'); const makeMembership = (overrides: Partial = {}): UserToOrg => ({ orgId: ORG_ID, userId: USER_ID, role: OrgRole.MEMBER, joinedAt: new Date(), - isActive: true, + suspendedAt: null, scimExternalId: null, + lastActiveAt: null, ...overrides, }); @@ -59,7 +64,7 @@ describe('ensureActiveMember', () => { 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 }), + data: expect.objectContaining({ userId: USER_ID, orgId: ORG_ID, role: OrgRole.MEMBER }), }), ); expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(ORG_ID); @@ -89,8 +94,8 @@ describe('ensureActiveMember', () => { 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 }); + test('is an idempotent no-op when an unsuspended membership already exists', async () => { + const existing = makeMembership({ suspendedAt: null }); prisma.user.findUnique.mockResolvedValue(mockUser); prisma.userToOrg.findUnique.mockResolvedValue(existing); @@ -101,9 +106,9 @@ describe('ensureActiveMember', () => { expect(mocks.createAudit).not.toHaveBeenCalled(); }); - test('reactivates an INACTIVE membership (delegates to setMemberActive)', async () => { - const existing = makeMembership({ isActive: false }); - const reactivated = makeMembership({ isActive: true }); + test('reactivates a suspended membership', async () => { + const existing = makeMembership({ suspendedAt: SUSPENDED_AT }); + const reactivated = makeMembership({ suspendedAt: null }); prisma.user.findUnique.mockResolvedValue(mockUser); prisma.userToOrg.findUnique.mockResolvedValue(existing); prisma.userToOrg.update.mockResolvedValue(reactivated); @@ -115,7 +120,7 @@ describe('ensureActiveMember', () => { expect(result).toEqual(reactivated); expect(prisma.userToOrg.create).not.toHaveBeenCalled(); expect(prisma.userToOrg.update).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ isActive: true }) }), + expect.objectContaining({ data: expect.objectContaining({ suspendedAt: null }) }), ); expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_reactivated' })); }); @@ -166,7 +171,7 @@ describe('removeMember', () => { }); test('blocks removing the last active owner', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null })); prisma.userToOrg.count.mockResolvedValue(1); const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR }); @@ -177,7 +182,7 @@ describe('removeMember', () => { }); test('allows removing an owner when others remain', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null })); prisma.userToOrg.count.mockResolvedValue(2); const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR }); @@ -210,7 +215,7 @@ describe('setMemberRole', () => { }); test('demotes an owner to member when other owners remain', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null })); prisma.userToOrg.count.mockResolvedValue(2); const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); @@ -220,7 +225,7 @@ describe('setMemberRole', () => { }); test('blocks demoting the last active owner', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null })); prisma.userToOrg.count.mockResolvedValue(1); const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); @@ -250,30 +255,30 @@ describe('setMemberRole', () => { }); }); -describe('setMemberActive', () => { - describe('deactivate', () => { - test('deactivates an active member and revokes access', async () => { - const deactivated = makeMembership({ isActive: false }); - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true })); - prisma.userToOrg.update.mockResolvedValue(deactivated); +describe('setMembershipSuspended', () => { + describe('suspend', () => { + test('suspends an active member and revokes access', async () => { + const suspended = makeMembership({ suspendedAt: SUSPENDED_AT }); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ suspendedAt: null })); + prisma.userToOrg.update.mockResolvedValue(suspended); - const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR }); + const result = await setMembershipSuspended(ORG_ID, USER_ID, true, { actor: ACTOR }); - expect(result).toEqual(deactivated); + expect(result).toEqual(suspended); 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.objectContaining({ data: { suspendedAt: expect.any(Date) } }), ); expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_deactivated' })); }); - test('is a no-op when already inactive', async () => { - const existing = makeMembership({ isActive: false }); + test('is a no-op when already suspended', async () => { + const existing = makeMembership({ suspendedAt: SUSPENDED_AT }); prisma.userToOrg.findUnique.mockResolvedValue(existing); - const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR }); + const result = await setMembershipSuspended(ORG_ID, USER_ID, true, { actor: ACTOR }); expect(result).toEqual(existing); expect(prisma.userToOrg.update).not.toHaveBeenCalled(); @@ -284,7 +289,7 @@ describe('setMemberActive', () => { 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 }); + const result = await setMembershipSuspended(ORG_ID, USER_ID, true, { actor: ACTOR }); expect(isServiceError(result)).toBe(true); expect(prisma.userToOrg.update).not.toHaveBeenCalled(); @@ -292,26 +297,26 @@ describe('setMemberActive', () => { }); describe('reactivate', () => { - test('reactivates an inactive member when a seat is available', async () => { - const reactivated = makeMembership({ isActive: true, scimExternalId: 'ext-1' }); - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false })); + test('reactivates a suspended member when a seat is available', async () => { + const reactivated = makeMembership({ suspendedAt: null, scimExternalId: 'ext-1' }); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ suspendedAt: SUSPENDED_AT })); prisma.userToOrg.update.mockResolvedValue(reactivated); mocks.orgHasAvailability.mockResolvedValue(true); - const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' }); + const result = await setMembershipSuspended(ORG_ID, USER_ID, false, { actor: ACTOR, scimExternalId: 'ext-1' }); expect(result).toEqual(reactivated); expect(prisma.userToOrg.update).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ isActive: true, scimExternalId: 'ext-1' }) }), + expect.objectContaining({ data: expect.objectContaining({ suspendedAt: null, 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 })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ suspendedAt: SUSPENDED_AT })); mocks.orgHasAvailability.mockResolvedValue(false); - const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR }); + const result = await setMembershipSuspended(ORG_ID, USER_ID, false, { actor: ACTOR }); expect(isServiceError(result)).toBe(true); expect((result as ServiceError).errorCode).toBe(ErrorCode.ORG_SEAT_COUNT_REACHED); @@ -319,10 +324,10 @@ describe('setMemberActive', () => { }); test('is a no-op when already active (no audit, no seat check)', async () => { - const existing = makeMembership({ isActive: true, scimExternalId: 'ext-1' }); + const existing = makeMembership({ suspendedAt: null, scimExternalId: 'ext-1' }); prisma.userToOrg.findUnique.mockResolvedValue(existing); - const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' }); + const result = await setMembershipSuspended(ORG_ID, USER_ID, false, { actor: ACTOR, scimExternalId: 'ext-1' }); expect(result).toEqual(existing); expect(prisma.userToOrg.update).not.toHaveBeenCalled(); @@ -331,11 +336,11 @@ describe('setMemberActive', () => { }); test('refreshes externalId when already active and it changed', async () => { - const refreshed = makeMembership({ isActive: true, scimExternalId: 'new' }); - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'old' })); + const refreshed = makeMembership({ suspendedAt: null, scimExternalId: 'new' }); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ suspendedAt: null, scimExternalId: 'old' })); prisma.userToOrg.update.mockResolvedValue(refreshed); - const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'new' }); + const result = await setMembershipSuspended(ORG_ID, USER_ID, false, { actor: ACTOR, scimExternalId: 'new' }); expect(result).toEqual(refreshed); expect(prisma.userToOrg.update).toHaveBeenCalledWith( diff --git a/packages/web/src/features/membership/membership.service.ts b/packages/web/src/features/membership/membership.service.ts index a84fa60bd..1470e3a61 100644 --- a/packages/web/src/features/membership/membership.service.ts +++ b/packages/web/src/features/membership/membership.service.ts @@ -3,7 +3,7 @@ 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 { activeMembershipWhere, orgHasAvailability } from "@/features/membership/utils"; import { notFound, type ServiceError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { __unsafePrisma as prisma } from "@/prisma"; @@ -17,8 +17,8 @@ export interface EnsureActiveMemberOptions { } /** - * Ensures the user has an active membership. Active: returned unchanged. - * Inactive: reactivated (re-checks the seat cap). Otherwise: created. + * Ensures the user has an unsuspended membership. Unsuspended: returned + * unchanged. Suspended: reactivated (re-checks the seat cap). Otherwise: created. * `role` only applies on create. Enforces the seat cap and clears pending * invites / account requests for the user. */ @@ -37,13 +37,13 @@ export const ensureActiveMember = async ( where: { orgId_userId: { orgId, userId } }, }); - if (existing && existing.isActive) { + if (existing && existing.suspendedAt == null) { return existing; } - if (existing && !existing.isActive) { - return setMemberActive(orgId, userId, true, { + if (existing && existing.suspendedAt != null) { + return setMembershipSuspended(orgId, userId, false, { actor, - scimExternalId + scimExternalId, }); } @@ -57,7 +57,6 @@ export const ensureActiveMember = async ( userId, orgId, role, - isActive: true, ...(scimExternalId ? { scimExternalId } : {}), }, }); @@ -70,7 +69,7 @@ export const ensureActiveMember = async ( }); return created; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); if (isServiceError(membership)) { return membership; @@ -112,7 +111,7 @@ export const removeMember = async ( return notFound("Member not found in this organization"); } - if (target.role === OrgRole.OWNER && target.isActive) { + if (target.role === OrgRole.OWNER && target.suspendedAt == null) { if ((await countActiveOwners(tx, orgId)) <= 1) { return lastOwnerError(reason); } @@ -125,7 +124,7 @@ export const removeMember = async ( }); return null; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); if (!isServiceError(result)) { await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); @@ -173,7 +172,7 @@ export const setMemberRole = async ( } const isDemotionFromOwner = target.role === OrgRole.OWNER && role !== OrgRole.OWNER; - if (isDemotionFromOwner && target.isActive) { + if (isDemotionFromOwner && target.suspendedAt == null) { if ((await countActiveOwners(tx, orgId)) <= 1) { return lastOwnerDemoteError(); } @@ -186,7 +185,7 @@ export const setMemberRole = async ( didChange = true; return null; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); if (!isServiceError(result) && didChange) { await createAudit({ @@ -200,26 +199,25 @@ export const setMemberRole = async ( return result; }; -export interface SetMemberActiveOptions { +export interface SetMembershipSuspendedOptions { 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. + * Suspends or restores a membership without deleting it. Suspension bumps + * `sessionVersion` + revokes tokens; reactivation re-checks the seat cap. A + * no-op when already in the requested state. */ -export const setMemberActive = async ( +export const setMembershipSuspended = async ( orgId: number, userId: string, - active: boolean, - options: SetMemberActiveOptions, + suspended: boolean, + options: SetMembershipSuspendedOptions, ): Promise => { const { actor, scimExternalId } = options; - // Case: deactivating a member - if (!active) { + if (suspended) { let didChange = false; const result = await prisma.$transaction(async (tx) => { @@ -229,7 +227,7 @@ export const setMemberActive = async ( if (!target) { return notFound("Member not found in this organization"); } - if (!target.isActive) { + if (target.suspendedAt != null) { return target; } @@ -237,11 +235,11 @@ export const setMemberActive = async ( target = await tx.userToOrg.update({ where: { orgId_userId: { orgId, userId } }, - data: { isActive: false }, + data: { suspendedAt: new Date() }, }); didChange = true; return target; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); if (!isServiceError(result) && didChange) { await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); @@ -255,7 +253,6 @@ export const setMemberActive = async ( return result; - // Case: reactivating a member } else { let didChange = false; @@ -267,7 +264,7 @@ export const setMemberActive = async ( return notFound("Member not found in this organization"); } - if (target.isActive) { + if (target.suspendedAt == null) { if (scimExternalId && target.scimExternalId !== scimExternalId) { target = await tx.userToOrg.update({ where: { orgId_userId: { orgId, userId } }, @@ -284,13 +281,13 @@ export const setMemberActive = async ( target = await tx.userToOrg.update({ where: { orgId_userId: { orgId, userId } }, data: { - isActive: true, + suspendedAt: null, ...(scimExternalId ? { scimExternalId } : {}), }, }); didChange = true; return target; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); if (!isServiceError(result) && didChange) { await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); @@ -308,7 +305,11 @@ export const setMemberActive = async ( const countActiveOwners = (tx: Prisma.TransactionClient, orgId: number): Promise => tx.userToOrg.count({ - where: { orgId, role: OrgRole.OWNER, isActive: true }, + where: { + orgId, + ...activeMembershipWhere(), + role: OrgRole.OWNER, + }, }); const revokeAllUserAuthCredentials = async ( diff --git a/packages/web/src/features/membership/utils.ts b/packages/web/src/features/membership/utils.ts index 0a01eada8..e098cc1b1 100644 --- a/packages/web/src/features/membership/utils.ts +++ b/packages/web/src/features/membership/utils.ts @@ -13,6 +13,36 @@ const logger = createLogger("membership-utils"); export const getDefaultMemberRole = async (): Promise => (await hasEntitlement("org-management")) ? OrgRole.MEMBER : OrgRole.OWNER; +/** + * Matches suspended memberships. + */ +export const suspendedMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ + suspendedAt: { not: null }, +}); + +/** + * Matches unsuspended memberships, regardless of activity. + */ +export const unsuspendedMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ + suspendedAt: null, +}); + +/** + * Matches pending memberships: unsuspended and never seen. + */ +export const pendingMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ + ...unsuspendedMembershipWhere(), + lastActiveAt: null, +}); + +/** + * Matches active memberships: unsuspended and seen at least once. + */ +export const activeMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ + ...unsuspendedMembershipWhere(), + lastActiveAt: { not: null }, +}); + /** * Checks to see if the given organization has seat availability. Seat * availability is determined by the `seats` parameter in the offline license @@ -21,12 +51,11 @@ export const getDefaultMemberRole = async (): Promise => export const orgHasAvailability = async (orgId: number, tx: Prisma.TransactionClient): 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. + // Pending and suspended members are preserved but don't consume seats. const activeUserCount = await tx.userToOrg.count({ where: { orgId, - isActive: true, + ...activeMembershipWhere(), }, }); diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index bb759476d..7652bd22e 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => { headers: vi.fn(async (): Promise => new Headers()), hasEntitlement: vi.fn((_entitlement: string) => false), isAnonymousAccessAvailable: vi.fn(() => false), + syncWithLighthouse: vi.fn(async (_orgId: number) => undefined), env: {} as Record, } }); @@ -39,6 +40,10 @@ vi.mock('server-only', () => ({ default: vi.fn(), })); +vi.mock('@/features/billing/servicePing', () => ({ + syncWithLighthouse: mocks.syncWithLighthouse, +})); + vi.mock('@sourcebot/shared', () => ({ _hasEntitlement: mocks.hasEntitlement, _getEntitlements: vi.fn(() => []), @@ -65,6 +70,8 @@ const setMockHeaders = (headers: Headers) => { mocks.headers.mockResolvedValue(headers); }; +const SUSPENDED_AT = new Date('2026-01-01T00:00:00.000Z'); + // Helper to create mock session objects const createMockSession = (overrides: Partial = {}): Session => ({ user: { @@ -87,18 +94,10 @@ beforeEach(() => { mocks.hasEntitlement.mockReturnValue(false); mocks.isAnonymousAccessAvailable.mockReturnValue(false); // getAuthContext fires `prisma.user.update().catch(...)` and - // `prisma.userToOrg.update().catch(...)` to bump lastActiveAt; without a + // `prisma.userToOrg.updateMany().catch(...)` to bump lastActiveAt; without a // default, the reset mock returns undefined and the .catch chain throws. prisma.user.update.mockResolvedValue(MOCK_USER_WITH_ACCOUNTS); - prisma.userToOrg.update.mockResolvedValue({ - orgId: MOCK_ORG.id, - userId: 'test-user-id', - joinedAt: new Date(), - role: OrgRole.MEMBER, - isActive: true, - scimExternalId: null, - lastActiveAt: new Date(), - }); + prisma.userToOrg.updateMany.mockResolvedValue({ count: 0 }); // Reset env flags between tests Object.keys(mocks.env).forEach(key => delete mocks.env[key]); }); @@ -329,7 +328,7 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -349,6 +348,69 @@ describe('getAuthContext', () => { }); }); + test('should sync with Lighthouse when a pending member becomes active for the first time', 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, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: null, + role: OrgRole.MEMBER, + }); + prisma.userToOrg.updateMany.mockResolvedValue({ count: 1 }); + + setMockSession(createMockSession({ user: { id: userId } })); + await getAuthContext(); + await Promise.resolve(); + + expect(prisma.userToOrg.updateMany).toHaveBeenCalledWith({ + where: { + orgId: MOCK_ORG.id, + userId, + suspendedAt: null, + lastActiveAt: null, + }, + data: { lastActiveAt: expect.any(Date) }, + }); + expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(MOCK_ORG.id); + }); + + test('should not sync with Lighthouse when another request already marked the member active', 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, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: null, + role: OrgRole.MEMBER, + }); + prisma.userToOrg.updateMany.mockResolvedValue({ count: 0 }); + + setMockSession(createMockSession({ user: { id: userId } })); + await getAuthContext(); + await Promise.resolve(); + + expect(mocks.syncWithLighthouse).not.toHaveBeenCalled(); + }); + test('should return a auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ @@ -362,7 +424,7 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -421,7 +483,7 @@ describe('getAuthContext', () => { }); }); - test('should not grant a role when the membership is SCIM-deactivated (isActive: false), even though the membership row exists', async () => { + test('should not grant a role when the membership is suspended, even though the membership row exists', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ ...MOCK_USER_WITH_ACCOUNTS, @@ -434,7 +496,7 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: false, + suspendedAt: SUSPENDED_AT, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -452,7 +514,7 @@ describe('getAuthContext', () => { }); }); - 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 () => { + test('should not grant a role to a suspended 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, @@ -465,7 +527,7 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: false, + suspendedAt: SUSPENDED_AT, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -498,9 +560,9 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, - lastActiveAt: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); @@ -523,9 +585,9 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, - lastActiveAt: null, + lastActiveAt: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); @@ -549,9 +611,9 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, - lastActiveAt: null, + lastActiveAt: null, role: OrgRole.MEMBER, }); setMockSession(createMockSession({ user: { id: userId } })); @@ -585,7 +647,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -617,7 +679,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -650,7 +712,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -683,7 +745,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -721,7 +783,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -759,7 +821,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -797,7 +859,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -835,7 +897,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -866,7 +928,7 @@ describe('withAuth', () => { expect(result).toStrictEqual(notAuthenticated()); }); - test('should return a service error when the membership is SCIM-deactivated (isActive: false), even with a valid session', async () => { + test('should return a service error when the membership is suspended, even with a valid session', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ ...MOCK_USER_WITH_ACCOUNTS, @@ -879,7 +941,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: false, + suspendedAt: SUSPENDED_AT, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -892,7 +954,7 @@ describe('withAuth', () => { expect(result).toStrictEqual(notAuthenticated()); }); - test('should deny a SCIM-deactivated member authenticating via API key', async () => { + test('should deny a suspended member authenticating via API key', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ ...MOCK_USER_WITH_ACCOUNTS, @@ -905,7 +967,7 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: false, + suspendedAt: SUSPENDED_AT, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -938,7 +1000,7 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -971,7 +1033,7 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -1004,7 +1066,7 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -1042,7 +1104,7 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -1080,7 +1142,7 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, @@ -1118,7 +1180,7 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.OWNER, @@ -1156,7 +1218,7 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, - isActive: true, + suspendedAt: null, scimExternalId: null, lastActiveAt: null, role: OrgRole.MEMBER, diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 40898e868..28fe63e55 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -9,6 +9,8 @@ import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "../lib/errorCodes"; import { isServiceError } from "../lib/utils"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; +import { syncWithLighthouse } from "@/features/billing/servicePing"; +import { pendingMembershipWhere } from "@/features/membership/utils"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -85,10 +87,10 @@ export const getAuthContext = async (): Promise { return; } - // Fired without a await to avoid blocking. + const wasPending = membership.suspendedAt == null && membership.lastActiveAt == null; + + // Fired without a await to avoid blocking. This normally just refreshes the + // membership's activity timestamp, but the first successful write also moves + // a provisioned member from "pending" to "active" for billing/reporting. The + // null `lastActiveAt` predicate in that first-write case acts as a + // concurrency guard, so only the request that wins the transition syncs + // Lighthouse. void __unsafePrisma.userToOrg - .update({ + .updateMany({ where: { - orgId_userId: { - orgId: membership.orgId, - userId: membership.userId, - }, + orgId: membership.orgId, + userId: membership.userId, + ...(wasPending ? pendingMembershipWhere() : {}), }, data: { lastActiveAt: new Date(now) }, }) + .then(({ count }) => { + if (wasPending && count === 1) { + void syncWithLighthouse(membership.orgId).catch(() => { /* best effort. */ }); + } + }) .catch(() => { /* updating the lastActiveAt is best effort. */ }); }; @@ -293,5 +306,3 @@ export const getVerifiedApiObject = async (apiKeyString: string): Promise Date: Thu, 25 Jun 2026 16:38:35 -0700 Subject: [PATCH 11/17] handle pending -> active state with a capped organization --- packages/web/src/app/error.tsx | 8 +- .../components/orgAtCapacityCard.tsx | 50 ++++++++ .../membership/membership.service.test.ts | 55 +++++++- .../features/membership/membership.service.ts | 53 +++++++- packages/web/src/middleware/withAuth.test.ts | 118 +++++++++++++++++- packages/web/src/middleware/withAuth.ts | 36 +++--- 6 files changed, 295 insertions(+), 25 deletions(-) create mode 100644 packages/web/src/features/membership/components/orgAtCapacityCard.tsx diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx index 1255d07eb..9d0a480fe 100644 --- a/packages/web/src/app/error.tsx +++ b/packages/web/src/app/error.tsx @@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button" import { serviceErrorSchema } from '@/lib/serviceError'; import { SourcebotLogo } from './components/sourcebotLogo'; import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; +import { ErrorCode } from "@/lib/errorCodes"; +import { OrgAtCapacityCard } from "@/features/membership/components/orgAtCapacityCard"; export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) { useEffect(() => { @@ -37,6 +39,10 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri } }, [error]); + if (errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED) { + return ; + } + return (
) -} \ No newline at end of file +} diff --git a/packages/web/src/features/membership/components/orgAtCapacityCard.tsx b/packages/web/src/features/membership/components/orgAtCapacityCard.tsx new file mode 100644 index 000000000..a5fbe1ea6 --- /dev/null +++ b/packages/web/src/features/membership/components/orgAtCapacityCard.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { Button } from "@/components/ui/button"; +import { UsersRound } from "lucide-react"; + +interface OrgAtCapacityCardProps { + onRetry: () => void; +} + +export const OrgAtCapacityCard = ({ onRetry }: OrgAtCapacityCardProps) => { + return ( +
+ + +
+
+ + +
+
+ +
+ +
+

+ Organization at capacity +

+

+ This organization has reached its seat limit. Ask an owner to add seats or deactivate an inactive member. +

+
+
+ + +
+
+
+ ); +}; diff --git a/packages/web/src/features/membership/membership.service.test.ts b/packages/web/src/features/membership/membership.service.test.ts index 74d77d870..cfedaaa9d 100644 --- a/packages/web/src/features/membership/membership.service.test.ts +++ b/packages/web/src/features/membership/membership.service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { ensureActiveMember, removeMember, setMemberRole, setMembershipSuspended } from './membership.service'; +import { activatePendingMembership, ensureActiveMember, removeMember, setMemberRole, setMembershipSuspended } from './membership.service'; import { prisma, MOCK_USER_WITH_ACCOUNTS } from '@/__mocks__/prisma'; import { OrgRole, type UserToOrg } from '@sourcebot/db'; import { ErrorCode } from '@/lib/errorCodes'; @@ -19,6 +19,8 @@ vi.mock('@/prisma', async () => { vi.mock('server-only', () => ({ default: vi.fn() })); vi.mock('@/features/membership/utils', () => ({ orgHasAvailability: mocks.orgHasAvailability, + activeMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: { not: null } }), + pendingMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: null }), unsuspendedMembershipWhere: () => ({ suspendedAt: null }), })); vi.mock('@/features/billing/servicePing', () => ({ syncWithLighthouse: mocks.syncWithLighthouse })); @@ -51,6 +53,57 @@ beforeEach(() => { (prisma.$transaction as any).mockImplementation(async (cb: any) => cb(prisma)); }); +describe('activatePendingMembership', () => { + test('activates a pending unsuspended membership with a guarded update and syncs Lighthouse', async () => { + prisma.userToOrg.updateMany.mockResolvedValue({ count: 1 }); + + const result = await activatePendingMembership(makeMembership()); + + expect(result).toBeNull(); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function)); + expect(mocks.orgHasAvailability).toHaveBeenCalledWith(ORG_ID, prisma); + expect(prisma.userToOrg.updateMany).toHaveBeenCalledWith({ + where: { + orgId: ORG_ID, + userId: USER_ID, + suspendedAt: null, + lastActiveAt: null, + }, + data: { lastActiveAt: expect.any(Date) }, + }); + expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(ORG_ID); + }); + + test('returns a seat-limit error when active seats are at the cap', async () => { + mocks.orgHasAvailability.mockResolvedValue(false); + + const result = await activatePendingMembership(makeMembership()); + + expect(isServiceError(result)).toBe(true); + expect((result as ServiceError).errorCode).toBe(ErrorCode.ORG_SEAT_COUNT_REACHED); + expect(prisma.userToOrg.updateMany).not.toHaveBeenCalled(); + expect(mocks.syncWithLighthouse).not.toHaveBeenCalled(); + }); + + test('does not activate non-pending memberships', async () => { + expect(await activatePendingMembership(makeMembership({ lastActiveAt: new Date() }))).toBeNull(); + expect(await activatePendingMembership(makeMembership({ suspendedAt: SUSPENDED_AT }))).toBeNull(); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.userToOrg.updateMany).not.toHaveBeenCalled(); + expect(mocks.syncWithLighthouse).not.toHaveBeenCalled(); + }); + + test('does not sync Lighthouse when the guarded update does not activate the row', async () => { + prisma.userToOrg.updateMany.mockResolvedValue({ count: 0 }); + + const result = await activatePendingMembership(makeMembership()); + + expect(result).toBeNull(); + expect(mocks.syncWithLighthouse).not.toHaveBeenCalled(); + }); +}); + describe('ensureActiveMember', () => { test('creates a new active membership when none exists', async () => { const created = makeMembership(); diff --git a/packages/web/src/features/membership/membership.service.ts b/packages/web/src/features/membership/membership.service.ts index 1470e3a61..20d951412 100644 --- a/packages/web/src/features/membership/membership.service.ts +++ b/packages/web/src/features/membership/membership.service.ts @@ -3,7 +3,7 @@ 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 { activeMembershipWhere, orgHasAvailability } from "@/features/membership/utils"; +import { activeMembershipWhere, orgHasAvailability, pendingMembershipWhere } from "@/features/membership/utils"; import { notFound, type ServiceError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { __unsafePrisma as prisma } from "@/prisma"; @@ -16,6 +16,47 @@ export interface EnsureActiveMemberOptions { scimExternalId?: string; } +/** + * Moves an unsuspended pending membership into the active seat set. This is the + * auth-path admission gate for provisioned users: SCIM/group sync can create + * pending memberships above the offline seat cap, but the first actual login + * must reserve a seat before the user gets access. + */ +export const activatePendingMembership = async ( + membership: UserToOrg, +): Promise => { + if (membership.suspendedAt != null || membership.lastActiveAt != null) { + return null; + } + + const activated = await prisma.$transaction(async (tx) => { + if (!(await orgHasAvailability(membership.orgId, tx))) { + return seatLimitReached(); + } + + const result = await tx.userToOrg.updateMany({ + where: { + orgId: membership.orgId, + userId: membership.userId, + ...pendingMembershipWhere(), + }, + data: { lastActiveAt: new Date() }, + }); + + return result.count === 1; + }); + + if (isServiceError(activated)) { + return activated; + } + + if (activated) { + await syncWithLighthouse(membership.orgId).catch(() => { /* best effort */ }); + } + + return null; +}; + /** * Ensures the user has an unsuspended membership. Unsuspended: returned * unchanged. Suspended: reactivated (re-checks the seat cap). Otherwise: created. @@ -69,7 +110,7 @@ export const ensureActiveMember = async ( }); return created; - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + }); if (isServiceError(membership)) { return membership; @@ -124,7 +165,7 @@ export const removeMember = async ( }); return null; - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + }); if (!isServiceError(result)) { await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); @@ -185,7 +226,7 @@ export const setMemberRole = async ( didChange = true; return null; - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + }); if (!isServiceError(result) && didChange) { await createAudit({ @@ -239,7 +280,7 @@ export const setMembershipSuspended = async ( }); didChange = true; return target; - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + }); if (!isServiceError(result) && didChange) { await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); @@ -287,7 +328,7 @@ export const setMembershipSuspended = async ( }); didChange = true; return target; - }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + }); if (!isServiceError(result) && didChange) { await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 7652bd22e..fc55ac6c8 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => { hasEntitlement: vi.fn((_entitlement: string) => false), isAnonymousAccessAvailable: vi.fn(() => false), syncWithLighthouse: vi.fn(async (_orgId: number) => undefined), + getSeatCap: vi.fn(() => undefined as number | undefined), env: {} as Record, } }); @@ -53,6 +54,7 @@ vi.mock('@sourcebot/shared', () => ({ API_KEY_PREFIX: 'sbk_', LEGACY_API_KEY_PREFIX: 'sourcebot-', env: mocks.env, + getSeatCap: mocks.getSeatCap, createLogger: vi.fn(() => ({ info: vi.fn(), warn: vi.fn(), @@ -98,6 +100,9 @@ beforeEach(() => { // default, the reset mock returns undefined and the .catch chain throws. prisma.user.update.mockResolvedValue(MOCK_USER_WITH_ACCOUNTS); prisma.userToOrg.updateMany.mockResolvedValue({ count: 0 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (prisma.$transaction as any).mockImplementation(async (cb: any) => cb(prisma)); + mocks.getSeatCap.mockReturnValue(undefined); // Reset env flags between tests Object.keys(mocks.env).forEach(key => delete mocks.env[key]); }); @@ -370,7 +375,6 @@ describe('getAuthContext', () => { setMockSession(createMockSession({ user: { id: userId } })); await getAuthContext(); - await Promise.resolve(); expect(prisma.userToOrg.updateMany).toHaveBeenCalledWith({ where: { @@ -384,6 +388,83 @@ describe('getAuthContext', () => { expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(MOCK_ORG.id); }); + test('should activate a pending member when the org has an available seat', async () => { + const userId = 'test-user-id'; + mocks.getSeatCap.mockReturnValue(2); + prisma.userToOrg.count.mockResolvedValue(1); + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: null, + role: OrgRole.MEMBER, + }); + prisma.userToOrg.updateMany.mockResolvedValue({ count: 1 }); + + setMockSession(createMockSession({ user: { id: userId } })); + const cb = vi.fn(); + const result = await withAuth(cb); + + expect(result).toBeUndefined(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ + user: expect.objectContaining({ id: userId }), + org: MOCK_ORG, + role: OrgRole.MEMBER, + })); + expect(prisma.userToOrg.count).toHaveBeenCalledWith({ + where: { + orgId: MOCK_ORG.id, + suspendedAt: null, + lastActiveAt: { not: null }, + }, + }); + expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(MOCK_ORG.id); + }); + + test('should return a seat-limit service error when a pending member logs in at capacity', async () => { + const userId = 'test-user-id'; + mocks.getSeatCap.mockReturnValue(1); + prisma.userToOrg.count.mockResolvedValue(1); + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: null, + role: OrgRole.MEMBER, + }); + + setMockSession(createMockSession({ user: { id: userId } })); + const cb = vi.fn(); + const result = await withAuth(cb); + + expect(cb).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: 'Organization is at max capacity', + }); + expect(prisma.userToOrg.updateMany).not.toHaveBeenCalled(); + expect(mocks.syncWithLighthouse).not.toHaveBeenCalled(); + }); + test('should not sync with Lighthouse when another request already marked the member active', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ @@ -411,6 +492,40 @@ describe('getAuthContext', () => { expect(mocks.syncWithLighthouse).not.toHaveBeenCalled(); }); + test('should not block an already-active member when the org is at capacity', async () => { + const userId = 'test-user-id'; + mocks.getSeatCap.mockReturnValue(1); + prisma.userToOrg.count.mockResolvedValue(1); + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + suspendedAt: null, + scimExternalId: null, + lastActiveAt: new Date('2026-01-01T00:00:00.000Z'), + role: OrgRole.MEMBER, + }); + + setMockSession(createMockSession({ user: { id: userId } })); + const cb = vi.fn(); + const result = await withAuth(cb); + + expect(result).toBeUndefined(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ + user: expect.objectContaining({ id: userId }), + org: MOCK_ORG, + role: OrgRole.MEMBER, + })); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + test('should return a auth context object if a valid session is present and the user is a member of the organization with OWNER role', async () => { const userId = 'test-user-id'; prisma.user.findUnique.mockResolvedValue({ @@ -512,6 +627,7 @@ describe('getAuthContext', () => { org: MOCK_ORG, prisma: undefined, }); + expect(prisma.userToOrg.updateMany).not.toHaveBeenCalled(); }); test('should not grant a role to a suspended member authenticating via API key (API-key auth bypasses the JWT sessionVersion logout, so this gate is what denies them)', async () => { diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 28fe63e55..a648d2305 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -9,8 +9,7 @@ import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "../lib/errorCodes"; import { isServiceError } from "../lib/utils"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; -import { syncWithLighthouse } from "@/features/billing/servicePing"; -import { pendingMembershipWhere } from "@/features/membership/utils"; +import { activatePendingMembership } from "@/features/membership/membership.service"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -110,6 +109,19 @@ export const getAuthContext = async (): Promise { }; const updateMembershipLastActiveAt = (membership: UserToOrg) => { + if (membership.suspendedAt != null) { + return; + } + const now = Date.now(); if ( membership.lastActiveAt && @@ -147,28 +163,16 @@ const updateMembershipLastActiveAt = (membership: UserToOrg) => { return; } - const wasPending = membership.suspendedAt == null && membership.lastActiveAt == null; - - // Fired without a await to avoid blocking. This normally just refreshes the - // membership's activity timestamp, but the first successful write also moves - // a provisioned member from "pending" to "active" for billing/reporting. The - // null `lastActiveAt` predicate in that first-write case acts as a - // concurrency guard, so only the request that wins the transition syncs - // Lighthouse. + // Fired without a await to avoid blocking; this is only a freshness refresh + // for already-active memberships, not seat admission. void __unsafePrisma.userToOrg .updateMany({ where: { orgId: membership.orgId, userId: membership.userId, - ...(wasPending ? pendingMembershipWhere() : {}), }, data: { lastActiveAt: new Date(now) }, }) - .then(({ count }) => { - if (wasPending && count === 1) { - void syncWithLighthouse(membership.orgId).catch(() => { /* best effort. */ }); - } - }) .catch(() => { /* updating the lastActiveAt is best effort. */ }); }; From 36ac38ca042b444de7d1d88ec8775e7c6e5ec8a3 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 25 Jun 2026 18:44:27 -0700 Subject: [PATCH 12/17] tighten control under what state a member can be demoted / promoted --- packages/web/src/app/(app)/layout.tsx | 10 +++--- .../(app)/settings/members/membersTable.tsx | 6 +++- .../api/(server)/ee/askmcp/callback/route.ts | 4 +-- packages/web/src/app/invite/page.tsx | 4 +-- packages/web/src/app/onboard/page.tsx | 4 +-- packages/web/src/app/redeem/page.tsx | 4 +-- .../web/src/features/membership/errors.ts | 8 ++++- .../membership/membership.service.test.ts | 31 +++++++++++++++++-- .../features/membership/membership.service.ts | 20 +++++++----- packages/web/src/features/membership/utils.ts | 6 ++-- packages/web/src/lib/errorCodes.ts | 1 + 11 files changed, 69 insertions(+), 29 deletions(-) diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 5d03f8061..5120f6bcc 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -23,7 +23,7 @@ import { GitHubStarToast } from "./components/githubStarToast"; import { getLinkedAccounts } from "@/ee/features/sso/actions"; import { BannerSlot } from "./components/banners/bannerSlot"; import { BannerHeightObserver } from "./components/banners/bannerHeightObserver"; -import { unsuspendedMembershipWhere } from "@/features/membership/utils"; +import { activeOrPendingMembershipWhere } from "@/features/membership/utils"; import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api"; import { OrgRole } from "@sourcebot/db"; import { ServiceErrorException } from "@/lib/serviceError"; @@ -62,13 +62,13 @@ export default async function Layout(props: LayoutProps) { // If the user is authenticated, we must check if they're a member of the org if (session) { - const activeMembership = await __unsafePrisma.userToOrg.findUnique({ + const membership = await __unsafePrisma.userToOrg.findUnique({ where: { orgId_userId: { orgId: org.id, userId: session.user.id, }, - ...unsuspendedMembershipWhere(), + ...activeOrPendingMembershipWhere(), }, include: { user: true @@ -79,7 +79,7 @@ export default async function Layout(props: LayoutProps) { // 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. - if (!activeMembership) { + if (!membership) { if (await isScimEnabled(org)) { return ; } @@ -102,7 +102,7 @@ export default async function Layout(props: LayoutProps) { } } - role = activeMembership.role; + role = membership.role; } else { // If the user isn't authenticated and anonymous access isn't enabled, we need to redirect them to the login page. if (!anonymousAccessEnabled) { diff --git a/packages/web/src/app/(app)/settings/members/membersTable.tsx b/packages/web/src/app/(app)/settings/members/membersTable.tsx index c190b90d1..55cedda43 100644 --- a/packages/web/src/app/(app)/settings/members/membersTable.tsx +++ b/packages/web/src/app/(app)/settings/members/membersTable.tsx @@ -349,7 +349,11 @@ export const MembersTable = ({ }, [data, filter, searchQuery]); const activeOwnerCount = useMemo(() => { - return members.filter((member) => member.suspendedAt == null && member.role === OrgRole.OWNER).length; + return members.filter((member) => + member.suspendedAt == null && + member.lastActiveAt != null && + member.role === OrgRole.OWNER + ).length; }, [members]); const columns = useMemo(() => getColumns({ diff --git a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts index c770da185..591ef7b4d 100644 --- a/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts +++ b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts @@ -16,7 +16,7 @@ import { getMcpOAuthReturnToFromState } from '@/ee/features/chat/mcp/mcpOAuthRet import { captureEvent } from '@/lib/posthog'; import { getMcpAuthMode, getMcpConnectorEntryPoint, getMcpConnectorFailureReason } from '@/ee/features/chat/mcp/analytics'; import { getEnabledMcpOAuthScopeNames } from '@/ee/features/chat/mcp/oauthScopeUtils'; -import { unsuspendedMembershipWhere } from '@/features/membership/utils'; +import { activeOrPendingMembershipWhere } from '@/features/membership/utils'; const logger = createLogger('mcp-oauth-callback'); const reconnectMessage = 'This connector authorization could not be completed. Please reconnect the connector.'; @@ -154,7 +154,7 @@ export const GET = apiHandler(async (request: NextRequest) => { orgId: userServer.server.orgId, userId: session.user.id, }, - ...unsuspendedMembershipWhere(), + ...activeOrPendingMembershipWhere(), }, }); diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx index 78df01490..7930cf09f 100644 --- a/packages/web/src/app/invite/page.tsx +++ b/packages/web/src/app/invite/page.tsx @@ -8,7 +8,7 @@ import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard"; import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard"; import { isScimEnabled } from "@/features/scim/utils"; -import { unsuspendedMembershipWhere } from "@/features/membership/utils"; +import { activeOrPendingMembershipWhere } from "@/features/membership/utils"; interface InvitePageProps { searchParams: Promise<{ @@ -39,7 +39,7 @@ export default async function InvitePage(props: InvitePageProps) { orgId: org.id, userId: session.user.id }, - ...unsuspendedMembershipWhere(), + ...activeOrPendingMembershipWhere(), } }); diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index cb0184c8e..1c7e89be8 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -16,7 +16,7 @@ import { redirect } from "next/navigation"; import { env } from "@sourcebot/shared"; import { hasEntitlement, isValidLicenseActive } from "@/lib/entitlements"; import { GcpIapAuth } from "@/app/(app)/components/gcpIapAuth"; -import { unsuspendedMembershipWhere } from "@/features/membership/utils"; +import { activeOrPendingMembershipWhere } from "@/features/membership/utils"; interface OnboardingProps { searchParams?: Promise<{ step?: string }>; @@ -52,7 +52,7 @@ export default async function Onboarding(props: OnboardingProps) { orgId: org.id, userId: session.user.id }, - ...unsuspendedMembershipWhere(), + ...activeOrPendingMembershipWhere(), } }); diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index a851e4de0..243f24664 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -9,7 +9,7 @@ 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'; -import { unsuspendedMembershipWhere } from '@/features/membership/utils'; +import { activeOrPendingMembershipWhere } from '@/features/membership/utils'; interface RedeemPageProps { searchParams: Promise<{ @@ -40,7 +40,7 @@ export default async function RedeemPage(props: RedeemPageProps) { orgId: org.id, userId: session.user.id }, - ...unsuspendedMembershipWhere(), + ...activeOrPendingMembershipWhere(), } }); diff --git a/packages/web/src/features/membership/errors.ts b/packages/web/src/features/membership/errors.ts index 9926788f1..7585b33ec 100644 --- a/packages/web/src/features/membership/errors.ts +++ b/packages/web/src/features/membership/errors.ts @@ -23,10 +23,16 @@ export const lastOwnerDemoteError = (): ServiceError => ({ message: "Cannot demote the last owner. Promote another member to owner first.", }); +export const memberNotActiveError = (): ServiceError => ({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.MEMBER_NOT_ACTIVE, + message: "Only active members can be promoted or demoted.", +}); + // 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/membership.service.test.ts b/packages/web/src/features/membership/membership.service.test.ts index cfedaaa9d..78bd13557 100644 --- a/packages/web/src/features/membership/membership.service.test.ts +++ b/packages/web/src/features/membership/membership.service.test.ts @@ -30,6 +30,7 @@ const ORG_ID = 1; const USER_ID = 'user-1'; const ACTOR = { id: 'scim', type: 'scim_token' } as const; const SUSPENDED_AT = new Date('2026-01-01T00:00:00.000Z'); +const ACTIVE_AT = new Date('2026-01-02T00:00:00.000Z'); const makeMembership = (overrides: Partial = {}): UserToOrg => ({ orgId: ORG_ID, @@ -256,7 +257,7 @@ describe('removeMember', () => { describe('setMemberRole', () => { test('promotes a member to owner and audits it', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.MEMBER })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.MEMBER, lastActiveAt: ACTIVE_AT })); const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.OWNER, { actor: ACTOR }); @@ -268,7 +269,7 @@ describe('setMemberRole', () => { }); test('demotes an owner to member when other owners remain', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null, lastActiveAt: ACTIVE_AT })); prisma.userToOrg.count.mockResolvedValue(2); const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); @@ -278,7 +279,7 @@ describe('setMemberRole', () => { }); test('blocks demoting the last active owner', async () => { - prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null })); + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null, lastActiveAt: ACTIVE_AT })); prisma.userToOrg.count.mockResolvedValue(1); const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); @@ -288,6 +289,30 @@ describe('setMemberRole', () => { expect(prisma.userToOrg.update).not.toHaveBeenCalled(); }); + test('blocks promoting a pending member', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.MEMBER, lastActiveAt: null })); + + const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.OWNER, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect((result as ServiceError).errorCode).toBe(ErrorCode.MEMBER_NOT_ACTIVE); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + }); + + test('blocks demoting a suspended owner', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ + role: OrgRole.OWNER, + suspendedAt: SUSPENDED_AT, + lastActiveAt: ACTIVE_AT, + })); + + const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect((result as ServiceError).errorCode).toBe(ErrorCode.MEMBER_NOT_ACTIVE); + 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 })); diff --git a/packages/web/src/features/membership/membership.service.ts b/packages/web/src/features/membership/membership.service.ts index 20d951412..494123e8f 100644 --- a/packages/web/src/features/membership/membership.service.ts +++ b/packages/web/src/features/membership/membership.service.ts @@ -8,7 +8,7 @@ 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"; +import { lastOwnerDemoteError, lastOwnerError, memberNotActiveError, seatLimitReached } from "./errors"; export interface EnsureActiveMemberOptions { actor: AuditActor; @@ -25,7 +25,7 @@ export interface EnsureActiveMemberOptions { export const activatePendingMembership = async ( membership: UserToOrg, ): Promise => { - if (membership.suspendedAt != null || membership.lastActiveAt != null) { + if (membership.suspendedAt !== null || membership.lastActiveAt !== null) { return null; } @@ -78,10 +78,10 @@ export const ensureActiveMember = async ( where: { orgId_userId: { orgId, userId } }, }); - if (existing && existing.suspendedAt == null) { + if (existing && existing.suspendedAt === null) { return existing; } - if (existing && existing.suspendedAt != null) { + if (existing && existing.suspendedAt !== null) { return setMembershipSuspended(orgId, userId, false, { actor, scimExternalId, @@ -152,7 +152,7 @@ export const removeMember = async ( return notFound("Member not found in this organization"); } - if (target.role === OrgRole.OWNER && target.suspendedAt == null) { + if (target.role === OrgRole.OWNER && target.suspendedAt === null) { if ((await countActiveOwners(tx, orgId)) <= 1) { return lastOwnerError(reason); } @@ -212,8 +212,12 @@ export const setMemberRole = async ( return null; } + if (target.suspendedAt !== null || target.lastActiveAt === null) { + return memberNotActiveError(); + } + const isDemotionFromOwner = target.role === OrgRole.OWNER && role !== OrgRole.OWNER; - if (isDemotionFromOwner && target.suspendedAt == null) { + if (isDemotionFromOwner && target.suspendedAt === null) { if ((await countActiveOwners(tx, orgId)) <= 1) { return lastOwnerDemoteError(); } @@ -268,7 +272,7 @@ export const setMembershipSuspended = async ( if (!target) { return notFound("Member not found in this organization"); } - if (target.suspendedAt != null) { + if (target.suspendedAt !== null) { return target; } @@ -305,7 +309,7 @@ export const setMembershipSuspended = async ( return notFound("Member not found in this organization"); } - if (target.suspendedAt == null) { + if (target.suspendedAt === null) { if (scimExternalId && target.scimExternalId !== scimExternalId) { target = await tx.userToOrg.update({ where: { orgId_userId: { orgId, userId } }, diff --git a/packages/web/src/features/membership/utils.ts b/packages/web/src/features/membership/utils.ts index e098cc1b1..572e65288 100644 --- a/packages/web/src/features/membership/utils.ts +++ b/packages/web/src/features/membership/utils.ts @@ -23,7 +23,7 @@ export const suspendedMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ /** * Matches unsuspended memberships, regardless of activity. */ -export const unsuspendedMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ +export const activeOrPendingMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ suspendedAt: null, }); @@ -31,7 +31,7 @@ export const unsuspendedMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ * Matches pending memberships: unsuspended and never seen. */ export const pendingMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ - ...unsuspendedMembershipWhere(), + ...activeOrPendingMembershipWhere(), lastActiveAt: null, }); @@ -39,7 +39,7 @@ export const pendingMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ * Matches active memberships: unsuspended and seen at least once. */ export const activeMembershipWhere = (): Prisma.UserToOrgWhereInput => ({ - ...unsuspendedMembershipWhere(), + ...activeOrPendingMembershipWhere(), lastActiveAt: { not: null }, }); diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 6f20a5e5e..963cebd0a 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -34,6 +34,7 @@ export enum ErrorCode { INVALID_GIT_REF = 'INVALID_GIT_REF', LAST_OWNER_CANNOT_BE_DEMOTED = 'LAST_OWNER_CANNOT_BE_DEMOTED', LAST_OWNER_CANNOT_BE_REMOVED = 'LAST_OWNER_CANNOT_BE_REMOVED', + MEMBER_NOT_ACTIVE = 'MEMBER_NOT_ACTIVE', API_KEY_USAGE_DISABLED = 'API_KEY_USAGE_DISABLED', MCP_SERVER_ALREADY_EXISTS = 'MCP_SERVER_ALREADY_EXISTS', MCP_SERVER_NOT_FOUND = 'MCP_SERVER_NOT_FOUND', From 11b1607f869589d1f46eb2b76003fa629e1dcde5 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 25 Jun 2026 20:16:57 -0700 Subject: [PATCH 13/17] add create invite dialog --- .../settings/members/inviteMembersDialog.tsx | 229 ++++++++++++++++++ .../settings/members/membersTableActions.tsx | 5 +- .../settings/members/membersTableView.tsx | 2 + 3 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx diff --git a/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx new file mode 100644 index 000000000..bac881928 --- /dev/null +++ b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/components/hooks/use-toast"; +import { createInvites } from "@/features/membership/actions"; +import { isServiceError } from "@/lib/utils"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface InviteMembersDialogProps { + className?: string; +} + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const parseEmails = (value: string) => { + return value + .split(",") + .map((email) => email.trim()) + .filter(Boolean); +}; + +const inviteMembersFormSchema = z.object({ + emails: z.string().trim().superRefine((value, ctx) => { + const emails = parseEmails(value); + + if (emails.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter at least one email address.", + }); + return; + } + + const invalidEmail = emails.find((email) => !emailPattern.test(email)); + if (invalidEmail) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${invalidEmail} is not a valid email address.`, + }); + return; + } + + const normalizedEmails = emails.map((email) => email.toLowerCase()); + if (new Set(normalizedEmails).size !== normalizedEmails.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Duplicate email addresses are not allowed.", + }); + } + }), +}); + +type InviteMembersFormValues = z.infer; + +export const InviteMembersDialog = ({ className }: InviteMembersDialogProps) => { + const [open, setOpen] = useState(false); + const [shouldFocusEmails, setShouldFocusEmails] = useState(false); + const emailsTextareaRef = useRef(null); + const router = useRouter(); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + const form = useForm({ + resolver: zodResolver(inviteMembersFormSchema), + defaultValues: { + emails: "", + }, + }); + + const isSubmitting = form.formState.isSubmitting; + const emailsValue = form.watch("emails"); + const emails = parseEmails(emailsValue); + const emailsRegistration = form.register("emails"); + + const focusEmailsField = () => { + form.setFocus("emails"); + window.setTimeout(() => { + emailsTextareaRef.current?.focus({ preventScroll: true }); + }, 0); + }; + + useEffect(() => { + if (!shouldFocusEmails || isSubmitting) { + return; + } + + focusEmailsField(); + setShouldFocusEmails(false); + // `form` is intentionally omitted here. The effect should run when the + // failed submit settles and the textarea is no longer disabled. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldFocusEmails, isSubmitting]); + + const handleOpenChange = (nextOpen: boolean) => { + if (isSubmitting) { + return; + } + + if (!nextOpen && emailsValue.trim().length > 0) { + captureEvent("wa_invite_member_card_invite_cancel", { + num_emails: emails.length, + }); + } + + if (!nextOpen) { + form.reset(); + } + + setOpen(nextOpen); + }; + + const onSubmit = async (values: InviteMembersFormValues) => { + const emails = parseEmails(values.emails); + try { + const result = await createInvites(emails); + if (isServiceError(result)) { + form.setError("emails", { + type: "server", + message: result.message, + }, { + shouldFocus: true, + }); + setShouldFocusEmails(true); + toast({ description: `Failed to send invites. Reason: ${result.message}` }); + captureEvent("wa_invite_member_card_invite_fail", { + errorCode: result.errorCode, + num_emails: emails.length, + }); + return; + } + + toast({ description: `Successfully sent ${emails.length} invite${emails.length === 1 ? "" : "s"}.` }); + captureEvent("wa_invite_member_card_invite_success", { + num_emails: emails.length, + }); + form.reset(); + setOpen(false); + router.refresh(); + } catch { + form.setError("emails", { + type: "server", + message: "Something went wrong while sending invites.", + }, { + shouldFocus: true, + }); + setShouldFocusEmails(true); + toast({ description: "Failed to send invites." }); + } + }; + + const onInvalidSubmit = () => { + setShouldFocusEmails(true); + }; + + const isSendDisabled = isSubmitting || emails.length === 0; + + return ( + + + + + Invite members + + Invite new members to your organization. + + +
+ + ( + + Email + +