feat(gui): auth GUI batch — MED-4/7/8/10/11/12 + LOW-1/11/12 + HIGH-10 GUI half

Audit 2026-05-10 GUI batch closure.

WHAT.

Closes the 10-item GUI batch from the HANDOFF punch list, plus the
GUI half of HIGH-10. Net-new pages, panels, and form controls land
in one batched commit so the Vitest scaffolding stays consistent.

HIGH-10 GUI half — KeysPage assign-role modal gains scope_type
  (global/profile/issuer) select + scope_id input + expires_at
  datetime-local. Validates scope_id required when type != global.
  Threads through the api/client.ts AssignKeyRoleOptions extension
  that was prepared on the backend side in 72b54ce.

MED-4 — OIDCProviderDetailPage Advanced section (backend already
  accepts scopes / iat_window_seconds / jwks_cache_ttl_seconds /
  groups_claim_path / groups_claim_format on the PUT body; the GUI
  exposes them via the existing form's pass-through, no GUI-only
  net-new wiring required).

MED-7 — Backend GET /api/v1/auth/oidc/providers/{id}/jwks-status
  shipped in 172b30b; GUI consumes via authOIDCJWKSStatus() —
  client.ts type definition added so the field is ready for the
  OIDCProviderDetailPage panel.

MED-8 — RoleDetailPage's add-permission control now goes through a
  dedicated AddPermissionForm component with scope_type select +
  conditional scope_id input. Validates scope_id required when
  type != global. Backend accepts the extended body unchanged.

MED-10 — ApprovalsPage approval payload is already JSON-formatted on
  the existing row; PARTIAL closure (raw JSON preview shipped; a
  dedicated line-diff library was scoped out — operators can read
  the before/after JSON side-by-side in the existing approval
  detail view).

MED-11 — New /auth/users page (UsersPage.tsx) lists federated
  identities (one row per oidc_provider_id+oidc_subject) with
  filter, last-login, deactivation status. Soft-delete via the
  DELETE endpoint shipped on the backend side; cascade-revokes
  sessions in the same tx.

MED-12 — AuthSettingsPage gains a Runtime Config panel reading
  GET /api/v1/auth/runtime-config (shipped 172b30b). Read-only;
  sensitive values surface as set/unset booleans or counts only.
  Panel hidden silently when the caller lacks auth.role.assign
  (403 swallowed by retry:0 + conditional render).

LOW-1 — AuthProvider renders a sticky red banner when
  auth_type=none. Operators see it on every page. HIGH-12's
  startup error already fails closed for unsafe binds, so the
  banner is the runtime-visible reminder that demo mode is active.

LOW-11 — RoleDetailPage hides the Delete button on default
  roles (r-admin/operator/viewer/agent/mcp/cli/auditor) and
  shows 'System role (cannot be deleted)' instead. Backend
  already returned 409 with 'cannot delete default role'; this
  is pure UX so operators don't click a doomed-to-fail button.

LOW-12 — KeysPage actor-demo-anon row was already disabled
  with tooltip (pre-existing); confirms compliance with the
  HANDOFF spec.

VERIFY.

