import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, KeyRound, Plus, RefreshCw, ShieldCheck, Trash2, X, } from "lucide-react" import { useArcadiaClient } from "@crema/arcadia-core-client" import { useToast } from "@crema/notification-ui" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type Column, } from "@crema/table-ui" import { ConfirmDialog, EmptyState } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { DataState, DialogError } from "~/components/data-state" import { errorMessage } from "~/lib/errors" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Switch } from "~/components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs" import { Textarea } from "~/components/ui/textarea" import { createIdentityProvider, deleteIdentityProvider, destroySamlSession, listIdentityProviders, listSamlSessions, updateIdentityProvider, type IdentityProvider, type IdentityProviderInput, type SamlSession, } from "~/lib/arcadia/sso" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterContext } from "@crema/aifirst-ui/context" export const meta = () => pageTitle("SSO") type Editor = | { kind: "create" } | { kind: "edit"; idp: IdentityProvider } | null /** Who a SAML session belongs to, in the operator's words. */ function sessionLabel(s: SamlSession): string { return s.name_id ?? s.user_id } export default function SsoRoute() { const session = useSession() const arcadia = useArcadiaClient() const toast = useToast() // Two tabs, two independent fetches. They used to share one load that // swallowed each failure with `.catch(() => [])`, so a broken endpoint was // indistinguishable from "no identity providers". const [idps, setIdps] = useState([]) const [idpsLoading, setIdpsLoading] = useState(true) const [idpsError, setIdpsError] = useState(null) const [sessions, setSessions] = useState([]) const [sessionsLoading, setSessionsLoading] = useState(true) const [sessionsError, setSessionsError] = useState(null) const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const [pendingSessionDestroy, setPendingSessionDestroy] = useState(null) const refreshIdps = useCallback(async () => { setIdpsError(null) setIdpsLoading(true) try { setIdps(await listIdentityProviders(arcadia)) } catch (err) { setIdpsError(err) } finally { setIdpsLoading(false) } }, [arcadia]) const refreshSessions = useCallback(async () => { setSessionsError(null) setSessionsLoading(true) try { setSessions(await listSamlSessions(arcadia)) } catch (err) { setSessionsError(err) } finally { setSessionsLoading(false) } }, [arcadia]) const refresh = useCallback(async () => { await Promise.all([refreshIdps(), refreshSessions()]) }, [refreshIdps, refreshSessions]) useEffect(() => { if (session) refresh() }, [session, refresh]) const loading = idpsLoading || sessionsLoading useRegisterContext("sso", { identity_providers: idps.length, enabled_idps: idps.filter((i) => i.enabled).length, active_sessions: sessions.length, }) const idpColumns = useMemo[]>( () => [ { id: "name", header: "Name", accessor: "name", sortable: true, cell: (i) => (
{i.name} {i.entity_id}
), }, { id: "enabled", header: "Enabled", accessor: "enabled", sortable: true, cell: (i) => ( ), }, { id: "cert", header: "Certificate", cell: (i) => i.has_certificate ? ( set ) : ( missing ), }, { id: "sso_url", header: "SSO URL", cell: (i) => ( {i.sso_url} ), }, { id: "updated", header: "Updated", accessor: "updated_at", sortable: true, cell: (i) => , }, { id: "actions", header: "", align: "right", cell: (i) => { const items: ActionItem[] = [ { id: "edit", label: "Edit", dataAction: `idp-${i.id}-edit`, onSelect: () => setEditor({ kind: "edit", idp: i }), }, { id: i.enabled ? "disable" : "enable", label: i.enabled ? "Disable" : "Enable", dataAction: `idp-${i.id}-toggle`, onSelect: async () => { const verb = i.enabled ? "disable" : "enable" try { await updateIdentityProvider(arcadia, i.id, { enabled: !i.enabled }) await refreshIdps() toast.success(`${i.enabled ? "Disabled" : "Enabled"} ${i.name}`) } catch (err) { toast.error(errorMessage(err, `${verb} ${i.name}`)) } }, }, { id: "delete", label: "Delete", icon: , destructive: true, dataAction: `idp-${i.id}-delete`, onSelect: () => setPendingDelete(i), }, ] return }, }, ], [arcadia, refreshIdps, toast], ) const idpTable = useTable({ data: idps, columns: idpColumns, getRowId: (i) => i.id, initialPageSize: 25, }) return (

Single sign-on

SAML identity providers configured for the current tenant, plus the active SAML session pool.

Identity providers ({idps.length}) Active sessions ({sessions.length}) } title="No identity providers." description="Connect a SAML IdP (Okta, Azure AD, Google Workspace, etc.) to enable SSO for this tenant." className="py-12" /> } > i.id} sort={idpTable.sort} onSortToggle={idpTable.toggleSort} loading={idpsLoading && idps.length > 0} stickyHeader /> } >
    {sessions.map((s) => (
  • {sessionLabel(s)} {s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? ( expired ) : ( active )} session_index: {s.session_index ?? "—"} · idp:{" "} {s.idp_id.slice(0, 8)}… · started{" "} {new Date(s.inserted_at).toLocaleString()} {s.expires_at ? ` · expires ${new Date(s.expires_at).toLocaleString()}` : ""}
  • ))}
