From fc41581e0d5ad4d0ec68bce9ec62c9cb768f8c01 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 7 Jul 2026 16:46:41 +0100 Subject: [PATCH 1/8] Rename Identity & Access to SSO & Directory Sync --- .../components/navigation/OrganizationSettingsSideMenu.tsx | 2 +- .../routes/_app.orgs.$organizationSlug.settings.sso/route.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index ada3aac5e5..e7bcd466df 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -149,7 +149,7 @@ export function OrganizationSettingsSideMenu({ )} {isSsoUsingPlugin && ( [{ title: "Identity & Access | Trigger.dev" }]; +export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigger.dev" }]; const Params = z.object({ organizationSlug: z.string() }); @@ -479,7 +479,7 @@ export default function Page() { return ( - + From 34d78eff39c0f18038d159e15339c099dc0c4fff Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 7 Jul 2026 17:36:33 +0100 Subject: [PATCH 2/8] feat(webapp): restructure SSO & Directory Sync settings layout Apply the /account/security section/row pattern to all SSO page states: narrower container, title+subtitle rows separated by divides instead of bordered boxes, secondary/primary buttons, and external-link arrows. Co-authored-by: Cursor --- .../route.tsx | 845 +++++++++--------- 1 file changed, 426 insertions(+), 419 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index 432a47faad..217a13828d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -1,13 +1,12 @@ import { - ArrowTopRightOnSquareIcon, + ArrowUpRightIcon, CheckCircleIcon, ClockIcon, ExclamationCircleIcon, - LockClosedIcon, } 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 { type ReactNode, useEffect, useState } from "react"; import { useFetcher, useRevalidator } from "@remix-run/react"; import { z } from "zod"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -16,6 +15,7 @@ import { PageBody, PageContainer, } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { @@ -25,7 +25,8 @@ import { DialogFooter, DialogHeader, } from "~/components/primitives/Dialog"; -import { Header2 } from "~/components/primitives/Headers"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Label } from "~/components/primitives/Label"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; @@ -40,6 +41,7 @@ import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.serve 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"; import { v3BillingPath } from "~/utils/pathBuilder"; @@ -49,9 +51,8 @@ export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigge const Params = z.object({ organizationSlug: z.string() }); async function resolveOrg(slug: string) { - // Use primary: this slug→id lookup scopes the org-level RBAC/entitlement - // checks (loader and action), and replica lag could run them against a - // stale or missing org scope. + // 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 }, @@ -64,9 +65,7 @@ function planAllowsSso(plan: unknown): boolean { return subscription?.plan?.code === "enterprise"; } -// The render-level upsell (planAllowsSso on the client) is cosmetic — -// any org member could still POST the actions directly. Mutations that -// provision real IdP-side resources are gated here, server-side. +// Client-side upsell is cosmetic; gate real IdP mutations server-side. async function requireSsoEntitlement(orgId: string): Promise { const plan = await getCurrentPlan(orgId); if (!planAllowsSso(plan)) { @@ -85,9 +84,8 @@ const EMPTY_DIRECTORY_SYNC_STATUS: DirectorySyncStatus = { groups: [], }; -// SSO availability for an org: the per-org feature flag wins, else the global -// flag (default off). This is the single rollout knob for the whole feature — -// SSO and Directory Sync are both gated by it (there is no separate dsync flag). +// 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 { const org = await prisma.organization.findFirst({ where: { id: orgId }, @@ -127,14 +125,11 @@ export const loader = dashboardLoader( const org = await resolveOrg(params.organizationSlug); return org ? { organizationId: org.id, orgTitle: org.title } : {}; }, - // No static `authorization` gate here: SSO is plan-gated *before* it's - // role-gated. A non-Enterprise org must render the upsell for everyone — - // gating on manage:sso at the wrapper would show a non-Owner "Permission - // denied" for a feature their org can't use yet. We resolve the plan in - // the body and only enforce manage:sso once the org is actually entitled. + // 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 when SSO_ENABLED is on and a real SSO plugin is loaded. + // True only with SSO_ENABLED on and a real plugin loaded. if (!(await ssoController.isUsingPlugin())) { throw new Response("Not Found", { status: 404 }); } @@ -144,9 +139,8 @@ export const loader = dashboardLoader( throw new Response("Not Found", { status: 404 }); } - // Plan first. When the org isn't on Enterprise the page renders the - // upsell state for every role, so we skip the role check (and the - // SSO/role queries it would gate) and return empty data. + // 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({ @@ -158,9 +152,8 @@ export const loader = dashboardLoader( }); } - // Entitled: the page is now a real config surface, so enforce the role - // gate. A non-Owner without manage:sso gets the permission panel — the - // same 403 the dashboardLoader `authorization` block would have thrown. + // Entitled: real config surface, so enforce the role gate (403 for + // non-Owner without manage:sso). if (!ability.can("manage", { type: "sso" })) { throwPermissionDenied(); } @@ -175,10 +168,8 @@ export const loader = dashboardLoader( const status = statusResult.isOk() ? statusResult.value : EMPTY_SSO_STATUS; const directorySync = dsyncResult.isOk() ? dsyncResult.value : EMPTY_DIRECTORY_SYNC_STATUS; - // JIT can't promote new users to Owner — that role is reserved for - // the founding member and explicit transfers. Plan-gated roles are - // filtered out via the assignable set so the UI doesn't offer - // something the org can't actually use. + // 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)); @@ -195,11 +186,10 @@ export const loader = dashboardLoader( const NULL_ROLE_VALUE = "__none__"; const DEFAULT_JIT_ROLE_NAME = "Developer"; -// Don't use `z.coerce.boolean()` — it goes through JS `Boolean()`, -// which treats the string "false" as truthy (any non-empty string). +// 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"); -// Only-changed group→role mappings sent by the deferred Directory Sync Save. +// 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", [ @@ -213,8 +203,7 @@ const ActionSchema = z.discriminatedUnion("action", [ action: z.literal("portal_link"), intent: z.enum(["sso", "domain_verification", "dsync"]), }), - // Directory Sync section is a single deferred Save (like the SSO config - // form): all settings + changed group mappings commit together. + // Single deferred Save: settings + changed group mappings commit together. z.object({ action: z.literal("save_dsync_config"), allowExternalDomainSync: boolish, @@ -264,10 +253,8 @@ export const action = dashboardAction( switch (parsed.data.action) { case "save_config": { const jitRoleId = parsed.data.jitRoleId === NULL_ROLE_VALUE ? null : parsed.data.jitRoleId; - // The form is a single Save, so the three fields must commit - // all-or-nothing: `updateConfig` writes them in one transaction - // (with the JIT-role RBAC check inside it), so a failure leaves - // none of the fields changed rather than a partial config. + // 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, @@ -294,7 +281,7 @@ export const action = dashboardAction( return Response.json({ ok: true, url: result.value.url }); } case "save_dsync_config": { - // Parse the changed group→role mappings the deferred Save sent. + // Parse the changed group→role mappings. let groupRoles: Array<{ groupId: string; roleId: string }>; try { groupRoles = GroupRolesSchema.parse(JSON.parse(parsed.data.groupRoles)); @@ -305,15 +292,12 @@ export const action = dashboardAction( parsed.data.directoryDefaultRoleId === NULL_ROLE_VALUE ? null : parsed.data.directoryDefaultRoleId; - // Hoist out of the narrowed `parsed.data` — the discriminated-union - // narrowing doesn't survive into the thunk closures below. + // Hoist out: the union narrowing doesn't survive into the thunks below. const { allowExternalDomainSync, allowManualMembership } = parsed.data; - // Apply the OrgSsoConfig columns first. Not one transaction (group - // mappings are separate rows), but each write is idempotent, so a retry - // of the whole Save converges. Thunks (not pre-started ResultAsyncs) so - // they run strictly one at a time and the first failure stops the rest - // rather than leaving later writes to apply in the background. + // 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({ @@ -335,10 +319,8 @@ export const action = dashboardAction( } } - // Each group remap returns the membership effects it implies for that - // group's current members (roles recomputed against the new mapping, - // deprovision when cleared to "No access" and it was their last mapped - // group). Collect and apply them so the remap takes effect immediately. + // 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({ @@ -361,20 +343,15 @@ export const action = dashboardAction( ); function defaultJitRoleId(jitRoles: ReadonlyArray, current: string | null): string { - // Persisted value wins, even when it points at something the picker - // can no longer offer — keeps the user's prior choice visible. + // 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; } -// A settings field that mirrors a server value but is locally editable, safe -// to use while the whole page polls: as long as the user hasn't touched the -// field, it adopts fresh server values from revalidation; once edited (dirty) -// it holds the user's value until the server catches up (a successful Save, or -// another admin setting the same value), at which point it snaps back to clean. -// `dirty` never fires a false positive from a poll because the override is -// dropped as soon as the server value matches it. +// 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(serverValue: T): { value: T; set: (next: T) => void; @@ -382,7 +359,7 @@ function useOverrideDraft(serverValue: T): { } { const [override, setOverride] = useState<{ value: T } | null>(null); useEffect(() => { - // Server caught up to the pending edit → clear the override (back to clean). + // 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; @@ -393,6 +370,58 @@ function useOverrideDraft(serverValue: T): { }; } +// Shared layout primitives (mirrors /account/security): a section header with a +// bottom divide, then rows of title+subtitle left / action right. +function SettingsSection({ children }: { children: ReactNode }) { + return
{children}
; +} + +function SectionHeader({ + title, + description, + action, +}: { + title: ReactNode; + description?: ReactNode; + action?: ReactNode; +}) { + return ( +
+
+ {title} + {description ? ( + + {description} + + ) : null} +
+ {action ?
{action}
: null} +
+ ); +} + +function SettingRow({ + title, + description, + action, + htmlFor, +}: { + title: ReactNode; + description?: ReactNode; + action: ReactNode; + htmlFor?: string; +}) { + return ( +
+
+ + {description ? {description} : null} +
+
{action}
+
+ ); +} + export default function Page() { const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData(); const organization = useOrganization(); @@ -402,9 +431,7 @@ export default function Page() { const activeConnections = status.connections.filter((c) => c.state === "active"); const hasActive = activeConnections.length > 0; - // Deferred-save: each field mirrors `status` but stays locally editable. - // `useOverrideDraft` lets the page poll safely — untouched fields adopt - // fresh server values, edited fields are preserved until Save. + // 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); @@ -434,13 +461,8 @@ export default function Page() { } }, [portalFetcher.data]); - // Poll the whole page while entitled — before an active connection this - // reflects portal progress (domain verified, connection activated), and - // once active it keeps SSO + Directory Sync state fresh (connection - // deleted/deactivated, directory activated/deactivated/deleted, new - // groups). Draft edits survive revalidation because every editable field - // goes through `useOverrideDraft` (dirty fields preserved, clean fields - // adopt server values). The upsell state is excluded by `isEntitled`. + // 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; @@ -482,7 +504,7 @@ export default function Page() { - + {!isEntitled ? ( ) : !status.hasIdpOrg ? ( @@ -512,9 +534,7 @@ export default function Page() { isSaving={isSaving} onOpenPortal={openPortal} onToggleEnforced={(next) => { - // Going on→off is harmless; going off→on locks users out so - // we still require explicit confirmation. The modal updates - // the draft only; nothing is persisted until Save. + // off→on locks users out, so confirm first (draft only). if (next && !status.enforced) { setEnforceModalOpen(true); } else { @@ -546,49 +566,54 @@ export default function Page() { function EnterpriseUpsellState({ organizationSlug }: { organizationSlug: string }) { return ( -
-
- - SSO is available on the Enterprise plan -
- - Single sign-on (SAML / OIDC) lets your IT admins manage who can access Trigger.dev through - your identity provider — Okta, Azure AD, Google Workspace, OneLogin, and more. Upgrade your - organization to Enterprise to configure it. - -
    -
  • Self-service domain verification and connection setup via the admin portal.
  • -
  • Just-in-time user provisioning for your verified domains.
  • -
  • Per-domain enforcement so contractors keep using existing sign-in methods.
  • -
-
- - Talk to sales - - - Contact us - + + Enterprise} + /> +
+
    +
  • Self-service domain verification and connection setup via the admin portal.
  • +
  • Just-in-time user provisioning for your verified domains.
  • +
  • Per-domain enforcement so contractors keep using existing sign-in methods.
  • +
  • Directory Sync (SCIM) to provision users and map directory groups to roles.
  • +
+
+ + Talk to sales + + + Contact us + +
-
+ ); } function NoIdpOrgState({ onOpenPortal }: { onOpenPortal: () => void }) { return ( -
- Configure SSO for your organization - - Single sign-on lets your IT admins manage who can access Trigger.dev through your identity - provider (Okta, Azure AD, Google Workspace, OneLogin, and more). - - -
+ + + + Start setup + + } + /> + ); } @@ -621,49 +646,52 @@ function NoActiveConnectionState({ const hasVerifiedDomain = verifiedDomains.length > 0; return ( -
-
- Domains - - Verify the email domains your team signs in with. Once a domain is verified you can - connect your identity provider. - + <> + + + {domains.length > 0 ? "Add domain" : "Verify domain"} + + } + /> {failedDomains.length > 0 && ( - - {failedDomains.length === 1 - ? `Domain verification failed for ${failedDomains[0].domain}. Re-check the DNS records in the admin portal and re-run verification.` - : `${failedDomains.length} domains failed verification. Re-check the DNS records in the admin portal and re-run verification.`} - +
+ + {failedDomains.length === 1 + ? `Domain verification failed for ${failedDomains[0].domain}. Re-check the DNS records in the admin portal and re-run verification.` + : `${failedDomains.length} domains failed verification. Re-check the DNS records in the admin portal and re-run verification.`} + +
)} {domains.length > 0 && } - -
+ {hasVerifiedDomain && ( -
- SSO - - Connect your identity provider to finish setting up single sign-on for your verified - domains. - - -
+ + + + Configure SSO + + } + /> + )} - {/* Directory Sync is independent of SSO — once a domain is verified an org - can connect a directory without ever configuring SSO. */} + {/* Directory Sync is independent of SSO (needs only a verified domain). */} {hasVerifiedDomain && hasSso ? ( ) : null} -
+ ); } function DomainList({ domains }: { domains: ReadonlyArray }) { return ( -
    +
      {domains.map((d) => { const visual = domainVisual(d.state); return (
    • - {d.domain} + {d.domain} {d.state === "failed" && d.verificationFailedReason && ( - + Reason: {d.verificationFailedReason} )}
      - + {visual.icon} {d.state} @@ -708,20 +738,17 @@ function domainVisual(state: DomainRow["state"]) { switch (state) { case "verified": return { - row: "border-emerald-500/30 bg-emerald-500/5", label: "text-emerald-400", icon: , }; case "failed": return { - row: "border-rose-500/30 bg-rose-500/5", label: "text-rose-400", icon: , }; case "pending": default: return { - row: "border-amber-500/20 bg-amber-500/5", label: "text-amber-400", icon: , }; @@ -769,110 +796,104 @@ function ActiveConnectionState({ onSave: () => void; }) { return ( -
      -
      - Verified domains + <> + + onOpenPortal("domain_verification")} + TrailingIcon={ArrowUpRightIcon} + > + {status.domains.length > 0 ? "Add domain" : "Verify domain"} + + } + /> {status.domains.length === 0 ? ( - - No domains verified yet. - +
      + No domains verified yet. +
      ) : ( )} - -
      + -
      - {orgTitle} – SSO connection + + onOpenPortal("sso")} + TrailingIcon={ArrowUpRightIcon} + > + Manage connection + + } + /> {activeConnections.map((conn) => (
      - - {conn.name ?? conn.connectionType} - - - Type: {conn.connectionType} - +
      + {conn.name ?? conn.connectionType} + Type: {conn.connectionType} +
      +
      ))} - -
      -
      -
      -
      - - Require SSO for matching domains - - - When on, users whose email matches a verified domain must use SSO to sign in. - -
      - -
      -
      -
      - - JIT provisioning - - - Auto-create memberships for first-time SSO sign-ins from your verified domains. - -
      - -
      -
      -
      - - Default role for JIT provisioned users - - - Role assigned to new users created via JIT provisioning. Owner is reserved and cannot - be granted automatically. - -
      - - value={draftJitRoleId} - setValue={(v) => onChangeJitRole(v)} - items={[...jitRoles]} - variant="tertiary/small" - dropdownIcon - text={(v) => jitRoles.find((r) => r.id === v)?.name ?? "Select a role"} - > - {(items) => - items.map((role) => ( - - - {role.name} - {role.description ? ( - {role.description} - ) : null} - - - )) - } - -
      -
      + + } + /> + + } + /> + + value={draftJitRoleId} + setValue={(v) => onChangeJitRole(v)} + items={[...jitRoles]} + variant="secondary/small" + dropdownIcon + text={(v) => jitRoles.find((r) => r.id === v)?.name ?? "Select a role"} + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + + } + /> +
      -
      + {hasSso ? ( onOpenPortal("dsync")} /> ) : null} -
      + + ); +} + +function StatusIndicator({ label, active = true }: { label: string; active?: boolean }) { + return ( + + + {label} + ); } @@ -897,11 +929,8 @@ function DirectorySyncSection({ const fetcher = useFetcher(); const isSaving = fetcher.state !== "idle"; - // Deferred save: edits stay local until Save commits them all together - // (mirrors the SSO Configuration form). `useOverrideDraft` keeps the fields - // safe under whole-page polling — untouched fields adopt fresh server - // values, edited ones are preserved. Role values keep the NULL_ROLE_VALUE - // sentinel in the draft; the action converts it to null on write. + // 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( @@ -914,10 +943,8 @@ function DirectorySyncSection({ const draftDefaultRole = defaultRoleDraft.value; const setDraftDefaultRole = defaultRoleDraft.set; - // Group mappings vary in count, so instead of one draft per group we keep a - // sparse map of only the groups the user has edited (overrides). Rendering - // falls back to the server value, so new groups arriving via polling show up - // immediately, and an override is dropped once the server catches up to it. + // 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>({}); useEffect(() => { setDraftGroupRoles((current) => { @@ -925,8 +952,7 @@ function DirectorySyncSection({ for (const g of directorySync.groups) { const override = current[g.groupId]; if (override === undefined) continue; - // Keep only overrides that still diverge from the server (drops - // saved/externally-matched edits and edits for removed groups). + // Keep only overrides still diverging from the server. if (override !== (g.mappedRoleId ?? NULL_ROLE_VALUE)) next[g.groupId] = override; } const currentKeys = Object.keys(current); @@ -945,7 +971,7 @@ function DirectorySyncSection({ externalDraft.dirty || manualDraft.dirty || defaultRoleDraft.dirty || groupRolesDirty; const submitSave = () => { - // Send only the group mappings that actually changed. + // Send only changed mappings. const changedGroups = directorySync.groups .filter((g) => { const override = draftGroupRoles[g.groupId]; @@ -965,187 +991,169 @@ function DirectorySyncSection({ }; return ( -
      - Directory Sync - - Sync users and groups from your identity provider (SCIM). Members in mapped groups are - provisioned automatically, their role follows the group mapping, and removing a user from - your directory removes their access here. - + + + {directorySync.directories.length === 0 ? "Connect a directory" : "Manage directory"} + + } + /> {directorySync.directories.length === 0 ? ( - +
      + + No directory connected yet. Once you connect a directory, users in mapped groups are + provisioned automatically. + +
      ) : ( <> {directorySync.directories.map((dir) => (
      -
      - - {dir.name ?? dir.type} - - - {dir.type} · {dir.state === "active" ? "Active" : "Inactive"} ·{" "} - {directorySync.userCount} {directorySync.userCount === 1 ? "user" : "users"} +
      + {dir.name ?? dir.type} + + {dir.type} · {directorySync.userCount}{" "} + {directorySync.userCount === 1 ? "user" : "users"}
      +
      ))} - - -
      -
      - - Sync users outside verified domains - - - By default only directory users whose email domain is verified for this org are - provisioned. Turn on to also provision users on other domains (e.g. contractors). - -
      - -
      - -
      -
      - - Allow manual membership management - - - 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. - -
      - -
      -
      -
      - - Default role for users without a mapped group - - - Directory users who belong to no mapped group are provisioned at this role - (Developer by default). Choose "No access" to leave them unprovisioned until they - join a mapped group. - -
      - - value={draftDefaultRole} - setValue={(v) => setDraftDefaultRole(v)} - items={[{ id: NULL_ROLE_VALUE, name: "No access", description: "" }, ...jitRoles]} - variant="tertiary/small" - dropdownIcon - text={(v) => - v === NULL_ROLE_VALUE - ? "No access" - : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") - } - > - {(items) => - items.map((role) => ( - - - {role.name} - {role.description ? ( - {role.description} - ) : null} - - - )) - } - -
      + + } + /> + + + } + /> + + + value={draftDefaultRole} + setValue={(v) => setDraftDefaultRole(v)} + items={[{ id: NULL_ROLE_VALUE, name: "No access", description: "" }, ...jitRoles]} + variant="secondary/small" + dropdownIcon + text={(v) => + v === NULL_ROLE_VALUE + ? "No access" + : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") + } + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + + } + /> -
      - - Group → role mapping +
      + Group → role mapping + + Map each directory group to a role. Members inherit the role of their mapped group. - {directorySync.groups.length === 0 ? ( - +
      + {directorySync.groups.length === 0 ? ( +
      + No directory groups synced yet. Groups appear here once your directory syncs them. - ) : ( - directorySync.groups.map((group) => { - const value = - draftGroupRoles[group.groupId] ?? group.mappedRoleId ?? NULL_ROLE_VALUE; - return ( -
      + ) : ( + directorySync.groups.map((group) => { + const value = draftGroupRoles[group.groupId] ?? group.mappedRoleId ?? NULL_ROLE_VALUE; + return ( +
      + + {group.name} + + + value={value} + setValue={(v) => + setDraftGroupRoles((prev) => ({ ...prev, [group.groupId]: v })) + } + items={[ + { id: NULL_ROLE_VALUE, name: "No access", description: "" }, + ...jitRoles, + ]} + variant="secondary/small" + dropdownIcon + text={(v) => + v === NULL_ROLE_VALUE + ? "No access" + : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") + } > - - {group.name} - - - value={value} - setValue={(v) => - setDraftGroupRoles((prev) => ({ ...prev, [group.groupId]: v })) - } - items={[ - { id: NULL_ROLE_VALUE, name: "No access", description: "" }, - ...jitRoles, - ]} - variant="tertiary/small" - dropdownIcon - text={(v) => - v === NULL_ROLE_VALUE - ? "No access" - : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") - } - > - {(items) => - items.map((role) => ( - - - {role.name} - {role.description ? ( - {role.description} - ) : null} - - - )) - } - -
      - ); - }) - )} -
      + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + +
      + ); + }) + )} -
      +
      )} -
      + ); } @@ -1177,12 +1185,12 @@ function PortalLinkDialog({ {url ?? ""}
      -
      -
      + {hasSso ? ( @@ -908,15 +856,40 @@ function ActiveConnectionState({ function StatusIndicator({ label, active = true }: { label: string; active?: boolean }) { return ( - - + + {label} ); } +// 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 ( + + + {name} + {description ? {description} : null} + + + ); +} + function DirectorySyncSection({ directorySync, jitRoles, @@ -992,7 +965,7 @@ function DirectorySyncSection({ return ( - {directorySync.directories.length === 0 ? ( -
      + No directory connected. Once connected, members of mapped groups are provisioned automatically. -
      + ) : ( <> {directorySync.directories.map((dir) => ( -
      -
      - {dir.name ?? dir.type} - - {dir.type} · {directorySync.userCount}{" "} - {directorySync.userCount === 1 ? "user" : "users"} - -
      - -
      + title={dir.name ?? dir.type} + description={`${dir.type} · ${directorySync.userCount} ${ + directorySync.userCount === 1 ? "user" : "users" + }`} + action={ + + } + /> ))} - - - v === NULL_ROLE_VALUE ? "No access" @@ -1074,83 +1046,80 @@ function DirectorySyncSection({ > {(items) => items.map((role) => ( - - - {role.name} - {role.description ? ( - {role.description} - ) : null} - - + )) } } /> -
      - Group → role mapping - - Map each directory group to a role. Members inherit their group's role. - -
      + {directorySync.groups.length === 0 ? ( -
      + No groups synced yet. Groups appear here after your directory syncs. -
      + ) : ( directorySync.groups.map((group) => { const value = draftGroupRoles[group.groupId] ?? group.mappedRoleId ?? NULL_ROLE_VALUE; return ( -
      - - {group.name} - - - value={value} - setValue={(v) => - setDraftGroupRoles((prev) => ({ ...prev, [group.groupId]: v })) - } - items={[ - { id: NULL_ROLE_VALUE, name: "No access", description: "" }, - ...jitRoles, - ]} - variant="secondary/small" - dropdownIcon - text={(v) => - v === NULL_ROLE_VALUE - ? "No access" - : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") - } - > - {(items) => - items.map((role) => ( - - - {role.name} - {role.description ? ( - {role.description} - ) : null} - - - )) - } - -
      + size="sm" + title={group.name} + titleClassName="font-medium" + action={ + + 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) => ( + + )) + } + + } + /> ); }) )} -
      + -
      + )}
      From a11ec1386182a3ca28a9f5c5fa3d1272d1541061 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 8 Jul 2026 17:47:57 +0100 Subject: [PATCH 5/8] fix(webapp): drop external-link icon from SSO modal buttons These buttons open the admin portal dialog rather than navigating away, so the top-right arrow icon was misleading. Keep it only on the genuinely external Contact us and Open in new tab actions. Co-authored-by: Cursor --- .../route.tsx | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index 4f3cd46a41..e154adee1d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -558,7 +558,7 @@ function NoIdpOrgState({ onOpenPortal }: { onOpenPortal: () => void }) { title="Get started" description="Verify your email domains, then connect your identity provider." action={ - } @@ -602,11 +602,7 @@ function NoActiveConnectionState({ title="Domains" description="Verify the email domains your team signs in with. Connect your identity provider once a domain is verified." action={ - } @@ -633,7 +629,7 @@ function NoActiveConnectionState({ title="Identity provider" description="Connect Okta, Azure AD, Google Workspace, and more." action={ - } @@ -754,11 +750,7 @@ function ActiveConnectionState({ title="Domains" description="The email domains your team signs in with." action={ - } @@ -777,11 +769,7 @@ function ActiveConnectionState({ title="SSO" description={`SSO connection for ${orgTitle}.`} action={ - } @@ -969,7 +957,7 @@ function DirectorySyncSection({ 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={ - } From cb48d076e3390ea58e3d185a4462aa283bfbc4a9 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 8 Jul 2026 18:22:46 +0100 Subject: [PATCH 6/8] feat(webapp): polish SSO admin portal link dialog Tighten the copy, shrink the description, use ClipboardField with a permanent copy button for the URL, remove the redundant Copy link footer button, and label the Open button with the provider derived from the link host (e.g. Open in WorkOS) with a safe fallback. Co-authored-by: Cursor --- .../route.tsx | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index e154adee1d..2e9dd24424 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -14,6 +14,7 @@ import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; import { Dialog, DialogContent, @@ -1114,6 +1115,23 @@ function DirectorySyncSection({ ); } +// 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 = { 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, @@ -1125,50 +1143,37 @@ function PortalLinkDialog({ }) { const purpose = intent === "domain_verification" - ? "This single-use link opens domain verification. Share it with whoever manages your DNS to confirm you own the domains." + ? "Single-use link to verify your email domains. Share it with whoever manages your DNS." : intent === "sso" - ? "This single-use link opens identity provider setup. Share it with whoever manages your identity provider to connect it." + ? "Single-use link to connect your identity provider. Share it with whoever manages it." : intent === "dsync" - ? "This single-use link opens Directory Sync (SCIM) setup. Share it with whoever manages your identity provider to connect your directory." - : "This single-use link opens your SSO setup."; + ? "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 ( (open ? undefined : onClose())}> Admin portal link - - {purpose} The link expires 5 minutes after this dialog opens. + + {purpose} It expires 5 minutes after this dialog opens. -
      - {url ?? ""} -
      + -
      - - -
      +
      From 3ba70fb8e7b52a1957d7203a014665a07ab66635 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 8 Jul 2026 18:48:01 +0100 Subject: [PATCH 7/8] chore: add server-changes entry for SSO UI improvements Co-authored-by: Cursor --- .server-changes/sso-directory-sync-ui.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/sso-directory-sync-ui.md diff --git a/.server-changes/sso-directory-sync-ui.md b/.server-changes/sso-directory-sync-ui.md new file mode 100644 index 0000000000..45d28ba71c --- /dev/null +++ b/.server-changes/sso-directory-sync-ui.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Refreshed the SSO & Directory Sync settings page layout and copy From 7646fae9a474d6d6dcd40040f3ffaa87879f06dc Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 8 Jul 2026 19:08:49 +0100 Subject: [PATCH 8/8] fix(webapp): type Select placement prop for Ariakit provider Co-authored-by: Cursor --- apps/webapp/app/components/primitives/Select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 19015705f2..66b291b752 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -111,7 +111,7 @@ export interface SelectProps extends Om clearSearchOnSelection?: boolean; dropdownIcon?: boolean | React.ReactNode; popoverClassName?: string; - placement?: Ariakit.SelectPopoverProps["placement"]; + placement?: Ariakit.SelectProviderProps["placement"]; } export function Select({