"use client"; /** * SsoTabs (TASK-0131) — the org admin "Integrations → SSO" surface. * * A SELF-CONTAINED component: it takes `orgId` and owns its own data * (react-query against `ssoApi`). TASK-0133's `AdminIntegrations` mounts the * SAME component inside the org console; until then a standalone route * (`/org/[orgId]/admin/integrations`) hosts it. * * Two views: * - NO provider → an empty state + a "Configure SSO" action that opens the * create form (SAML or OIDC). * - provider set → the masked provider summary + an edit form (config only — * the secrets are reveal-once), a test-connection panel, the enforce * toggle (with confirm), and the danger-zone delete. * * Secrets are REVEAL-ONCE: the SAML X.509 cert PEM / OIDC client secret are * pasted into a field on create/rotate, POSTed once, and the server encrypts * them (AES-256-GCM). The read returns only `certificateConfigured` / * `clientSecretConfigured` booleans — the values are never fetched back, so * there is nothing to "reveal" after save (the UI just shows a "configured" * badge + a "Rotate" affordance that re-opens the paste field). * * A11y: the create/edit forms are real
s with labelled inputs; the * enforce toggle is a role="switch"; the danger-zone delete uses the shared * ConfirmDialog (focus-trapped). */ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Plus, RefreshCw, ShieldCheck, ShieldOff, Trash2, Webhook, KeyRound, Fingerprint } from "lucide-react"; import { ssoApi, type SsoOidcConfig, type SsoProviderView, type SsoSamlConfig, type SsoTestResult } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import { cn } from "@/lib/utils"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Field, Section, inputCls } from "@/components/settings/settings-shell"; interface SsoTabsProps { orgId: string; } type SsoType = "SAML" | "OIDC"; interface FormState { type: SsoType; name: string; // SAML samlEntityId: string; samlSsoUrl: string; samlAcsUrl: string; samlNameIdAttribute: string; samlEmailAttribute: string; samlMetadataUrl: string; samlIdpEntityId: string; samlCertificate: string; samlPreviousCertificate: string; // OIDC oidcIssuer: string; oidcClientId: string; oidcAuthorizationEndpoint: string; oidcTokenEndpoint: string; oidcRedirectUri: string; oidcJwksUri: string; oidcScope: string; oidcClientSecret: string; } const EMPTY_FORM: FormState = { type: "SAML", name: "", samlEntityId: "", samlSsoUrl: "", samlAcsUrl: "", samlNameIdAttribute: "nameId", samlEmailAttribute: "mail", samlMetadataUrl: "", samlIdpEntityId: "", samlCertificate: "", samlPreviousCertificate: "", oidcIssuer: "", oidcClientId: "", oidcAuthorizationEndpoint: "", oidcTokenEndpoint: "", oidcRedirectUri: "", oidcJwksUri: "", oidcScope: "openid email profile", oidcClientSecret: "" }; const btnPrimary = "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"; const btnSecondary = "inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent disabled:opacity-50"; const btnDanger = "inline-flex items-center gap-1.5 rounded-md border border-destructive/50 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"; export function SsoTabs({ orgId }: SsoTabsProps) { const qc = useQueryClient(); const providerQuery = useQuery({ queryKey: ["orgs", orgId, "sso"], queryFn: () => ssoApi.get(orgId), retry: false }); const provider: SsoProviderView | null = providerQuery.data?.provider ?? null; const [mode, setMode] = useState<"view" | "create" | "edit">("view"); const [form, setForm] = useState(EMPTY_FORM); const [confirmEnforce, setConfirmEnforce] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [testResult, setTestResult] = useState(null); const invalidate = () => qc.invalidateQueries({ queryKey: ["orgs", orgId, "sso"] }); // When the provider loads and we're in "edit", hydrate the form with the // masked config (the secrets stay empty — they're only set on save). useEffect(() => { if (mode === "edit" && provider) { setForm((f) => ({ ...f, type: provider.type, name: provider.name, ...(provider.type === "SAML" ? { samlEntityId: (provider.config as SsoSamlConfig).entityId, samlSsoUrl: (provider.config as SsoSamlConfig).ssoUrl, samlAcsUrl: (provider.config as SsoSamlConfig).acsUrl, samlNameIdAttribute: (provider.config as SsoSamlConfig).nameIdAttribute ?? "nameId", samlEmailAttribute: (provider.config as SsoSamlConfig).emailAttribute ?? "mail", samlMetadataUrl: (provider.config as SsoSamlConfig).metadataUrl ?? "", samlIdpEntityId: (provider.config as SsoSamlConfig).idpEntityId ?? "" } : { oidcIssuer: (provider.config as SsoOidcConfig).issuer, oidcClientId: (provider.config as SsoOidcConfig).clientId, oidcAuthorizationEndpoint: (provider.config as SsoOidcConfig).authorizationEndpoint, oidcTokenEndpoint: (provider.config as SsoOidcConfig).tokenEndpoint, oidcRedirectUri: (provider.config as SsoOidcConfig).redirectUri, oidcJwksUri: (provider.config as SsoOidcConfig).jwksUri ?? "", oidcScope: (provider.config as SsoOidcConfig).scope ?? "openid email profile" }) })); } }, [mode, provider]); const set = (key: K, value: FormState[K]) => setForm((f) => ({ ...f, [key]: value })); const createMutation = useMutation({ mutationFn: () => form.type === "SAML" ? ssoApi.create(orgId, { type: "SAML", name: form.name, certificate: form.samlCertificate, ...(form.samlPreviousCertificate ? { previousCertificate: form.samlPreviousCertificate } : {}), config: { entityId: form.samlEntityId, ssoUrl: form.samlSsoUrl, acsUrl: form.samlAcsUrl, nameIdAttribute: form.samlNameIdAttribute || "nameId", emailAttribute: form.samlEmailAttribute || "mail", ...(form.samlMetadataUrl ? { metadataUrl: form.samlMetadataUrl } : {}), ...(form.samlIdpEntityId ? { idpEntityId: form.samlIdpEntityId } : {}) } }) : ssoApi.create(orgId, { type: "OIDC", name: form.name, clientSecret: form.oidcClientSecret, config: { issuer: form.oidcIssuer, clientId: form.oidcClientId, authorizationEndpoint: form.oidcAuthorizationEndpoint, tokenEndpoint: form.oidcTokenEndpoint, redirectUri: form.oidcRedirectUri, jwksUri: form.oidcJwksUri || undefined, scope: form.oidcScope } }), onSuccess: () => { toast("success", "SSO provider configured"); setMode("view"); setForm(EMPTY_FORM); invalidate(); }, onError: (e: Error) => toast("error", "Could not save SSO", e.message) }); const updateMutation = useMutation({ mutationFn: () => { const body: Parameters[1] = { name: form.name }; if (provider?.type === "SAML") { body.config = { entityId: form.samlEntityId, ssoUrl: form.samlSsoUrl, acsUrl: form.samlAcsUrl, nameIdAttribute: form.samlNameIdAttribute || "nameId", emailAttribute: form.samlEmailAttribute || "mail" }; if (form.samlCertificate) body.certificate = form.samlCertificate; // rotate } else { body.config = { issuer: form.oidcIssuer, clientId: form.oidcClientId, authorizationEndpoint: form.oidcAuthorizationEndpoint, tokenEndpoint: form.oidcTokenEndpoint, redirectUri: form.oidcRedirectUri, scope: form.oidcScope }; if (form.oidcClientSecret) body.clientSecret = form.oidcClientSecret; // rotate } return ssoApi.update(orgId, body); }, onSuccess: () => { toast("success", "SSO provider updated"); setMode("view"); invalidate(); }, onError: (e: Error) => toast("error", "Could not update SSO", e.message) }); const enforceMutation = useMutation({ mutationFn: (enforce: boolean) => ssoApi.setEnforce(orgId, enforce), onSuccess: (res) => { toast("success", `SSO enforce ${res.enforce ? "ON" : "OFF"}`); setConfirmEnforce(null); invalidate(); }, onError: (e: Error) => { toast("error", "Could not change enforce", e.message); setConfirmEnforce(null); } }); const deleteMutation = useMutation({ mutationFn: () => ssoApi.remove(orgId), onSuccess: () => { toast("success", "SSO provider removed"); setConfirmDelete(false); setTestResult(null); invalidate(); }, onError: (e: Error) => { toast("error", "Could not remove SSO", e.message); setConfirmDelete(false); } }); const testMutation = useMutation({ mutationFn: () => ssoApi.test(orgId), onSuccess: (res) => setTestResult(res) }); const createValid = useMemo(() => { if (!form.name.trim()) return false; if (form.type === "SAML") { return Boolean( form.samlEntityId && form.samlSsoUrl && form.samlAcsUrl && form.samlCertificate.trim() ); } return Boolean( form.oidcIssuer && form.oidcClientId && form.oidcAuthorizationEndpoint && form.oidcTokenEndpoint && form.oidcRedirectUri && form.oidcClientSecret.trim() ); }, [form]); const updateValid = useMemo(() => { if (!form.name.trim()) return false; if (form.type === "SAML") return Boolean(form.samlEntityId && form.samlSsoUrl && form.samlAcsUrl); return Boolean( form.oidcIssuer && form.oidcClientId && form.oidcAuthorizationEndpoint && form.oidcTokenEndpoint ); }, [form]); if (providerQuery.isLoading) { return (
Loading SSO settings…
); } // ----------------------------- empty / create ----------------------------- if (!provider && mode !== "create") { return (

No SSO provider configured yet.

); } if (mode === "create" || mode === "edit") { return ( { e.preventDefault(); if (mode === "create" && createValid) createMutation.mutate(); if (mode === "edit" && updateValid) updateMutation.mutate(); }} >

{mode === "create" ? "Configure SSO" : "Edit SSO"}

Provider type
{(["SAML", "OIDC"] as SsoType[]).map((t) => ( ))}
set("name", e.target.value)} placeholder={form.type === "SAML" ? "e.g. Acme Okta" : "e.g. Acme Okta OIDC"} /> {form.type === "SAML" ? ( <> set("samlEntityId", e.target.value)} placeholder="https://factory.example.com/saml/metadata/acme" />
set("samlSsoUrl", e.target.value)} placeholder="https://idp.example.com/saml/sso" /> set("samlAcsUrl", e.target.value)} placeholder="https://factory.example.com/saml/acs" /> set("samlNameIdAttribute", e.target.value)} /> set("samlEmailAttribute", e.target.value)} /> set("samlMetadataUrl", e.target.value)} placeholder="https://idp.example.com/metadata" /> set("samlIdpEntityId", e.target.value)} />
{mode === "create" ? ( <>