import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, KeyRound, Plus, RefreshCw, ShieldCheck, Trash2, X, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } 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 { useRegisterAdminContext } from "~/lib/admin-context" export const meta = () => pageTitle("SSO") type Editor = | { kind: "create" } | { kind: "edit"; idp: IdentityProvider } | null export default function SsoRoute() { const session = useSession() const arcadia = useArcadiaClient() const [idps, setIdps] = useState([]) const [sessions, setSessions] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const [pendingSessionDestroy, setPendingSessionDestroy] = useState(null) const refresh = useCallback(async () => { setError(null) setLoading(true) try { const [i, s] = await Promise.all([ listIdentityProviders(arcadia).catch(() => [] as IdentityProvider[]), listSamlSessions(arcadia).catch(() => [] as SamlSession[]), ]) setIdps(i) setSessions(s) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load SSO data.") } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (session) refresh() }, [session, refresh]) useRegisterAdminContext("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 () => { try { await updateIdentityProvider(arcadia, i.id, { enabled: !i.enabled }) setInfo(`${i.name} ${i.enabled ? "disabled" : "enabled"}.`) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Toggle failed.") } }, }, { id: "delete", label: "Delete", icon: , destructive: true, dataAction: `idp-${i.id}-delete`, onSelect: () => setPendingDelete(i), }, ] return }, }, ], [arcadia, refresh], ) 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.

{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null} Identity providers ({idps.length}) Active sessions ({sessions.length}) {idpTable.total === 0 && !loading ? ( } 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={loading && idps.length > 0} stickyHeader /> )} {sessions.length === 0 ? ( ) : (
    {sessions.map((s) => (
  • {s.name_id ?? s.user_id} {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 ? `${pendingDelete.name} will be removed. Existing SAML sessions remain valid until they expire.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return try { await deleteIdentityProvider(arcadia, pendingDelete.id) setPendingDelete(null) setInfo("Identity provider deleted.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Delete failed.") setPendingDelete(null) } }} /> !o && setPendingSessionDestroy(null)} title="Destroy SAML session?" description={ pendingSessionDestroy ? `Session for ${pendingSessionDestroy.name_id ?? pendingSessionDestroy.user_id} will be revoked.` : "" } confirmLabel="Destroy" variant="danger" onConfirm={async () => { if (!pendingSessionDestroy) return try { await destroySamlSession(arcadia, pendingSessionDestroy.id) setPendingSessionDestroy(null) setInfo("Session destroyed.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Destroy failed.") setPendingSessionDestroy(null) } }} /> setEditor(null)} onSaved={async (msg) => { setEditor(null) if (msg) setInfo(msg) await refresh() }} onError={setError} />
) } function IdpEditorDialog({ state, onClose, onSaved, onError, }: { state: Editor onClose: () => void onSaved: (msg?: string) => Promise onError: (msg: string | null) => void }) { 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) useEffect(() => { if (!open) return 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 () => { onError(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("Identity provider updated.") } else { await createIdentityProvider(arcadia, input) await onSaved("Identity provider created.") } } catch (err) { onError( err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.", ) } finally { 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-app/api/v1/auth/saml/callback" className="font-mono text-xs" data-action="idp-form-callback" />