import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { listOIDCProviders, createOIDCProvider, type OIDCProvider, type OIDCProviderRequest, } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; import OIDCTestConnectionPanel from './OIDCTestConnectionPanel'; // ============================================================================= // Bundle 2 Phase 8 — OIDCProvidersPage. // // Lists every configured OIDC identity provider in the tenant. Each // row shows id, name, issuer URL, client_id, and a deep-link to the // provider detail page. // // Render-time permission gating: // - Page itself requires auth.oidc.list; non-holders see an // ErrorState directing them to ask an admin. // - "Configure provider" button is HIDDEN unless the caller holds // auth.oidc.create (server-side enforcement is still load-bearing). // // data-testid attributes flag every interactive element so the future // E2E suite can assert behaviour without brittle CSS selectors. Same // pattern as Bundle 1's RolesPage. // ============================================================================= interface CreateProviderModalProps { isOpen: boolean; onClose: () => void; onSuccess: () => void; } // Audit 2026-05-11 A-3 — validateEmailDomain mirrors the backend // validator at internal/auth/oidc/domain/types.go (CRIT-5 closure). // Rejects entries containing `@` / whitespace / `*` / mixed-case, and // empties. Returns "" on success; a non-empty string on failure (used // directly as the inline error message). The server is still the // source of truth; this is the fast-feedback layer. export function validateEmailDomain(input: string): string { if (!input) return 'Empty entry'; if (input !== input.trim()) return 'Leading or trailing whitespace'; if (input !== input.toLowerCase()) return 'Must be all lowercase'; if (input.includes('@')) return 'Entries are domains, not email addresses — drop the "@" and the local part'; if (input.includes(' ') || /\s/.test(input)) return 'No whitespace'; if (input.includes('*')) return 'No wildcards — list each subdomain explicitly'; if (!input.includes('.')) return 'Must be a fully-qualified domain (e.g. acme.com)'; return ''; } function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModalProps) { const [form, setForm] = useState({ name: '', issuer_url: '', client_id: '', client_secret: '', redirect_uri: '', groups_claim_path: 'groups', groups_claim_format: 'string-array', fetch_userinfo: false, scopes: ['openid', 'profile', 'email'], allowed_email_domains: [], iat_window_seconds: 300, jwks_cache_ttl_seconds: 3600, }); // Audit 2026-05-11 A-3 — chip-input scratch state for the // allowed_email_domains tenant-isolation gate. Operators add domains // one at a time; each goes through validateEmailDomain before being // appended to form.allowed_email_domains. const [emailDomainInput, setEmailDomainInput] = useState(''); const [emailDomainErr, setEmailDomainErr] = useState(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); if (!isOpen) return null; const update = (k: K, v: OIDCProviderRequest[K]) => { setForm(prev => ({ ...prev, [k]: v })); setDirty(true); }; const addEmailDomain = () => { const trimmed = emailDomainInput.trim().toLowerCase(); setEmailDomainErr(null); const v = validateEmailDomain(trimmed); if (v !== '') { setEmailDomainErr(v); return; } const current = form.allowed_email_domains || []; if (current.includes(trimmed)) { setEmailDomainErr('Already in the list'); return; } update('allowed_email_domains', [...current, trimmed]); setEmailDomainInput(''); }; const removeEmailDomain = (d: string) => { update( 'allowed_email_domains', (form.allowed_email_domains || []).filter(x => x !== d), ); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!form.name.trim() || !form.issuer_url.trim() || !form.client_id.trim() || !form.client_secret) return; setSubmitting(true); setError(null); try { await createOIDCProvider(form); setDirty(false); onSuccess(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }; const handleClose = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; setDirty(false); setError(null); setEmailDomainInput(''); setEmailDomainErr(null); onClose(); }; return (
e.stopPropagation()} data-testid="create-oidc-provider-modal" >

Configure OIDC provider

{error && (
{error}
)}
update('name', e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" required data-testid="oidc-provider-name-input" />
update('issuer_url', e.target.value)} placeholder="https://idp.example.com/realm/main" className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" required data-testid="oidc-provider-issuer-url-input" />
update('client_id', e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" required data-testid="oidc-provider-client-id-input" />
update('client_secret', e.target.value)} className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" required data-testid="oidc-provider-client-secret-input" />
update('redirect_uri', e.target.value)} placeholder="https://certctl.example.com/auth/oidc/callback" className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" required data-testid="oidc-provider-redirect-uri-input" />
update('groups_claim_path', 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-groups-claim-path-input" />
{/* Audit 2026-05-11 A-3 — Allowed email domains chip control. When the list is non-empty, only users whose email-domain matches one of these entries can complete OIDC login. For multi-tenant IdPs (Auth0, Azure AD common endpoint, Google Workspace) this is the only thing preventing cross-tenant logins; the CRIT-5 backend gate is load-bearing but the GUI never exposed it until this fix. */}

When non-empty, only users whose email domain exactly matches one of these entries can log in. Subdomains are NOT auto-accepted — list each one explicitly. Empty list means any domain. Case-insensitive exact match.

{(form.allowed_email_domains || []).length > 0 && (
{(form.allowed_email_domains || []).map(d => ( {d} ))}
)}
{ setEmailDomainInput(e.target.value); if (emailDomainErr) setEmailDomainErr(null); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addEmailDomain(); } }} placeholder="acme.com" className="flex-1 px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" data-testid="oidc-create-allowed-email-domains-input" />
{emailDomainErr && (

{emailDomainErr}

)}
{/* Audit 2026-05-11 Fix 09 — Test Connection panel (MED-5 GUI half). Dry-run the issuer URL + JWKS reachability + alg-downgrade defense against MED-5's POST /api/v1/auth/oidc/test. Renders inline so the operator sees the result before committing. */}
); } export default function OIDCProvidersPage() { const { hasPerm } = useAuthMe(); const queryClient = useQueryClient(); const [showCreate, setShowCreate] = useState(false); const canList = hasPerm('auth.oidc.list'); const canCreate = hasPerm('auth.oidc.create'); const { data, isLoading, error } = useQuery({ queryKey: ['oidc-providers'], queryFn: listOIDCProviders, enabled: canList, }); if (!canList) { return (
); } return (
setShowCreate(true)} className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700" data-testid="oidc-providers-create-button" > Configure provider ) } /> {isLoading && (
Loading providers…
)} {error && } {data && data.providers.length === 0 && (

No OIDC providers configured.{' '} {canCreate ? 'Click "Configure provider" to add one.' : 'Ask an administrator to configure one.'}

)} {data && data.providers.length > 0 && (
{data.providers.map((p: OIDCProvider) => ( ))}
Name Issuer URL Client ID Created
{p.name} {p.issuer_url} {p.client_id} {p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
)} setShowCreate(false)} onSuccess={() => { setShowCreate(false); queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); }} />
); }