!o && setPendingDelete(null)} title="Delete identity provider?" description={ pendingDelete ? `Nobody can sign in through ${pendingDelete.name} once it's deleted, and its configuration — including the certificate — is gone for good. Existing SAML sessions stay valid until they expire. Disable it instead if this is temporary.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return const name = pendingDelete.name try { await deleteIdentityProvider(arcadia, pendingDelete.id) setPendingDelete(null) await refresh() toast.success(`Deleted ${name}`) } catch (err) { setPendingDelete(null) toast.error(errorMessage(err, `delete ${name}`)) } }} /> !o && setPendingSessionDestroy(null)} title="Destroy SAML session?" description={ pendingSessionDestroy ? `${sessionLabel(pendingSessionDestroy)} is signed out immediately and has to authenticate with the IdP again.` : "" } confirmLabel="Destroy" variant="danger" onConfirm={async () => { if (!pendingSessionDestroy) return const who = sessionLabel(pendingSessionDestroy) try { await destroySamlSession(arcadia, pendingSessionDestroy.id) setPendingSessionDestroy(null) await refreshSessions() toast.success(`Destroyed the session for ${who}`) } catch (err) { setPendingSessionDestroy(null) toast.error(errorMessage(err, `destroy the session for ${who}`)) } }} /> setEditor(null)} onSaved={async (message) => { setEditor(null) await refreshIdps() toast.success(message) }} />
) } function IdpEditorDialog({ state, onClose, onSaved, }: { state: Editor onClose: () => void onSaved: (message: string) => Promise }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.kind === "edit" const initial = isEdit ? state.idp : null const [name, setName] = useState("") const [entityId, setEntityId] = useState("") const [ssoUrl, setSsoUrl] = useState("") const [sloUrl, setSloUrl] = useState("") const [metadataUrl, setMetadataUrl] = useState("") const [callbackUrl, setCallbackUrl] = useState("") const [signRequests, setSignRequests] = useState(false) const [enabled, setEnabled] = useState(true) const [certificate, setCertificate] = useState("") const [attrJson, setAttrJson] = useState("{}") const [saving, setSaving] = useState(false) // Inside the dialog, above the buttons — a page banner here would sit behind // the scrim, and a rejected certificate would look like nothing happened. const [error, setError] = useState(null) useEffect(() => { if (!open) return setError(null) setSaving(false) if (initial) { setName(initial.name) setEntityId(initial.entity_id) setSsoUrl(initial.sso_url) setSloUrl(initial.slo_url ?? "") setMetadataUrl(initial.metadata_url ?? "") setCallbackUrl(initial.callback_url ?? "") setSignRequests(initial.sign_requests) setEnabled(initial.enabled) setCertificate("") // never pre-fill setAttrJson(JSON.stringify(initial.attribute_mapping ?? {}, null, 2)) } else { setName("") setEntityId("") setSsoUrl("") setSloUrl("") setMetadataUrl("") setCallbackUrl("") setSignRequests(false) setEnabled(true) setCertificate("") setAttrJson('{\n "email": "email",\n "first_name": "givenName",\n "last_name": "surname"\n}') } }, [open, initial]) const submit = async () => { setError(null) setSaving(true) try { let attribute_mapping: Record = {} try { attribute_mapping = attrJson.trim() === "" ? {} : JSON.parse(attrJson) } catch { throw new Error("Attribute mapping must be valid JSON (key→value strings).") } const input: IdentityProviderInput = { name, entity_id: entityId, sso_url: ssoUrl, slo_url: sloUrl || null, metadata_url: metadataUrl || null, callback_url: callbackUrl || null, sign_requests: signRequests, enabled, attribute_mapping, } if (certificate.trim()) input.certificate = certificate if (isEdit && initial) { await updateIdentityProvider(arcadia, initial.id, input) await onSaved(`Saved ${name.trim()}`) } else { await createIdentityProvider(arcadia, input) await onSaved(`Created ${name.trim()}`) } } catch (err) { // Keep the pasted certificate and JSON on screen — retyping them is the // last thing an operator should have to do after a failed save. setError(err) setSaving(false) } } return ( !o && onClose()}> {isEdit ? `Edit ${initial?.name}` : "New identity provider"} {isEdit ? "Leave the certificate field blank to keep the existing one." : "Paste values from the IdP metadata XML, or supply the metadata URL and let arcadia fetch the rest."}
setName(e.target.value)} placeholder="Okta — Production" data-action="idp-form-name" />
setEntityId(e.target.value)} placeholder="https://idp.example.com/saml" className="font-mono text-xs" data-action="idp-form-entity" />
setSsoUrl(e.target.value)} placeholder="https://idp.example.com/saml/sso" className="font-mono text-xs" data-action="idp-form-sso" />
setSloUrl(e.target.value)} placeholder="https://idp.example.com/saml/slo" className="font-mono text-xs" data-action="idp-form-slo" />
setMetadataUrl(e.target.value)} placeholder="https://idp.example.com/metadata.xml" className="font-mono text-xs" data-action="idp-form-metadata" />
setCallbackUrl(e.target.value)} placeholder="https://your-arcadia-core/api/v1/auth/saml/callback" className="font-mono text-xs" data-action="idp-form-callback" />