mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 14:28:52 +00:00
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 in551812b. 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 d85114f; 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 (shippedd85114f). 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:
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { authBootstrapAvailable } from '../../api/client';
|
||||
import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client';
|
||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
|
||||
@@ -27,6 +27,15 @@ export default function AuthSettingsPage() {
|
||||
staleTime: 60_000,
|
||||
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 (
|
||||
<div className="space-y-4" data-testid="auth-settings-page">
|
||||
@@ -121,6 +130,39 @@ export default function AuthSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,14 +188,30 @@ function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) {
|
||||
const [roleID, setRoleID] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!roleID) return;
|
||||
if (scopeType !== 'global' && !scopeID.trim()) {
|
||||
setError(`scope_id is required when scope_type is ${scopeType}`);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
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();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -232,6 +248,49 @@ function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) {
|
||||
</option>
|
||||
))}
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -30,6 +30,20 @@ import ErrorState from '../../components/ErrorState';
|
||||
// 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() {
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
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);
|
||||
setActionError(null);
|
||||
try {
|
||||
await authAddRolePermission(role.id, { permission: perm });
|
||||
await authAddRolePermission(role.id, { permission: perm, ...(scope ?? {}) });
|
||||
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
@@ -132,14 +149,29 @@ export default function RoleDetailPage() {
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleDelete}
|
||||
disabled={submitting}
|
||||
data-testid="role-delete-button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
// Audit 2026-05-10 LOW-11 closure — hide Delete on
|
||||
// default roles. The backend already rejects deletion of
|
||||
// default roles (DELETE returns 409 with
|
||||
// 'cannot delete default role'); this is pure UX so
|
||||
// operators don't click a button that's destined to fail.
|
||||
DEFAULT_ROLE_IDS.has(role.id) ? (
|
||||
<span
|
||||
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>
|
||||
}
|
||||
@@ -166,24 +198,10 @@ export default function RoleDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && availablePerms.length > 0 && (
|
||||
<select
|
||||
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
|
||||
defaultValue=""
|
||||
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>
|
||||
<AddPermissionForm
|
||||
availablePerms={availablePerms.map((p) => p.name)}
|
||||
onSubmit={(perm, scope) => void handleAddPermission(perm, scope)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{permissions.length === 0 ? (
|
||||
@@ -339,3 +357,71 @@ function EditRoleModal({ roleId, initialName, initialDescription, onClose, onSuc
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user