diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa46b346e..e5066dbad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the ability to configure email code and credentials login from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303)
- Added a list of configured SSO providers from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303)
+- [EE] Added a SCIM 2.0 server for automated user provisioning and deprovisioning from identity providers (Okta, Entra). [#1306](https://github.com/sourcebot-dev/sourcebot/pull/1306)
### Fixed
- Validated that `SOURCEBOT_ENCRYPTION_KEY` is exactly 32 characters at startup, failing fast with an actionable message instead of a runtime encryption error. [#1305](https://github.com/sourcebot-dev/sourcebot/pull/1305)
diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json
index 126baf4c3..3fe693a3e 100644
--- a/docs/api-reference/sourcebot-public.openapi.json
+++ b/docs/api-reference/sourcebot-public.openapi.json
@@ -1148,6 +1148,11 @@
"MEMBER"
]
},
+ "suspendedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
"createdAt": {
"type": "string",
"format": "date-time"
@@ -1163,6 +1168,7 @@
"name",
"email",
"role",
+ "suspendedAt",
"createdAt",
"lastActivityAt"
]
diff --git a/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql
new file mode 100644
index 000000000..22838d192
--- /dev/null
+++ b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql
@@ -0,0 +1,29 @@
+-- AlterTable
+ALTER TABLE "Org" ADD COLUMN "isScimEnabled" BOOLEAN NOT NULL DEFAULT false;
+
+-- AlterTable
+ALTER TABLE "UserToOrg" ADD COLUMN "suspendedAt" TIMESTAMP(3),
+ADD COLUMN "scimExternalId" TEXT;
+
+-- CreateTable
+CREATE TABLE "ScimToken" (
+ "name" TEXT NOT NULL,
+ "hash" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUsedAt" TIMESTAMP(3),
+ "orgId" INTEGER NOT NULL,
+
+ CONSTRAINT "ScimToken_pkey" PRIMARY KEY ("hash")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ScimToken_hash_key" ON "ScimToken"("hash");
+
+-- CreateIndex
+CREATE INDEX "ScimToken_orgId_idx" ON "ScimToken"("orgId");
+
+-- CreateIndex
+CREATE INDEX "UserToOrg_orgId_scimExternalId_idx" ON "UserToOrg"("orgId", "scimExternalId");
+
+-- AddForeignKey
+ALTER TABLE "ScimToken" ADD CONSTRAINT "ScimToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql
new file mode 100644
index 000000000..ccb3d03e9
--- /dev/null
+++ b/packages/db/prisma/migrations/20260624194710_add_lastactiveat_to_usertoorg/migration.sql
@@ -0,0 +1,15 @@
+-- AlterTable
+ALTER TABLE "UserToOrg" ADD COLUMN "lastActiveAt" TIMESTAMP(3);
+
+-- Backfill per-membership activity from the global User.lastActiveAt. In a
+-- single-tenant deployment a user belongs to exactly one org, so the global
+-- timestamp is exactly the per-org timestamp. In multi-tenant deployments this
+-- seeds every membership with the user's global last-active time as the best
+-- available signal; the per-org value diverges naturally from the next
+-- authenticated action onward. Without this, every existing membership would
+-- read as "never active" (NULL) until each member's next request.
+UPDATE "UserToOrg" AS uto
+SET "lastActiveAt" = u."lastActiveAt"
+FROM "User" AS u
+WHERE uto."userId" = u."id"
+ AND u."lastActiveAt" IS NOT NULL;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 54444bbe2..4479b2f40 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -272,9 +272,12 @@ model Org {
connections Connection[]
repos Repo[]
apiKeys ApiKey[]
+ scimTokens ScimToken[]
isOnboarded Boolean @default(false)
imageUrl String?
+ isScimEnabled Boolean @default(false)
+
/// @deprecated This property can be controlled by the environment
/// variable `REQUIRE_APPROVAL_NEW_MEMBERS`. To ensure that we use
/// the correct setting, use the helper function `isMemberApprovalRequired`
@@ -397,7 +400,23 @@ model UserToOrg {
role OrgRole @default(MEMBER)
+ /// When set, the membership is suspended and the user is treated as a
+ /// non-member for auth purposes (see `getAuthContext`).
+ suspendedAt DateTime?
+
+ /// The IdP-supplied `externalId` for this membership when provisioned via
+ /// SCIM. Null for members that joined through invites or self-serve sign-up.
+ scimExternalId String?
+
+ /// Last time the user performed an authenticated action *in this org*. Unlike
+ /// `User.lastActiveAt` (which is global to the instance), this is scoped to
+ /// the membership, so it distinguishes a member who has been active in this
+ /// org from one who was provisioned but never signed in here. Null means the
+ /// member has never been active in this org.
+ lastActiveAt DateTime?
+
@@id([orgId, userId])
+ @@index([orgId, scimExternalId])
}
model ApiKey {
@@ -414,6 +433,23 @@ model ApiKey {
createdById String
}
+/// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate
+/// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is
+/// not tied to a user — it acts on behalf of the SCIM integration for the
+/// whole org. Only the HMAC hash of the secret is stored.
+model ScimToken {
+ name String
+ hash String @id @unique
+
+ createdAt DateTime @default(now())
+ lastUsedAt DateTime?
+
+ org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
+ orgId Int
+
+ @@index([orgId])
+}
+
model Audit {
id String @id @default(cuid())
timestamp DateTime @default(now())
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 5bb33d146..c299ef1cc 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -11,6 +11,7 @@ export const LEGACY_API_KEY_PREFIX = 'sourcebot-';
export const API_KEY_PREFIX = 'sbk_';
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';
+export const SCIM_TOKEN_PREFIX = 'sbscim_';
/**
* Default settings.
diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts
index fbb4be79b..c5b8842be 100644
--- a/packages/shared/src/crypto.ts
+++ b/packages/shared/src/crypto.ts
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { env } from './env.server.js';
import { Token } from '@sourcebot/schemas/v3/shared.type';
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
-import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js';
+import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX } from './constants.js';
const algorithm = 'aes-256-cbc';
const ivLength = 16; // 16 bytes for CBC
@@ -56,6 +56,16 @@ export function generateApiKey(): { key: string; hash: string } {
};
}
+export function generateScimToken(): { token: string; hash: string } {
+ const secret = crypto.randomBytes(32).toString('hex');
+ const hash = hashSecret(secret);
+
+ return {
+ token: `${SCIM_TOKEN_PREFIX}${secret}`,
+ hash,
+ };
+}
+
export function generateOAuthToken(): { token: string; hash: string } {
const secret = crypto.randomBytes(32).toString('hex');
const hash = hashSecret(secret);
diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts
index c03b2f56e..85982c147 100644
--- a/packages/shared/src/entitlements.ts
+++ b/packages/shared/src/entitlements.ts
@@ -42,7 +42,8 @@ const ALL_ENTITLEMENTS = [
"org-management",
"oauth",
"ask",
- "mcp"
+ "mcp",
+ "scim"
] as const;
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];
diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts
index 6a09b9340..bbe52f31e 100644
--- a/packages/shared/src/index.server.ts
+++ b/packages/shared/src/index.server.ts
@@ -58,6 +58,7 @@ export {
decrypt,
hashSecret,
generateApiKey,
+ generateScimToken,
generateOAuthToken,
generateOAuthRefreshToken,
verifySignature,
diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs
index 48e01b40c..97d000fb8 100644
--- a/packages/web/next.config.mjs
+++ b/packages/web/next.config.mjs
@@ -59,6 +59,13 @@ const nextConfig = {
{
source: "/api/mcp",
destination: "/api/ee/mcp",
+ },
+ // The SCIM 2.0 server lives under /api/ee/scim/v2 (EE-licensed route
+ // tree) but is exposed at the clean /scim/v2 path that IdPs (Okta,
+ // Entra) are configured to send provisioning requests to.
+ {
+ source: "/scim/v2/:path*",
+ destination: "/api/ee/scim/v2/:path*",
}
];
},
diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts
index 5e5c28682..556d896fc 100644
--- a/packages/web/src/__mocks__/prisma.ts
+++ b/packages/web/src/__mocks__/prisma.ts
@@ -17,7 +17,7 @@ export const MOCK_ORG: Org = {
updatedAt: new Date(),
isOnboarded: true,
imageUrl: null,
- metadata: null,
+ isScimEnabled: false,
memberApprovalRequired: false,
isCredentialsLoginEnabled: true,
isEmailCodeLoginEnabled: false,
diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index efd996c1b..c64979173 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -1,26 +1,20 @@
'use server';
import { createAudit } from "@/ee/features/audit/audit";
-import { env, getSMTPConnectionURL } from "@sourcebot/shared";
import { ErrorCode } from "@/lib/errorCodes";
-import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError";
-import { __unsafePrisma } from "@/prisma";
-import { render } from "@react-email/components";
-import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared";
+import { notFound, ServiceError } from "@/lib/serviceError";
+import { sew } from "@/middleware/sew";
import { ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
-import { createLogger } from "@sourcebot/shared";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
+import { createLogger, env, generateApiKey, getTokenFromConfig } from "@sourcebot/shared";
import { StatusCodes } from "http-status-codes";
import { cookies } from "next/headers";
-import { createTransport } from "nodemailer";
-import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
-import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from "./lib/constants";
-import { RepositoryQuery } from "./lib/types";
-import { getAuthenticatedUser, withAuth, withOptionalAuth } from "./middleware/withAuth";
import { getBrowsePath } from "./app/(app)/browse/hooks/utils";
-import { sew } from "@/middleware/sew";
+import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants";
+import { RepositoryQuery } from "./lib/types";
+import { withAuth, withOptionalAuth } from "./middleware/withAuth";
const logger = createLogger('web-actions');
@@ -375,110 +369,6 @@ export const getRepoInfoByName = async (repoName: string) => sew(() =>
}
}));
-// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth
-export const createAccountRequest = async () => sew(async () => {
- const authResult = await getAuthenticatedUser();
- if (!authResult) {
- return notAuthenticated();
- }
-
- const { user } = authResult;
-
- const org = await __unsafePrisma.org.findUnique({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- });
-
- if (!org) {
- return notFound("Organization not found");
- }
-
- const existingRequest = await __unsafePrisma.accountRequest.findUnique({
- where: {
- requestedById_orgId: {
- requestedById: user.id,
- orgId: org.id,
- },
- },
- });
-
- if (existingRequest) {
- logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`);
- return {
- success: true,
- existingRequest: true,
- }
- }
-
- if (!existingRequest) {
- await __unsafePrisma.accountRequest.create({
- data: {
- requestedById: user.id,
- orgId: org.id,
- },
- });
-
- const smtpConnectionUrl = getSMTPConnectionURL();
- if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
- // TODO: This is needed because we can't fetch the origin from the request headers when this is called
- // on user creation (the header isn't set when next-auth calls onCreateUser for some reason)
- const deploymentUrl = env.AUTH_URL;
-
- const owners = await __unsafePrisma.user.findMany({
- where: {
- orgs: {
- some: {
- orgId: org.id,
- role: "OWNER",
- },
- },
- },
- });
-
- if (owners.length === 0) {
- logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`);
- } else {
- const html = await render(JoinRequestSubmittedEmail({
- baseUrl: deploymentUrl,
- requestor: {
- name: user.name ?? undefined,
- email: user.email,
- avatarUrl: user.image ?? undefined,
- },
- orgName: org.name,
- orgImageUrl: org.imageUrl ?? undefined,
- }));
-
- const ownerEmails = owners
- .map((owner) => owner.email)
- .filter((email): email is string => email !== null);
-
- const transport = createTransport(smtpConnectionUrl);
- const result = await transport.sendMail({
- to: ownerEmails,
- from: env.EMAIL_FROM_ADDRESS,
- subject: `New account request for ${org.name} on Sourcebot`,
- html,
- text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`,
- });
-
- const failed = result.rejected.concat(result.pending).filter(Boolean);
- if (failed.length > 0) {
- logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`);
- }
- }
- } else {
- logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`);
- }
- }
-
- return {
- success: true,
- existingRequest: false,
- }
-});
-
export const getSearchContexts = async () => sew(() =>
withOptionalAuth(async ({ org, prisma }) => {
const searchContexts = await prisma.searchContext.findMany({
diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx
index 39edf49df..04ff3caf6 100644
--- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx
+++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx
@@ -3,7 +3,7 @@ import { auth } from "@/auth";
import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants";
import { HomeView } from "@/hooks/useHomeView";
import { getConnectionStats } from "@/actions";
-import { getOrgAccountRequests } from "@/features/userManagement/actions";
+import { getOrgAccountRequests } from "@/features/membership/actions";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
import { OrgRole } from "@prisma/client";
diff --git a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx b/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx
deleted file mode 100644
index 85398a7db..000000000
--- a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-"use client"
-
-import { Button } from "@/components/ui/button"
-import { Clock } from "lucide-react"
-import { useState } from "react"
-import { useToast } from "@/components/hooks/use-toast"
-import { createAccountRequest } from "@/actions"
-import { isServiceError } from "@/lib/utils"
-import { useRouter } from "next/navigation"
-
-
-export function SubmitAccountRequestButton() {
- const { toast } = useToast()
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = useState(false)
-
- const handleSubmit = async () => {
- setIsSubmitting(true)
- const result = await createAccountRequest()
- if (!isServiceError(result)) {
- if (result.existingRequest) {
- toast({
- title: "Request Already Submitted",
- description: "Your request to join the organization has already been submitted. Please wait for it to be approved.",
- variant: "default",
- })
- } else {
- toast({
- title: "Request Submitted",
- description: "Your request to join the organization has been submitted.",
- variant: "default",
- })
- }
- // Refresh the page to trigger layout re-render and show PendingApprovalCard
- router.refresh()
- } else {
- toast({
- title: "Failed to Submit",
- description: `There was an error submitting your request. Reason: ${result.message}`,
- variant: "destructive",
- })
- }
- setIsSubmitting(false)
- }
-
- return (
-
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/(app)/components/submitJoinRequest.tsx b/packages/web/src/app/(app)/components/submitJoinRequest.tsx
deleted file mode 100644
index fdfdc0a20..000000000
--- a/packages/web/src/app/(app)/components/submitJoinRequest.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
-import { SourcebotLogo } from "@/app/components/sourcebotLogo"
-import { SubmitAccountRequestButton } from "./submitAccountRequestButton"
-
-export const SubmitJoinRequest = async () => {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- Request Access
-
-
- Submit a request to join this organization
-
-
-
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx
index ec355a56d..5120f6bcc 100644
--- a/packages/web/src/app/(app)/layout.tsx
+++ b/packages/web/src/app/(app)/layout.tsx
@@ -10,17 +10,20 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, OPTIONAL_PROVID
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
import { notFound, redirect } from "next/navigation";
-import { PendingApprovalCard } from "./components/pendingApproval";
-import { SubmitJoinRequest } from "./components/submitJoinRequest";
+import { PendingApprovalCard } from "../../features/membership/components/pendingApprovalCard";
+import { SubmitJoinRequestCard } from "../../features/membership/components/submitJoinRequestCard";
+import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard";
+import { isScimEnabled } from "@/features/scim/utils";
import { env, getOfflineLicenseMetadata, SOURCEBOT_VERSION, isMemberApprovalRequired } from "@sourcebot/shared";
import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements";
import { GcpIapAuth } from "./components/gcpIapAuth";
-import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
+import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { GitHubStarToast } from "./components/githubStarToast";
import { getLinkedAccounts } from "@/ee/features/sso/actions";
import { BannerSlot } from "./components/banners/bannerSlot";
import { BannerHeightObserver } from "./components/banners/bannerHeightObserver";
+import { activeOrPendingMembershipWhere } from "@/features/membership/utils";
import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api";
import { OrgRole } from "@sourcebot/db";
import { ServiceErrorException } from "@/lib/serviceError";
@@ -63,8 +66,9 @@ export default async function Layout(props: LayoutProps) {
where: {
orgId_userId: {
orgId: org.id,
- userId: session.user.id
- }
+ userId: session.user.id,
+ },
+ ...activeOrPendingMembershipWhere(),
},
include: {
user: true
@@ -76,26 +80,25 @@ export default async function Layout(props: LayoutProps) {
// the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats.
// 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org.
if (!membership) {
+ if (await isScimEnabled(org)) {
+ return ;
+ }
+
if (!isMemberApprovalRequired(org)) {
- return (
-
-
-
-
- )
- } else {
- const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({
- where: {
- orgId: org.id,
- requestedById: session.user.id
- }
- });
-
- if (hasPendingApproval) {
- return
- } else {
- return
+ return ;
+ }
+
+ const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({
+ where: {
+ orgId: org.id,
+ requestedById: session.user.id
}
+ });
+
+ if (hasPendingApproval) {
+ return
+ } else {
+ return
}
}
diff --git a/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx
new file mode 100644
index 000000000..cdf17facf
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/accountAskAgent/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function AccountAskAgentSettingsLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/analytics/layout.tsx b/packages/web/src/app/(app)/settings/analytics/layout.tsx
new file mode 100644
index 000000000..ec8f6d691
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/analytics/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function AnalyticsSettingsLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx
index dfc24fa63..7fdba3f2b 100644
--- a/packages/web/src/app/(app)/settings/apiKeys/layout.tsx
+++ b/packages/web/src/app/(app)/settings/apiKeys/layout.tsx
@@ -2,11 +2,12 @@ import { notFound } from "next/navigation";
import { OrgRole } from "@sourcebot/db";
import { env } from "@sourcebot/shared";
import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { SettingsContainer } from "../components/settingsContainer";
export default authenticatedPage<{ children: React.ReactNode }>(async ({ role }, { children }) => {
if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && role !== OrgRole.OWNER) {
return notFound();
}
- return <>{children}>;
+ return {children} ;
});
diff --git a/packages/web/src/app/(app)/settings/components/settingsCard.tsx b/packages/web/src/app/(app)/settings/components/settingsCard.tsx
index 6649e002b..a9c267506 100644
--- a/packages/web/src/app/(app)/settings/components/settingsCard.tsx
+++ b/packages/web/src/app/(app)/settings/components/settingsCard.tsx
@@ -30,15 +30,19 @@ interface BasicSettingsCardProps {
description?: string;
children: ReactNode;
footer?: ReactNode;
+ badge?: ReactNode;
className?: string;
}
-export function BasicSettingsCard({ name, description, children, footer, className }: BasicSettingsCardProps) {
+export function BasicSettingsCard({ name, description, children, footer, badge, className }: BasicSettingsCardProps) {
return (
-
{name}
+
{description && (
{description}
)}
diff --git a/packages/web/src/app/(app)/settings/components/settingsContainer.tsx b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx
new file mode 100644
index 000000000..26ec413d1
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/components/settingsContainer.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+import { cn } from "@/lib/utils";
+
+type SettingsContainerVariant = "centered" | "full";
+
+interface SettingsContainerProps {
+ children: React.ReactNode;
+ variant?: SettingsContainerVariant;
+}
+
+export const SettingsContainer = ({ children, variant = "centered" }: SettingsContainerProps) => {
+ const isFull = variant === "full";
+ return (
+
+
+
+ );
+};
diff --git a/packages/web/src/app/(app)/settings/connections/layout.tsx b/packages/web/src/app/(app)/settings/connections/layout.tsx
index ec521d14e..c89c498e6 100644
--- a/packages/web/src/app/(app)/settings/connections/layout.tsx
+++ b/packages/web/src/app/(app)/settings/connections/layout.tsx
@@ -1,6 +1,7 @@
import { authenticatedPage } from "@/middleware/authenticatedPage";
import { OrgRole } from "@sourcebot/db";
+import { SettingsContainer } from "../components/settingsContainer";
export default authenticatedPage<{ children: React.ReactNode }>(async (_auth, { children }) => {
- return <>{children}>;
+ return
{children} ;
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
diff --git a/packages/web/src/app/(app)/settings/general/layout.tsx b/packages/web/src/app/(app)/settings/general/layout.tsx
new file mode 100644
index 000000000..e5b1afe41
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/general/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function GeneralSettingsLayout({ children }: { children: React.ReactNode }) {
+ return
{children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx
index cf640ac11..422785ced 100644
--- a/packages/web/src/app/(app)/settings/layout.tsx
+++ b/packages/web/src/app/(app)/settings/layout.tsx
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { isServiceError } from "@/lib/utils";
import { getConnectionStats } from "@/actions";
-import { getOrgAccountRequests } from "@/features/userManagement/actions";
+import { getOrgAccountRequests } from "@/features/membership/actions";
import { ServiceErrorException } from "@/lib/serviceError";
import { OrgRole } from "@prisma/client";
import { env } from "@sourcebot/shared";
@@ -33,13 +33,7 @@ export default async function SettingsLayout(
}
return (
-
+ <>{children}>
)
}
diff --git a/packages/web/src/app/(app)/settings/license/layout.tsx b/packages/web/src/app/(app)/settings/license/layout.tsx
new file mode 100644
index 000000000..24d60263c
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function LicenseSettingsLayout({ children }: { children: React.ReactNode }) {
+ return
{children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx
index 419422ee8..83ba0a7d2 100644
--- a/packages/web/src/app/(app)/settings/license/page.tsx
+++ b/packages/web/src/app/(app)/settings/license/page.tsx
@@ -15,6 +15,7 @@ import { getAllInvoices } from "@/ee/features/lighthouse/actions";
import { syncWithLighthouse } from "@/features/billing/servicePing";
import { isServiceError } from "@/lib/utils";
import { getYearlyTermStatus } from "./types";
+import { activeMembershipWhere } from "@/features/membership/utils";
type LicensePageProps = {
searchParams?: Promise
>;
@@ -49,7 +50,12 @@ export default authenticatedPage(async ({ prisma, org }, props
: await prisma.license.findUnique({ where: { orgId: org.id } });
const yearlyTermStatus = getYearlyTermStatus(license);
- const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } });
+ const currentActiveUserCount = await prisma.userToOrg.count({
+ where: {
+ orgId: org.id,
+ ...activeMembershipWhere(),
+ },
+ });
const invoicesResult = license ? await getAllInvoices() : null;
const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : [];
@@ -97,7 +103,7 @@ export default authenticatedPage(async ({ prisma, org }, props
&& !isOnlineLicenseInactive
&& yearlyTermStatus && (
)}
diff --git a/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx
new file mode 100644
index 000000000..7f5a6d9ee
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/linked-accounts/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function LinkedAccountsSettingsLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/mcp/layout.tsx b/packages/web/src/app/(app)/settings/mcp/layout.tsx
new file mode 100644
index 000000000..8ffa6bb7a
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/mcp/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function McpSettingsLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx
deleted file mode 100644
index b81c16996..000000000
--- a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-'use client';
-
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
-import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useForm } from "react-hook-form";
-import { useCallback, useState } from "react";
-import { z } from "zod";
-import { PlusCircleIcon, Loader2, AlertTriangle } from "lucide-react";
-import { OrgRole } from "@prisma/client";
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
-import { createInvites } from "@/features/userManagement/actions";
-import { isServiceError } from "@/lib/utils";
-import { useToast } from "@/components/hooks/use-toast";
-import { useRouter } from "next/navigation";
-import useCaptureEvent from "@/hooks/useCaptureEvent";
-export const inviteMemberFormSchema = z.object({
- emails: z.array(z.object({
- email: z.string().email()
- }))
- .refine((emails) => {
- const emailSet = new Set(emails.map(e => e.email.toLowerCase()));
- return emailSet.size === emails.length;
- }, "Duplicate email addresses are not allowed")
-});
-
-interface InviteMemberCardProps {
- currentUserRole: OrgRole;
- seatsAvailable: boolean;
-}
-
-export const InviteMemberCard = ({ currentUserRole, seatsAvailable }: InviteMemberCardProps) => {
- const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
- const [isLoading, setIsLoading] = useState(false);
- const { toast } = useToast();
- const router = useRouter();
- const captureEvent = useCaptureEvent();
-
- const form = useForm>({
- resolver: zodResolver(inviteMemberFormSchema),
- defaultValues: {
- emails: [{ email: "" }]
- },
- });
-
- const addEmailField = useCallback(() => {
- const emails = form.getValues().emails;
- form.setValue('emails', [...emails, { email: "" }]);
- }, [form]);
-
- const onSubmit = useCallback((data: z.infer) => {
- setIsLoading(true);
- createInvites(data.emails.map(e => e.email))
- .then((res) => {
- if (isServiceError(res)) {
- toast({
- description: `❌ Failed to invite members. Reason: ${res.message}`
- });
- captureEvent('wa_invite_member_card_invite_fail', {
- errorCode: res.errorCode,
- num_emails: data.emails.length,
- });
- } else {
- form.reset();
- router.push(`?tab=invites`);
- toast({
- description: `✅ Successfully invited ${data.emails.length} members`
- });
- captureEvent('wa_invite_member_card_invite_success', {
- num_emails: data.emails.length,
- });
- }
- })
- .finally(() => {
- setIsLoading(false);
- });
- }, [form, toast, router, captureEvent]);
-
- const isDisabled = !seatsAvailable || currentUserRole !== OrgRole.OWNER || isLoading;
-
- return (
- <>
-
-
- Invite Member
- Invite new members to your organization.
-
- {!seatsAvailable && (
-
-
-
-
-
- Maximum seats reached
-
-
- You've reached the maximum number of seats for your license. Upgrade your plan to invite additional members.
-
-
-
-
- )}
-
-
-
-
-
-
- Invite Team Members
-
- {`Your team is growing! By confirming, you will be inviting ${form.getValues().emails.length} new members to your organization.`}
-
-
-
-
- {form.getValues().emails.map(({ email }, index) => (
-
- {email}
-
- ))}
-
-
-
- captureEvent('wa_invite_member_card_invite_cancel', {
- num_emails: form.getValues().emails.length,
- })}>Cancel
- onSubmit(form.getValues())}
- >
- Invite
-
-
-
-
- >
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx
deleted file mode 100644
index b5ebb4883..000000000
--- a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-'use client';
-
-import { OrgRole } from "@sourcebot/db";
-import { useToast } from "@/components/hooks/use-toast";
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
-import { Button } from "@/components/ui/button";
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { createPathWithQueryParams, isServiceError } from "@/lib/utils";
-import { UserAvatar } from "@/components/userAvatar";
-import { Copy, MoreVertical, Search } from "lucide-react";
-import { useCallback, useMemo, useState } from "react";
-import { cancelInvite } from "@/features/userManagement/actions";
-import { useRouter } from "next/navigation";
-import useCaptureEvent from "@/hooks/useCaptureEvent";
-interface Invite {
- id: string;
- email: string;
- createdAt: Date;
-}
-
-interface InviteListProps {
- invites: Invite[]
- currentUserRole: OrgRole
-}
-
-export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
- const [searchQuery, setSearchQuery] = useState("")
- const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest")
- const [isCancelInviteDialogOpen, setIsCancelInviteDialogOpen] = useState(false)
- const [inviteToCancel, setInviteToCancel] = useState(null)
- const { toast } = useToast();
- const router = useRouter();
- const captureEvent = useCaptureEvent();
-
- const filteredInvites = useMemo(() => {
- return invites
- .filter((invite) => {
- const searchLower = searchQuery.toLowerCase();
- const matchesSearch =
- invite.email.toLowerCase().includes(searchLower);
- return matchesSearch;
- })
- .sort((a, b) => {
- return dateSort === "newest"
- ? b.createdAt.getTime() - a.createdAt.getTime()
- : a.createdAt.getTime() - b.createdAt.getTime()
- });
- }, [invites, searchQuery, dateSort]);
-
- const onCancelInvite = useCallback((inviteId: string) => {
- cancelInvite(inviteId)
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to cancel invite. Reason: ${response.message}`
- })
- captureEvent('wa_invites_list_cancel_invite_fail', {
- errorCode: response.errorCode,
- })
- } else {
- toast({
- description: `✅ Invite cancelled successfully.`
- })
- captureEvent('wa_invites_list_cancel_invite_success', {})
- router.refresh();
- }
- });
- }, [toast, router, captureEvent]);
-
- return (
-
-
-
-
- setSearchQuery(e.target.value)}
- />
-
-
-
setDateSort(value as "newest" | "oldest")}>
-
-
-
-
- Newest
- Oldest
-
-
-
-
-
-
- {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) => (
-
-
-
- {
- const url = createPathWithQueryParams(`${window.location.origin}/redeem?invite_id=${invite.id}`);
- navigator.clipboard.writeText(url)
- .then(() => {
- toast({
- description: `✅ Copied invite link for ${invite.email} to clipboard`
- })
- captureEvent('wa_invites_list_copy_invite_link_success', {})
- })
- .catch(() => {
- toast({
- description: "❌ Failed to copy invite link"
- })
- captureEvent('wa_invites_list_copy_invite_link_fail', {})
- })
- }}
- >
-
- Copy invite link
-
-
-
-
-
-
-
-
- {
- navigator.clipboard.writeText(invite.email)
- .then(() => {
- toast({
- description: `✅ Email copied to clipboard.`
- })
- captureEvent('wa_invites_list_copy_email_success', {})
- })
- .catch(() => {
- toast({
- description: `❌ Failed to copy email.`
- })
- captureEvent('wa_invites_list_copy_email_fail', {})
- })
- }}
- >
- Copy email
-
- {currentUserRole === OrgRole.OWNER && (
- {
- setIsCancelInviteDialogOpen(true);
- setInviteToCancel(invite);
- }}
- >
- Cancel invite
-
- )}
-
-
-
-
- ))
- )}
-
-
-
-
-
- Cancel Invite
-
- Are you sure you want to cancel this invite for {inviteToCancel?.email} ?
-
-
-
-
- Back
-
- {
- onCancelInvite(inviteToCancel?.id ?? "");
- }}
- >
- Cancel
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/(app)/settings/members/components/membersList.tsx b/packages/web/src/app/(app)/settings/members/components/membersList.tsx
deleted file mode 100644
index 9976f11de..000000000
--- a/packages/web/src/app/(app)/settings/members/components/membersList.tsx
+++ /dev/null
@@ -1,430 +0,0 @@
-'use client';
-
-import { Input } from "@/components/ui/input";
-import { Search, MoreVertical } from "lucide-react";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
-import { Button } from "@/components/ui/button";
-import { useCallback, useMemo, useState } from "react";
-import { OrgRole } from "@prisma/client";
-import { UserAvatar } from "@/components/userAvatar";
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
-import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions";
-import { leaveOrg, removeMemberFromOrg } from "@/features/userManagement/actions";
-import { isServiceError } from "@/lib/utils";
-import { useToast } from "@/components/hooks/use-toast";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { useRouter } from "next/navigation";
-import useCaptureEvent from "@/hooks/useCaptureEvent";
-import Link from "next/link";
-
-type Member = {
- id: string
- email: string
- name?: string
- role: OrgRole
- joinedAt: Date
- avatarUrl?: string
-}
-
-export interface MembersListProps {
- members: Member[],
- currentUserId: string,
- currentUserRole: OrgRole,
- orgName: string,
- hasOrgManagement: boolean,
-}
-
-const ROLES_AND_PERMISSIONS_DOCS_LINK = "https://docs.sourcebot.dev/docs/configuration/auth/roles-and-permissions"
-
-export const MembersList = ({ members, currentUserId, currentUserRole, orgName, hasOrgManagement }: MembersListProps) => {
- const [searchQuery, setSearchQuery] = useState("")
- const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all")
- const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest")
- const [memberToRemove, setMemberToRemove] = useState(null)
- const [memberToPromote, setMemberToPromote] = useState(null)
- const [memberToDemote, setMemberToDemote] = useState(null)
- const { toast } = useToast()
- const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false)
- const [isPromoteDialogOpen, setIsPromoteDialogOpen] = useState(false)
- const [isDemoteDialogOpen, setIsDemoteDialogOpen] = useState(false)
- const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false)
- const router = useRouter();
- const captureEvent = useCaptureEvent();
-
- const ownerCount = useMemo(() => members.filter(m => m.role === OrgRole.OWNER).length, [members]);
-
- const filteredMembers = useMemo(() => {
- return members
- .filter((member) => {
- const searchLower = searchQuery.toLowerCase();
- const matchesSearch =
- member.name?.toLowerCase().includes(searchLower) || member.email.toLowerCase().includes(searchLower);
- const matchesRole = roleFilter === "all" || member.role === roleFilter;
- return matchesSearch && matchesRole;
- })
- .sort((a, b) => {
- return dateSort === "newest"
- ? b.joinedAt.getTime() - a.joinedAt.getTime()
- : a.joinedAt.getTime() - b.joinedAt.getTime()
- });
- }, [members, searchQuery, roleFilter, dateSort]);
-
- const onRemoveMember = useCallback((memberId: string) => {
- removeMemberFromOrg(memberId)
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to remove member. Reason: ${response.message}`
- })
- captureEvent('wa_members_list_remove_member_fail', {
- errorCode: response.errorCode,
- })
- } else {
- toast({
- description: `✅ Member removed successfully.`
- })
- captureEvent('wa_members_list_remove_member_success', {})
- router.refresh();
- }
- });
- }, [toast, router, captureEvent]);
-
- const onPromoteToOwner = useCallback((memberId: string) => {
- promoteToOwner(memberId)
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to promote member. Reason: ${response.message}`
- })
- captureEvent('wa_members_list_promote_to_owner_fail', {
- errorCode: response.errorCode,
- })
- } else {
- toast({
- description: `✅ Member promoted to owner.`
- })
- captureEvent('wa_members_list_promote_to_owner_success', {})
- router.refresh();
- }
- });
- }, [toast, router, captureEvent]);
-
- const onDemoteToMember = useCallback((memberId: string) => {
- demoteToMember(memberId)
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to demote owner. Reason: ${response.message}`
- })
- captureEvent('wa_members_list_demote_to_member_fail', {
- errorCode: response.errorCode,
- })
- } else {
- toast({
- description: `✅ Owner demoted to member.`
- })
- captureEvent('wa_members_list_demote_to_member_success', {})
- router.refresh();
- }
- });
- }, [toast, router, captureEvent]);
-
- const onLeaveOrg = useCallback(() => {
- leaveOrg()
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to leave organization. Reason: ${response.message}`
- })
- captureEvent('wa_members_list_leave_org_fail', {
- errorCode: response.errorCode,
- })
- } else {
- toast({
- description: `✅ You have left the organization.`
- })
- captureEvent('wa_members_list_leave_org_success', {})
- router.refresh();
- }
- });
- }, [toast, router, captureEvent]);
-
- return (
-
-
-
-
-
- setSearchQuery(e.target.value)}
- />
-
-
-
setRoleFilter(value as "all" | OrgRole)}>
-
-
-
-
- All Team Roles
- Owner
- Member
-
-
-
-
setDateSort(value as "newest" | "oldest")}>
-
-
-
-
- Newest
- Oldest
-
-
-
-
-
-
- {filteredMembers.length === 0 ? (
-
-
No Members Found
-
- No members found matching your filters.
-
-
- ) : (
- filteredMembers.map((member) => (
-
-
-
-
-
{member.name}
-
{member.email}
-
-
-
- {member.role.toLowerCase()}
-
-
-
-
-
-
-
- {
- navigator.clipboard.writeText(member.email)
- .then(() => {
- toast({
- description: `✅ Email copied to clipboard.`
- })
- })
- .catch(() => {
- toast({
- description: `❌ Failed to copy email.`
- })
- })
- }}
- >
- Copy email
-
- {member.id !== currentUserId && currentUserRole === OrgRole.OWNER && member.role !== OrgRole.OWNER && (
-
-
-
- {
- setMemberToPromote(member);
- setIsPromoteDialogOpen(true);
- }}
- >
- Promote to owner
-
-
-
- {!hasOrgManagement && (
-
- Upgrade your plan to manage roles. Learn more
-
- )}
-
- )}
- {currentUserRole === OrgRole.OWNER && member.role === OrgRole.OWNER && (
-
-
-
- {
- setMemberToDemote(member);
- setIsDemoteDialogOpen(true);
- }}
- >
- Demote to member
-
-
-
- {(ownerCount <= 1 || !hasOrgManagement) && (
-
- {!hasOrgManagement
- ? <>Upgrade your plan to manage roles. Learn more>
- : "Cannot demote the last owner. Promote another member to owner first."
- }
-
- )}
-
- )}
- {member.id !== currentUserId && currentUserRole === OrgRole.OWNER && (
- {
- setMemberToRemove(member);
- setIsRemoveDialogOpen(true);
- }}
- >
- Remove
-
- )}
- {member.id === currentUserId && (
-
-
-
- {
- setIsLeaveOrgDialogOpen(true);
- }}
- >
- Leave organization
-
-
-
- {currentUserRole === OrgRole.OWNER && ownerCount <= 1 && (
-
- You are the last owner. Promote another member to owner before leaving.
-
- )}
-
- )}
-
-
-
-
- ))
- )}
-
-
-
-
-
- Remove Member
-
- {`Are you sure you want to remove ${memberToRemove?.name ?? memberToRemove?.email}?`}
-
-
-
- Cancel
- {
- onRemoveMember(memberToRemove?.id ?? "");
- }}
- >
- Remove
-
-
-
-
-
-
-
- Promote to Owner
-
- {`Are you sure you want to promote ${memberToPromote?.name ?? memberToPromote?.email} to owner? They will have full administrative access.`}
-
-
-
- Cancel
- {
- onPromoteToOwner(memberToPromote?.id ?? "");
- }}
- >
- Promote
-
-
-
-
-
-
-
- Demote to Member
-
- {memberToDemote?.id === currentUserId
- ? `Are you sure you want to step down as owner? You will lose administrative access.`
- : `Are you sure you want to demote ${memberToDemote?.name ?? memberToDemote?.email} from owner to member? They will lose administrative access.`
- }
-
-
-
- Cancel
- {
- onDemoteToMember(memberToDemote?.id ?? "");
- }}
- >
- Demote
-
-
-
-
-
-
-
- Leave Organization
-
- {`Are you sure you want to leave the organization?`}
-
-
-
- Cancel
-
- Leave
-
-
-
-
-
-
- )
-}
diff --git a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx
deleted file mode 100644
index 2412f0eb7..000000000
--- a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-'use client';
-
-import { OrgRole } from "@sourcebot/db";
-import { useToast } from "@/components/hooks/use-toast";
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { isServiceError } from "@/lib/utils";
-import { UserAvatar } from "@/components/userAvatar";
-import { CheckCircle, Search, XCircle } from "lucide-react";
-import { useCallback, useMemo, useState } from "react";
-import { rejectAccountRequest, approveAccountRequest } from "@/features/userManagement/actions";
-import { useRouter } from "next/navigation";
-import useCaptureEvent from "@/hooks/useCaptureEvent";
-
-interface Request {
- id: string;
- email: string;
- createdAt: Date;
- name?: string;
- image?: string;
-}
-
-interface RequestsListProps {
- requests: Request[]
- currentUserRole: OrgRole
-}
-
-export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) => {
- const [searchQuery, setSearchQuery] = useState("")
- const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest")
- const [isApproveRequestDialogOpen, setIsApproveRequestDialogOpen] = useState(false)
- const [isRejectRequestDialogOpen, setIsRejectRequestDialogOpen] = useState(false)
- const [requestToAction, setRequestToAction] = useState(null)
- const { toast } = useToast();
- const router = useRouter();
- const captureEvent = useCaptureEvent();
-
- const filteredRequests = useMemo(() => {
- return requests
- .filter((request) => {
- const searchLower = searchQuery.toLowerCase();
- const matchesSearch =
- request.email.toLowerCase().includes(searchLower) ||
- (request.name?.toLowerCase().includes(searchLower) || false);
- return matchesSearch;
- })
- .sort((a, b) => {
- return dateSort === "newest"
- ? b.createdAt.getTime() - a.createdAt.getTime()
- : a.createdAt.getTime() - b.createdAt.getTime()
- });
- }, [requests, searchQuery, dateSort]);
-
- const onApproveRequest = useCallback((requestId: string) => {
- approveAccountRequest(requestId)
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to approve request. Reason: ${response.message}`
- })
- captureEvent('wa_requests_list_approve_request_fail', {
- errorCode: response.errorCode,
- })
- } else {
- toast({
- description: `✅ Request approved successfully.`
- })
- captureEvent('wa_requests_list_approve_request_success', {})
- router.refresh();
- }
- });
- }, [toast, router, captureEvent]);
-
- const onRejectRequest = useCallback((requestId: string) => {
- rejectAccountRequest(requestId)
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `❌ Failed to reject request.`
- })
- captureEvent('wa_requests_list_reject_request_fail', {
- errorCode: response.errorCode,
- })
- } else {
- toast({
- description: `✅ Request rejected successfully.`
- })
- captureEvent('wa_requests_list_reject_request_success', {})
- router.refresh();
- }
- });
- }, [toast, router, captureEvent]);
-
- return (
-
-
-
-
- setSearchQuery(e.target.value)}
- />
-
-
-
setDateSort(value as "newest" | "oldest")}>
-
-
-
-
- Newest
- Oldest
-
-
-
-
-
-
- {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 && (
- <>
- {
- setRequestToAction(request);
- setIsApproveRequestDialogOpen(true);
- }}
- >
-
- Approve
-
- {
- setRequestToAction(request);
- setIsRejectRequestDialogOpen(true);
- }}
- >
-
- Reject
-
- >
- )}
-
-
- ))
- )}
-
-
-
- {/* Approve Request Dialog */}
-
-
-
- Approve Request
-
- Are you sure you want to approve the request from {requestToAction?.email} ? They will be added as a member to your organization.
-
-
-
-
- Back
-
- {
- onApproveRequest(requestToAction?.id ?? "");
- }}
- >
- Approve
-
-
-
-
-
- {/* Reject Request Dialog */}
-
-
-
- Reject Request
-
- Are you sure you want to reject the request from {requestToAction?.email} ?
-
-
-
-
- Back
-
- {
- onRejectRequest(requestToAction?.id ?? "");
- }}
- >
- Reject
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx
new file mode 100644
index 000000000..bac881928
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/members/inviteMembersDialog.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Loader2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
+import { Textarea } from "@/components/ui/textarea";
+import { useToast } from "@/components/hooks/use-toast";
+import { createInvites } from "@/features/membership/actions";
+import { isServiceError } from "@/lib/utils";
+import useCaptureEvent from "@/hooks/useCaptureEvent";
+
+interface InviteMembersDialogProps {
+ className?: string;
+}
+
+const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+const parseEmails = (value: string) => {
+ return value
+ .split(",")
+ .map((email) => email.trim())
+ .filter(Boolean);
+};
+
+const inviteMembersFormSchema = z.object({
+ emails: z.string().trim().superRefine((value, ctx) => {
+ const emails = parseEmails(value);
+
+ if (emails.length === 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Enter at least one email address.",
+ });
+ return;
+ }
+
+ const invalidEmail = emails.find((email) => !emailPattern.test(email));
+ if (invalidEmail) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `${invalidEmail} is not a valid email address.`,
+ });
+ return;
+ }
+
+ const normalizedEmails = emails.map((email) => email.toLowerCase());
+ if (new Set(normalizedEmails).size !== normalizedEmails.length) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Duplicate email addresses are not allowed.",
+ });
+ }
+ }),
+});
+
+type InviteMembersFormValues = z.infer;
+
+export const InviteMembersDialog = ({ className }: InviteMembersDialogProps) => {
+ const [open, setOpen] = useState(false);
+ const [shouldFocusEmails, setShouldFocusEmails] = useState(false);
+ const emailsTextareaRef = useRef(null);
+ const router = useRouter();
+ const { toast } = useToast();
+ const captureEvent = useCaptureEvent();
+ const form = useForm({
+ resolver: zodResolver(inviteMembersFormSchema),
+ defaultValues: {
+ emails: "",
+ },
+ });
+
+ const isSubmitting = form.formState.isSubmitting;
+ const emailsValue = form.watch("emails");
+ const emails = parseEmails(emailsValue);
+ const emailsRegistration = form.register("emails");
+
+ const focusEmailsField = () => {
+ form.setFocus("emails");
+ window.setTimeout(() => {
+ emailsTextareaRef.current?.focus({ preventScroll: true });
+ }, 0);
+ };
+
+ useEffect(() => {
+ if (!shouldFocusEmails || isSubmitting) {
+ return;
+ }
+
+ focusEmailsField();
+ setShouldFocusEmails(false);
+ // `form` is intentionally omitted here. The effect should run when the
+ // failed submit settles and the textarea is no longer disabled.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [shouldFocusEmails, isSubmitting]);
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (isSubmitting) {
+ return;
+ }
+
+ if (!nextOpen && emailsValue.trim().length > 0) {
+ captureEvent("wa_invite_member_card_invite_cancel", {
+ num_emails: emails.length,
+ });
+ }
+
+ if (!nextOpen) {
+ form.reset();
+ }
+
+ setOpen(nextOpen);
+ };
+
+ const onSubmit = async (values: InviteMembersFormValues) => {
+ const emails = parseEmails(values.emails);
+ try {
+ const result = await createInvites(emails);
+ if (isServiceError(result)) {
+ form.setError("emails", {
+ type: "server",
+ message: result.message,
+ }, {
+ shouldFocus: true,
+ });
+ setShouldFocusEmails(true);
+ toast({ description: `Failed to send invites. Reason: ${result.message}` });
+ captureEvent("wa_invite_member_card_invite_fail", {
+ errorCode: result.errorCode,
+ num_emails: emails.length,
+ });
+ return;
+ }
+
+ toast({ description: `Successfully sent ${emails.length} invite${emails.length === 1 ? "" : "s"}.` });
+ captureEvent("wa_invite_member_card_invite_success", {
+ num_emails: emails.length,
+ });
+ form.reset();
+ setOpen(false);
+ router.refresh();
+ } catch {
+ form.setError("emails", {
+ type: "server",
+ message: "Something went wrong while sending invites.",
+ }, {
+ shouldFocus: true,
+ });
+ setShouldFocusEmails(true);
+ toast({ description: "Failed to send invites." });
+ }
+ };
+
+ const onInvalidSubmit = () => {
+ setShouldFocusEmails(true);
+ };
+
+ const isSendDisabled = isSubmitting || emails.length === 0;
+
+ return (
+
+ setOpen(true)}>
+ Invite
+
+
+
+ Invite members
+
+ Invite new members to your organization.
+
+
+
+
+
+
+ );
+};
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 (
+ onValueChange(nextValue as MemberFilter)}>
+
+
+
+
+ {FILTER_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+};
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..8423752a0
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/members/membersTable.tsx
@@ -0,0 +1,555 @@
+"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";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+declare module "@tanstack/react-table" {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ interface ColumnMeta {
+ className?: string;
+ }
+}
+import { ArrowDown, ArrowUp, Info } 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+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;
+ suspendedAt?: Date | null;
+ 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 -----------------------------------------------------
+
+export 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; description: string }[] = [
+ {
+ id: "requests",
+ label: "Requests",
+ description: "People who requested access to the organization.",
+ },
+ {
+ id: "active",
+ label: "Active",
+ description: "Users who have access to the organization. Active users count toward billing.",
+ },
+ {
+ id: "pending",
+ label: "Pending",
+ description: "Users who have access to the organization but have never signed in. Pending users do not count toward billing.",
+ },
+ {
+ id: "suspended",
+ label: "Suspended",
+ description: "Users who cannot access the organization. Suspended users do not count toward billing.",
+ },
+ {
+ id: "invited",
+ label: "Invited",
+ description: "People with pending invitations to the organization.",
+ },
+];
+
+const COLUMN_WIDTHS = ["auto", "180px", "120px", "120px", "120px", "64px"];
+
+const collator = new Intl.Collator(undefined, {
+ numeric: true,
+ sensitivity: "base",
+});
+
+export const getDisplayName = (row: TableRowData) => {
+ if (row.kind === "invite") {
+ return row.email;
+ }
+ return row.name ?? row.email;
+};
+
+const getRoleLabel = (row: TableRowData) => {
+ if (row.kind === "member") {
+ return row.role.toLowerCase();
+ }
+ return "";
+};
+
+const getStatusLabel = (row: TableRowData) => {
+ if (row.kind === "member") {
+ return row.section;
+ }
+ 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();
+};
+
+export 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;
+};
+
+export 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 sortByRole: SortingFn = (rowA, rowB) => {
+ return collator.compare(getRoleLabel(rowA.original), getRoleLabel(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 (
+ column.toggleSorting(sortDirection === "asc")}
+ aria-label={`Sort by ${children}`}
+ >
+ {children}
+
+
+ );
+};
+
+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 suspended (`suspendedAt != null`)
+ * - pending: unsuspended but never signed in to this org (`lastActiveAt == null`)
+ * - active: active and has signed in at least once
+ */
+export const getMemberSection = (member: Member): MemberRow["section"] => {
+ if (member.suspendedAt != null) {
+ 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 hasName = r.kind !== "invite" && r.name != null;
+ const imageUrl = r.kind === "member" ? r.avatarUrl : r.kind === "request" ? r.image : undefined;
+ return (
+
+
+
+
+ {name}
+
+ {hasName && (
+
{r.email}
+ )}
+
+
+ );
+ },
+ },
+ {
+ id: "role",
+ accessorFn: getRoleLabel,
+ meta: { className: "whitespace-nowrap" },
+ sortingFn: sortByRole,
+ header: ({ column }) => Role ,
+ cell: ({ row }) => {
+ const r = row.original;
+ if (r.kind === "member") {
+ const roleLabel = r.role.toLowerCase();
+ const stateLabel = r.section === "pending" || r.section === "suspended"
+ ? ` (${r.section})`
+ : "";
+
+ return (
+
+ {roleLabel}
+ {stateLabel}
+
+ );
+ }
+ return - ;
+ },
+ },
+ {
+ id: "status",
+ accessorFn: getStatusLabel,
+ meta: { className: "whitespace-nowrap" },
+ sortingFn: sortByStatus,
+ header: ({ column }) => Status ,
+ cell: ({ row }) => (
+
+ {getStatusLabel(row.original)}
+
+ ),
+ },
+ {
+ 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.suspendedAt == null &&
+ member.lastActiveAt != null &&
+ 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.
+
+ Clear filter
+
+
+
+
+
+
+ ) : visibleSections.map((section) => (
+
+
+
+
+
+
+ {section.label}
+
+
+
+
+
+
+
+
+ {section.description}
+
+
+
+ •
+ {section.rows.length}
+ {section.id === "requests" && (
+
+ )}
+
+
+
+ {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..516dd49f8
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/members/membersTableActions.tsx
@@ -0,0 +1,385 @@
+"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 isSuspended = row.kind === "member" && row.suspendedAt != null;
+ const isActiveMember = row.kind === "member" && row.suspendedAt == null && row.lastActiveAt != null;
+ const isLastActiveOwner = row.kind === "member"
+ && !isSuspended
+ && 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" && (
+ <>
+ {isActiveMember && row.role === OrgRole.MEMBER && (
+ setConfirmingAction("promote")}
+ >
+ Promote to owner
+
+ )}
+ {isActiveMember && row.role === OrgRole.OWNER && (
+ setConfirmingAction("demote")}
+ >
+ Demote to member
+
+ )}
+ {!isSuspended ? (
+ 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..a4418dc14
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/members/membersTableView.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Search } from "lucide-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useDebounce } from "@uidotdev/usehooks";
+import { OrgRole } from "@sourcebot/db";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { MembersFilterSelect } from "./membersFilterSelect";
+import { InviteMembersDialog } from "./inviteMembersDialog";
+import {
+ MembersTable,
+ getMemberSection,
+ type Invite,
+ type Member,
+ type MemberFilter,
+ type Request,
+ type TableRowData,
+} from "./membersTable";
+
+interface MembersTableViewProps {
+ members: Member[];
+ invites: Invite[];
+ requests: Request[];
+ currentUserId: string;
+ hasOrgManagement: boolean;
+ scimEnabled: boolean;
+}
+
+const FILTER_QUERY_PARAM = "filter";
+const SEARCH_QUERY_PARAM = "search";
+
+const isMemberFilter = (value: string | null): value is MemberFilter => {
+ return value === "all"
+ || value === "owners"
+ || value === "members"
+ || value === "active"
+ || value === "pending"
+ || value === "suspended"
+ || value === "invited"
+ || value === "requests";
+};
+
+const csvEscape = (value: string | number | null | undefined) => {
+ return `"${String(value ?? "").replaceAll('"', '""')}"`;
+};
+
+const downloadCsv = (filename: string, rows: string[][]) => {
+ const csv = rows
+ .map((row) => row.map(csvEscape).join(","))
+ .join("\n");
+ const blob = new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
+};
+
+export const MembersTableView = ({
+ members,
+ invites,
+ requests,
+ currentUserId,
+ hasOrgManagement,
+ scimEnabled,
+}: MembersTableViewProps) => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const urlFilter = searchParams.get(FILTER_QUERY_PARAM);
+ const urlSearchQuery = searchParams.get(SEARCH_QUERY_PARAM) ?? "";
+ const urlMemberFilter = isMemberFilter(urlFilter) ? urlFilter : "all";
+ const [filter, setFilter] = useState(urlMemberFilter);
+ const [searchQuery, setSearchQuery] = useState(urlSearchQuery);
+ const debouncedSearchQuery = useDebounce(searchQuery, 300);
+ const searchParamsString = searchParams.toString();
+ const rows = useMemo(() => {
+ const memberRows: TableRowData[] = members.map((member) => ({
+ ...member,
+ kind: "member",
+ section: getMemberSection(member),
+ }));
+ const inviteRows: TableRowData[] = invites.map((invite) => ({
+ ...invite,
+ kind: "invite",
+ section: "invited",
+ }));
+ const requestRows: TableRowData[] = requests.map((request) => ({
+ ...request,
+ kind: "request",
+ section: "requests",
+ }));
+
+ return [...memberRows, ...inviteRows, ...requestRows];
+ }, [invites, members, requests]);
+
+ useEffect(() => {
+ setFilter(urlMemberFilter);
+ setSearchQuery(urlSearchQuery);
+ }, [urlMemberFilter, urlSearchQuery]);
+
+ const updateUrlFilters = useCallback((nextFilter: MemberFilter, nextSearchQuery: string) => {
+ const nextParams = new URLSearchParams(searchParamsString);
+ const trimmedSearchQuery = nextSearchQuery.trim();
+
+ if (nextFilter === "all") {
+ nextParams.delete(FILTER_QUERY_PARAM);
+ } else {
+ nextParams.set(FILTER_QUERY_PARAM, nextFilter);
+ }
+
+ if (trimmedSearchQuery.length === 0) {
+ nextParams.delete(SEARCH_QUERY_PARAM);
+ } else {
+ nextParams.set(SEARCH_QUERY_PARAM, trimmedSearchQuery);
+ }
+
+ const nextQueryString = nextParams.toString();
+ const currentQueryString = searchParamsString;
+ if (nextQueryString === currentQueryString) {
+ return;
+ }
+
+ router.replace(`${pathname}${nextQueryString ? `?${nextQueryString}` : ""}`, { scroll: false });
+ }, [pathname, router, searchParamsString]);
+
+ useEffect(() => {
+ if (debouncedSearchQuery !== searchQuery) {
+ return;
+ }
+
+ updateUrlFilters(filter, debouncedSearchQuery);
+ }, [debouncedSearchQuery, filter, searchQuery, updateUrlFilters]);
+
+ const handleFilterChange = (nextFilter: MemberFilter) => {
+ setFilter(nextFilter);
+ updateUrlFilters(nextFilter, searchQuery);
+ };
+
+ const clearFilters = () => {
+ setFilter("all");
+ setSearchQuery("");
+ updateUrlFilters("all", "");
+ };
+
+ const handleExportCsv = () => {
+ const csvRows = [
+ ["Name", "Email", "Role", "Status", "Joined", "Last seen"],
+ ...rows.map((row) => [
+ row.kind === "invite" ? "" : row.name ?? "",
+ row.email,
+ row.kind === "member"
+ ? row.role === OrgRole.OWNER ? "Owner" : "Member"
+ : "-",
+ row.kind === "request" ? "Requested" : row.section[0].toUpperCase() + row.section.slice(1),
+ (row.kind === "member" ? row.joinedAt : row.createdAt).toISOString(),
+ row.kind !== "member"
+ ? ""
+ : row.lastActiveAt?.toISOString() ?? "Never",
+ ]),
+ ];
+
+ downloadCsv(`${new Date().toISOString().slice(0, 10)}-members.csv`, csvRows);
+ };
+
+ return (
+
+
+
+
+ setSearchQuery(event.target.value)}
+ placeholder="Search by name or email"
+ className="pl-9"
+ />
+
+
+
+ Export CSV
+
+
+
+
+
+ );
+};
diff --git a/packages/web/src/app/(app)/settings/members/page.tsx b/packages/web/src/app/(app)/settings/members/page.tsx
index d991b5df6..75cca18df 100644
--- a/packages/web/src/app/(app)/settings/members/page.tsx
+++ b/packages/web/src/app/(app)/settings/members/page.tsx
@@ -1,159 +1,50 @@
-import { MembersList } from "./components/membersList";
-import { getOrgMembers, getOrgInvites, getOrgAccountRequests } from "@/features/userManagement/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 { MembersTableView } from "./membersTableView";
+import { isScimEnabled } from "@/features/scim/utils";
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 "@/lib/authUtils";
-import { getSeatCap } from "@sourcebot/shared";
-type MembersSettingsPageProps = {
- searchParams: Promise<{
- tab?: string
- }>
-}
+export default authenticatedPage(async ({ org, user }) => {
+ const [members, invites, requests, scimEnabled, hasOrgManagement] = await Promise.all([
+ getOrgMembers(),
+ getOrgInvites(),
+ getOrgAccountRequests(),
+ isScimEnabled(org),
+ hasEntitlement("org-management"),
+ ]);
-export default authenticatedPage(async ({ org, role, user }, props) => {
- const searchParams = await props.searchParams;
-
- const {
- tab
- } = searchParams;
-
- 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);
- const seatCap = getSeatCap();
-
return (
-
-
-
-
Members
-
Invite and manage members of your organization.
-
- {seatCap && (
-
-
- {members.length}
- of
- {seatCap}
- seats used
-
-
- )}
+
+
+
Members
+
+ Invite and manage members of your organization.
+
-
-
-
-
-
-
- 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/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/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}
+
+
+ {baseUrlCopied ? : }
+
+
+
+
+
+
+
+ {tokens.length} SCIM token{tokens.length !== 1 ? "s" : ""}
+
+
+
+
+ {
+ setNewlyCreatedToken(null);
+ setNewTokenName("");
+ setIsCreateDialogOpen(true);
+ }}
+ >
+
+ New SCIM token
+
+
+
+
+ {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}
+
+
+ {copySuccess ? : }
+
+
+
+ ) : (
+
+ setNewTokenName(e.target.value)}
+ placeholder="Enter a name for your SCIM token"
+ className="mb-2"
+ />
+
+ )}
+
+
+ {newlyCreatedToken ? (
+ Done
+ ) : (
+ <>
+ Cancel
+
+ {isCreatingToken && }
+ Create
+
+ >
+ )}
+
+
+
+
+
+ {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.
+
+
+
setIsUpsellDialogOpen(true)}>
+ Upgrade
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/web/src/app/(app)/settings/security/layout.tsx b/packages/web/src/app/(app)/settings/security/layout.tsx
new file mode 100644
index 000000000..b75c5e731
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/security/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function SecuritySettingsLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/security/page.tsx b/packages/web/src/app/(app)/settings/security/page.tsx
index e48e88c5f..e1d56ea85 100644
--- a/packages/web/src/app/(app)/settings/security/page.tsx
+++ b/packages/web/src/app/(app)/settings/security/page.tsx
@@ -5,16 +5,21 @@ import { CredentialsLoginEnabledSettingsCard } from "./components/credentialsLog
import { EmailCodeLoginEnabledSettingsCard } from "./components/emailCodeLoginEnabledSettingsCard";
import { IdentityProviderSettingsCard } from "./components/identityProviderSettingsCard";
import { IdentityProviderUpsellCard } from "./components/identityProviderUpsellCard";
+import { ScimProvisioningSettings } from "./components/scimProvisioningSettings";
+import { ScimEnabledSettingsCard } from "./components/scimEnabledSettingsCard";
+import { ScimUpsellCard } from "./components/scimUpsellCard";
+import { getScimTokens } from "@/ee/features/scim/actions";
import { UpgradeBadge } from "@/app/(app)/@sidebar/components/upgradeBadge";
import { getProviders, IdentityProvider } from "@/auth";
import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements";
-import { createInviteLink } from "@/lib/utils";
+import { createInviteLink, isServiceError } from "@/lib/utils";
import { authenticatedPage } from "@/middleware/authenticatedPage";
import { OrgRole } from "@sourcebot/db";
import { env, getSMTPConnectionURL, isCredentialsLoginEnabled, isEmailCodeLoginEnabled, isMemberApprovalRequired } from "@sourcebot/shared";
import { SettingsCardGroup } from "../components/settingsCard";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
+import { isScimEnabled } from "@/features/scim/utils";
export default authenticatedPage(async ({ org }) => {
const anonymousAccessEnabled = await isAnonymousAccessEnabled();
@@ -22,6 +27,12 @@ export default authenticatedPage(async ({ org }) => {
const hasSSOEntitlement = await hasEntitlement("sso");
const identityProviders = await getConfiguredIdentityProviders();
+ const hasScimEntitlement = await hasEntitlement("scim");
+ const scimBaseUrl = `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2`;
+ const scimTokensResult = hasScimEntitlement ? await getScimTokens() : [];
+ const scimTokens = isServiceError(scimTokensResult) ? [] : scimTokensResult;
+ const scimEnabled = await isScimEnabled(org)
+
return (
@@ -45,9 +56,11 @@ 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 ? (
+
+ ) : (
+ <>
+
+
+
+ {scimEnabled && (
+
+ )}
+ >
+ )}
)
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/app/api/(server)/ee/askmcp/callback/route.ts b/packages/web/src/app/api/(server)/ee/askmcp/callback/route.ts
index 4d7879d0c..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,6 +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 { activeOrPendingMembershipWhere } from '@/features/membership/utils';
const logger = createLogger('mcp-oauth-callback');
const reconnectMessage = 'This connector authorization could not be completed. Please reconnect the connector.';
@@ -153,6 +154,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
orgId: userServer.server.orgId,
userId: session.user.id,
},
+ ...activeOrPendingMembershipWhere(),
},
});
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..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),
},
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..25ade6e9f
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts
@@ -0,0 +1,135 @@
+import { apiHandler } from '@/lib/apiHandler';
+import { removeMember, setMembershipSuspended } from '@/features/membership/membership.service';
+import { scimError, scimJson, toScimUser, type ScimMembership } from '@/ee/features/scim/mapper';
+import {
+ coerceActive,
+ parseScimPatchOperations,
+ resolveEmail,
+ scimPatchOpSchema,
+ scimUserReplaceSchema,
+} from '@/ee/features/scim/schemas';
+import { withScimAuth, type ScimAuthContext } from '@/ee/features/scim/withScimAuth';
+import { isServiceError } from '@/lib/utils';
+import { NextRequest } from 'next/server';
+
+const loadMembership = (prisma: ScimAuthContext['prisma'], orgId: number, userId: string): Promise
=>
+ prisma.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ include: { user: true },
+ });
+
+// Applies an active state transition, toggling the membership only when the
+// value actually changes. Returns a SCIM error Response on failure.
+const applyActive = async (orgId: number, userId: string, current: boolean, next: boolean | undefined): Promise => {
+ if (next === undefined || next === current) {
+ return null;
+ }
+ const result = await setMembershipSuspended(orgId, userId, !next, {
+ actor: { id: 'scim', type: 'scim_token' },
+ });
+ if (isServiceError(result)) {
+ return scimError(result.statusCode, result.message);
+ }
+ return null;
+};
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const GET = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+ return scimJson(toScimUser(membership), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const PUT = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+
+ const parsed = scimUserReplaceSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return scimError(400, 'Invalid SCIM user payload', 'invalidValue');
+ }
+ const payload = parsed.data;
+
+ const name = payload.name?.formatted ?? payload.displayName ?? undefined;
+ const email = resolveEmail(payload);
+ await prisma.user.update({
+ where: { id },
+ data: { name, email },
+ });
+
+ const activeError = await applyActive(org.id, id, membership.suspendedAt == null, coerceActive(payload.active));
+ if (activeError) {
+ return activeError;
+ }
+
+ const refreshed = await loadMembership(prisma, org.id, id);
+ return scimJson(toScimUser(refreshed!), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const PATCH = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+
+ const parsed = scimPatchOpSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return scimError(400, 'Invalid SCIM PatchOp payload', 'invalidValue');
+ }
+
+ // Reduce the operations into the attributes we persist (name, email,
+ // active). IdPs send these via path-based ops or a no-path bulk object;
+ // `parseScimPatchOperations` normalizes both. Unrecognized ops/paths are
+ // ignored rather than rejected, per the SCIM lenient-parsing convention.
+ const changes = parseScimPatchOperations(parsed.data.Operations);
+
+ if (changes.name !== undefined || changes.email !== undefined) {
+ await prisma.user.update({
+ where: { id },
+ data: {
+ ...(changes.name !== undefined ? { name: changes.name } : {}),
+ ...(changes.email !== undefined ? { email: changes.email } : {}),
+ },
+ });
+ }
+
+ const activeError = await applyActive(org.id, id, membership.suspendedAt == null, changes.active);
+ if (activeError) {
+ return activeError;
+ }
+
+ const refreshed = await loadMembership(prisma, org.id, id);
+ return scimJson(toScimUser(refreshed!), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const DELETE = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const { id } = await params;
+ const membership = await loadMembership(prisma, org.id, id);
+ if (!membership) {
+ return scimError(404, `User ${id} not found`);
+ }
+ // Per RFC 7644, DELETE removes the resource: hard-delete the membership
+ // (the User row is preserved so re-provisioning reuses the same SCIM id).
+ // Reversible suspension is still available via PATCH/PUT `active: false`.
+ const result = await removeMember(org.id, id, {
+ actor: { id: 'scim', type: 'scim_token' },
+ });
+ if (isServiceError(result)) {
+ return scimError(result.statusCode, result.message);
+ }
+ return new Response(null, { status: 204 });
+ }));
diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts
new file mode 100644
index 000000000..064848166
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts
@@ -0,0 +1,121 @@
+import { apiHandler } from '@/lib/apiHandler';
+import { ensureActiveMember, setMembershipSuspended } from '@/features/membership/membership.service';
+import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper';
+import {
+ coerceActive,
+ parseScimFilter,
+ resolveEmail,
+ scimUserCreateSchema,
+} from '@/ee/features/scim/schemas';
+import { withScimAuth } from '@/ee/features/scim/withScimAuth';
+import { ErrorCode } from '@/lib/errorCodes';
+import { isServiceError } from '@/lib/utils';
+import { OrgRole } from '@sourcebot/db';
+import { env } from '@sourcebot/shared';
+import { NextRequest } from 'next/server';
+import { SCIM_DEFAULT_COUNT, SCIM_MAX_COUNT } from '@/ee/features/scim/constants';
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const GET = apiHandler(async (request: NextRequest) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const params = request.nextUrl.searchParams;
+ const filterParam = params.get('filter');
+ const startIndex = Math.max(1, parseInt(params.get('startIndex') ?? '1', 10) || 1);
+ const count = Math.min(SCIM_MAX_COUNT, Math.max(0, parseInt(params.get('count') ?? `${SCIM_DEFAULT_COUNT}`, 10) || SCIM_DEFAULT_COUNT));
+
+ // A filter that's present but unrecognized yields an empty result set
+ // (never a 404/400) so the IdP can decide create-vs-update safely.
+ const filter = parseScimFilter(filterParam);
+ if (filterParam && !filter) {
+ return scimJson(toScimListResponse([], 0, startIndex), 200);
+ }
+
+ const where = {
+ orgId: org.id,
+ ...(filter?.attribute === 'userName' ? { user: { email: { equals: filter.value, mode: 'insensitive' as const } } } : {}),
+ ...(filter?.attribute === 'externalId' ? { scimExternalId: filter.value } : {}),
+ };
+
+ const [total, memberships] = await Promise.all([
+ prisma.userToOrg.count({ where }),
+ prisma.userToOrg.findMany({
+ where,
+ include: { user: true },
+ orderBy: { joinedAt: 'asc' },
+ skip: startIndex - 1,
+ take: count,
+ }),
+ ]);
+
+ return scimJson(toScimListResponse(memberships.map(toScimUser), total, startIndex), 200);
+ }));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth
+export const POST = apiHandler(async (request: NextRequest) =>
+ withScimAuth(request, async ({ org, prisma }) => {
+ const parsed = scimUserCreateSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return scimError(400, 'Invalid SCIM user payload', 'invalidValue');
+ }
+ const payload = parsed.data;
+ const email = resolveEmail(payload);
+ const name = payload.name?.formatted ?? payload.displayName ?? undefined;
+ const desiredActive = coerceActive(payload.active) ?? true;
+
+ // Find-or-create the user by email.
+ let user = await prisma.user.findUnique({ where: { email } });
+ if (!user) {
+ user = await prisma.user.create({ data: { email, name } });
+ }
+
+ const scimActor = { id: 'scim', type: 'scim_token' } as const;
+ const existing = await prisma.userToOrg.findUnique({
+ where: { orgId_userId: { orgId: org.id, userId: user.id } },
+ });
+
+ // Map the membership state to the SCIM response: an unsuspended member
+ // is a conflict, a suspended member is reactivated (role preserved), and
+ // a brand-new member is created.
+ let httpStatus: number;
+ if (existing && existing.suspendedAt == null) {
+ return scimError(409, 'User is already a member of this organization', 'uniqueness');
+ } else if (existing) {
+ const result = await setMembershipSuspended(org.id, user.id, false, {
+ actor: scimActor,
+ scimExternalId: payload.externalId,
+ });
+ if (isServiceError(result)) {
+ const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined;
+ return scimError(result.statusCode, result.message, scimType);
+ }
+ httpStatus = 200;
+ } else {
+ const result = await ensureActiveMember(org.id, user.id, {
+ actor: scimActor,
+ role: OrgRole.MEMBER,
+ scimExternalId: payload.externalId,
+ });
+ if (isServiceError(result)) {
+ const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined;
+ return scimError(result.statusCode, result.message, scimType);
+ }
+ httpStatus = 201;
+ }
+
+ // IdPs normally provision active and suspend later via PATCH; honor a rare
+ // explicit `active: false` on provisioning.
+ if (!desiredActive) {
+ const deactivated = await setMembershipSuspended(org.id, user.id, true, { actor: scimActor });
+ if (isServiceError(deactivated)) {
+ return scimError(deactivated.statusCode, deactivated.message);
+ }
+ }
+
+ const membership = await prisma.userToOrg.findUniqueOrThrow({
+ where: { orgId_userId: { orgId: org.id, userId: user.id } },
+ include: { user: true },
+ });
+ return scimJson(toScimUser(membership), httpStatus, {
+ Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}`,
+ });
+ }));
diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts
index 58ed5738f..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,6 +56,7 @@ export const GET = apiHandler(async () => {
name: membership.user.name,
email: membership.user.email,
role: membership.role,
+ suspendedAt: membership.suspendedAt,
createdAt: membership.user.createdAt,
lastActivityAt: lastActivity?.timestamp ?? null,
};
@@ -90,4 +91,3 @@ export const GET = apiHandler(async () => {
return Response.json(result);
});
-
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 (
-
- {isLoading && }
- Join Organization
-
- );
-}
\ 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({
+ {
+ signOut({
redirectTo: "/login",
}).then(() => {
posthog.reset();
});
}}
+ className="flex flex-row items-center gap-2 text-sm text-muted-foreground cursor-pointer"
>
-
-
- Log out
-
-
+
+ Log out
+
);
-}
\ No newline at end of file
+}
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/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..7930cf09f 100644
--- a/packages/web/src/app/invite/page.tsx
+++ b/packages/web/src/app/invite/page.tsx
@@ -5,8 +5,10 @@ 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";
+import { activeOrPendingMembershipWhere } from "@/features/membership/utils";
interface InvitePageProps {
searchParams: Promise<{
@@ -36,7 +38,8 @@ export default async function InvitePage(props: InvitePageProps) {
orgId_userId: {
orgId: org.id,
userId: session.user.id
- }
+ },
+ ...activeOrPendingMembershipWhere(),
}
});
@@ -45,12 +48,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/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx
index ab6dd7ff6..1c7e89be8 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 { activeOrPendingMembershipWhere } from "@/features/membership/utils";
interface OnboardingProps {
searchParams?: Promise<{ step?: string }>;
@@ -50,7 +51,8 @@ export default async function Onboarding(props: OnboardingProps) {
orgId_userId: {
orgId: org.id,
userId: session.user.id
- }
+ },
+ ...activeOrPendingMembershipWhere(),
}
});
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..243f24664 100644
--- a/packages/web/src/app/redeem/page.tsx
+++ b/packages/web/src/app/redeem/page.tsx
@@ -1,12 +1,15 @@
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';
+import { activeOrPendingMembershipWhere } from '@/features/membership/utils';
interface RedeemPageProps {
searchParams: Promise<{
@@ -31,6 +34,25 @@ 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
+ },
+ ...activeOrPendingMembershipWhere(),
+ }
+ });
+
+ // If already a member, redirect to the organization
+ if (membership) {
+ redirect(`/`);
+ }
+
+ if (await isScimEnabled(org)) {
+ return
+ }
+
const inviteInfo = await getInviteInfo(inviteId);
return (
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts
index 00fa426df..f22114ce8 100644
--- a/packages/web/src/auth.ts
+++ b/packages/web/src/auth.ts
@@ -15,13 +15,13 @@ import MagicLinkEmail from './emails/magicLinkEmail';
import bcrypt from 'bcryptjs';
import { getEEIdentityProviders } from '@/ee/features/sso/sso';
import { hasEntitlement } from '@/lib/entitlements';
-import { onCreateUser } from '@/lib/authUtils';
import { createAudit } from '@/ee/features/audit/audit';
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
import { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter';
import { getAnonymousId } from '@/lib/anonymousId';
import { captureEvent } from '@/lib/posthog';
import { isEmailCodeLoginEnabled, isCredentialsLoginEnabled } from '@sourcebot/shared'
+import { onCreateUser } from './features/membership/onCreateUser';
export const runtime = 'nodejs';
@@ -143,7 +143,7 @@ export const getProviders = async () => {
sessionVersion: newUser.sessionVersion,
}
- onCreateUser({ user: authJsUser });
+ await onCreateUser({ user: authJsUser });
return authJsUser;
// Otherwise, the user exists, so verify the password.
diff --git a/packages/web/src/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) => (
+
;
export const auditTargetSchema = z.object({
id: z.string(),
- type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat"]),
+ type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat", "scim_token"]),
})
export type AuditTarget = z.infer;
export const auditMetadataSchema = z.object({
message: z.string().optional(),
api_key: z.string().optional(),
+ scim_token: z.string().optional(),
emails: z.string().optional(), // comma separated list of emails
source: z.string().optional(), // request source (e.g., 'mcp') from X-Sourcebot-Client-Source header
})
diff --git a/packages/web/src/ee/features/membership/actions.ts b/packages/web/src/ee/features/membership/actions.ts
new file mode 100644
index 000000000..f848945ac
--- /dev/null
+++ b/packages/web/src/ee/features/membership/actions.ts
@@ -0,0 +1,54 @@
+'use server';
+
+import { sew } from "@/middleware/sew";
+import { ErrorCode } from "@/lib/errorCodes";
+import { ServiceError } from "@/lib/serviceError";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+import { hasEntitlement } from "@/lib/entitlements";
+import { isServiceError } from "@/lib/utils";
+import { setMemberRole } from "@/features/membership/membership.service";
+import { StatusCodes } from "http-status-codes";
+
+const orgManagementNotAvailable = (): ServiceError => ({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
+ message: "Organization management is not available in your current plan",
+});
+
+export const promoteToOwner = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('org-management')) {
+ return orgManagementNotAvailable();
+ }
+
+ const result = await setMemberRole(org.id, memberId, OrgRole.OWNER, {
+ actor: { id: user.id, type: "user" },
+ });
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const demoteToMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('org-management')) {
+ return orgManagementNotAvailable();
+ }
+
+ const result = await setMemberRole(org.id, memberId, OrgRole.MEMBER, {
+ actor: { id: user.id, type: "user" },
+ });
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
diff --git a/packages/web/src/ee/features/scim/actions.ts b/packages/web/src/ee/features/scim/actions.ts
new file mode 100644
index 000000000..0d3d31276
--- /dev/null
+++ b/packages/web/src/ee/features/scim/actions.ts
@@ -0,0 +1,169 @@
+'use server';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import { ErrorCode } from "@/lib/errorCodes";
+import { hasEntitlement } from "@/lib/entitlements";
+import { ServiceError } from "@/lib/serviceError";
+import { sew } from "@/middleware/sew";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+import { env, generateScimToken as generateScimTokenSecret } from "@sourcebot/shared";
+import { StatusCodes } from "http-status-codes";
+
+const scimNotAvailable = (): ServiceError => ({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
+ message: "SCIM provisioning is not available in your current plan",
+});
+
+/**
+ * The base URL an IdP (Okta, Entra) is configured to send SCIM requests to.
+ * Exposed at the clean `/scim/v2` path via a rewrite in `next.config.mjs`.
+ */
+export const getScimBaseUrl = async (): Promise<{ baseUrl: string } | ServiceError> => sew(() =>
+ withAuth(async ({ role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+ return { baseUrl: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2` };
+ })));
+
+/**
+ * Whether SCIM provisioning is currently enabled (toggled on) for the org.
+ * This is the explicit opt-in switch, independent of whether any tokens exist.
+ */
+export const getIsScimEnabled = async (): Promise<{ enabled: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+ return { enabled: org.isScimEnabled };
+ })));
+
+/**
+ * Enables or disables SCIM provisioning for the org. Disabling is a kill switch:
+ * existing tokens stop authenticating and JIT suppression lifts, but tokens are
+ * preserved so provisioning can be resumed by toggling back on.
+ */
+export const setScimEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ await prisma.org.update({
+ where: { id: org.id },
+ data: { isScimEnabled: enabled },
+ });
+
+ await createAudit({
+ action: enabled ? "scim.enabled" : "scim.disabled",
+ actor: { id: user.id, type: "user" },
+ target: { id: org.id.toString(), type: "org" },
+ orgId: org.id,
+ });
+
+ return { success: true };
+ })));
+
+export const generateScimToken = async (name: string): Promise<{ token: string } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ const existing = await prisma.scimToken.findFirst({
+ where: {
+ orgId: org.id,
+ name,
+ },
+ });
+
+ if (existing) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.API_KEY_ALREADY_EXISTS,
+ message: `A SCIM token named "${name}" already exists`,
+ } satisfies ServiceError;
+ }
+
+ const { token, hash } = generateScimTokenSecret();
+ const scimToken = await prisma.scimToken.create({
+ data: {
+ name,
+ hash,
+ orgId: org.id,
+ },
+ });
+
+ await createAudit({
+ action: "scim_token.created",
+ actor: { id: user.id, type: "user" },
+ target: { id: scimToken.hash, type: "scim_token" },
+ orgId: org.id,
+ metadata: { scim_token: name },
+ });
+
+ return { token };
+ })));
+
+export const revokeScimToken = async (name: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ const scimToken = await prisma.scimToken.findFirst({
+ where: {
+ orgId: org.id,
+ name,
+ },
+ });
+
+ if (!scimToken) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.API_KEY_NOT_FOUND,
+ message: `SCIM token "${name}" not found`,
+ } satisfies ServiceError;
+ }
+
+ await prisma.scimToken.delete({
+ where: { hash: scimToken.hash },
+ });
+
+ await createAudit({
+ action: "scim_token.deleted",
+ actor: { id: user.id, type: "user" },
+ target: { id: scimToken.hash, type: "scim_token" },
+ orgId: org.id,
+ metadata: { scim_token: name },
+ });
+
+ return { success: true };
+ })));
+
+export const getScimTokens = async (): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!await hasEntitlement('scim')) {
+ return scimNotAvailable();
+ }
+
+ const tokens = await prisma.scimToken.findMany({
+ where: { orgId: org.id },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return tokens.map((token) => ({
+ name: token.name,
+ createdAt: token.createdAt,
+ lastUsedAt: token.lastUsedAt,
+ }));
+ })));
diff --git a/packages/web/src/ee/features/scim/constants.ts b/packages/web/src/ee/features/scim/constants.ts
new file mode 100644
index 000000000..dae10ec9a
--- /dev/null
+++ b/packages/web/src/ee/features/scim/constants.ts
@@ -0,0 +1,14 @@
+// SCIM 2.0 schema URNs (RFC 7643 / 7644).
+export const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
+export const SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
+export const SCIM_PATCH_OP_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
+export const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
+export const SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
+export const SCIM_RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType";
+export const SCIM_SCHEMA_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema";
+
+export const SCIM_CONTENT_TYPE = "application/scim+json";
+
+// Default and max page sizes for list responses.
+export const SCIM_DEFAULT_COUNT = 100;
+export const SCIM_MAX_COUNT = 200;
diff --git a/packages/web/src/ee/features/scim/mapper.ts b/packages/web/src/ee/features/scim/mapper.ts
new file mode 100644
index 000000000..0db3db5a0
--- /dev/null
+++ b/packages/web/src/ee/features/scim/mapper.ts
@@ -0,0 +1,80 @@
+import { Prisma } from "@sourcebot/db";
+import { env } from "@sourcebot/shared";
+import {
+ SCIM_CONTENT_TYPE,
+ SCIM_ERROR_SCHEMA,
+ SCIM_LIST_RESPONSE_SCHEMA,
+ SCIM_USER_SCHEMA,
+} from "./constants";
+
+// A membership row with its linked user, as returned by the SCIM endpoints.
+export type ScimMembership = Prisma.UserToOrgGetPayload<{ include: { user: true } }>;
+
+const scimUserLocation = (userId: string): string =>
+ `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${userId}`;
+
+/**
+ * Maps a Sourcebot membership + user into a SCIM 2.0 User resource. The SCIM
+ * `id` is the stable `User.id`; `userName` and the primary email are the
+ * user's email; `active` reflects whether the membership is unsuspended.
+ */
+export const toScimUser = (membership: ScimMembership) => {
+ const { user } = membership;
+ const [givenName, ...rest] = (user.name ?? "").split(" ");
+ const familyName = rest.join(" ");
+
+ return {
+ schemas: [SCIM_USER_SCHEMA],
+ id: user.id,
+ ...(membership.scimExternalId ? { externalId: membership.scimExternalId } : {}),
+ userName: user.email ?? undefined,
+ name: user.name ? {
+ formatted: user.name,
+ givenName: givenName || undefined,
+ familyName: familyName || undefined,
+ } : undefined,
+ emails: user.email ? [{ value: user.email, primary: true }] : [],
+ active: membership.suspendedAt == null,
+ meta: {
+ resourceType: "User",
+ created: membership.joinedAt.toISOString(),
+ lastModified: membership.joinedAt.toISOString(),
+ location: scimUserLocation(user.id),
+ },
+ };
+};
+
+/** Wraps a list of SCIM resources in a SCIM ListResponse envelope. */
+export const toScimListResponse = (
+ resources: unknown[],
+ totalResults: number,
+ startIndex: number,
+) => ({
+ schemas: [SCIM_LIST_RESPONSE_SCHEMA],
+ totalResults,
+ startIndex,
+ itemsPerPage: resources.length,
+ Resources: resources,
+});
+
+/** Builds a `Response` with the SCIM content type. */
+export const scimJson = (body: unknown, status: number, headers?: Record): Response =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ "Content-Type": SCIM_CONTENT_TYPE,
+ ...headers,
+ },
+ });
+
+/**
+ * Builds a SCIM error `Response`. Per RFC 7644 the `status` in the body is a
+ * string and must match the HTTP status.
+ */
+export const scimError = (status: number, detail: string, scimType?: string): Response =>
+ scimJson({
+ schemas: [SCIM_ERROR_SCHEMA],
+ status: status.toString(),
+ ...(scimType ? { scimType } : {}),
+ detail,
+ }, status);
diff --git a/packages/web/src/ee/features/scim/schemas.test.ts b/packages/web/src/ee/features/scim/schemas.test.ts
new file mode 100644
index 000000000..aa36271ac
--- /dev/null
+++ b/packages/web/src/ee/features/scim/schemas.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, test } from 'vitest';
+import { parseScimPatchOperations, scimPatchOpSchema } from './schemas';
+
+// Builds a typed Operations array via the schema, mirroring what the route
+// passes into parseScimPatchOperations after validation.
+const ops = (operations: unknown[]): ReturnType['Operations'] =>
+ scimPatchOpSchema.parse({ Operations: operations }).Operations;
+
+describe('parseScimPatchOperations', () => {
+ test('extracts active from a path-based replace (boolean)', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'active', value: false },
+ ]))).toEqual({ active: false });
+ });
+
+ test('coerces a stringified active value', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'active', value: 'false' },
+ ]))).toEqual({ active: false });
+ });
+
+ test('extracts active from the no-path bulk form', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', value: { active: false } },
+ ]))).toEqual({ active: false });
+ });
+
+ test('extracts a name change from displayName', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'displayName', value: 'Jane Doe' },
+ ]))).toEqual({ name: 'Jane Doe' });
+ });
+
+ test('extracts a name change from name.formatted', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'name.formatted', value: 'Jane Doe' },
+ ]))).toEqual({ name: 'Jane Doe' });
+ });
+
+ test('extracts an email change from userName (lowercased)', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'userName', value: 'Jane.New@Corp.COM' },
+ ]))).toEqual({ email: 'jane.new@corp.com' });
+ });
+
+ test('extracts an email change from a filtered emails path', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'emails[type eq "work"].value', value: 'jane@corp.com' },
+ ]))).toEqual({ email: 'jane@corp.com' });
+ });
+
+ test('matches op and path case-insensitively', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'REPLACE', path: 'Active', value: true },
+ ]))).toEqual({ active: true });
+ });
+
+ test('honors `add` operations as well as `replace`', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'add', path: 'displayName', value: 'New Name' },
+ ]))).toEqual({ name: 'New Name' });
+ });
+
+ test('ignores unrecognized operations (e.g. remove)', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'remove', path: 'name.givenName' },
+ ]))).toEqual({});
+ });
+
+ test('ignores unrecognized paths', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'title', value: 'Engineer' },
+ ]))).toEqual({});
+ });
+
+ test('combines name, email, and active across multiple operations', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'name.formatted', value: 'Jane Doe' },
+ { op: 'replace', path: 'userName', value: 'jane@corp.com' },
+ { op: 'replace', path: 'active', value: false },
+ ]))).toEqual({ name: 'Jane Doe', email: 'jane@corp.com', active: false });
+ });
+
+ test('handles a no-path bulk object with multiple attributes', () => {
+ expect(parseScimPatchOperations(ops([
+ {
+ op: 'replace',
+ value: {
+ active: true,
+ userName: 'jane@corp.com',
+ name: { formatted: 'Jane Doe' },
+ },
+ },
+ ]))).toEqual({ name: 'Jane Doe', email: 'jane@corp.com', active: true });
+ });
+
+ test('prefers the primary email from a bulk emails array', () => {
+ expect(parseScimPatchOperations(ops([
+ {
+ op: 'replace',
+ value: {
+ emails: [
+ { value: 'secondary@corp.com', primary: false },
+ { value: 'primary@corp.com', primary: true },
+ ],
+ },
+ },
+ ]))).toEqual({ email: 'primary@corp.com' });
+ });
+
+ test('later operations override earlier ones', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'active', value: true },
+ { op: 'replace', path: 'active', value: false },
+ ]))).toEqual({ active: false });
+ });
+
+ test('returns an empty object when no relevant operations are present', () => {
+ expect(parseScimPatchOperations(ops([
+ { op: 'replace', path: 'locale', value: 'en-US' },
+ { op: 'remove', path: 'title' },
+ ]))).toEqual({});
+ });
+});
diff --git a/packages/web/src/ee/features/scim/schemas.ts b/packages/web/src/ee/features/scim/schemas.ts
new file mode 100644
index 000000000..15cd7c03f
--- /dev/null
+++ b/packages/web/src/ee/features/scim/schemas.ts
@@ -0,0 +1,249 @@
+import { z } from "zod";
+import {
+ SCIM_RESOURCE_TYPE_SCHEMA,
+ SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA,
+ SCIM_USER_SCHEMA,
+} from "./constants";
+
+// ----- Request body schemas (lenient: IdPs send extra attributes) -----
+
+const scimEmailSchema = z.object({
+ value: z.string(),
+ primary: z.boolean().optional(),
+ type: z.string().optional(),
+}).passthrough();
+
+const scimNameSchema = z.object({
+ formatted: z.string().optional(),
+ givenName: z.string().optional(),
+ familyName: z.string().optional(),
+}).passthrough();
+
+export const scimUserCreateSchema = z.object({
+ userName: z.string(),
+ externalId: z.string().optional(),
+ name: scimNameSchema.optional(),
+ emails: z.array(scimEmailSchema).optional(),
+ // `active` may arrive as a boolean or a string ("true"/"false").
+ active: z.union([z.boolean(), z.string()]).optional(),
+ displayName: z.string().optional(),
+}).passthrough();
+export type ScimUserCreate = z.infer;
+
+export const scimUserReplaceSchema = scimUserCreateSchema;
+export type ScimUserReplace = z.infer;
+
+export const scimPatchOpSchema = z.object({
+ schemas: z.array(z.string()).optional(),
+ Operations: z.array(z.object({
+ op: z.string(),
+ path: z.string().optional(),
+ value: z.unknown().optional(),
+ }).passthrough()),
+}).passthrough();
+export type ScimPatchOp = z.infer;
+
+/** Coerces a SCIM `active` value (boolean | "true"/"false" | undefined). */
+export const coerceActive = (value: unknown): boolean | undefined => {
+ if (typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "string") {
+ if (value.toLowerCase() === "true") {
+ return true;
+ }
+ if (value.toLowerCase() === "false") {
+ return false;
+ }
+ }
+ return undefined;
+};
+
+/** Resolves the primary email from a SCIM user payload. */
+export const resolveEmail = (payload: ScimUserCreate): string => {
+ const primary = payload.emails?.find((e) => e.primary)?.value;
+ return (primary ?? payload.emails?.[0]?.value ?? payload.userName).toLowerCase();
+};
+
+/** The subset of attributes Sourcebot persists from a SCIM PatchOp. */
+export interface ScimPatchChanges {
+ name?: string;
+ email?: string;
+ active?: boolean;
+}
+
+// Resolves a display name from a SCIM `name` complex value / `displayName`,
+// mirroring the precedence used elsewhere (formatted, then displayName).
+const resolveNameFromValue = (value: Record): string | undefined => {
+ const name = value.name;
+ const formatted = (name && typeof name === "object" && !Array.isArray(name))
+ ? (name as Record).formatted
+ : undefined;
+ if (typeof formatted === "string") {
+ return formatted;
+ }
+ if (typeof value.displayName === "string") {
+ return value.displayName;
+ }
+ return undefined;
+};
+
+// Resolves the primary email from a SCIM `emails` array / `userName` value.
+const resolveEmailFromValue = (value: Record): string | undefined => {
+ const emails = value.emails;
+ if (Array.isArray(emails)) {
+ const primary = emails.find((e) => e && typeof e === "object" && (e as Record).primary)
+ ?? emails[0];
+ const email = (primary && typeof primary === "object") ? (primary as Record).value : undefined;
+ if (typeof email === "string") {
+ return email.toLowerCase();
+ }
+ }
+ if (typeof value.userName === "string") {
+ return value.userName.toLowerCase();
+ }
+ return undefined;
+};
+
+/**
+ * Reduces a SCIM PatchOp's operations into the subset of changes Sourcebot
+ * persists: display name, email, and active state. Handles both path-based ops
+ * (`{op,path,value}`, e.g. `name.formatted`, `userName`, `active`) and the
+ * no-path bulk form (`{op,value:{...}}`). Operator and attribute names are
+ * matched case-insensitively. Later operations override earlier ones, and any
+ * unrecognized op/path is ignored (lenient, never an error).
+ */
+export const parseScimPatchOperations = (operations: ScimPatchOp["Operations"]): ScimPatchChanges => {
+ const changes: ScimPatchChanges = {};
+
+ for (const operation of operations) {
+ const op = operation.op.toLowerCase();
+ if (op !== "replace" && op !== "add") {
+ continue;
+ }
+
+ const value = operation.value;
+ const path = operation.path?.toLowerCase();
+
+ // No-path bulk form: `value` is an object of attributes to replace.
+ if (path === undefined) {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ const record = value as Record;
+ const active = coerceActive(record.active);
+ if (active !== undefined) {
+ changes.active = active;
+ }
+ const name = resolveNameFromValue(record);
+ if (name !== undefined) {
+ changes.name = name;
+ }
+ const email = resolveEmailFromValue(record);
+ if (email !== undefined) {
+ changes.email = email;
+ }
+ }
+ continue;
+ }
+
+ if (path === "active") {
+ const active = coerceActive(value);
+ if (active !== undefined) {
+ changes.active = active;
+ }
+ } else if (path === "username") {
+ if (typeof value === "string") {
+ changes.email = value.toLowerCase();
+ }
+ } else if (path === "displayname" || path === "name.formatted") {
+ if (typeof value === "string") {
+ changes.name = value;
+ }
+ } else if (path.startsWith("emails")) {
+ // e.g. `emails[type eq "work"].value` → maps to the primary email.
+ if (typeof value === "string") {
+ changes.email = value.toLowerCase();
+ }
+ }
+ }
+
+ return changes;
+};
+
+// ----- Filter parsing -----
+
+export type ScimFilter =
+ | { attribute: "userName" | "externalId"; value: string }
+ | null;
+
+/**
+ * Parses the narrow set of SCIM filters IdPs actually send:
+ * `userName eq "value"` and `externalId eq "value"`. Operator and attribute
+ * are matched case-insensitively. Anything else returns `null`, which callers
+ * treat as "no matching results" rather than an error.
+ */
+export const parseScimFilter = (filter: string | null): ScimFilter => {
+ if (!filter) {
+ return null;
+ }
+ const match = filter.match(/^\s*(userName|externalId)\s+eq\s+"([^"]*)"\s*$/i);
+ if (!match) {
+ return null;
+ }
+ const attribute = match[1].toLowerCase() === "username" ? "userName" : "externalId";
+ return { attribute, value: match[2] };
+};
+
+// ----- Static discovery documents -----
+
+export const serviceProviderConfig = {
+ schemas: [SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA],
+ patch: { supported: true },
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
+ filter: { supported: true, maxResults: 200 },
+ changePassword: { supported: false },
+ sort: { supported: false },
+ etag: { supported: false },
+ authenticationSchemes: [{
+ type: "oauthbearertoken",
+ name: "OAuth Bearer Token",
+ description: "Authentication via the SCIM bearer token generated in Sourcebot settings.",
+ primary: true,
+ }],
+ meta: { resourceType: "ServiceProviderConfig" },
+};
+
+export const userResourceType = {
+ schemas: [SCIM_RESOURCE_TYPE_SCHEMA],
+ id: "User",
+ name: "User",
+ endpoint: "/Users",
+ description: "User Account",
+ schema: SCIM_USER_SCHEMA,
+ meta: { resourceType: "ResourceType" },
+};
+
+export const userSchemaDefinition = {
+ id: SCIM_USER_SCHEMA,
+ name: "User",
+ description: "User Account",
+ attributes: [
+ { name: "userName", type: "string", multiValued: false, required: true, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "server" },
+ { name: "active", type: "boolean", multiValued: false, required: false, mutability: "readWrite", returned: "default" },
+ {
+ name: "name", type: "complex", multiValued: false, required: false, mutability: "readWrite", returned: "default",
+ subAttributes: [
+ { name: "formatted", type: "string", multiValued: false, required: false },
+ { name: "givenName", type: "string", multiValued: false, required: false },
+ { name: "familyName", type: "string", multiValued: false, required: false },
+ ],
+ },
+ {
+ name: "emails", type: "complex", multiValued: true, required: false, mutability: "readWrite", returned: "default",
+ subAttributes: [
+ { name: "value", type: "string", multiValued: false, required: false },
+ { name: "primary", type: "boolean", multiValued: false, required: false },
+ ],
+ },
+ ],
+ meta: { resourceType: "Schema" },
+};
diff --git a/packages/web/src/ee/features/scim/withScimAuth.ts b/packages/web/src/ee/features/scim/withScimAuth.ts
new file mode 100644
index 000000000..c10d5b13d
--- /dev/null
+++ b/packages/web/src/ee/features/scim/withScimAuth.ts
@@ -0,0 +1,77 @@
+import { __unsafePrisma } from "@/prisma";
+import { hasEntitlement } from "@/lib/entitlements";
+import { hashSecret, SCIM_TOKEN_PREFIX, createLogger } from "@sourcebot/shared";
+import { Org, PrismaClient } from "@sourcebot/db";
+import { NextRequest } from "next/server";
+import { scimError } from "./mapper";
+
+const logger = createLogger('scim-auth');
+
+export type ScimAuthContext = {
+ org: Org;
+ prisma: PrismaClient;
+};
+
+/**
+ * Authenticates a SCIM request via its `Authorization: Bearer sbscim_…` token
+ * and runs `fn` with an org-scoped (userless) context. Unlike `withAuth`, this
+ * does NOT resolve a user/role or apply the user-scoped Prisma extension: the
+ * caller is the IdP provisioning integration, acting org-wide. All responses
+ * (including errors) use the SCIM content type and error envelope.
+ */
+export const withScimAuth = async (
+ request: NextRequest,
+ fn: (ctx: ScimAuthContext) => Promise,
+): Promise => {
+ const authorization = request.headers.get("Authorization") ?? undefined;
+ if (!authorization?.startsWith("Bearer ")) {
+ return scimError(401, "Missing or malformed Authorization header");
+ }
+
+ const bearer = authorization.slice("Bearer ".length);
+ if (!bearer.startsWith(SCIM_TOKEN_PREFIX)) {
+ return scimError(401, "Invalid SCIM token");
+ }
+
+ const secret = bearer.slice(SCIM_TOKEN_PREFIX.length);
+ if (!secret) {
+ return scimError(401, "Invalid SCIM token");
+ }
+
+ const scimToken = await __unsafePrisma.scimToken.findUnique({
+ where: { hash: hashSecret(secret) },
+ include: { org: true },
+ });
+ if (!scimToken) {
+ return scimError(401, "Invalid SCIM token");
+ }
+
+ // Enforce the entitlement per-request so a license downgrade disables SCIM
+ // immediately, even with valid tokens still configured in the IdP.
+ if (!await hasEntitlement('scim')) {
+ return scimError(403, "SCIM provisioning is not available in your current plan");
+ }
+
+ // SCIM is an explicit opt-in: a valid token is rejected unless an owner has
+ // toggled provisioning on. Disabling acts as a kill switch that pauses all
+ // provisioning without requiring tokens to be revoked.
+ if (!scimToken.org.isScimEnabled) {
+ return scimError(403, "SCIM provisioning is disabled for this organization");
+ }
+
+ // Best-effort usage tracking; never block the request on it.
+ __unsafePrisma.scimToken.update({
+ where: { hash: scimToken.hash },
+ data: { lastUsedAt: new Date() },
+ }).catch(() => { /* ignore */ });
+
+ try {
+ return await fn({
+ org: scimToken.org,
+ prisma: __unsafePrisma
+ });
+ } catch (error) {
+ logger.error(`Unhandled SCIM error: ${error instanceof Error ? error.message : String(error)}`);
+ return scimError(500, "Internal server error");
+ }
+};
diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts
index 30777536a..ca5b6b686 100644
--- a/packages/web/src/ee/features/sso/sso.ts
+++ b/packages/web/src/ee/features/sso/sso.ts
@@ -1,5 +1,4 @@
import type { IdentityProvider } from "@/auth";
-import { onCreateUser } from "@/lib/authUtils";
import { __unsafePrisma } from "@/prisma";
import { hasEntitlement } from "@/lib/entitlements";
import { createLogger, env, getIdentityProviderConfigs, getTokenFromConfig } from "@sourcebot/shared";
@@ -16,6 +15,7 @@ import Google from "next-auth/providers/google";
import Keycloak from "next-auth/providers/keycloak";
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
import Okta from "next-auth/providers/okta";
+import { onCreateUser } from "@/features/membership/onCreateUser";
const logger = createLogger('web-sso');
diff --git a/packages/web/src/ee/features/userManagement/actions.ts b/packages/web/src/ee/features/userManagement/actions.ts
deleted file mode 100644
index d02b98b4b..000000000
--- a/packages/web/src/ee/features/userManagement/actions.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-'use server';
-
-import { sew } from "@/middleware/sew";
-import { createAudit } from "@/ee/features/audit/audit";
-import { ErrorCode } from "@/lib/errorCodes";
-import { notFound, ServiceError } from "@/lib/serviceError";
-import { withAuth } from "@/middleware/withAuth";
-import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
-import { OrgRole, Prisma } from "@sourcebot/db";
-import { hasEntitlement } from "@/lib/entitlements";
-import { StatusCodes } from "http-status-codes";
-
-const orgManagementNotAvailable = (): ServiceError => ({
- statusCode: StatusCodes.FORBIDDEN,
- errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
- message: "Organization management is not available in your current plan",
-});
-
-export const promoteToOwner = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- if (!await hasEntitlement('org-management')) {
- return orgManagementNotAvailable();
- }
-
- if (memberId === user.id) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: "You are already an owner.",
- } satisfies ServiceError;
- }
-
- const targetMember = await prisma.userToOrg.findUnique({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- });
-
- if (!targetMember) {
- return notFound("Member not found in this organization");
- }
-
- if (targetMember.role === OrgRole.OWNER) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: "This member is already an owner.",
- } satisfies ServiceError;
- }
-
- await prisma.userToOrg.update({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- data: {
- role: "OWNER",
- },
- });
-
- await createAudit({
- action: "org.member_promoted_to_owner",
- actor: { id: user.id, type: "user" },
- target: { id: memberId, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} promoted ${memberId} to owner`,
- },
- });
-
- return { success: true };
- }))
-);
-
-export const demoteToMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- if (!await hasEntitlement('org-management')) {
- return orgManagementNotAvailable();
- }
-
- const guardError = await prisma.$transaction(async (tx) => {
- const targetMember = await tx.userToOrg.findUnique({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- });
-
- if (!targetMember) {
- return notFound("Member not found in this organization");
- }
-
- if (targetMember.role !== OrgRole.OWNER) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: "This member is not an owner.",
- } satisfies ServiceError;
- }
-
- const ownerCount = await tx.userToOrg.count({
- where: {
- orgId: org.id,
- role: OrgRole.OWNER,
- },
- });
-
- if (ownerCount <= 1) {
- return {
- statusCode: StatusCodes.FORBIDDEN,
- errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED,
- message: "Cannot demote the last owner. Promote another member to owner first.",
- } satisfies ServiceError;
- }
-
- await tx.userToOrg.update({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: memberId,
- },
- },
- data: {
- role: "MEMBER",
- },
- });
-
- return null;
- }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
-
- if (guardError) {
- return guardError;
- }
-
- await createAudit({
- action: "org.owner_demoted_to_member",
- actor: { id: user.id, type: "user" },
- target: { id: memberId, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} demoted ${memberId} to member`,
- },
- });
-
- return { success: true };
- }))
-);
diff --git a/packages/web/src/features/billing/actions.ts b/packages/web/src/features/billing/actions.ts
index c9070b219..159b8abf3 100644
--- a/packages/web/src/features/billing/actions.ts
+++ b/packages/web/src/features/billing/actions.ts
@@ -15,6 +15,7 @@ import { captureEvent } from "@/lib/posthog";
import { UpsellSource } from "@/lib/posthogEvents";
import { client } from "./client";
import { z } from "zod";
+import { activeMembershipWhere } from "@/features/membership/utils";
export const activateLicense = async (activationCode: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async ({ org, role, prisma }) =>
@@ -130,6 +131,7 @@ export const createCheckoutSession = async ({
const memberCount = await prisma.userToOrg.count({
where: {
orgId: org.id,
+ ...activeMembershipWhere(),
},
});
const quantity = Math.max(memberCount, 1);
diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts
index e6b052766..ff48b4e98 100644
--- a/packages/web/src/features/billing/servicePing.ts
+++ b/packages/web/src/features/billing/servicePing.ts
@@ -13,6 +13,7 @@ import { client } from "./client";
import { ServicePingRequest } from "./types";
import { ServiceErrorException } from "@/lib/serviceError";
import { getConfiguredLanguageModels } from "@/features/chat/utils.server";
+import { activeMembershipWhere } from "@/features/membership/utils";
const logger = createLogger('service-ping');
@@ -32,40 +33,41 @@ export const syncWithLighthouse = async (orgId: number) => {
const mauCutoff = new Date(now - 30 * DAY_MS);
const [
- userCount,
- repoCount,
+ activeUserCount,
dauCount,
wauCount,
mauCount,
+ repoCount,
] = await Promise.all([
__unsafePrisma.userToOrg.count({
where: {
orgId,
+ ...activeMembershipWhere(),
},
}),
- __unsafePrisma.repo.count({
+ __unsafePrisma.userToOrg.count({
where: {
orgId,
- },
- }),
- __unsafePrisma.user.count({
- where: {
- orgs: { some: { orgId } },
lastActiveAt: { gte: dauCutoff },
},
}),
- __unsafePrisma.user.count({
+ __unsafePrisma.userToOrg.count({
where: {
- orgs: { some: { orgId } },
+ orgId,
lastActiveAt: { gte: wauCutoff },
},
}),
- __unsafePrisma.user.count({
+ __unsafePrisma.userToOrg.count({
where: {
- orgs: { some: { orgId } },
+ orgId,
lastActiveAt: { gte: mauCutoff },
},
}),
+ __unsafePrisma.repo.count({
+ where: {
+ orgId,
+ },
+ }),
]);
const activationCode = license?.activationCode
@@ -78,7 +80,7 @@ export const syncWithLighthouse = async (orgId: number) => {
installId: env.SOURCEBOT_INSTALL_ID,
version: SOURCEBOT_VERSION,
hostname: env.AUTH_URL,
- userCount,
+ userCount: activeUserCount,
repoCount,
dauCount,
wauCount,
@@ -202,4 +204,4 @@ const recordServicePingInDB = async (orgId: number, payload: ServicePingRequest)
// the actual ping from being sent to Lighthouse.
logger.error(`Failed to record service ping in database:\n ${error}`);
}
-};
\ No newline at end of file
+};
diff --git a/packages/web/src/features/billing/types.ts b/packages/web/src/features/billing/types.ts
index 24ed0020c..0be11074b 100644
--- a/packages/web/src/features/billing/types.ts
+++ b/packages/web/src/features/billing/types.ts
@@ -4,6 +4,11 @@ export const servicePingRequestSchema = z.object({
installId: z.string(),
version: z.string(),
hostname: z.string(),
+ /**
+ * The number of billed users: active (non-suspended) members who have been
+ * active in the org at least once. Provisioned members who have never
+ * signed in do not count towards this.
+ */
userCount: z.number().int().nonnegative(),
repoCount: z.number().int().nonnegative(),
dauCount: z.number().int().nonnegative(),
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
index fccc6df8a..9481b387e 100644
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -9,6 +9,7 @@ import { withAuth, withOptionalAuth } from "@/middleware/withAuth";
import { ChatVisibility, Prisma } from "@sourcebot/db";
import { SBChatMessage } from "./types";
import { checkAskEntitlement, isChatSharedWithUser, isOwnerOfChat } from "./utils.server";
+import { activeMembershipWhere } from "../membership/utils";
export const createChat = async ({ source }: { source?: string } = {}) => sew(() =>
withOptionalAuth(async ({ org, user, prisma }) => {
@@ -372,6 +373,7 @@ export const shareChatWithUsers = async ({ chatId, userIds }: { chatId: string,
const memberships = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
+ ...activeMembershipWhere(),
userId: {
in: userIds,
},
diff --git a/packages/web/src/features/membership/actions/accountRequests.ts b/packages/web/src/features/membership/actions/accountRequests.ts
new file mode 100644
index 000000000..913b4d5c4
--- /dev/null
+++ b/packages/web/src/features/membership/actions/accountRequests.ts
@@ -0,0 +1,278 @@
+'use server';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail";
+import { ensureActiveMember } from "@/features/membership/membership.service";
+import { getDefaultMemberRole } from "@/features/membership/utils";
+import { membershipManagedByIdpError } from "@/features/membership/errors";
+import { isScimEnabled } from "@/features/scim/utils";
+import { notAuthenticated, notFound } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { sew } from "@/middleware/sew";
+import { getAuthenticatedUser, withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { render } from "@react-email/components";
+import { OrgRole } from "@sourcebot/db";
+import { env, getSMTPConnectionURL } from "@sourcebot/shared";
+import { createTransport } from "nodemailer";
+import { logger } from "../logger";
+import { __unsafePrisma } from "@/prisma";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+import JoinRequestSubmittedEmail from "@/emails/joinRequestSubmittedEmail";
+
+// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth
+export const createAccountRequest = async () => sew(async () => {
+ const authResult = await getAuthenticatedUser();
+ if (!authResult) {
+ return notAuthenticated();
+ }
+
+ const { user } = authResult;
+
+ const org = await __unsafePrisma.org.findUnique({
+ where: {
+ id: SINGLE_TENANT_ORG_ID,
+ },
+ });
+
+ if (!org) {
+ return notFound("Organization not found");
+ }
+
+ // With SCIM enabled the IdP is the source of truth for membership, so
+ // un-provisioned users can't request to join.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const existingRequest = await __unsafePrisma.accountRequest.findUnique({
+ where: {
+ requestedById_orgId: {
+ requestedById: user.id,
+ orgId: org.id,
+ },
+ },
+ });
+
+ if (existingRequest) {
+ logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`);
+ return {
+ success: true,
+ existingRequest: true,
+ }
+ }
+
+ if (!existingRequest) {
+ await __unsafePrisma.accountRequest.create({
+ data: {
+ requestedById: user.id,
+ orgId: org.id,
+ },
+ });
+
+ const smtpConnectionUrl = getSMTPConnectionURL();
+ if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
+ // TODO: This is needed because we can't fetch the origin from the request headers when this is called
+ // on user creation (the header isn't set when next-auth calls onCreateUser for some reason)
+ const deploymentUrl = env.AUTH_URL;
+
+ const owners = await __unsafePrisma.user.findMany({
+ where: {
+ orgs: {
+ some: {
+ orgId: org.id,
+ role: "OWNER",
+ },
+ },
+ },
+ });
+
+ if (owners.length === 0) {
+ logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`);
+ } else {
+ const html = await render(JoinRequestSubmittedEmail({
+ baseUrl: deploymentUrl,
+ requestor: {
+ name: user.name ?? undefined,
+ email: user.email,
+ avatarUrl: user.image ?? undefined,
+ },
+ orgName: org.name,
+ orgImageUrl: org.imageUrl ?? undefined,
+ }));
+
+ const ownerEmails = owners
+ .map((owner) => owner.email)
+ .filter((email): email is string => email !== null);
+
+ const transport = createTransport(smtpConnectionUrl);
+ const result = await transport.sendMail({
+ to: ownerEmails,
+ from: env.EMAIL_FROM_ADDRESS,
+ subject: `New account request for ${org.name} on Sourcebot`,
+ html,
+ text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`,
+ });
+
+ const failed = result.rejected.concat(result.pending).filter(Boolean);
+ if (failed.length > 0) {
+ logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`);
+ }
+ }
+ } else {
+ logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`);
+ }
+ }
+
+ return {
+ success: true,
+ existingRequest: false,
+ }
+});
+
+export const approveAccountRequest = async (requestId: string) => sew(async () =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ // With SCIM enabled the IdP is the source of truth for membership;
+ // approving a request would mint a member it never provisioned.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const failAuditCallback = async (error: string) => {
+ await createAudit({
+ action: "user.join_request_approve_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: requestId,
+ type: "account_join_request"
+ },
+ orgId: org.id,
+ metadata: {
+ message: error,
+ }
+ });
+ }
+
+ const request = await prisma.accountRequest.findUnique({
+ where: {
+ id: requestId,
+ },
+ include: {
+ requestedBy: true,
+ },
+ });
+
+ if (!request || request.orgId !== org.id) {
+ await failAuditCallback("Request not found");
+ return notFound();
+ }
+
+ const addUserToOrgRes = await ensureActiveMember(org.id, request.requestedById, {
+ actor: { id: request.requestedById, type: "user" },
+ role: await getDefaultMemberRole(),
+ });
+ if (isServiceError(addUserToOrgRes)) {
+ await failAuditCallback(addUserToOrgRes.message);
+ return addUserToOrgRes;
+ }
+
+
+ await createAudit({
+ action: "user.join_request_approved",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: org.id,
+ target: {
+ id: requestId,
+ type: "account_join_request"
+ }
+ });
+
+ // Send approval email to the user
+ const smtpConnectionUrl = getSMTPConnectionURL();
+ if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
+ const html = await render(JoinRequestApprovedEmail({
+ baseUrl: env.AUTH_URL,
+ user: {
+ name: request.requestedBy.name ?? undefined,
+ email: request.requestedBy.email,
+ avatarUrl: request.requestedBy.image ?? undefined,
+ },
+ orgName: org.name,
+ }));
+
+ const transport = createTransport(smtpConnectionUrl);
+ const result = await transport.sendMail({
+ to: request.requestedBy.email,
+ from: env.EMAIL_FROM_ADDRESS,
+ subject: `Your request to join ${org.name} has been approved`,
+ html,
+ text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`,
+ });
+
+ const failed = result.rejected.concat(result.pending).filter(Boolean);
+ if (failed.length > 0) {
+ logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`);
+ }
+ } else {
+ logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`);
+ }
+
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const rejectAccountRequest = async (requestId: string) => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const request = await prisma.accountRequest.findUnique({
+ where: {
+ id: requestId,
+ },
+ });
+
+ if (!request || request.orgId !== org.id) {
+ return notFound();
+ }
+
+ await prisma.accountRequest.delete({
+ where: {
+ id: requestId,
+ },
+ });
+
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const getOrgAccountRequests = async () => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const requests = await prisma.accountRequest.findMany({
+ where: {
+ orgId: org.id,
+ },
+ include: {
+ requestedBy: true,
+ },
+ });
+
+ return requests.map((request) => ({
+ id: request.id,
+ email: request.requestedBy.email,
+ createdAt: request.createdAt,
+ name: request.requestedBy.name ?? undefined,
+ image: request.requestedBy.image ?? undefined,
+ }));
+ })));
+
diff --git a/packages/web/src/features/membership/actions/index.ts b/packages/web/src/features/membership/actions/index.ts
new file mode 100644
index 000000000..13ef2b22f
--- /dev/null
+++ b/packages/web/src/features/membership/actions/index.ts
@@ -0,0 +1,3 @@
+export * from './members';
+export * from './invites';
+export * from './accountRequests';
\ No newline at end of file
diff --git a/packages/web/src/features/membership/actions/invites.ts b/packages/web/src/features/membership/actions/invites.ts
new file mode 100644
index 000000000..e6ca6bc07
--- /dev/null
+++ b/packages/web/src/features/membership/actions/invites.ts
@@ -0,0 +1,436 @@
+'use server';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import InviteUserEmail from "@/emails/inviteUserEmail";
+import { ensureActiveMember } from "@/features/membership/membership.service";
+import { getDefaultMemberRole, orgHasAvailability } from "@/features/membership/utils";
+import { membershipManagedByIdpError } from "@/features/membership/errors";
+import { isScimEnabled } from "@/features/scim/utils";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+import { ErrorCode } from "@/lib/errorCodes";
+import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { sew } from "@/middleware/sew";
+import { getAuthenticatedUser, withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { __unsafePrisma } from "@/prisma";
+import { render } from "@react-email/components";
+import { OrgRole } from "@sourcebot/db";
+import { env, getSMTPConnectionURL, isMemberApprovalRequired } from "@sourcebot/shared";
+import { StatusCodes } from "http-status-codes";
+import { createTransport } from "nodemailer";
+import { logger } from "../logger";
+
+export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ // With SCIM enabled the IdP is the source of truth for membership;
+ // invites would add members outside it.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const failAuditCallback = async (error: string) => {
+ await createAudit({
+ action: "user.invite_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: error,
+ emails: emails.join(", ")
+ }
+ });
+ }
+
+ const hasAvailability = await orgHasAvailability(org.id, prisma);
+ if (!hasAvailability) {
+ await createAudit({
+ action: "user.invite_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: "Organization has reached maximum number of seats",
+ emails: emails.join(", ")
+ }
+ });
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
+ message: "The organization has reached the maximum number of seats. Unable to create a new invite",
+ } satisfies ServiceError;
+ }
+
+ // Check for existing invites
+ const existingInvites = await prisma.invite.findMany({
+ where: {
+ recipientEmail: {
+ in: emails
+ },
+ orgId: org.id,
+ }
+ });
+
+ if (existingInvites.length > 0) {
+ await failAuditCallback("A pending invite already exists for one or more of the provided emails");
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_INVITE,
+ message: `A pending invite already exists for one or more of the provided emails.`,
+ } satisfies ServiceError;
+ }
+
+ // Check for members that are already in the org
+ const existingMembers = await prisma.userToOrg.findMany({
+ where: {
+ user: {
+ email: {
+ in: emails,
+ }
+ },
+ orgId: org.id,
+ },
+ });
+
+ if (existingMembers.length > 0) {
+ await failAuditCallback("One or more of the provided emails are already members of this org");
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_INVITE,
+ message: `One or more of the provided emails are already members of this org.`,
+ } satisfies ServiceError;
+ }
+
+ await prisma.invite.createMany({
+ data: emails.map((email) => ({
+ recipientEmail: email,
+ hostUserId: user.id,
+ orgId: org.id,
+ })),
+ skipDuplicates: true,
+ });
+
+ // Send invites to recipients
+ const smtpConnectionUrl = getSMTPConnectionURL();
+ if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
+ await Promise.all(emails.map(async (email) => {
+ const invite = await prisma.invite.findUnique({
+ where: {
+ recipientEmail_orgId: {
+ recipientEmail: email,
+ orgId: org.id,
+ },
+ },
+ include: {
+ org: true,
+ }
+ });
+
+ if (!invite) {
+ return;
+ }
+
+ const recipient = await prisma.user.findUnique({
+ where: {
+ email,
+ },
+ });
+ const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`;
+ const transport = createTransport(smtpConnectionUrl);
+ const html = await render(InviteUserEmail({
+ baseUrl: env.AUTH_URL,
+ host: {
+ name: user.name ?? undefined,
+ email: user.email,
+ avatarUrl: user.image ?? undefined,
+ },
+ recipient: {
+ name: recipient?.name ?? undefined,
+ },
+ orgName: invite.org.name,
+ orgImageUrl: invite.org.imageUrl ?? undefined,
+ inviteLink,
+ }));
+
+ const result = await transport.sendMail({
+ to: email,
+ from: env.EMAIL_FROM_ADDRESS,
+ subject: `Join ${invite.org.name} on Sourcebot`,
+ html,
+ text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`,
+ });
+
+ const failed = result.rejected.concat(result.pending).filter(Boolean);
+ if (failed.length > 0) {
+ logger.error(`Failed to send invite email to ${email}: ${failed}`);
+ }
+ }));
+ } else {
+ logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`);
+ }
+
+ await createAudit({
+ action: "user.invites_created",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ emails: emails.join(", ")
+ }
+ });
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const invite = await prisma.invite.findUnique({
+ where: {
+ id: inviteId,
+ orgId: org.id,
+ },
+ });
+
+ if (!invite) {
+ return notFound();
+ }
+
+ await prisma.invite.delete({
+ where: {
+ id: inviteId,
+ },
+ });
+
+ return {
+ success: true,
+ }
+ })
+ ));
+
+export const getOrgInvites = async () => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const invites = await prisma.invite.findMany({
+ where: {
+ orgId: org.id,
+ },
+ });
+
+ return invites.map((invite) => ({
+ id: invite.id,
+ email: invite.recipientEmail,
+ createdAt: invite.createdAt,
+ }));
+ })));
+
+// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing
+export const joinOrganization = async (inviteLinkId?: string) => sew(async () => {
+ const authResult = await getAuthenticatedUser();
+ if (!authResult) {
+ return notAuthenticated();
+ }
+
+ const { user } = authResult;
+
+ const org = await __unsafePrisma.org.findUnique({
+ where: {
+ id: SINGLE_TENANT_ORG_ID,
+ },
+ });
+
+ if (!org) {
+ return orgNotFound();
+ }
+
+ // With SCIM enabled the IdP is the source of truth for membership; joining
+ // via an invite link would bypass it.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ // If member approval is required we must be using a valid invite link
+ if (isMemberApprovalRequired(org)) {
+ if (!org.inviteLinkEnabled) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVITE_LINK_NOT_ENABLED,
+ message: "Invite link is not enabled.",
+ } satisfies ServiceError;
+ }
+
+ if (org.inviteLinkId !== inviteLinkId) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_INVITE_LINK,
+ message: "Invalid invite link.",
+ } satisfies ServiceError;
+ }
+ }
+
+ const addUserToOrgRes = await ensureActiveMember(org.id, user.id, {
+ actor: { id: user.id, type: "user" },
+ role: await getDefaultMemberRole(),
+ });
+ if (isServiceError(addUserToOrgRes)) {
+ return addUserToOrgRes;
+ }
+
+ return {
+ success: true,
+ }
+});
+
+// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing
+export const redeemInvite = async (inviteId: string): Promise<{ success: boolean; } | ServiceError> => sew(async () => {
+ const authResult = await getAuthenticatedUser();
+ if (!authResult) {
+ return notAuthenticated();
+ }
+
+ const { user } = authResult;
+
+ const invite = await __unsafePrisma.invite.findUnique({
+ where: {
+ id: inviteId,
+ },
+ include: {
+ org: true,
+ }
+ });
+
+ if (!invite) {
+ return notFound();
+ }
+
+ // With SCIM enabled the IdP is the source of truth for membership; accepting
+ // an invite would bypass it.
+ if (await isScimEnabled(invite.org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const failAuditCallback = async (error: string) => {
+ await createAudit({
+ action: "user.invite_accept_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: inviteId,
+ type: "invite"
+ },
+ orgId: invite.org.id,
+ metadata: {
+ message: error
+ }
+ });
+ };
+
+ const hasAvailability = await orgHasAvailability(invite.org.id, __unsafePrisma);
+ if (!hasAvailability) {
+ await failAuditCallback("Organization is at max capacity");
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
+ message: "Organization is at max capacity",
+ } satisfies ServiceError;
+ }
+
+ // Check if the user is the recipient of the invite
+ if (user.email !== invite.recipientEmail) {
+ await failAuditCallback("User is not the recipient of the invite");
+ return notFound();
+ }
+
+ const addUserToOrgRes = await ensureActiveMember(invite.orgId, user.id, {
+ actor: { id: user.id, type: "user" },
+ role: await getDefaultMemberRole(),
+ });
+ if (isServiceError(addUserToOrgRes)) {
+ await failAuditCallback(addUserToOrgRes.message);
+ return addUserToOrgRes;
+ }
+
+ await createAudit({
+ action: "user.invite_accepted",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: invite.org.id,
+ target: {
+ id: inviteId,
+ type: "invite"
+ }
+ });
+
+ return {
+ success: true,
+ };
+});
+
+
+// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since the invitee is not yet a member
+export const getInviteInfo = async (inviteId: string) => sew(async () => {
+ const authResult = await getAuthenticatedUser();
+ if (!authResult) {
+ return notAuthenticated();
+ }
+
+ const { user } = authResult;
+
+ const invite = await __unsafePrisma.invite.findUnique({
+ where: {
+ id: inviteId,
+ },
+ include: {
+ org: true,
+ host: true,
+ }
+ });
+
+ if (!invite) {
+ return notFound();
+ }
+
+ if (invite.recipientEmail !== user.email) {
+ return notFound();
+ }
+
+ return {
+ id: invite.id,
+ orgName: invite.org.name,
+ orgImageUrl: invite.org.imageUrl ?? undefined,
+ host: {
+ name: invite.host.name ?? undefined,
+ email: invite.host.email,
+ avatarUrl: invite.host.image ?? undefined,
+ },
+ recipient: {
+ name: user.name ?? undefined,
+ email: user.email,
+ }
+ };
+});
diff --git a/packages/web/src/features/membership/actions/members.ts b/packages/web/src/features/membership/actions/members.ts
new file mode 100644
index 000000000..edffc30e1
--- /dev/null
+++ b/packages/web/src/features/membership/actions/members.ts
@@ -0,0 +1,114 @@
+'use server';
+
+import { membershipManagedByIdpError } from "@/features/membership/errors";
+import { removeMember, setMembershipSuspended } from "@/features/membership/membership.service";
+import { isScimEnabled } from "@/features/scim/utils";
+import { ServiceError } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { sew } from "@/middleware/sew";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+
+export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await removeMember(org.id, memberId, {
+ actor: { id: user.id, type: "user" },
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const suspendMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await setMembershipSuspended(org.id, memberId, true, {
+ actor: { id: user.id, type: "user" },
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const reactivateMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await setMembershipSuspended(org.id, memberId, false, {
+ actor: { id: user.id, type: "user" },
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ }))
+);
+
+export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org }) => {
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
+
+ const result = await removeMember(org.id, user.id, {
+ actor: { id: user.id, type: "user" },
+ reason: "left",
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return {
+ success: true,
+ }
+ }));
+
+
+export const getOrgMembers = async () => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const members = await prisma.userToOrg.findMany({
+ where: {
+ orgId: org.id,
+ },
+ include: {
+ user: true,
+ },
+ });
+
+ return members.map((member) => ({
+ id: member.userId,
+ email: member.user.email,
+ name: member.user.name ?? undefined,
+ avatarUrl: member.user.image ?? undefined,
+ role: member.role,
+ joinedAt: member.joinedAt,
+ suspendedAt: member.suspendedAt,
+ scimManaged: !!member.scimExternalId,
+ lastActiveAt: member.lastActiveAt,
+ }));
+ })));
diff --git a/packages/web/src/features/membership/components/joinOrganizationCard.tsx b/packages/web/src/features/membership/components/joinOrganizationCard.tsx
new file mode 100644
index 000000000..37f2f0023
--- /dev/null
+++ b/packages/web/src/features/membership/components/joinOrganizationCard.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { SourcebotLogo } from "@/app/components/sourcebotLogo";
+import { useToast } from "@/components/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { joinOrganization } from "@/features/membership/actions";
+import { isServiceError } from "@/lib/utils";
+import { Loader2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { LogoutEscapeHatch } from "../../../app/components/logoutEscapeHatch";
+
+export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) {
+ const [isLoading, setIsLoading] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const handleJoinOrganization = async () => {
+ setIsLoading(true);
+
+ try {
+ const result = await joinOrganization(inviteLinkId);
+
+ if (isServiceError(result)) {
+ toast({
+ title: "Failed to join organization",
+ description: result.message,
+ variant: "destructive",
+ });
+ return;
+ }
+
+ router.refresh();
+ } catch (error) {
+ console.error("Error joining organization:", error);
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Welcome to Sourcebot! Click the button below to join this organization.
+
+
+
+ {isLoading && }
+ Join 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/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.
+
+
+
+
+
+ Try again
+
+
+
+
+ );
+};
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..7585b33ec
--- /dev/null
+++ b/packages/web/src/features/membership/errors.ts
@@ -0,0 +1,38 @@
+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.",
+});
+
+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.",
+});
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..78bd13557
--- /dev/null
+++ b/packages/web/src/features/membership/membership.service.test.ts
@@ -0,0 +1,430 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+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';
+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,
+ activeMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: { not: null } }),
+ pendingMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: null }),
+ 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 ACTIVE_AT = new Date('2026-01-02T00:00:00.000Z');
+
+const makeMembership = (overrides: Partial = {}): UserToOrg => ({
+ orgId: ORG_ID,
+ userId: USER_ID,
+ role: OrgRole.MEMBER,
+ joinedAt: new Date(),
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: 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('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();
+ prisma.user.findUnique.mockResolvedValue(mockUser);
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+ prisma.userToOrg.create.mockResolvedValue(created);
+
+ const result = await ensureActiveMember(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 }),
+ }),
+ );
+ 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 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' }) }),
+ );
+ });
+
+ 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 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 } });
+ });
+
+ 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);
+
+ 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('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);
+ mocks.orgHasAvailability.mockResolvedValue(true);
+
+ const result = await ensureActiveMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER });
+
+ expect(isServiceError(result)).toBe(false);
+ expect(result).toEqual(reactivated);
+ expect(prisma.userToOrg.create).not.toHaveBeenCalled();
+ expect(prisma.userToOrg.update).toHaveBeenCalledWith(
+ expect.objectContaining({ data: expect.objectContaining({ suspendedAt: null }) }),
+ );
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_reactivated' }));
+ });
+
+ 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 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);
+ expect(prisma.userToOrg.create).not.toHaveBeenCalled();
+ });
+
+ test('errors when the user does not exist', async () => {
+ prisma.user.findUnique.mockResolvedValue(null);
+
+ const result = await ensureActiveMember(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, suspendedAt: null }));
+ 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, suspendedAt: null }));
+ 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, lastActiveAt: ACTIVE_AT }));
+
+ 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, suspendedAt: null, lastActiveAt: ACTIVE_AT }));
+ 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, suspendedAt: null, lastActiveAt: ACTIVE_AT }));
+ 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('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 }));
+
+ 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('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 setMembershipSuspended(ORG_ID, USER_ID, true, { actor: ACTOR });
+
+ 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: { suspendedAt: expect.any(Date) } }),
+ );
+ expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_deactivated' }));
+ });
+
+ test('is a no-op when already suspended', async () => {
+ const existing = makeMembership({ suspendedAt: SUSPENDED_AT });
+ prisma.userToOrg.findUnique.mockResolvedValue(existing);
+
+ const result = await setMembershipSuspended(ORG_ID, USER_ID, true, { actor: ACTOR });
+
+ expect(result).toEqual(existing);
+ 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 setMembershipSuspended(ORG_ID, USER_ID, true, { actor: ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('reactivate', () => {
+ 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 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({ 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({ suspendedAt: SUSPENDED_AT }));
+ mocks.orgHasAvailability.mockResolvedValue(false);
+
+ 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);
+ expect(prisma.userToOrg.update).not.toHaveBeenCalled();
+ });
+
+ test('is a no-op when already active (no audit, no seat check)', async () => {
+ const existing = makeMembership({ suspendedAt: null, scimExternalId: 'ext-1' });
+ prisma.userToOrg.findUnique.mockResolvedValue(existing);
+
+ const result = await setMembershipSuspended(ORG_ID, USER_ID, false, { actor: ACTOR, scimExternalId: 'ext-1' });
+
+ 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({ suspendedAt: null, scimExternalId: 'new' });
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ suspendedAt: null, scimExternalId: 'old' }));
+ prisma.userToOrg.update.mockResolvedValue(refreshed);
+
+ const result = await setMembershipSuspended(ORG_ID, USER_ID, false, { actor: ACTOR, scimExternalId: 'new' });
+
+ expect(result).toEqual(refreshed);
+ 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..494123e8f
--- /dev/null
+++ b/packages/web/src/features/membership/membership.service.ts
@@ -0,0 +1,395 @@
+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, pendingMembershipWhere } 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, memberNotActiveError, seatLimitReached } from "./errors";
+
+export interface EnsureActiveMemberOptions {
+ actor: AuditActor;
+ role: OrgRole;
+ 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.
+ * `role` only applies on create. Enforces the seat cap and clears pending
+ * invites / account requests for the user.
+ */
+export const ensureActiveMember = async (
+ orgId: number,
+ userId: string,
+ options: EnsureActiveMemberOptions,
+): 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 && existing.suspendedAt === null) {
+ return existing;
+ }
+ if (existing && existing.suspendedAt !== null) {
+ return setMembershipSuspended(orgId, userId, false, {
+ actor,
+ scimExternalId,
+ });
+ }
+
+ const membership = await prisma.$transaction(async (tx) => {
+ if (!(await orgHasAvailability(orgId, tx))) {
+ return seatLimitReached();
+ }
+
+ const created = await tx.userToOrg.create({
+ data: {
+ userId,
+ orgId,
+ role,
+ ...(scimExternalId ? { scimExternalId } : {}),
+ },
+ });
+
+ await tx.accountRequest.deleteMany({
+ where: { requestedById: userId, orgId },
+ });
+ await tx.invite.deleteMany({
+ where: { recipientEmail: user.email, orgId },
+ });
+
+ return created;
+ });
+
+ if (isServiceError(membership)) {
+ return membership;
+ }
+
+ 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.suspendedAt === null) {
+ 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;
+ });
+
+ 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;
+ }
+
+ if (target.suspendedAt !== null || target.lastActiveAt === null) {
+ return memberNotActiveError();
+ }
+
+ const isDemotionFromOwner = target.role === OrgRole.OWNER && role !== OrgRole.OWNER;
+ if (isDemotionFromOwner && target.suspendedAt === null) {
+ if ((await countActiveOwners(tx, orgId)) <= 1) {
+ return lastOwnerDemoteError();
+ }
+ }
+
+ await tx.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: { role },
+ });
+ didChange = true;
+
+ return null;
+ });
+
+ 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 SetMembershipSuspendedOptions {
+ actor: AuditActor;
+ scimExternalId?: string;
+}
+
+/**
+ * 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 setMembershipSuspended = async (
+ orgId: number,
+ userId: string,
+ suspended: boolean,
+ options: SetMembershipSuspendedOptions,
+): Promise => {
+ const { actor, scimExternalId } = options;
+
+ if (suspended) {
+ let didChange = false;
+
+ const result = await prisma.$transaction(async (tx) => {
+ let target = await tx.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ });
+ if (!target) {
+ return notFound("Member not found in this organization");
+ }
+ if (target.suspendedAt !== null) {
+ return target;
+ }
+
+ await revokeAllUserAuthCredentials(tx, userId, orgId);
+
+ target = await tx.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: { suspendedAt: new Date() },
+ });
+ didChange = true;
+ return target;
+ });
+
+ 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;
+
+ } else {
+ let didChange = false;
+
+ const result = await prisma.$transaction(async (tx) => {
+ let target = await tx.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId } },
+ });
+ if (!target) {
+ return notFound("Member not found in this organization");
+ }
+
+ if (target.suspendedAt === null) {
+ if (scimExternalId && target.scimExternalId !== scimExternalId) {
+ target = await tx.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: { scimExternalId },
+ });
+ }
+ return target;
+ }
+
+ if (!(await orgHasAvailability(orgId, tx))) {
+ return seatLimitReached();
+ }
+
+ target = await tx.userToOrg.update({
+ where: { orgId_userId: { orgId, userId } },
+ data: {
+ suspendedAt: null,
+ ...(scimExternalId ? { scimExternalId } : {}),
+ },
+ });
+ didChange = true;
+ return target;
+ });
+
+ if (!isServiceError(result) && didChange) {
+ await syncWithLighthouse(orgId).catch(() => { /* best effort */ });
+ await createAudit({
+ action: "org.member_reactivated",
+ actor,
+ target: { id: userId, type: "user" },
+ orgId,
+ });
+ }
+
+ return result;
+ }
+};
+
+const countActiveOwners = (tx: Prisma.TransactionClient, orgId: number): Promise =>
+ tx.userToOrg.count({
+ where: {
+ orgId,
+ ...activeMembershipWhere(),
+ role: OrgRole.OWNER,
+ },
+ });
+
+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..869eb73f6
--- /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 { ensureActiveMember } 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 ensureActiveMember(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 ensureActiveMember(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..572e65288
--- /dev/null
+++ b/packages/web/src/features/membership/utils.ts
@@ -0,0 +1,71 @@
+import { hasEntitlement } from "@/lib/entitlements";
+import { createLogger, getSeatCap } from "@sourcebot/shared";
+import { OrgRole, Prisma } 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;
+
+/**
+ * Matches suspended memberships.
+ */
+export const suspendedMembershipWhere = (): Prisma.UserToOrgWhereInput => ({
+ suspendedAt: { not: null },
+});
+
+/**
+ * Matches unsuspended memberships, regardless of activity.
+ */
+export const activeOrPendingMembershipWhere = (): Prisma.UserToOrgWhereInput => ({
+ suspendedAt: null,
+});
+
+/**
+ * Matches pending memberships: unsuspended and never seen.
+ */
+export const pendingMembershipWhere = (): Prisma.UserToOrgWhereInput => ({
+ ...activeOrPendingMembershipWhere(),
+ lastActiveAt: null,
+});
+
+/**
+ * Matches active memberships: unsuspended and seen at least once.
+ */
+export const activeMembershipWhere = (): Prisma.UserToOrgWhereInput => ({
+ ...activeOrPendingMembershipWhere(),
+ 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
+ * key, if available.
+ */
+export const orgHasAvailability = async (orgId: number, tx: Prisma.TransactionClient): Promise => {
+ const seatCap = getSeatCap();
+
+ // Pending and suspended members are preserved but don't consume seats.
+ const activeUserCount = await tx.userToOrg.count({
+ where: {
+ orgId,
+ ...activeMembershipWhere(),
+ },
+ });
+
+ if (
+ seatCap &&
+ activeUserCount >= seatCap
+ ) {
+ logger.error(`orgHasAvailability: org ${orgId} has reached max capacity`);
+ return false;
+ }
+
+ return true;
+};
diff --git a/packages/web/src/features/scim/utils.ts b/packages/web/src/features/scim/utils.ts
new file mode 100644
index 000000000..a9c0fc680
--- /dev/null
+++ b/packages/web/src/features/scim/utils.ts
@@ -0,0 +1,10 @@
+import { __unsafePrisma } from "@/prisma";
+import { hasEntitlement } from "@/lib/entitlements";
+import { Org } from "@sourcebot/db";
+
+export const isScimEnabled = async (org: Org): Promise => {
+ if (!await hasEntitlement('scim')) {
+ return false;
+ }
+ return org?.isScimEnabled ?? false;
+};
diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts
deleted file mode 100644
index 8a6bc334f..000000000
--- a/packages/web/src/features/userManagement/actions.ts
+++ /dev/null
@@ -1,579 +0,0 @@
-'use server';
-
-import { createAudit } from "@/ee/features/audit/audit";
-import { syncWithLighthouse } from "@/features/billing/servicePing";
-import InviteUserEmail from "@/emails/inviteUserEmail";
-import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail";
-import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
-import { ErrorCode } from "@/lib/errorCodes";
-import { notFound, ServiceError } from "@/lib/serviceError";
-import { isServiceError } from "@/lib/utils";
-import { sew } from "@/middleware/sew";
-import { withAuth } from "@/middleware/withAuth";
-import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
-import { render } from "@react-email/components";
-import { OrgRole, Prisma, PrismaClient } from "@sourcebot/db";
-import { createLogger, env, getSMTPConnectionURL } from "@sourcebot/shared";
-import { StatusCodes } from "http-status-codes";
-import { createTransport } from "nodemailer";
-
-const logger = createLogger('user-management');
-
-export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const guardError = await _removeUserFromOrg(prisma, {
- orgId: org.id,
- userId: memberId,
- lastOwnerMessage: "Cannot remove the last owner of the organization.",
- });
-
- if (guardError) {
- return guardError;
- }
-
- await createAudit({
- action: "org.member_removed",
- actor: { id: user.id, type: "user" },
- target: { id: memberId, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} removed ${memberId} from the organization`,
- },
- });
-
- return { success: true };
- }))
-);
-
-export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ user, org, prisma }) => {
- const guardError = await _removeUserFromOrg(prisma, {
- orgId: org.id,
- userId: user.id,
- lastOwnerMessage: "You are the last owner of this organization. Promote another member to owner before leaving.",
- });
-
- if (guardError) {
- return guardError;
- }
-
- await createAudit({
- action: "org.member_left",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} left the organization`,
- },
- });
-
- return {
- success: true,
- }
- }));
-
-
-const _removeUserFromOrg = async (
- prisma: PrismaClient,
- { orgId, userId, lastOwnerMessage }: { orgId: number; userId: string; lastOwnerMessage: string },
-): Promise => {
- const result = await prisma.$transaction(async (tx) => {
- const target = await tx.userToOrg.findUnique({
- where: {
- orgId_userId: {
- orgId,
- userId,
- }
- }
- });
-
- if (!target) {
- return notFound("Member not found in this organization");
- }
-
- if (target.role === OrgRole.OWNER) {
- const ownerCount = await tx.userToOrg.count({
- where: {
- orgId,
- role: OrgRole.OWNER,
- },
- });
-
- if (ownerCount <= 1) {
- return {
- statusCode: StatusCodes.FORBIDDEN,
- errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED,
- message: lastOwnerMessage,
- } satisfies ServiceError;
- }
- }
-
- await invalidateAllSessionsForUser(tx, userId);
- await revokeUserOAuthTokens(tx, userId);
- await revokeUserApiKeysInOrg(tx, userId, orgId);
-
- await tx.userToOrg.delete({
- where: {
- orgId_userId: {
- orgId,
- userId,
- }
- }
- });
-
- return null;
- }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
-
- // Sync with lighthouse s.t., the subscription
- // quantity will update immediately.
- if (!isServiceError(result)) {
- await syncWithLighthouse(orgId).catch(() => { /* ignore error */ });
- }
-
- return result;
-};
-
-
-export const rejectAccountRequest = async (requestId: string) => sew(() =>
- withAuth(async ({ org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const request = await prisma.accountRequest.findUnique({
- where: {
- id: requestId,
- },
- });
-
- if (!request || request.orgId !== org.id) {
- return notFound();
- }
-
- await prisma.accountRequest.delete({
- where: {
- id: requestId,
- },
- });
-
- return {
- success: true,
- }
- })
- ));
-
-
-export const approveAccountRequest = async (requestId: string) => sew(async () =>
- withAuth(async ({ org, user, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const failAuditCallback = async (error: string) => {
- await createAudit({
- action: "user.join_request_approve_failed",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: requestId,
- type: "account_join_request"
- },
- orgId: org.id,
- metadata: {
- message: error,
- }
- });
- }
-
- const request = await prisma.accountRequest.findUnique({
- where: {
- id: requestId,
- },
- include: {
- requestedBy: true,
- },
- });
-
- if (!request || request.orgId !== org.id) {
- await failAuditCallback("Request not found");
- return notFound();
- }
-
- const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id);
- if (isServiceError(addUserToOrgRes)) {
- await failAuditCallback(addUserToOrgRes.message);
- return addUserToOrgRes;
- }
-
-
- await createAudit({
- action: "user.join_request_approved",
- actor: {
- id: user.id,
- type: "user"
- },
- orgId: org.id,
- target: {
- id: requestId,
- type: "account_join_request"
- }
- });
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: request.requestedById, type: "user" },
- orgId: org.id,
- metadata: {
- message: `${user.id} approved join request ${requestId} for ${request.requestedById}`,
- },
- });
-
- // Send approval email to the user
- const smtpConnectionUrl = getSMTPConnectionURL();
- if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
- const html = await render(JoinRequestApprovedEmail({
- baseUrl: env.AUTH_URL,
- user: {
- name: request.requestedBy.name ?? undefined,
- email: request.requestedBy.email,
- avatarUrl: request.requestedBy.image ?? undefined,
- },
- orgName: org.name,
- }));
-
- const transport = createTransport(smtpConnectionUrl);
- const result = await transport.sendMail({
- to: request.requestedBy.email,
- from: env.EMAIL_FROM_ADDRESS,
- subject: `Your request to join ${org.name} has been approved`,
- html,
- text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`,
- });
-
- const failed = result.rejected.concat(result.pending).filter(Boolean);
- if (failed.length > 0) {
- logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`);
- }
- } else {
- logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`);
- }
-
- return {
- success: true,
- }
- })
- ));
-
-
-
-export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ org, user, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const failAuditCallback = async (error: string) => {
- await createAudit({
- action: "user.invite_failed",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: org.id.toString(),
- type: "org"
- },
- orgId: org.id,
- metadata: {
- message: error,
- emails: emails.join(", ")
- }
- });
- }
-
- const hasAvailability = await orgHasAvailability(org.id);
- if (!hasAvailability) {
- await createAudit({
- action: "user.invite_failed",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: org.id.toString(),
- type: "org"
- },
- orgId: org.id,
- metadata: {
- message: "Organization has reached maximum number of seats",
- emails: emails.join(", ")
- }
- });
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
- message: "The organization has reached the maximum number of seats. Unable to create a new invite",
- } satisfies ServiceError;
- }
-
- // Check for existing invites
- const existingInvites = await prisma.invite.findMany({
- where: {
- recipientEmail: {
- in: emails
- },
- orgId: org.id,
- }
- });
-
- if (existingInvites.length > 0) {
- await failAuditCallback("A pending invite already exists for one or more of the provided emails");
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_INVITE,
- message: `A pending invite already exists for one or more of the provided emails.`,
- } satisfies ServiceError;
- }
-
- // Check for members that are already in the org
- const existingMembers = await prisma.userToOrg.findMany({
- where: {
- user: {
- email: {
- in: emails,
- }
- },
- orgId: org.id,
- },
- });
-
- if (existingMembers.length > 0) {
- await failAuditCallback("One or more of the provided emails are already members of this org");
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_INVITE,
- message: `One or more of the provided emails are already members of this org.`,
- } satisfies ServiceError;
- }
-
- await prisma.invite.createMany({
- data: emails.map((email) => ({
- recipientEmail: email,
- hostUserId: user.id,
- orgId: org.id,
- })),
- skipDuplicates: true,
- });
-
- // Send invites to recipients
- const smtpConnectionUrl = getSMTPConnectionURL();
- if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
- await Promise.all(emails.map(async (email) => {
- const invite = await prisma.invite.findUnique({
- where: {
- recipientEmail_orgId: {
- recipientEmail: email,
- orgId: org.id,
- },
- },
- include: {
- org: true,
- }
- });
-
- if (!invite) {
- return;
- }
-
- const recipient = await prisma.user.findUnique({
- where: {
- email,
- },
- });
- const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`;
- const transport = createTransport(smtpConnectionUrl);
- const html = await render(InviteUserEmail({
- baseUrl: env.AUTH_URL,
- host: {
- name: user.name ?? undefined,
- email: user.email,
- avatarUrl: user.image ?? undefined,
- },
- recipient: {
- name: recipient?.name ?? undefined,
- },
- orgName: invite.org.name,
- orgImageUrl: invite.org.imageUrl ?? undefined,
- inviteLink,
- }));
-
- const result = await transport.sendMail({
- to: email,
- from: env.EMAIL_FROM_ADDRESS,
- subject: `Join ${invite.org.name} on Sourcebot`,
- html,
- text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`,
- });
-
- const failed = result.rejected.concat(result.pending).filter(Boolean);
- if (failed.length > 0) {
- logger.error(`Failed to send invite email to ${email}: ${failed}`);
- }
- }));
- } else {
- logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`);
- }
-
- await createAudit({
- action: "user.invites_created",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: org.id.toString(),
- type: "org"
- },
- orgId: org.id,
- metadata: {
- emails: emails.join(", ")
- }
- });
- return {
- success: true,
- }
- })
- ));
-
-export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
- withAuth(async ({ org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const invite = await prisma.invite.findUnique({
- where: {
- id: inviteId,
- orgId: org.id,
- },
- });
-
- if (!invite) {
- return notFound();
- }
-
- await prisma.invite.delete({
- where: {
- id: inviteId,
- },
- });
-
- return {
- success: true,
- }
- })
- ));
-
-
-export const getOrgMembers = async () => sew(() =>
- withAuth(async ({ org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const members = await prisma.userToOrg.findMany({
- where: {
- orgId: org.id,
- },
- include: {
- user: true,
- },
- });
-
- return members.map((member) => ({
- id: member.userId,
- email: member.user.email,
- name: member.user.name ?? undefined,
- avatarUrl: member.user.image ?? undefined,
- role: member.role,
- joinedAt: member.joinedAt,
- }));
- })));
-
-export const getOrgInvites = async () => sew(() =>
- withAuth(async ({ org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const invites = await prisma.invite.findMany({
- where: {
- orgId: org.id,
- },
- });
-
- return invites.map((invite) => ({
- id: invite.id,
- email: invite.recipientEmail,
- createdAt: invite.createdAt,
- }));
- })));
-
-
-export const getOrgAccountRequests = async () => sew(() =>
- withAuth(async ({ org, role, prisma }) =>
- withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- const requests = await prisma.accountRequest.findMany({
- where: {
- orgId: org.id,
- },
- include: {
- requestedBy: true,
- },
- });
-
- return requests.map((request) => ({
- id: request.id,
- email: request.requestedBy.email,
- createdAt: request.createdAt,
- name: request.requestedBy.name ?? undefined,
- image: request.requestedBy.image ?? undefined,
- }));
- })));
-
-/**
- * Invalidates every active JWT cookie for the given user by incrementing
- * their `sessionVersion`. The next request from any of their active
- * sessions will compare the cookie's baked-in version against the
- * (now-bumped) value on the User row, fail, and be treated as logged out.
- */
-const invalidateAllSessionsForUser = async (
- prisma: Prisma.TransactionClient,
- userId: string,
-): Promise => {
- await prisma.user.update({
- where: { id: userId },
- data: { sessionVersion: { increment: 1 } },
- });
-};
-
-const revokeUserApiKeysInOrg = async (
- prisma: Prisma.TransactionClient,
- userId: string,
- orgId: number,
-): Promise => {
- await prisma.apiKey.deleteMany({
- where: {
- createdById: userId,
- orgId,
- }
- });
-};
-
-const revokeUserOAuthTokens = async (
- prisma: Prisma.TransactionClient,
- userId: string,
-): Promise => {
- await prisma.oAuthToken.deleteMany({
- where: {
- userId
- }
- });
- await prisma.oAuthRefreshToken.deleteMany({
- where: {
- userId
- }
- });
- await prisma.oAuthAuthorizationCode.deleteMany({
- where: {
- userId
- }
- });
-};
-
diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts
deleted file mode 100644
index ef0dd6551..000000000
--- a/packages/web/src/lib/authUtils.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-import type { User as AuthJsUser } from "next-auth";
-import { __unsafePrisma } from "@/prisma";
-import { OrgRole } from "@sourcebot/db";
-import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
-import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError";
-import { createLogger, getSeatCap } from "@sourcebot/shared";
-import { createAudit } from "@/ee/features/audit/audit";
-import { StatusCodes } from "http-status-codes";
-import { ErrorCode } from "./errorCodes";
-import { syncWithLighthouse } from "@/features/billing/servicePing";
-import { hasEntitlement } from "./entitlements";
-
-const logger = createLogger('web-auth-utils');
-
-export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
- if (!user.id) {
- logger.error("User ID is undefined on user creation");
- await createAudit({
- action: "user.creation_failed",
- actor: {
- id: "undefined",
- type: "user"
- },
- target: {
- id: "undefined",
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: "User ID is undefined on user creation"
- }
- });
- throw new Error("User ID is undefined on user creation");
- }
-
- const defaultOrg = await __unsafePrisma.org.findUnique({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- include: {
- members: true,
- }
- });
-
- if (defaultOrg === null) {
- await createAudit({
- action: "user.creation_failed",
- actor: {
- id: user.id,
- type: "user"
- },
- target: {
- id: user.id,
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: "Default org not found on single tenant user creation"
- }
- });
- throw new Error("Default org not found on single tenant user creation");
- }
-
- // First user to sign up bootstraps the org as its OWNER. This is how a
- // fresh deployment gets its initial admin without manual setup.
- const isFirstUser = defaultOrg.members.length === 0;
- if (isFirstUser) {
- await __unsafePrisma.$transaction(async (tx) => {
- await tx.org.update({
- where: {
- id: SINGLE_TENANT_ORG_ID,
- },
- data: {
- members: {
- create: {
- role: OrgRole.OWNER,
- user: {
- connect: {
- id: user.id,
- }
- }
- }
- }
- }
- });
- });
-
- await createAudit({
- action: "user.owner_created",
- actor: {
- id: user.id,
- type: "user"
- },
- orgId: SINGLE_TENANT_ORG_ID,
- target: {
- id: SINGLE_TENANT_ORG_ID.toString(),
- type: "org"
- }
- });
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: `${user.id} joined the organization as the initial owner`,
- },
- });
- }
-
- // Subsequent users auto-join only when the org is in open self-serve
- // mode. Their role depends on the `org-management` entitlement: on paid
- // plans they join as MEMBER, on free they join as OWNER (no role
- // distinction exists without the entitlement). If memberApprovalRequired
- // is true, the user is left without a membership and must submit an
- // AccountRequest for an owner to approve via addUserToOrganization.
- else if (!defaultOrg.memberApprovalRequired) {
- // Don't exceed the licensed seat count. The user row still exists;
- // they just aren't attached to the org until a seat frees up.
- const hasAvailability = await orgHasAvailability(defaultOrg.id);
- if (!hasAvailability) {
- logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`);
- return;
- }
-
- const hasOrgManagement = await hasEntitlement("org-management");
-
- await __unsafePrisma.userToOrg.create({
- data: {
- userId: user.id,
- orgId: SINGLE_TENANT_ORG_ID,
- role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER,
- }
- });
-
- await createAudit({
- action: "org.member_added",
- actor: { id: user.id, type: "user" },
- target: { id: user.id, type: "user" },
- orgId: SINGLE_TENANT_ORG_ID,
- metadata: {
- message: `${user.id} joined the organization (member approval not required)`,
- },
- });
- }
-
- // Dynamic import to avoid circular dependency:
- // authUtils -> posthog -> auth -> authUtils
- const { captureEvent } = await import("@/lib/posthog");
- await captureEvent('wa_user_created', { userId: user.id });
-
- // Sync with lighthouse s.t., the subscription
- // quantity will update immediately.
- await syncWithLighthouse(defaultOrg.id).catch(() => { /* ignore error */ });
-};
-
-
-/**
- * Checks to see if the given organization has seat availability.
- * Seat availability is determined by the `seats` parameter in
- * the offline license key, if available.
- */
-export const orgHasAvailability = async (orgId: number): Promise => {
- const org = await __unsafePrisma.org.findUniqueOrThrow({
- where: {
- id: orgId,
- },
- include: {
- members: true,
- }
- });
-
- const seatCap = getSeatCap();
- const memberCount = org.members.length;
-
- if (
- seatCap &&
- memberCount >= seatCap
- ) {
- logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`);
- return false;
- }
-
- return true;
-}
-
-export const addUserToOrganization = async (userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
- const user = await __unsafePrisma.user.findUnique({
- where: {
- id: userId,
- },
- });
-
- if (!user) {
- logger.error(`addUserToOrganization: user not found for id ${userId}`);
- return userNotFound();
- }
-
- const org = await __unsafePrisma.org.findUnique({
- where: {
- id: orgId,
- },
- });
-
- if (!org) {
- logger.error(`addUserToOrganization: org not found for id ${orgId}`);
- return orgNotFound();
- }
-
- const hasAvailability = await orgHasAvailability(org.id);
- if (!hasAvailability) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
- message: "Organization is at max capacity",
- } satisfies ServiceError;
- }
-
- const hasOrgManagement = await hasEntitlement('org-management');
-
- await __unsafePrisma.$transaction(async (tx) => {
- // Upsert rather than create: the user may already be a member from the
- // self-serve auto-join in onCreateUser, in which case this call is
- // just here to trigger the AccountRequest / Invite cleanup below.
- await tx.userToOrg.upsert({
- where: {
- orgId_userId: {
- orgId: org.id,
- userId: user.id,
- },
- },
- create: {
- userId: user.id,
- orgId: org.id,
- role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER,
- },
- update: {},
- });
-
- // Delete the account request if it exists since we've added the user to the org
- const accountRequest = await tx.accountRequest.findUnique({
- where: {
- requestedById_orgId: {
- requestedById: user.id,
- orgId: orgId,
- }
- },
- });
-
- if (accountRequest) {
- logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've been added to the org`);
- await tx.accountRequest.delete({
- where: {
- id: accountRequest.id,
- }
- });
- }
-
- // Delete any invites that may exist for this user since we've added them to the org
- const invites = await tx.invite.findMany({
- where: {
- recipientEmail: user.email,
- orgId: org.id,
- },
- })
-
- for (const invite of invites) {
- logger.info(`Deleting invite ${invite.id} for ${user.email} since they've been added to the org`);
- await tx.invite.delete({
- where: {
- id: invite.id,
- },
- });
- }
- });
-
- // Sync with lighthouse s.t., the subscription
- // quantity will update immediately.
- await syncWithLighthouse(org.id).catch(() => { /* ignore error */ });
-
- return {
- success: true,
- }
-};
\ No newline at end of file
diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts
index 2cea6d4ac..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',
@@ -44,4 +45,5 @@ export enum ErrorCode {
EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED = 'EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED',
MEMBER_APPROVAL_CONTROLLED_BY_ENV = 'MEMBER_APPROVAL_CONTROLLED_BY_ENV',
ANONYMOUS_ACCESS_CONTROLLED_BY_ENV = 'ANONYMOUS_ACCESS_CONTROLLED_BY_ENV',
+ MEMBERSHIP_MANAGED_BY_IDP = 'MEMBERSHIP_MANAGED_BY_IDP',
}
diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts
index 3ad283254..c172de94a 100644
--- a/packages/web/src/lib/posthogEvents.ts
+++ b/packages/web/src/lib/posthogEvents.ts
@@ -10,6 +10,7 @@ export type UpsellSource =
'license_settings' |
'mcp_settings' |
'sso_settings' |
+ 'scim_settings' |
'chat_connectors';
export type SourcebotWebClientSource = 'sourcebot-web-client';
diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts
index bc0586615..fc55ac6c8 100644
--- a/packages/web/src/middleware/withAuth.test.ts
+++ b/packages/web/src/middleware/withAuth.test.ts
@@ -15,6 +15,8 @@ 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),
+ getSeatCap: vi.fn(() => undefined as number | undefined),
env: {} as Record,
}
});
@@ -39,6 +41,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(() => []),
@@ -48,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(),
@@ -65,6 +72,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: {
@@ -86,9 +95,14 @@ 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.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.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]);
});
@@ -319,6 +333,9 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
@@ -336,6 +353,179 @@ 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();
+
+ 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 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({
+ ...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 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({
@@ -349,6 +539,9 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
@@ -405,6 +598,74 @@ describe('getAuthContext', () => {
});
});
+ 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,
+ id: userId,
+ });
+ prisma.org.findUnique.mockResolvedValue({
+ ...MOCK_ORG,
+ });
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: userId,
+ orgId: MOCK_ORG.id,
+ suspendedAt: SUSPENDED_AT,
+ scimExternalId: null,
+ lastActiveAt: 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,
+ });
+ 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 () => {
+ 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,
+ suspendedAt: SUSPENDED_AT,
+ scimExternalId: null,
+ lastActiveAt: null,
+ role: OrgRole.MEMBER,
+ });
+ prisma.apiKey.findUnique.mockResolvedValue({
+ ...MOCK_API_KEY,
+ hash: 'apikey',
+ createdById: userId,
+ });
+ setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
+
+ const authContext = await getAuthContext();
+ expect(authContext).toStrictEqual({
+ user: {
+ ...MOCK_USER_WITH_ACCOUNTS,
+ id: userId,
+ },
+ org: MOCK_ORG,
+ prisma: undefined,
+ });
+ });
+
describe('DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS', () => {
test('should return a 403 service error when flag is enabled and a non-owner authenticates via api key', async () => {
mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true';
@@ -415,6 +676,9 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId });
@@ -437,6 +701,9 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId });
@@ -460,6 +727,9 @@ describe('getAuthContext', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
setMockSession(createMockSession({ user: { id: userId } }));
@@ -493,6 +763,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
vi.mocked(userScopedPrismaClientExtension).mockResolvedValue(extension as never);
@@ -522,6 +795,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -552,6 +828,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -582,6 +861,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -617,6 +899,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -652,6 +937,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -687,6 +975,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -722,6 +1013,9 @@ describe('withAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
setMockSession(null);
@@ -749,6 +1043,63 @@ describe('withAuth', () => {
expect(cb).not.toHaveBeenCalled();
expect(result).toStrictEqual(notAuthenticated());
});
+
+ 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,
+ id: userId,
+ });
+ prisma.org.findUnique.mockResolvedValue({
+ ...MOCK_ORG,
+ });
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: userId,
+ orgId: MOCK_ORG.id,
+ suspendedAt: SUSPENDED_AT,
+ scimExternalId: null,
+ lastActiveAt: 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 suspended 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,
+ suspendedAt: SUSPENDED_AT,
+ scimExternalId: null,
+ lastActiveAt: null,
+ role: OrgRole.MEMBER,
+ });
+ prisma.apiKey.findUnique.mockResolvedValue({
+ ...MOCK_API_KEY,
+ hash: 'apikey',
+ createdById: userId,
+ });
+ setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
+
+ const cb = vi.fn();
+ const result = await withAuth(cb);
+ expect(cb).not.toHaveBeenCalled();
+ expect(result).toStrictEqual(notAuthenticated());
+ });
});
describe('withOptionalAuth', () => {
@@ -765,6 +1116,9 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -795,6 +1149,9 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
setMockSession(createMockSession({ user: { id: 'test-user-id' } }));
@@ -825,6 +1182,9 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -860,6 +1220,9 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -895,6 +1258,9 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.MEMBER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -930,6 +1296,9 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: null,
role: OrgRole.OWNER,
});
prisma.apiKey.findUnique.mockResolvedValue({
@@ -965,6 +1334,9 @@ describe('withOptionalAuth', () => {
joinedAt: new Date(),
userId: userId,
orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ 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 0e930fa63..a648d2305 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";
@@ -9,6 +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 { activatePendingMembership } from "@/features/membership/membership.service";
const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000;
@@ -85,7 +86,10 @@ export const getAuthContext = async (): Promise {
.catch(() => { /* updaing the lastActiveAt is best effort. */ });
};
+const updateMembershipLastActiveAt = (membership: UserToOrg) => {
+ if (membership.suspendedAt != null) {
+ return;
+ }
+
+ const now = Date.now();
+ if (
+ membership.lastActiveAt &&
+ (now - membership.lastActiveAt.getTime()) < LAST_ACTIVE_AT_THRESHOLD_MS
+ ) {
+ return;
+ }
+
+ // 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,
+ },
+ 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> => {
@@ -263,5 +310,3 @@ export const getVerifiedApiObject = async (apiKeyString: string): Promise