Files
2026-07-13 13:32:57 +08:00

1214 lines
42 KiB
TypeScript

import {
ArrowUpRightIcon,
CheckCircleIcon,
ClockIcon,
ExclamationCircleIcon,
} from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { redirect } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import { useFetcher, useRevalidator } from "@remix-run/react";
import { z } from "zod";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button } from "~/components/primitives/Buttons";
import { Feedback } from "~/components/Feedback";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
SettingsActions,
SettingsBlock,
SettingsContainer,
SettingsHeader,
SettingsRow,
SettingsSection,
} from "~/components/primitives/SettingsLayout";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Switch } from "~/components/primitives/Switch";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import type { DirectorySyncEffect, DirectorySyncStatus, Role } from "@trigger.dev/plugins";
import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server";
import { flag } from "~/v3/featureFlags.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { cn } from "~/utils/cn";
import { throwPermissionDenied } from "~/utils/permissionDenied";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigger.dev" }];
const Params = z.object({ organizationSlug: z.string() });
async function resolveOrg(slug: string) {
// Primary (not replica): this scopes the RBAC/entitlement checks, so lag
// could run them against a stale/missing org.
return prisma.organization.findFirst({
where: { slug },
select: { id: true, title: true },
});
}
function planAllowsSso(plan: unknown): boolean {
if (!plan || typeof plan !== "object") return false;
const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }).v3Subscription;
return subscription?.plan?.code === "enterprise";
}
// Client-side upsell is cosmetic; gate real IdP mutations server-side.
async function requireSsoEntitlement(orgId: string): Promise<void> {
const plan = await getCurrentPlan(orgId);
if (!planAllowsSso(plan)) {
throw new Response("SSO requires an Enterprise plan", { status: 403 });
}
}
const EMPTY_DIRECTORY_SYNC_STATUS: DirectorySyncStatus = {
hasDirectory: false,
hasActiveDirectory: false,
allowExternalDomainSync: false,
allowManualMembership: true,
directoryDefaultRoleId: null,
userCount: 0,
directories: [],
groups: [],
};
// Per-org flag wins, else global (default off). Single knob for both SSO and
// Directory Sync (no separate dsync flag).
async function resolveHasSso(orgId: string): Promise<boolean> {
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: { featureFlags: true },
});
const perOrg = (org?.featureFlags as Record<string, unknown> | null)?.[FEATURE_FLAG.hasSso];
if (perOrg === true) return true;
return (await flag({ key: FEATURE_FLAG.hasSso, defaultValue: false })) === true;
}
const EMPTY_SSO_STATUS = {
hasIdpOrg: false,
enforced: false,
jitProvisioningEnabled: false,
jitDefaultRoleId: null,
idpOrgId: null,
primaryConnectionId: null,
hasActiveDirectory: false,
domains: [] as Array<{
domain: string;
verified: boolean;
state: "pending" | "verified" | "failed";
verificationFailedReason: string | null;
}>,
connections: [] as Array<{
id: string;
name: string | null;
connectionType: string;
state: "active" | "inactive";
}>,
};
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const org = await resolveOrg(params.organizationSlug);
return org ? { organizationId: org.id, orgTitle: org.title } : {};
},
// Plan-gated before role-gated: non-Enterprise orgs render the upsell for
// everyone, so we enforce manage:sso in the body only once entitled.
},
async ({ context, ability }) => {
// True only with SSO_ENABLED on and a real plugin loaded.
if (!(await ssoController.isUsingPlugin())) {
throw new Response("Not Found", { status: 404 });
}
const orgId = context.organizationId;
if (!orgId) {
throw new Response("Not Found", { status: 404 });
}
// Not Enterprise: render the upsell for every role, skip role check +
// queries, return empty data.
const plan = await getCurrentPlan(orgId);
if (!planAllowsSso(plan)) {
return typedjson({
status: EMPTY_SSO_STATUS,
orgTitle: context.orgTitle,
jitRoles: [] as Role[],
directorySync: EMPTY_DIRECTORY_SYNC_STATUS,
hasSso: false,
});
}
// Entitled: real config surface, so enforce the role gate (403 for
// non-Owner without manage:sso).
if (!ability.can("manage", { type: "sso" })) {
throwPermissionDenied();
}
const [statusResult, allRoles, assignableIds, dsyncResult, hasSso] = await Promise.all([
ssoController.getStatus(orgId),
rbac.allRoles(orgId),
rbac.getAssignableRoleIds(orgId),
ssoController.getDirectorySyncStatus(orgId),
resolveHasSso(orgId),
]);
const status = statusResult.isOk() ? statusResult.value : EMPTY_SSO_STATUS;
const directorySync = dsyncResult.isOk() ? dsyncResult.value : EMPTY_DIRECTORY_SYNC_STATUS;
// JIT can't grant Owner (reserved), and non-assignable/plan-gated roles
// are filtered out.
const assignable = new Set(assignableIds);
const jitRoles = allRoles.filter((r) => r.name !== "Owner" && assignable.has(r.id));
return typedjson({
status,
orgTitle: context.orgTitle,
jitRoles,
directorySync,
hasSso,
});
}
);
const NULL_ROLE_VALUE = "__none__";
const DEFAULT_JIT_ROLE_NAME = "Developer";
// Not `z.coerce.boolean()`: it treats the string "false" as truthy.
const boolish = z.union([z.literal("true"), z.literal("false")]).transform((v) => v === "true");
// Changed group→role mappings from the deferred Directory Sync Save.
const GroupRolesSchema = z.array(z.object({ groupId: z.string(), roleId: z.string() }));
const ActionSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("save_config"),
enforced: boolish,
jitEnabled: boolish,
jitRoleId: z.string(),
}),
z.object({
action: z.literal("portal_link"),
intent: z.enum(["sso", "domain_verification", "dsync"]),
}),
// Single deferred Save: settings + changed group mappings commit together.
z.object({
action: z.literal("save_dsync_config"),
allowExternalDomainSync: boolish,
allowManualMembership: boolish,
directoryDefaultRoleId: z.string(),
groupRoles: z.string(),
}),
]);
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const org = await resolveOrg(params.organizationSlug);
return org ? { organizationId: org.id } : {};
},
authorization: { action: "manage", resource: { type: "sso" } },
},
async ({ request, context, user, params }) => {
const orgId = context.organizationId;
if (!orgId) {
throw new Response("Not Found", { status: 404 });
}
// Mirror the loader gate.
if (!(await ssoController.isUsingPlugin())) {
throw new Response("Not Found", { status: 404 });
}
await requireSsoEntitlement(orgId);
const formData = await request.formData();
const parsed = ActionSchema.safeParse({
action: formData.get("action"),
enforced: formData.get("enforced") ?? undefined,
jitEnabled: formData.get("jitEnabled") ?? undefined,
jitRoleId: formData.get("jitRoleId") ?? undefined,
intent: formData.get("intent") ?? undefined,
allowExternalDomainSync: formData.get("allowExternalDomainSync") ?? undefined,
allowManualMembership: formData.get("allowManualMembership") ?? undefined,
directoryDefaultRoleId: formData.get("directoryDefaultRoleId") ?? undefined,
groupRoles: formData.get("groupRoles") ?? undefined,
});
if (!parsed.success) {
return new Response("Bad Request", { status: 400 });
}
switch (parsed.data.action) {
case "save_config": {
const jitRoleId = parsed.data.jitRoleId === NULL_ROLE_VALUE ? null : parsed.data.jitRoleId;
// All-or-nothing: `updateConfig` writes the three fields in one
// transaction (with the JIT-role RBAC check inside).
const result = await ssoController.updateConfig({
organizationId: orgId,
enforced: parsed.data.enforced,
jitProvisioningEnabled: parsed.data.jitEnabled,
jitDefaultRoleId: jitRoleId,
});
if (result.isErr()) {
return new Response(`Error: ${result.error}`, { status: 400 });
}
return redirect(`/orgs/${params.organizationSlug}/settings/sso`);
}
case "portal_link": {
const url = new URL(request.url);
const returnUrl = `${url.protocol}//${url.host}/orgs/${params.organizationSlug}/settings/sso`;
const result = await ssoController.generatePortalLink({
organizationId: orgId,
userId: user.id,
intent: parsed.data.intent,
returnUrl,
});
if (result.isErr()) {
return Response.json({ ok: false, error: result.error }, { status: 400 });
}
return Response.json({ ok: true, url: result.value.url });
}
case "save_dsync_config": {
// Parse the changed group→role mappings.
let groupRoles: Array<{ groupId: string; roleId: string }>;
try {
groupRoles = GroupRolesSchema.parse(JSON.parse(parsed.data.groupRoles));
} catch {
return new Response("Bad Request", { status: 400 });
}
const defaultRoleId =
parsed.data.directoryDefaultRoleId === NULL_ROLE_VALUE
? null
: parsed.data.directoryDefaultRoleId;
// Hoist out: the union narrowing doesn't survive into the thunks below.
const { allowExternalDomainSync, allowManualMembership } = parsed.data;
// Config columns first. Not transactional, but each write is
// idempotent so retrying the whole Save converges. Thunks run serially
// so the first failure stops the rest.
const configWrites = [
() =>
ssoController.setAllowExternalDomainSync({
organizationId: orgId,
allowed: allowExternalDomainSync,
}),
() =>
ssoController.setAllowManualMembership({
organizationId: orgId,
allowed: allowManualMembership,
}),
() =>
ssoController.setDirectoryDefaultRole({ organizationId: orgId, roleId: defaultRoleId }),
];
for (const write of configWrites) {
const result = await write();
if (result.isErr()) {
return new Response(`Error: ${result.error}`, { status: 400 });
}
}
// Each remap returns membership effects for its members (role
// recompute, or deprovision when cleared); apply them immediately.
const effects: DirectorySyncEffect[] = [];
for (const g of groupRoles) {
const result = await ssoController.setDirectoryGroupRole({
organizationId: orgId,
groupId: g.groupId,
roleId: g.roleId === NULL_ROLE_VALUE ? null : g.roleId,
});
if (result.isErr()) {
return new Response(`Error: ${result.error}`, { status: 400 });
}
effects.push(...result.value.effects);
}
if (effects.length > 0) {
await applyDirectorySyncEffects(effects);
}
return redirect(`/orgs/${params.organizationSlug}/settings/sso`);
}
}
}
);
function defaultJitRoleId(jitRoles: ReadonlyArray<Role>, current: string | null): string {
// Persisted value wins, even if the picker can no longer offer it.
if (current) return current;
const dev = jitRoles.find((r) => r.name === DEFAULT_JIT_ROLE_NAME);
return dev?.id ?? NULL_ROLE_VALUE;
}
// Locally-editable mirror of a server value, poll-safe: untouched fields adopt
// fresh server values; edited (dirty) fields hold until the server catches up,
// then snap back to clean.
function useOverrideDraft<T>(serverValue: T): {
value: T;
set: (next: T) => void;
dirty: boolean;
} {
const [override, setOverride] = useState<{ value: T } | null>(null);
useEffect(() => {
// Server matches the pending edit → clear the override.
setOverride((current) => (current && Object.is(current.value, serverValue) ? null : current));
}, [serverValue]);
const value = override ? override.value : serverValue;
return {
value,
set: (next) => setOverride({ value: next }),
dirty: override != null && !Object.is(override.value, serverValue),
};
}
export default function Page() {
const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const _plan = useCurrentPlan();
const isEntitled = planAllowsSso(_plan);
const activeConnections = status.connections.filter((c) => c.state === "active");
const hasActive = activeConnections.length > 0;
// Deferred-save drafts; `useOverrideDraft` keeps them poll-safe until Save.
const initialJitRoleId = defaultJitRoleId(jitRoles, status.jitDefaultRoleId);
const enforcedDraft = useOverrideDraft(status.enforced);
const jitEnabledDraft = useOverrideDraft(status.jitProvisioningEnabled);
const jitRoleDraft = useOverrideDraft(initialJitRoleId);
const draftEnforced = enforcedDraft.value;
const setDraftEnforced = enforcedDraft.set;
const draftJitEnabled = jitEnabledDraft.value;
const setDraftJitEnabled = jitEnabledDraft.set;
const draftJitRoleId = jitRoleDraft.value;
const setDraftJitRoleId = jitRoleDraft.set;
const isDirty = enforcedDraft.dirty || jitEnabledDraft.dirty || jitRoleDraft.dirty;
const [portalUrl, setPortalUrl] = useState<string | null>(null);
const [portalIntent, setPortalIntent] = useState<"sso" | "domain_verification" | "dsync" | null>(
null
);
const [enforceModalOpen, setEnforceModalOpen] = useState(false);
const portalFetcher = useFetcher<{ ok: boolean; url?: string; error?: string }>();
const saveFetcher = useFetcher();
const isSaving = saveFetcher.state !== "idle";
const revalidator = useRevalidator();
useEffect(() => {
if (portalFetcher.data?.ok && portalFetcher.data.url) {
setPortalUrl(portalFetcher.data.url);
}
}, [portalFetcher.data]);
// Poll while entitled to reflect portal progress and keep SSO + Directory
// Sync state fresh; drafts survive via `useOverrideDraft`.
const shouldPoll = isEntitled;
useEffect(() => {
if (!shouldPoll) return;
const id = setInterval(() => {
if (
revalidator.state !== "idle" ||
portalFetcher.state !== "idle" ||
saveFetcher.state !== "idle" ||
(typeof document !== "undefined" && document.visibilityState === "hidden")
) {
return;
}
revalidator.revalidate();
}, 5000);
return () => clearInterval(id);
}, [shouldPoll, revalidator, portalFetcher.state, saveFetcher.state]);
const openPortal = (intent: "sso" | "domain_verification" | "dsync") => {
setPortalUrl(null);
setPortalIntent(intent);
portalFetcher.submit({ action: "portal_link", intent }, { method: "POST" });
};
const submitSave = () => {
saveFetcher.submit(
{
action: "save_config",
enforced: draftEnforced ? "true" : "false",
jitEnabled: draftJitEnabled ? "true" : "false",
jitRoleId: draftJitRoleId,
},
{ method: "POST" }
);
};
return (
<PageContainer>
<NavBar>
<PageTitle title="SSO & Directory Sync" />
</NavBar>
<PageBody scrollable={true}>
<SettingsContainer>
{!isEntitled ? (
<EnterpriseUpsellState />
) : !status.hasIdpOrg ? (
<NoIdpOrgState onOpenPortal={() => openPortal("domain_verification")} />
) : !hasActive ? (
<NoActiveConnectionState
domains={status.domains}
directorySync={directorySync}
jitRoles={jitRoles}
hasSso={hasSso}
onOpenSso={() => openPortal("sso")}
onOpenDomain={() => openPortal("domain_verification")}
onOpenDsync={() => openPortal("dsync")}
/>
) : (
<ActiveConnectionState
orgTitle={orgTitle ?? organization.title}
status={status}
activeConnections={activeConnections}
jitRoles={jitRoles}
directorySync={directorySync}
hasSso={hasSso}
draftEnforced={draftEnforced}
draftJitEnabled={draftJitEnabled}
draftJitRoleId={draftJitRoleId}
isDirty={isDirty}
isSaving={isSaving}
onOpenPortal={openPortal}
onToggleEnforced={(next) => {
// off→on locks users out, so confirm first (draft only).
if (next && !status.enforced) {
setEnforceModalOpen(true);
} else {
setDraftEnforced(next);
}
}}
onToggleJit={(next) => setDraftJitEnabled(next)}
onChangeJitRole={(roleId) => setDraftJitRoleId(roleId ?? NULL_ROLE_VALUE)}
onSave={submitSave}
/>
)}
</SettingsContainer>
</PageBody>
<PortalLinkDialog url={portalUrl} intent={portalIntent} onClose={() => setPortalUrl(null)} />
<EnforceConfirmDialog
open={enforceModalOpen}
orgTitle={orgTitle ?? organization.title}
onCancel={() => setEnforceModalOpen(false)}
onConfirm={() => {
setDraftEnforced(true);
setEnforceModalOpen(false);
}}
/>
</PageContainer>
);
}
function EnterpriseUpsellState() {
return (
<SettingsSection>
<SettingsHeader
title={
<span className="flex items-center gap-2">
SSO & Directory Sync
<Badge variant="small">Enterprise</Badge>
</span>
}
description="Single sign-on (SAML/OIDC) and Directory Sync (SCIM) let your IT team manage access to Trigger.dev from your identity provider, such as Okta, Azure AD, or Google Workspace."
/>
<div className="w-full space-y-4 py-4">
<ul className="ml-4 list-disc space-y-1.5 text-sm text-text-dimmed">
<li>Verify domains and connect your identity provider from the admin portal.</li>
<li>Just-in-time provisioning for your verified domains.</li>
<li>Enforce SSO by domain, so contractors keep their existing sign-in.</li>
<li>Sync users and map directory groups to roles with SCIM.</li>
</ul>
<div className="flex flex-wrap gap-2">
<Feedback
defaultValue="enterprise"
button={<Button variant="primary/small">Contact us</Button>}
/>
</div>
</div>
</SettingsSection>
);
}
function NoIdpOrgState({ onOpenPortal }: { onOpenPortal: () => void }) {
return (
<SettingsSection>
<SettingsHeader
title="SSO"
description="Manage access to Trigger.dev from your identity provider, such as Okta, Azure AD, or Google Workspace."
/>
<SettingsRow
title="Get started"
description="Verify your email domains, then connect your identity provider."
action={
<Button variant="secondary/small" onClick={onOpenPortal}>
Start setup
</Button>
}
/>
</SettingsSection>
);
}
type DomainRow = {
domain: string;
verified: boolean;
state: "pending" | "verified" | "failed";
verificationFailedReason: string | null;
};
function NoActiveConnectionState({
domains,
directorySync,
jitRoles,
hasSso,
onOpenSso,
onOpenDomain,
onOpenDsync,
}: {
domains: ReadonlyArray<DomainRow>;
directorySync: DirectorySyncStatus;
jitRoles: ReadonlyArray<Role>;
hasSso: boolean;
onOpenSso: () => void;
onOpenDomain: () => void;
onOpenDsync: () => void;
}) {
const verifiedDomains = domains.filter((d) => d.state === "verified");
const failedDomains = domains.filter((d) => d.state === "failed");
const hasVerifiedDomain = verifiedDomains.length > 0;
return (
<>
<SettingsSection>
<SettingsHeader
title="Domains"
description="Verify the email domains your team signs in with. Connect your identity provider once a domain is verified."
action={
<Button variant="secondary/small" onClick={onOpenDomain}>
{domains.length > 0 ? "Add domain" : "Verify domain"}
</Button>
}
/>
{failedDomains.length > 0 && (
<SettingsBlock>
<Callout variant="error">
{failedDomains.length === 1
? `Verification failed for ${failedDomains[0].domain}. Check the DNS records in the admin portal and try again.`
: `${failedDomains.length} domains failed verification. Check the DNS records in the admin portal and try again.`}
</Callout>
</SettingsBlock>
)}
{domains.length > 0 && <DomainList domains={domains} />}
</SettingsSection>
{hasVerifiedDomain && (
<SettingsSection>
<SettingsHeader
title="SSO"
description="Connect your identity provider to enable SSO for your verified domains."
/>
<SettingsRow
title="Identity provider"
description="Connect Okta, Azure AD, Google Workspace, and more."
action={
<Button variant="secondary/small" onClick={onOpenSso}>
Configure SSO
</Button>
}
/>
</SettingsSection>
)}
{/* Directory Sync is independent of SSO (needs only a verified domain). */}
{hasVerifiedDomain && hasSso ? (
<DirectorySyncSection
directorySync={directorySync}
jitRoles={jitRoles}
onOpenPortal={onOpenDsync}
/>
) : null}
</>
);
}
function DomainList({ domains }: { domains: ReadonlyArray<DomainRow> }) {
return (
<>
{domains.map((d) => {
const visual = domainVisual(d.state);
return (
<SettingsRow
key={d.domain}
size="sm"
action={
<span
className={cn("flex shrink-0 items-center gap-1 text-sm capitalize", visual.label)}
>
{visual.icon}
{d.state}
</span>
}
>
<div className="flex flex-col">
<span className="font-mono text-sm text-text-bright">{d.domain}</span>
{d.state === "failed" && d.verificationFailedReason && (
<span className="mt-0.5 text-xs text-rose-400">
Reason: <span className="font-mono">{d.verificationFailedReason}</span>
</span>
)}
</div>
</SettingsRow>
);
})}
</>
);
}
function domainVisual(state: DomainRow["state"]) {
switch (state) {
case "verified":
return {
label: "text-success",
icon: <CheckCircleIcon className="size-3.5" />,
};
case "failed":
return {
label: "text-error",
icon: <ExclamationCircleIcon className="size-3.5" />,
};
case "pending":
default:
return {
label: "text-warning",
icon: <ClockIcon className="size-3.5" />,
};
}
}
function ActiveConnectionState({
orgTitle,
status,
activeConnections,
jitRoles,
directorySync,
hasSso,
draftEnforced,
draftJitEnabled,
draftJitRoleId,
isDirty,
isSaving,
onOpenPortal,
onToggleEnforced,
onToggleJit,
onChangeJitRole,
onSave,
}: {
orgTitle: string;
status: {
enforced: boolean;
jitProvisioningEnabled: boolean;
jitDefaultRoleId: string | null;
domains: ReadonlyArray<DomainRow>;
};
activeConnections: ReadonlyArray<{ id: string; name: string | null; connectionType: string }>;
jitRoles: ReadonlyArray<Role>;
directorySync: DirectorySyncStatus;
hasSso: boolean;
draftEnforced: boolean;
draftJitEnabled: boolean;
draftJitRoleId: string;
isDirty: boolean;
isSaving: boolean;
onOpenPortal: (intent: "sso" | "domain_verification" | "dsync") => void;
onToggleEnforced: (next: boolean) => void;
onToggleJit: (next: boolean) => void;
onChangeJitRole: (roleId: string | null) => void;
onSave: () => void;
}) {
return (
<>
<SettingsSection>
<SettingsHeader
title="Domains"
description="The email domains your team signs in with."
action={
<Button variant="secondary/small" onClick={() => onOpenPortal("domain_verification")}>
{status.domains.length > 0 ? "Add domain" : "Verify domain"}
</Button>
}
/>
{status.domains.length === 0 ? (
<SettingsBlock>
<Paragraph variant="small">No domains verified yet.</Paragraph>
</SettingsBlock>
) : (
<DomainList domains={status.domains} />
)}
</SettingsSection>
<SettingsSection>
<SettingsHeader
title="SSO"
description={`SSO connection for ${orgTitle}.`}
action={
<Button variant="secondary/small" onClick={() => onOpenPortal("sso")}>
Manage connection
</Button>
}
/>
{activeConnections.map((conn) => (
<SettingsRow
key={conn.id}
title={conn.name ?? conn.connectionType}
description={`Type: ${conn.connectionType}`}
action={<StatusIndicator label="Active" />}
/>
))}
<SettingsRow
title="Require SSO for matching domains"
description="Users with an email on a verified domain must sign in with SSO."
action={
<Switch variant="medium" checked={draftEnforced} onCheckedChange={onToggleEnforced} />
}
/>
<SettingsRow
title="Just-in-time provisioning"
description="Automatically add members on their first SSO sign-in from a verified domain."
action={
<Switch variant="medium" checked={draftJitEnabled} onCheckedChange={onToggleJit} />
}
/>
<SettingsRow
title="Default role for new users"
description="Assigned to users created by just-in-time provisioning. Owner can't be granted automatically."
action={
<Select<string, Role>
value={draftJitRoleId}
setValue={(v) => onChangeJitRole(v)}
items={[...jitRoles]}
variant="secondary/small"
dropdownIcon
popoverClassName="max-w-xs"
placement="bottom-end"
text={(v) => jitRoles.find((r) => r.id === v)?.name ?? "Select a role"}
>
{(items) =>
items.map((role) => (
<RoleSelectItem
key={role.id}
id={role.id}
name={role.name}
description={role.description}
/>
))
}
</Select>
}
/>
<SettingsActions>
<Button variant="primary/small" disabled={!isDirty || isSaving} onClick={onSave}>
{isSaving ? "Saving…" : "Save"}
</Button>
</SettingsActions>
</SettingsSection>
{hasSso ? (
<DirectorySyncSection
directorySync={directorySync}
jitRoles={jitRoles}
onOpenPortal={() => onOpenPortal("dsync")}
/>
) : null}
</>
);
}
function StatusIndicator({ label, active = true }: { label: string; active?: boolean }) {
return (
<span
className={cn(
"flex flex-none items-center gap-1.5 text-sm",
active ? "text-success" : "text-text-dimmed"
)}
>
<span className={cn("size-1.5 rounded-full", active ? "bg-success" : "bg-charcoal-500")} />
{label}
</span>
);
}
// Option content for the role dropdowns: a bright title with a wrapping,
// dimmed description beneath it. `wrap` lets long descriptions flow onto
// multiple lines instead of running off the popover edge.
function RoleSelectItem({
id,
name,
description,
}: {
id: string;
name: string;
description?: string;
}) {
return (
<SelectItem value={id} wrap>
<span className="flex flex-col gap-1">
<span className="text-text-bright">{name}</span>
{description ? <span className="text-xs text-text-dimmed">{description}</span> : null}
</span>
</SelectItem>
);
}
function DirectorySyncSection({
directorySync,
jitRoles,
onOpenPortal,
}: {
directorySync: DirectorySyncStatus;
jitRoles: ReadonlyArray<Role>;
onOpenPortal: () => void;
}) {
const fetcher = useFetcher();
const isSaving = fetcher.state !== "idle";
// Deferred save; `useOverrideDraft` keeps fields poll-safe. Role drafts hold
// the NULL_ROLE_VALUE sentinel; the action converts it to null.
const externalDraft = useOverrideDraft(directorySync.allowExternalDomainSync);
const manualDraft = useOverrideDraft(directorySync.allowManualMembership);
const defaultRoleDraft = useOverrideDraft(
directorySync.directoryDefaultRoleId ?? NULL_ROLE_VALUE
);
const draftExternal = externalDraft.value;
const setDraftExternal = externalDraft.set;
const draftManual = manualDraft.value;
const setDraftManual = manualDraft.set;
const draftDefaultRole = defaultRoleDraft.value;
const setDraftDefaultRole = defaultRoleDraft.set;
// Sparse override map of only edited groups; rendering falls back to the
// server value so polled-in groups appear and matched overrides drop.
const [draftGroupRoles, setDraftGroupRoles] = useState<Record<string, string>>({});
useEffect(() => {
setDraftGroupRoles((current) => {
const next: Record<string, string> = {};
for (const g of directorySync.groups) {
const override = current[g.groupId];
if (override === undefined) continue;
// Keep only overrides still diverging from the server.
if (override !== (g.mappedRoleId ?? NULL_ROLE_VALUE)) next[g.groupId] = override;
}
const currentKeys = Object.keys(current);
const unchanged =
currentKeys.length === Object.keys(next).length &&
currentKeys.every((k) => next[k] === current[k]);
return unchanged ? current : next;
});
}, [directorySync.groups]);
const groupRolesDirty = directorySync.groups.some((g) => {
const override = draftGroupRoles[g.groupId];
return override !== undefined && override !== (g.mappedRoleId ?? NULL_ROLE_VALUE);
});
const isDirty =
externalDraft.dirty || manualDraft.dirty || defaultRoleDraft.dirty || groupRolesDirty;
const submitSave = () => {
// Send only changed mappings.
const changedGroups = directorySync.groups
.filter((g) => {
const override = draftGroupRoles[g.groupId];
return override !== undefined && override !== (g.mappedRoleId ?? NULL_ROLE_VALUE);
})
.map((g) => ({ groupId: g.groupId, roleId: draftGroupRoles[g.groupId] }));
fetcher.submit(
{
action: "save_dsync_config",
allowExternalDomainSync: draftExternal ? "true" : "false",
allowManualMembership: draftManual ? "true" : "false",
directoryDefaultRoleId: draftDefaultRole,
groupRoles: JSON.stringify(changedGroups),
},
{ method: "POST" }
);
};
return (
<SettingsSection>
<SettingsHeader
title="Directory Sync"
description="Sync users and groups from your identity provider over SCIM. Members of mapped groups are provisioned with the group's role, and removing them from your directory revokes their access."
action={
<Button variant="secondary/small" onClick={onOpenPortal}>
{directorySync.directories.length === 0 ? "Connect a directory" : "Manage directory"}
</Button>
}
/>
{directorySync.directories.length === 0 ? (
<SettingsBlock>
<Paragraph variant="small">
No directory connected. Once connected, members of mapped groups are provisioned
automatically.
</Paragraph>
</SettingsBlock>
) : (
<>
{directorySync.directories.map((dir) => (
<SettingsRow
key={dir.id}
title={dir.name ?? dir.type}
description={`${dir.type} · ${directorySync.userCount} ${
directorySync.userCount === 1 ? "user" : "users"
}`}
action={
<StatusIndicator
label={dir.state === "active" ? "Active" : "Inactive"}
active={dir.state === "active"}
/>
}
/>
))}
<SettingsRow
title="Sync users outside verified domains"
description="By default, only users on a verified domain are provisioned. Turn on to also provision users on other domains, such as contractors."
action={
<Switch
variant="medium"
disabled={isSaving}
checked={draftExternal}
onCheckedChange={setDraftExternal}
/>
}
/>
<SettingsRow
title="Allow manual membership management"
description="On by default. Turn off to let Directory Sync manage membership exclusively. While a directory is active, inviting, removing, and leaving are disabled in the dashboard."
action={
<Switch
variant="medium"
disabled={isSaving}
checked={draftManual}
onCheckedChange={setDraftManual}
/>
}
/>
<SettingsRow
title="Default role for unmapped users"
description={`Assigned to directory users who aren't in a mapped group. Choose "No access" to leave them unprovisioned until they join one.`}
action={
<Select<string, Role | { id: string; name: string; description: string }>
value={draftDefaultRole}
setValue={(v) => setDraftDefaultRole(v)}
items={[{ id: NULL_ROLE_VALUE, name: "No access", description: "" }, ...jitRoles]}
variant="secondary/small"
dropdownIcon
popoverClassName="max-w-xs"
placement="bottom-end"
text={(v) =>
v === NULL_ROLE_VALUE
? "No access"
: (jitRoles.find((r) => r.id === v)?.name ?? "Select a role")
}
>
{(items) =>
items.map((role) => (
<RoleSelectItem
key={role.id}
id={role.id}
name={role.name}
description={role.description}
/>
))
}
</Select>
}
/>
<SettingsHeader
as="h3"
title="Group roles"
description="Map each directory group to a role. Members inherit their group's role."
/>
{directorySync.groups.length === 0 ? (
<SettingsBlock>
<Paragraph variant="small">
No groups synced yet. Groups appear here after your directory syncs.
</Paragraph>
</SettingsBlock>
) : (
directorySync.groups.map((group) => {
const value = draftGroupRoles[group.groupId] ?? group.mappedRoleId ?? NULL_ROLE_VALUE;
return (
<SettingsRow
key={group.groupId}
size="sm"
title={group.name}
titleClassName="font-medium"
action={
<Select<string, Role | { id: string; name: string; description: string }>
value={value}
setValue={(v) =>
setDraftGroupRoles((prev) => ({ ...prev, [group.groupId]: v }))
}
items={[
{ id: NULL_ROLE_VALUE, name: "No access", description: "" },
...jitRoles,
]}
variant="secondary/small"
dropdownIcon
popoverClassName="max-w-xs"
placement="bottom-end"
text={(v) =>
v === NULL_ROLE_VALUE
? "No access"
: (jitRoles.find((r) => r.id === v)?.name ?? "Select a role")
}
>
{(items) =>
items.map((role) => (
<RoleSelectItem
key={role.id}
id={role.id}
name={role.name}
description={role.description}
/>
))
}
</Select>
}
/>
);
})
)}
<SettingsActions>
<Button variant="primary/small" disabled={!isDirty || isSaving} onClick={submitSave}>
{isSaving ? "Saving…" : "Save"}
</Button>
</SettingsActions>
</>
)}
</SettingsSection>
);
}
// The portal is hosted by our SSO vendor, so the friendly name is derived from
// the link's host (e.g. setup.workos.com -> WorkOS). Falls back to the
// capitalized root label, then to a generic label if the URL can't be parsed.
const KNOWN_PORTAL_PROVIDERS: Record<string, string> = { workos: "WorkOS" };
function portalProviderName(url: string | null): string | null {
if (!url) return null;
try {
const labels = new URL(url).hostname.split(".").filter(Boolean);
const root = labels.length >= 2 ? labels[labels.length - 2] : labels[0];
if (!root) return null;
return KNOWN_PORTAL_PROVIDERS[root.toLowerCase()] ?? root[0].toUpperCase() + root.slice(1);
} catch {
return null;
}
}
function PortalLinkDialog({
url,
intent,
onClose,
}: {
url: string | null;
intent: "sso" | "domain_verification" | "dsync" | null;
onClose: () => void;
}) {
const purpose =
intent === "domain_verification"
? "Single-use link to verify your email domains. Share it with whoever manages your DNS."
: intent === "sso"
? "Single-use link to connect your identity provider. Share it with whoever manages it."
: intent === "dsync"
? "Single-use link to set up Directory Sync over SCIM. Share it with whoever manages your identity provider."
: "Single-use link to set up SSO.";
const providerName = portalProviderName(url);
return (
<Dialog open={url !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Admin portal link</DialogHeader>
<DialogDescription className="text-sm">
{purpose} It expires 5 minutes after this dialog opens.
</DialogDescription>
<ClipboardField value={url ?? ""} variant="secondary/medium" />
<DialogFooter>
<Button variant="secondary/small" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary/small"
TrailingIcon={ArrowUpRightIcon}
onClick={() => {
if (!url) return;
// Single-use link; `noopener,noreferrer` isolates the new tab.
window.open(url, "_blank", "noopener,noreferrer");
onClose();
}}
>
{providerName ? `Open in ${providerName}` : "Open in new tab"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function EnforceConfirmDialog({
open,
orgTitle,
onCancel,
onConfirm,
}: {
open: boolean;
orgTitle: string;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : onCancel())}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Enable SSO enforcement for {orgTitle}?</DialogHeader>
<DialogDescription>
Users with an email on a verified domain will be redirected to your identity provider to
sign in. They can no longer use magic link, GitHub, or Google for that domain.
<br />
<br />
Users with other emails, such as contractors, keep their existing sign-in methods.
</DialogDescription>
<DialogFooter>
<Button variant="secondary/small" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary/small" onClick={onConfirm}>
Enable enforcement
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}