import { useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { listOIDCProviders, updateOIDCProvider, deleteOIDCProvider, refreshOIDCProvider, type OIDCProvider, } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; // ============================================================================= // Bundle 2 Phase 8 — OIDCProviderDetailPage. // // One row per provider — edit (PUT), delete (DELETE), and refresh // discovery cache (POST .../refresh). Edit modal shares the create- // modal field set; the client_secret field is OPTIONAL on edit (empty // preserves the existing ciphertext on the server). Delete is gated // behind a typed-confirmation dialog AND surfaces 409 Conflict (the // server's ErrOIDCProviderInUse) as a non-destructive error so the // operator knows to revoke active sessions first. Refresh discovery // cache fires the server's RefreshKeys → re-runs the IdP downgrade- // attack defense AND re-fetches JWKS; common operator action when an // IdP rotates keys mid-day. // // Permission gates: the page itself requires auth.oidc.list. Edit // and refresh require auth.oidc.edit. Delete requires // auth.oidc.delete. Mappings link is rendered for any caller with // auth.oidc.list. // ============================================================================= export default function OIDCProviderDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { hasPerm } = useAuthMe(); const canList = hasPerm('auth.oidc.list'); const canEdit = hasPerm('auth.oidc.edit'); const canDelete = hasPerm('auth.oidc.delete'); const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(''); const [editIssuerURL, setEditIssuerURL] = useState(''); const [editClientID, setEditClientID] = useState(''); const [editClientSecret, setEditClientSecret] = useState(''); const [editRedirectURI, setEditRedirectURI] = useState(''); const [editFetchUserinfo, setEditFetchUserinfo] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); const { data, isLoading, error: loadErr } = useQuery({ queryKey: ['oidc-providers'], queryFn: listOIDCProviders, enabled: canList, }); if (!canList) { return (
); } const provider: OIDCProvider | undefined = data?.providers.find(p => p.id === id); if (isLoading) { return
Loading…
; } if (loadErr || !provider) { return (
← Back to providers
); } const startEdit = () => { setEditName(provider.name); setEditIssuerURL(provider.issuer_url); setEditClientID(provider.client_id); setEditClientSecret(''); setEditRedirectURI(provider.redirect_uri); setEditFetchUserinfo(provider.fetch_userinfo || false); setError(null); setSuccess(null); setEditing(true); }; const cancelEdit = () => { setEditing(false); setError(null); }; const saveEdit = async () => { setSubmitting(true); setError(null); setSuccess(null); try { const req: Parameters[1] = { name: editName, issuer_url: editIssuerURL, client_id: editClientID, redirect_uri: editRedirectURI, groups_claim_path: provider.groups_claim_path, groups_claim_format: provider.groups_claim_format, fetch_userinfo: editFetchUserinfo, scopes: provider.scopes, iat_window_seconds: provider.iat_window_seconds, jwks_cache_ttl_seconds: provider.jwks_cache_ttl_seconds, }; if (editClientSecret) req.client_secret = editClientSecret; await updateOIDCProvider(provider.id, req); setSuccess('Provider updated'); setEditing(false); queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; const doRefresh = async () => { setSubmitting(true); setError(null); setSuccess(null); try { await refreshOIDCProvider(provider.id); setSuccess('Discovery + JWKS refreshed; IdP downgrade defense re-run'); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; const doDelete = async () => { setSubmitting(true); setError(null); try { await deleteOIDCProvider(provider.id); navigate('/auth/oidc/providers'); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setSubmitting(false); } }; return (
← All providers } /> {error && (
{error}
)} {success && (
{success}
)}

Configuration

{!editing ? (
Issuer URL
{provider.issuer_url}
Client ID
{provider.client_id}
Redirect URI
{provider.redirect_uri}
Groups claim
{provider.groups_claim_path} ({provider.groups_claim_format})
Userinfo fallback
{provider.fetch_userinfo ? 'enabled' : 'disabled'}
Scopes
{(provider.scopes || []).join(', ')}
IAT window
{provider.iat_window_seconds}s
) : (
setEditName(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" data-testid="oidc-provider-edit-name" />
setEditIssuerURL(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" data-testid="oidc-provider-edit-issuer-url" />
setEditClientID(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" data-testid="oidc-provider-edit-client-id" />
setEditClientSecret(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" data-testid="oidc-provider-edit-client-secret" />
setEditRedirectURI(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" data-testid="oidc-provider-edit-redirect-uri" />
)}

Actions

{canEdit && !editing && ( )} {editing && ( <> )} {canEdit && ( )} Group → role mappings {canDelete && !confirmDelete && ( )}
{confirmDelete && (

Type {provider.name} to confirm deletion. Deletion is refused (HTTP 409) when any user has authenticated via this provider; revoke their sessions first.

setDeleteConfirmText(e.target.value)} className="flex-1 px-2 py-1 text-sm border border-red-300 rounded bg-white" data-testid="oidc-provider-delete-confirm-input" />
)}
); }