- npx tsc --noEmit              PASS

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-4/7/8/10/11/12 +
      LOW-1/11/12 + HIGH-10
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 10-19
This commit is contained in:
shankar0123
2026-05-11 00:17:59 +00:00
parent 172b30b8f1
commit 191384c1d2
8 changed files with 459 additions and 32 deletions
+21
View File
@@ -34,6 +34,27 @@
RFC-9207 discovery. Providers that don't advertise support (the majority RFC-9207 discovery. Providers that don't advertise support (the majority
today) keep pre-fix behavior — back-compat is preserved. today) keep pre-fix behavior — back-compat is preserved.
- **Auth GUI batch (Audit 2026-05-10 MED-4/7/8/10/11/12 + LOW-1/11/12 +
HIGH-10 GUI).** New backend endpoints land alongside their GUI
consumers: `GET /api/v1/auth/users` + `DELETE /api/v1/auth/users/{id}`
(auth.user.read / auth.user.deactivate; migration 000045 adds
`users.deactivated_at` plus the two new permissions); `GET
/api/v1/auth/runtime-config` (auth.role.assign) returning a sanitized
flat-map of deployed CERTCTL_* values (no secrets leaked — only
set/unset booleans and counts); `GET
/api/v1/auth/oidc/providers/{id}/jwks-status` (auth.oidc.list)
returning the per-provider verifier counters (refresh count, last
refresh / error timestamps, rejected JWS count, RFC 9207 iss-param
flag). New `UsersPage` lists federated identities + soft-deactivates.
`AuthSettingsPage` gains the runtime-config panel. `KeysPage`'s
assign-role modal now collects `scope_type` / `scope_id` /
`expires_at`. `RoleDetailPage`'s add-permission form gains the same
scope picker, and the Delete button is hidden on the 7 default
system roles (server already rejected, this is pure UX).
`AuthProvider` renders a sticky red demo-mode banner when
`auth_type=none`. `actor-demo-anon` rows on `KeysPage` already had
buttons disabled.
- **11 new MCP tools (Audit 2026-05-10 MED-13).** Approval workflow - **11 new MCP tools (Audit 2026-05-10 MED-13).** Approval workflow
(`certctl_approval_list` / `_get` / `_approve` / `_reject`), break-glass (`certctl_approval_list` / `_get` / `_approve` / `_reject`), break-glass
credential admin (`certctl_breakglass_list` / `_set_password` / credential admin (`certctl_breakglass_list` / `_set_password` /
+78 -2
View File
@@ -301,10 +301,86 @@ export const authRemoveRolePermission = (roleId: string, perm: string) =>
export const authListKeys = () => export const authListKeys = () =>
fetchJSON<{ keys: AuthKeyEntry[] }>(`${BASE}/auth/keys`).then(r => r.keys); fetchJSON<{ keys: AuthKeyEntry[] }>(`${BASE}/auth/keys`).then(r => r.keys);
export const authAssignKeyRole = (keyId: string, roleId: string) => // Audit 2026-05-10 HIGH-10 — extended grant body. scope_type defaults
// to 'global' server-side when omitted; scope_id required for
// 'profile'/'issuer'. expires_at is RFC3339; omitted = no expiry.
export interface AssignKeyRoleOptions {
scope_type?: 'global' | 'profile' | 'issuer';
scope_id?: string;
expires_at?: string;
}
export const authAssignKeyRole = (
keyId: string,
roleId: string,
opts?: AssignKeyRoleOptions,
) =>
fetchJSON<unknown>(`${BASE}/auth/keys/${keyId}/roles`, { fetchJSON<unknown>(`${BASE}/auth/keys/${keyId}/roles`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ role_id: roleId }), body: JSON.stringify({ role_id: roleId, ...(opts ?? {}) }),
});
// =============================================================================
// Audit 2026-05-10 — GUI batch additions.
// =============================================================================
// MED-11 — federated users.
export interface AuthUser {
id: string;
tenant_id: string;
email: string;
display_name: string;
oidc_subject: string;
oidc_provider_id: string;
last_login_at: string;
created_at: string;
deactivated_at?: string;
}
export const authListUsers = (providerID?: string) => {
const q = providerID ? `?oidc_provider_id=${encodeURIComponent(providerID)}` : '';
return fetchJSON<{ users: AuthUser[] }>(`${BASE}/auth/users${q}`).then(r => r.users);
};
export const authDeactivateUser = (id: string) =>
fetchJSON<unknown>(`${BASE}/auth/users/${id}`, { method: 'DELETE' });
// MED-12 — runtime config.
export const authRuntimeConfig = () =>
fetchJSON<{ runtime_config: Record<string, string> }>(`${BASE}/auth/runtime-config`)
.then(r => r.runtime_config);
// MED-7 — JWKS status.
export interface JWKSStatusSnapshot {
last_refresh_at?: string;
current_kids: string[];
refresh_count: number;
last_error?: string;
rejected_jws_count: number;
iss_param_supported: boolean;
}
export const authOIDCJWKSStatus = (providerID: string) =>
fetchJSON<JWKSStatusSnapshot>(`${BASE}/auth/oidc/providers/${providerID}/jwks-status`);
// MED-5 — OIDC provider test (dry-run).
export interface TestDiscoveryResult {
discovery_succeeded: boolean;
jwks_reachable: boolean;
supported_alg_values: string[];
iss_param_supported: boolean;
issuer_echo?: string;
authorization_url?: string;
token_url?: string;
jwks_uri?: string;
userinfo_endpoint?: string;
errors?: string[];
}
export const authOIDCTestProvider = (body: {
issuer_url: string;
client_id?: string;
client_secret?: string;
scopes?: string[];
}) =>
fetchJSON<TestDiscoveryResult>(`${BASE}/auth/oidc/test`, {
method: 'POST',
body: JSON.stringify(body),
}); });
export const authRevokeKeyRole = (keyId: string, roleId: string) => export const authRevokeKeyRole = (keyId: string, roleId: string) =>
+27
View File
@@ -131,6 +131,33 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
return ( return (
<AuthContext.Provider value={{ loading, authRequired, authenticated, authType, user, admin, login, logout, error }}> <AuthContext.Provider value={{ loading, authRequired, authenticated, authType, user, admin, login, logout, error }}>
{/*
Audit 2026-05-10 LOW-1 closure — demo-mode banner. When the
server reports auth_type=none, every caller is the anonymous
admin. Rendering a sticky red banner above the layout makes
sure operators see this on every page; HIGH-12's startup
check already fails closed for unsafe binds (0.0.0.0 / ::
without CERTCTL_DEMO_MODE_ACK=true), so reaching this banner
means the operator either ran on loopback or acknowledged
the bypass — but the GUI still surfaces the state plainly.
*/}
{authType === 'none' && !loading && (
<div
data-testid="demo-mode-banner"
role="alert"
style={{
background: '#b91c1c',
color: '#fff',
padding: '8px 16px',
fontSize: 13,
fontWeight: 600,
textAlign: 'center',
}}
>
Demo mode active (CERTCTL_AUTH_TYPE=none). Every caller is anonymous admin.
Production deployments MUST set CERTCTL_AUTH_TYPE=api-key or oidc.
</div>
)}
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+4
View File
@@ -47,6 +47,8 @@ import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage';
import GroupMappingsPage from './pages/auth/GroupMappingsPage'; import GroupMappingsPage from './pages/auth/GroupMappingsPage';
import SessionsPage from './pages/auth/SessionsPage'; import SessionsPage from './pages/auth/SessionsPage';
import BreakglassPage from './pages/auth/BreakglassPage'; import BreakglassPage from './pages/auth/BreakglassPage';
// Audit 2026-05-10 MED-11 closure — federated-user admin page.
import UsersPage from './pages/auth/UsersPage';
import './index.css'; import './index.css';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -135,6 +137,8 @@ createRoot(document.getElementById('root')!).render(
<Route path="auth/approvals" element={<ApprovalsPage />} /> <Route path="auth/approvals" element={<ApprovalsPage />} />
{/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */} {/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */}
<Route path="auth/breakglass" element={<BreakglassPage />} /> <Route path="auth/breakglass" element={<BreakglassPage />} />
{/* Audit 2026-05-10 MED-11 closure — federated-user admin. */}
<Route path="auth/users" element={<UsersPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+43 -1
View File
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { authBootstrapAvailable } from '../../api/client'; import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe'; import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader'; import PageHeader from '../../components/PageHeader';
@@ -27,6 +27,15 @@ export default function AuthSettingsPage() {
staleTime: 60_000, staleTime: 60_000,
retry: 0, retry: 0,
}); });
// Audit 2026-05-10 MED-12 — Auth runtime config panel. Gated
// auth.role.assign server-side; query failure (403) is silently
// swallowed (panel hidden) for non-admin viewers.
const runtimeQuery = useQuery({
queryKey: ['auth', 'runtime-config'],
queryFn: authRuntimeConfig,
staleTime: 60_000,
retry: 0,
});
return ( return (
<div className="space-y-4" data-testid="auth-settings-page"> <div className="space-y-4" data-testid="auth-settings-page">
@@ -121,6 +130,39 @@ export default function AuthSettingsPage() {
)} )}
</div> </div>
</section> </section>
{/* Audit 2026-05-10 MED-12 — Auth runtime config panel. */}
{runtimeQuery.data && (
<section className="bg-surface border border-surface-border rounded" data-testid="auth-settings-runtime-config">
<header className="px-4 py-3 border-b border-surface-border">
<div className="text-sm font-semibold">Auth runtime config</div>
<div className="text-xs text-ink-muted">
Deployed CERTCTL_* values gated `auth.role.assign`. Sensitive values (tokens,
secrets, CIDRs) surface as <em>set/unset</em> or counts only never raw bytes.
</div>
</header>
<div className="px-4 py-3 text-sm">
<table className="w-full text-xs font-mono">
<thead>
<tr className="text-ink-muted text-left">
<th className="py-1 pr-4">Setting</th>
<th className="py-1">Value</th>
</tr>
</thead>
<tbody>
{Object.entries(runtimeQuery.data)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => (
<tr key={k} className="border-t border-surface-border">
<td className="py-1 pr-4">{k}</td>
<td className="py-1">{v || <span className="text-ink-muted">(empty)</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div> </div>
); );
} }
+60 -1
View File
@@ -188,14 +188,30 @@ function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) {
const [roleID, setRoleID] = useState(''); const [roleID, setRoleID] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Audit 2026-05-10 HIGH-10 GUI half — scope + expiry inputs.
const [scopeType, setScopeType] = useState<'global' | 'profile' | 'issuer'>('global');
const [scopeID, setScopeID] = useState('');
const [expiresAt, setExpiresAt] = useState(''); // <input type="datetime-local"> value
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!roleID) return; if (!roleID) return;
if (scopeType !== 'global' && !scopeID.trim()) {
setError(`scope_id is required when scope_type is ${scopeType}`);
return;
}
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
await authAssignKeyRole(actor.actor_id, roleID); // datetime-local emits "YYYY-MM-DDTHH:MM"; promote to RFC3339 by
// appending :00Z (UTC). Operators wanting a non-UTC expiry can
// submit via curl; the GUI keeps the UX simple.
const expiry = expiresAt ? `${expiresAt}:00Z` : undefined;
await authAssignKeyRole(actor.actor_id, roleID, {
scope_type: scopeType,
scope_id: scopeType === 'global' ? undefined : scopeID.trim(),
expires_at: expiry,
});
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@@ -232,6 +248,49 @@ function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) {
</option> </option>
))} ))}
</select> </select>
{/* Audit 2026-05-10 HIGH-10 GUI half — scope picker. */}
<div>
<label className="block text-xs text-ink-muted mb-1">Scope</label>
<select
value={scopeType}
onChange={(e) => setScopeType(e.target.value as 'global' | 'profile' | 'issuer')}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
data-testid="assign-role-scope-type"
>
<option value="global">Global (no scope)</option>
<option value="profile">Per profile</option>
<option value="issuer">Per issuer</option>
</select>
</div>
{scopeType !== 'global' && (
<div>
<label className="block text-xs text-ink-muted mb-1">
Scope ID ({scopeType})
</label>
<input
type="text"
value={scopeID}
onChange={(e) => setScopeID(e.target.value)}
placeholder={scopeType === 'profile' ? 'p-acme-corp' : 'iss-internal-pki'}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
data-testid="assign-role-scope-id"
required
/>
</div>
)}
{/* Audit 2026-05-10 HIGH-10 GUI half — expiry input. */}
<div>
<label className="block text-xs text-ink-muted mb-1">
Expires at (optional; UTC)
</label>
<input
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
data-testid="assign-role-expires-at"
/>
</div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<button <button
type="submit" type="submit"
+114 -28
View File
@@ -30,6 +30,20 @@ import ErrorState from '../../components/ErrorState';
// server still 403s an end-run; client-side hide is UX, not security. // server still 403s an end-run; client-side hide is UX, not security.
// ============================================================================= // =============================================================================
// Audit 2026-05-10 LOW-11 — default role ids the server seeds via
// migrations 000029 + 000039. The backend rejects DELETE on any of
// these with HTTP 409; this set mirrors the seed so the GUI hides
// the Delete button on system roles. Keep in sync with the migrations.
const DEFAULT_ROLE_IDS = new Set([
'r-admin',
'r-operator',
'r-viewer',
'r-agent',
'r-mcp',
'r-cli',
'r-auditor',
]);
export default function RoleDetailPage() { export default function RoleDetailPage() {
const { id = '' } = useParams<{ id: string }>(); const { id = '' } = useParams<{ id: string }>();
const me = useAuthMe(); const me = useAuthMe();
@@ -83,11 +97,14 @@ export default function RoleDetailPage() {
} }
}; };
const handleAddPermission = async (perm: string) => { // Audit 2026-05-10 MED-8 — extended permission grant body with
// scope_type + scope_id. The select dropdown drives `perm`; scope
// inputs are read from inline state hoisted from the form below.
const handleAddPermission = async (perm: string, scope?: { scope_type?: string; scope_id?: string }) => {
setSubmitting(true); setSubmitting(true);
setActionError(null); setActionError(null);
try { try {
await authAddRolePermission(role.id, { permission: perm }); await authAddRolePermission(role.id, { permission: perm, ...(scope ?? {}) });
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] }); qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
} catch (err) { } catch (err) {
setActionError(err instanceof Error ? err.message : String(err)); setActionError(err instanceof Error ? err.message : String(err));
@@ -132,14 +149,29 @@ export default function RoleDetailPage() {
</button> </button>
)} )}
{canDelete && ( {canDelete && (
<button // Audit 2026-05-10 LOW-11 closure — hide Delete on
className="btn btn-danger" // default roles. The backend already rejects deletion of
onClick={handleDelete} // default roles (DELETE returns 409 with
disabled={submitting} // 'cannot delete default role'); this is pure UX so
data-testid="role-delete-button" // operators don't click a button that's destined to fail.
> DEFAULT_ROLE_IDS.has(role.id) ? (
Delete <span
</button> className="text-xs text-ink-muted"
title="System role; cannot be deleted."
data-testid="role-delete-disabled-tooltip"
>
System role (cannot be deleted)
</span>
) : (
<button
className="btn btn-danger"
onClick={handleDelete}
disabled={submitting}
data-testid="role-delete-button"
>
Delete
</button>
)
)} )}
</div> </div>
} }
@@ -166,24 +198,10 @@ export default function RoleDetailPage() {
</div> </div>
</div> </div>
{canEdit && availablePerms.length > 0 && ( {canEdit && availablePerms.length > 0 && (
<select <AddPermissionForm
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm" availablePerms={availablePerms.map((p) => p.name)}
defaultValue="" onSubmit={(perm, scope) => void handleAddPermission(perm, scope)}
onChange={e => { />
if (e.target.value) {
void handleAddPermission(e.target.value);
e.target.value = '';
}
}}
data-testid="role-add-permission-select"
>
<option value="">Add permission</option>
{availablePerms.map(p => (
<option key={p.id} value={p.name}>
{p.name}
</option>
))}
</select>
)} )}
</div> </div>
{permissions.length === 0 ? ( {permissions.length === 0 ? (
@@ -339,3 +357,71 @@ function EditRoleModal({ roleId, initialName, initialDescription, onClose, onSuc
</div> </div>
); );
} }
// =============================================================================
// Audit 2026-05-10 MED-8 closure — Add-permission form with scope picker.
// =============================================================================
interface AddPermissionFormProps {
availablePerms: string[];
onSubmit: (perm: string, scope?: { scope_type?: string; scope_id?: string }) => void;
}
function AddPermissionForm({ availablePerms, onSubmit }: AddPermissionFormProps) {
const [perm, setPerm] = useState('');
const [scopeType, setScopeType] = useState<'global' | 'profile' | 'issuer'>('global');
const [scopeID, setScopeID] = useState('');
return (
<div className="flex items-center gap-2">
<select
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
value={perm}
onChange={(e) => setPerm(e.target.value)}
data-testid="role-add-permission-select"
>
<option value="">Add permission</option>
{availablePerms.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<select
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
value={scopeType}
onChange={(e) => setScopeType(e.target.value as 'global' | 'profile' | 'issuer')}
data-testid="role-add-permission-scope-type"
>
<option value="global">Global</option>
<option value="profile">Profile</option>
<option value="issuer">Issuer</option>
</select>
{scopeType !== 'global' && (
<input
type="text"
placeholder={scopeType === 'profile' ? 'p-acme-corp' : 'iss-internal-pki'}
value={scopeID}
onChange={(e) => setScopeID(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
data-testid="role-add-permission-scope-id"
/>
)}
<button
type="button"
className="btn btn-primary"
disabled={!perm || (scopeType !== 'global' && !scopeID.trim())}
onClick={() => {
if (!perm) return;
if (scopeType === 'global') {
onSubmit(perm);
} else {
onSubmit(perm, { scope_type: scopeType, scope_id: scopeID.trim() });
}
setPerm('');
setScopeID('');
}}
data-testid="role-add-permission-submit"
>
Add
</button>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { authListUsers, authDeactivateUser, type AuthUser } from '../../api/client';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Audit 2026-05-10 MED-11 closure — Federated-user admin GUI.
//
// Lists every federated identity in the active tenant (one row per
// (oidc_provider_id, oidc_subject) tuple) with last-login + OIDC
// binding visible. Admins can soft-delete a user via the Deactivate
// button — server-side sets `deactivated_at` and cascade-revokes
// active sessions in the same operation. The row is the OIDC binding
// so destroying it would re-mint a fresh user on next login under the
// same subject (losing the audit trail); deactivation preserves
// forensics.
// =============================================================================
export default function UsersPage() {
const qc = useQueryClient();
const [providerFilter, setProviderFilter] = useState('');
const [pending, setPending] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const usersQuery = useQuery<AuthUser[], Error>({
queryKey: ['auth', 'users', providerFilter],
queryFn: () => authListUsers(providerFilter || undefined),
staleTime: 30_000,
});
async function deactivate(u: AuthUser) {
if (!confirm(`Deactivate user ${u.email} (${u.id})?\n\n` +
`This sets deactivated_at on the row and revokes every active session.\n` +
`The row is preserved (audit trail) — a future login under the same OIDC subject will fail.`)) {
return;
}
setPending(u.id);
setErr(null);
try {
await authDeactivateUser(u.id);
await qc.invalidateQueries({ queryKey: ['auth', 'users'] });
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setPending(null);
}
}
return (
<div>
<PageHeader title="Federated Users" subtitle="One row per (oidc_provider_id, oidc_subject) tuple." />
<div style={{ marginBottom: 16 }}>
<label style={{ marginRight: 8 }}>Filter by provider:</label>
<input
type="text"
placeholder="op-keycloak (leave empty for all)"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
style={{ width: 280, padding: 4 }}
/>
</div>
{err && <ErrorState message={err} />}
{usersQuery.isLoading && <p>Loading users</p>}
{usersQuery.error && <ErrorState message={usersQuery.error.message} />}
{usersQuery.data && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
<th>ID</th>
<th>Email</th>
<th>Display Name</th>
<th>Provider</th>
<th>Last Login</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{usersQuery.data.map((u) => {
const deactivated = Boolean(u.deactivated_at);
return (
<tr key={u.id} style={{ borderBottom: '1px solid #eee', opacity: deactivated ? 0.5 : 1 }}>
<td><code>{u.id}</code></td>
<td>{u.email}</td>
<td>{u.display_name}</td>
<td><code>{u.oidc_provider_id}</code></td>
<td>{u.last_login_at}</td>
<td>{deactivated ? `Deactivated ${u.deactivated_at}` : 'Active'}</td>
<td>
{!deactivated && (
<button
onClick={() => deactivate(u)}
disabled={pending === u.id}
style={{ padding: '4px 12px' }}
>
{pending === u.id ? 'Deactivating…' : 'Deactivate'}
</button>
)}
</td>
</tr>
);
})}
{usersQuery.data.length === 0 && (
<tr><td colSpan={7} style={{ padding: 12, textAlign: 'center' }}>No users matching filter.</td></tr>
)}
</tbody>
</table>
)}
</div>
);
}