Add governed encryption administration

This commit is contained in:
2026-08-04 01:27:52 +02:00
parent 42f35f8d00
commit ba92d8bf32
15 changed files with 1472 additions and 4 deletions
+409
View File
@@ -0,0 +1,409 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowRightLeft,
Check,
Plus,
RefreshCw,
RotateCw,
ShieldAlert,
ShieldOff,
Trash2,
X
} from "lucide-react";
import {
AdminPageLayout,
Button,
Card,
DataGrid,
Dialog,
FormField,
MetricCard,
StatusBadge,
TableActionGroup,
hasScope,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
changeVaultKey,
createVault,
decideRecovery,
listEnvelopes,
listMigrations,
listRecoveries,
listVaults,
loadDisablePreflight,
reconcileMigration,
reconcileVault,
requestMigration,
requestRecovery,
rotateVault,
type DisablePreflight,
type EnvelopeSummary,
type MigrationSummary,
type RecoverySummary,
type VaultCreatePayload,
type VaultSummary
} from "../api/encryption";
type Props = { settings: ApiSettings; auth: AuthInfo };
type LifecycleAction = "rotate" | "revoke" | "destruction";
type LifecycleDraft = {
action: LifecycleAction;
vault: VaultSummary;
reason: string;
policyRef: string;
assuranceRef: string;
effectiveAt: string;
};
type MigrationDraft = {
envelope: EnvelopeSummary;
targetVaultId: string;
mode: "rewrap" | "reencrypt" | "decrypt" | "export" | "destroy";
policyRef: string;
assuranceRef: string;
};
type RecoveryDecisionDraft = {
recovery: RecoverySummary;
decision: "approve" | "reject";
reason: string;
assuranceRef: string;
};
const EMPTY_VAULT: VaultCreatePayload = {
vault_id: "",
name: "",
provider_id: "local_aesgcm",
purpose: "feature-content",
algorithm_suite: "AES-256-GCM",
scope_type: "tenant",
scope_id: null,
policy_ref: "",
recovery_quorum: 2,
profile_kind: "server_envelope",
idempotency_key: ""
};
export default function EncryptionAdminPanel({ settings, auth }: Props) {
const canAdmin = hasScope(auth, "encryption:vault:admin");
const canRecover = hasScope(auth, "encryption:recovery:approve");
const [vaults, setVaults] = useState<VaultSummary[]>([]);
const [envelopes, setEnvelopes] = useState<EnvelopeSummary[]>([]);
const [migrations, setMigrations] = useState<MigrationSummary[]>([]);
const [recoveries, setRecoveries] = useState<RecoverySummary[]>([]);
const [preflight, setPreflight] = useState<DisablePreflight | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [creating, setCreating] = useState(false);
const [vaultDraft, setVaultDraft] = useState<VaultCreatePayload>(EMPTY_VAULT);
const [lifecycle, setLifecycle] = useState<LifecycleDraft | null>(null);
const [migration, setMigration] = useState<MigrationDraft | null>(null);
const [requestingRecovery, setRequestingRecovery] = useState(false);
const [recoveryDraft, setRecoveryDraft] = useState({ vaultId: "", reason: "", requestedScope: "vault-status-and-rewrap", policyRef: "", assuranceRef: "", expiresAt: futureLocalTime(24) });
const [recoveryDecision, setRecoveryDecision] = useState<RecoveryDecisionDraft | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextVaults, nextEnvelopes, nextMigrations, nextRecoveries, nextPreflight] = await Promise.all([
listVaults(settings),
canAdmin ? listEnvelopes(settings) : Promise.resolve([]),
canAdmin ? listMigrations(settings) : Promise.resolve([]),
listRecoveries(settings),
canAdmin ? loadDisablePreflight(settings) : Promise.resolve(null)
]);
setVaults(nextVaults);
setEnvelopes(nextEnvelopes);
setMigrations(nextMigrations);
setRecoveries(nextRecoveries);
setPreflight(nextPreflight);
} catch (caught) {
setError(errorMessage(caught));
} finally {
setLoading(false);
}
}, [canAdmin, settings]);
useEffect(() => {
void load();
}, [load]);
async function perform(operation: () => Promise<unknown>, message: string): Promise<boolean> {
if (busy) return false;
setBusy(true);
setError("");
setSuccess("");
try {
await operation();
setSuccess(message);
await load();
return true;
} catch (caught) {
setError(errorMessage(caught));
return false;
} finally {
setBusy(false);
}
}
const vaultColumns = useMemo<DataGridColumn<VaultSummary>[]>(() => [
{ id: "name", header: "Vault", width: 190, sortable: true, filterable: true, render: (row) => <><strong>{row.name}</strong><div className="muted small-note">{row.vault_id}</div></>, value: (row) => `${row.name} ${row.vault_id}` },
{ id: "provider", header: "Provider", width: 145, sortable: true, filterable: true, render: (row) => row.provider_id, value: (row) => row.provider_id },
{ id: "profile", header: "Profile", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.profile_kind), value: (row) => row.profile_kind },
{ id: "scope", header: "Scope", width: 155, sortable: true, filterable: true, render: (row) => `${humanize(row.scope_type)}${row.scope_id ? `: ${row.scope_id}` : ""}`, value: (row) => `${row.scope_type}:${row.scope_id ?? ""}` },
{ id: "key", header: "Current key", width: 145, sortable: true, filterable: true, render: (row) => row.current_key_version ? `v${row.current_key_version} · ${humanize(row.current_key_state ?? "unknown")}` : "Not provisioned", value: (row) => row.current_key_version ?? 0 },
{ id: "state", header: "State", width: 125, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "revision", header: "Revision", width: 95, sortable: true, filterable: true, filterType: "integer", render: (row) => row.revision, value: (row) => row.revision },
{
id: "actions", header: "Actions", width: 210, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={4} actions={[
{ id: "reconcile", label: "Reconcile provider state", icon: <RefreshCw aria-hidden="true" />, disabled: !canAdmin, onClick: () => void perform(() => reconcileVault(settings, row.vault_id), `Vault ${row.name} was reconciled.`) },
{ id: "rotate", label: "Rotate key", icon: <RotateCw aria-hidden="true" />, disabled: !canAdmin || row.state !== "active", onClick: () => openLifecycle("rotate", row) },
{ id: "revoke", label: "Revoke current key", icon: <ShieldOff aria-hidden="true" />, variant: "danger", disabled: !canAdmin || !row.current_key_version, onClick: () => openLifecycle("revoke", row) },
{ id: "destroy", label: "Schedule key destruction", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: !canAdmin || !row.current_key_version, onClick: () => openLifecycle("destruction", row) }
]} />
}
], [canAdmin, settings]);
const envelopeColumns = useMemo<DataGridColumn<EnvelopeSummary>[]>(() => [
{ id: "owner", header: "Owner", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.owner_module), value: (row) => row.owner_module },
{ id: "resource", header: "Resource", width: 260, sortable: true, filterable: true, render: (row) => <><strong>{row.resource_type}</strong><div className="muted small-note">{row.resource_id}</div></>, value: (row) => `${row.resource_type} ${row.resource_id}` },
{ id: "vault", header: "Vault / key", width: 180, sortable: true, filterable: true, render: (row) => `${row.vault_id} · v${row.key_version}`, value: (row) => `${row.vault_id}:${row.key_version}` },
{ id: "profile", header: "Profile", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.profile_kind), value: (row) => row.profile_kind },
{ id: "state", header: "State", width: 135, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "migrate", label: "Prepare migration", icon: <ArrowRightLeft aria-hidden="true" />, disabled: row.state !== "active", onClick: () => openMigration(row) }]} /> }
], [vaults]);
const migrationColumns = useMemo<DataGridColumn<MigrationSummary>[]>(() => [
{ id: "source", header: "Source envelope", width: 245, sortable: true, filterable: true, render: (row) => row.source_envelope_id, value: (row) => row.source_envelope_id },
{ id: "mode", header: "Mode", width: 125, sortable: true, filterable: true, render: (row) => humanize(row.mode), value: (row) => row.mode },
{ id: "target", header: "Target", width: 190, sortable: true, filterable: true, render: (row) => `${row.target_vault_id} · v${row.target_key_version}`, value: (row) => `${row.target_vault_id}:${row.target_key_version}` },
{ id: "state", header: "State", width: 135, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "evidence", header: "Evidence", width: 100, sortable: true, filterable: true, filterType: "integer", render: (row) => row.evidence_refs.length, value: (row) => row.evidence_refs.length },
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "reconcile", label: "Reconcile migration state", icon: <RefreshCw aria-hidden="true" />, onClick: () => void perform(() => reconcileMigration(settings, row.migration_id), "Migration state was reconciled.") }]} /> }
], [settings]);
const recoveryColumns = useMemo<DataGridColumn<RecoverySummary>[]>(() => [
{ id: "vault", header: "Vault", width: 175, sortable: true, filterable: true, render: (row) => row.vault_id, value: (row) => row.vault_id },
{ id: "scope", header: "Requested scope", width: 220, sortable: true, filterable: true, render: (row) => humanize(row.requested_scope), value: (row) => row.requested_scope },
{ id: "requester", header: "Requester", width: 180, sortable: true, filterable: true, render: (row) => row.requester_account_id, value: (row) => row.requester_account_id },
{ id: "quorum", header: "Quorum", width: 120, sortable: true, filterable: true, render: (row) => `${row.approvals}/${row.quorum}`, value: (row) => row.approvals },
{ id: "state", header: "State", width: 125, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "expiry", header: "Expires", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at },
{ id: "actions", header: "Actions", width: 120, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={2} actions={[
{ id: "approve", label: "Approve recovery", icon: <Check aria-hidden="true" />, variant: "primary", disabled: !canRecover || row.state !== "pending", onClick: () => openRecoveryDecision(row, "approve") },
{ id: "reject", label: "Reject recovery", icon: <X aria-hidden="true" />, variant: "danger", disabled: !canRecover || row.state !== "pending", onClick: () => openRecoveryDecision(row, "reject") }
]} /> }
], [canRecover]);
function openLifecycle(action: LifecycleAction, vault: VaultSummary) {
setLifecycle({ action, vault, reason: "", policyRef: vault.policy_ref, assuranceRef: "", effectiveAt: futureLocalTime(24) });
}
function openMigration(envelope: EnvelopeSummary) {
const target = vaults.find((vault) => vault.state === "active" && vault.current_key_version);
setMigration({ envelope, targetVaultId: target?.vault_id ?? "", mode: "rewrap", policyRef: "", assuranceRef: "" });
}
function openRecoveryDecision(recovery: RecoverySummary, decision: "approve" | "reject") {
setRecoveryDecision({ recovery, decision, reason: "", assuranceRef: "" });
}
async function submitVault() {
if (await perform(() => createVault(settings, { ...vaultDraft, idempotency_key: crypto.randomUUID() }), "The encryption vault was created.")) {
setCreating(false);
setVaultDraft(EMPTY_VAULT);
}
}
async function submitLifecycle() {
if (!lifecycle || !lifecycle.vault.current_key_version) return;
const common = { expected_revision: lifecycle.vault.revision, reason: lifecycle.reason.trim(), policy_decision_ref: lifecycle.policyRef.trim(), assurance_evidence_ref: lifecycle.assuranceRef.trim(), idempotency_key: crypto.randomUUID() };
const operation = lifecycle.action === "rotate"
? () => rotateVault(settings, lifecycle.vault.vault_id, common)
: () => changeVaultKey(settings, lifecycle.vault.vault_id, lifecycle.action, { ...common, key_version: lifecycle.vault.current_key_version!, effective_at: lifecycle.action === "destruction" ? new Date(lifecycle.effectiveAt).toISOString() : null });
if (await perform(operation, `Vault key ${humanize(lifecycle.action)} was recorded.`)) {
setLifecycle(null);
}
}
async function submitMigration() {
if (!migration) return;
const target = vaults.find((vault) => vault.vault_id === migration.targetVaultId);
if (!target?.current_key_version || !target.algorithm_suite) return;
if (await perform(() => requestMigration(settings, {
envelope_id: migration.envelope.envelope_id,
target_provider_id: target.provider_id,
target_vault_id: target.vault_id,
target_key_version: target.current_key_version!,
target_algorithm_suite: target.algorithm_suite!,
mode: migration.mode,
policy_decision_ref: migration.policyRef.trim(),
assurance_evidence_ref: migration.assuranceRef.trim(),
idempotency_key: crypto.randomUUID()
}), "The migration was authorized. The owning module must perform and confirm the content operation.")) {
setMigration(null);
}
}
async function submitRecovery() {
if (await perform(() => requestRecovery(settings, {
vault_id: recoveryDraft.vaultId,
reason: recoveryDraft.reason.trim(),
requested_scope: recoveryDraft.requestedScope.trim(),
policy_decision_ref: recoveryDraft.policyRef.trim(),
assurance_evidence_ref: recoveryDraft.assuranceRef.trim(),
idempotency_key: crypto.randomUUID(),
expires_at: new Date(recoveryDraft.expiresAt).toISOString()
}), "The recovery ceremony was requested. It releases no key material and changes no resource ownership.")) {
setRequestingRecovery(false);
}
}
async function submitRecoveryDecision() {
if (!recoveryDecision) return;
if (await perform(() => decideRecovery(settings, recoveryDecision.recovery.recovery_id, {
decision: recoveryDecision.decision,
reason: recoveryDecision.reason.trim(),
assurance_evidence_ref: recoveryDecision.assuranceRef.trim(),
expected_revision: recoveryDecision.recovery.revision,
idempotency_key: crypto.randomUUID()
}), `The recovery decision was recorded as ${recoveryDecision.decision}.`)) {
setRecoveryDecision(null);
}
}
const unresolved = preflight?.unresolved_count ?? envelopes.filter((item) => !["migrated", "decrypted", "exported", "destroyed"].includes(item.state)).length;
return <AdminPageLayout
title="Encryption"
description="Govern vault metadata, protection migrations, recovery quorum, and disable readiness without exposing cryptographic material."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={busy}><RefreshCw aria-hidden="true" /> Reload</Button>{canAdmin && <Button variant="primary" onClick={() => setCreating(true)} disabled={busy}><Plus aria-hidden="true" /> Add vault</Button>}</>}>
<div className="metric-grid compact">
<MetricCard label="Vaults" value={vaults.length} tone="info" />
<MetricCard label="Protected envelopes" value={preflight?.protected_count ?? envelopes.length} tone="info" />
<MetricCard label="Unresolved before disable" value={unresolved} tone={unresolved ? "warning" : "good"} />
<MetricCard label="Pending recoveries" value={recoveries.filter((item) => item.state === "pending").length} tone={recoveries.some((item) => item.state === "pending") ? "warning" : "good"} />
</div>
<Card title="Key vaults" collapsible collapseKey="encryption-vaults">
<p className="muted small-note">The operator view contains lifecycle metadata only. Provider key references and key material are intentionally excluded.</p>
<div className="encryption-table"><DataGrid id="encryption-vaults" rows={vaults} columns={vaultColumns} initialFit="container" getRowKey={(row) => row.vault_id} emptyText="No encryption vaults are configured." /></div>
</Card>
{canAdmin && <>
<Card title="Protection envelopes" collapsible collapseKey="encryption-envelopes">
<p className="muted small-note">Encryption tracks protection state; the owning module retains content authorization, retention, and migration execution.</p>
<div className="encryption-table"><DataGrid id="encryption-envelopes" rows={envelopes} columns={envelopeColumns} initialFit="container" getRowKey={(row) => row.envelope_id} emptyText="No protection envelopes were registered." /></div>
</Card>
<Card title="Protection migrations" collapsible collapseKey="encryption-migrations">
<p className="muted small-note">Rewrap and re-encryption are two-phase operations. A request is not success until the owning module records evidence for the durable content change.</p>
<div className="encryption-table"><DataGrid id="encryption-migrations" rows={migrations} columns={migrationColumns} initialFit="container" getRowKey={(row) => row.migration_id} emptyText="No protection migrations were requested." /></div>
</Card>
<Card title="Disable preflight" collapsible collapseKey="encryption-disable-preflight">
<div className={`encryption-preflight ${preflight?.allowed ? "is-ready" : "is-blocked"}`}>
<ShieldAlert aria-hidden="true" />
<div><strong>{preflight?.allowed ? "Encryption can be disabled" : "Encryption cannot be disabled"}</strong><p>{preflight?.allowed ? "Every registered envelope has a terminal, evidenced disposition." : `${unresolved} envelope(s) still require migration, authorized decryption, explicit export, or cryptographic destruction.`}</p></div>
</div>
{preflight?.blocking_envelope_refs.length ? <ul className="encryption-blockers">{preflight.blocking_envelope_refs.map((item) => <li key={item}>{item}</li>)}</ul> : null}
</Card>
</>}
<Card title="Recovery ceremonies" actions={canRecover ? <Button variant="primary" onClick={() => { setRecoveryDraft((value) => ({ ...value, vaultId: value.vaultId || vaults[0]?.vault_id || "" })); setRequestingRecovery(true); }} disabled={!vaults.length || busy}><Plus aria-hidden="true" /> Request recovery</Button> : undefined}>
<p className="muted small-note">Recovery requires recent high assurance and distinct custodians. The requester cannot approve; quorum authorization neither releases keys nor transfers ownership.</p>
<div className="encryption-table"><DataGrid id="encryption-recoveries" rows={recoveries} columns={recoveryColumns} initialFit="container" getRowKey={(row) => row.recovery_id} emptyText="No recovery ceremonies were requested." /></div>
</Card>
<Dialog open={creating} title="Add encryption vault" onClose={() => !busy && setCreating(false)} closeDisabled={busy} footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitVault()} disabled={busy || !vaultDraft.vault_id.trim() || !vaultDraft.name.trim() || !vaultDraft.policy_ref.trim()}>Create vault</Button></>}>
<div className="encryption-form-grid">
<FormField label="Vault ID"><input value={vaultDraft.vault_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, vault_id: event.target.value })} /></FormField>
<FormField label="Name"><input value={vaultDraft.name} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, name: event.target.value })} /></FormField>
<FormField label="Provider"><input value={vaultDraft.provider_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, provider_id: event.target.value })} /></FormField>
<FormField label="Protection profile"><select value={vaultDraft.profile_kind} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, profile_kind: event.target.value as VaultCreatePayload["profile_kind"] })}><option value="server_envelope">Server envelope</option><option value="tenant_held">Tenant-held</option><option value="end_to_end">End-to-end metadata only</option></select></FormField>
<FormField label="Algorithm suite"><input value={vaultDraft.algorithm_suite} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, algorithm_suite: event.target.value })} /></FormField>
<FormField label="Recovery quorum"><input type="number" min={1} max={32} value={vaultDraft.recovery_quorum} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, recovery_quorum: Number(event.target.value) })} /></FormField>
<FormField label="Purpose"><input value={vaultDraft.purpose} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, purpose: event.target.value })} /></FormField>
<FormField label="Policy reference"><input value={vaultDraft.policy_ref} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, policy_ref: event.target.value })} /></FormField>
</div>
<p className="muted small-note">Selecting end-to-end records a profile label only. It does not install or certify a client E2EE protocol.</p>
</Dialog>
<Dialog open={Boolean(lifecycle)} title={`${humanize(lifecycle?.action ?? "key")} vault key`} onClose={() => !busy && setLifecycle(null)} closeDisabled={busy} footer={<><Button onClick={() => setLifecycle(null)} disabled={busy}>Cancel</Button><Button variant={lifecycle?.action === "rotate" ? "primary" : "danger"} onClick={() => void submitLifecycle()} disabled={busy || !lifecycle?.reason.trim() || !lifecycle?.policyRef.trim() || !lifecycle?.assuranceRef.trim()}>Confirm {lifecycle?.action}</Button></>}>
{lifecycle && <>
<p>{lifecycleText(lifecycle.action)}</p>
<div className="encryption-form-grid">
<FormField label="Reason"><textarea rows={3} value={lifecycle.reason} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, reason: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={lifecycle.policyRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={lifecycle.assuranceRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, assuranceRef: event.target.value })} /></FormField>
{lifecycle.action === "destruction" && <FormField label="Destruction effective at"><input type="datetime-local" value={lifecycle.effectiveAt} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, effectiveAt: event.target.value })} /></FormField>}
</div>
</>}
</Dialog>
<Dialog open={Boolean(migration)} title="Prepare protection migration" onClose={() => !busy && setMigration(null)} closeDisabled={busy} footer={<><Button onClick={() => setMigration(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitMigration()} disabled={busy || !migration?.targetVaultId || !migration?.policyRef.trim() || !migration?.assuranceRef.trim()}>Authorize migration</Button></>}>
{migration && <>
<p>This authorizes a two-phase content operation. The owning module must durably update or dispose of its content and record evidence before the migration can succeed.</p>
<div className="encryption-form-grid">
<FormField label="Source envelope"><input value={migration.envelope.envelope_id} disabled /></FormField>
<FormField label="Mode"><select value={migration.mode} disabled={busy} onChange={(event) => setMigration({ ...migration, mode: event.target.value as MigrationDraft["mode"] })}><option value="rewrap">Rewrap</option><option value="reencrypt">Re-encrypt</option><option value="decrypt">Decrypt</option><option value="export">Export</option><option value="destroy">Destroy</option></select></FormField>
<FormField label="Target vault"><select value={migration.targetVaultId} disabled={busy} onChange={(event) => setMigration({ ...migration, targetVaultId: event.target.value })}>{vaults.filter((vault) => vault.current_key_version && vault.state === "active").map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name} · v{vault.current_key_version}</option>)}</select></FormField>
<FormField label="Policy decision reference"><input value={migration.policyRef} disabled={busy} onChange={(event) => setMigration({ ...migration, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={migration.assuranceRef} disabled={busy} onChange={(event) => setMigration({ ...migration, assuranceRef: event.target.value })} /></FormField>
</div>
</>}
</Dialog>
<Dialog open={requestingRecovery} title="Request recovery ceremony" onClose={() => !busy && setRequestingRecovery(false)} closeDisabled={busy} footer={<><Button onClick={() => setRequestingRecovery(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitRecovery()} disabled={busy || !recoveryDraft.vaultId || !recoveryDraft.reason.trim() || !recoveryDraft.requestedScope.trim() || !recoveryDraft.policyRef.trim() || !recoveryDraft.assuranceRef.trim()}>Request recovery</Button></>}>
<p>The request expires automatically and needs the vault's configured number of distinct custodians. You cannot approve your own request.</p>
<div className="encryption-form-grid">
<FormField label="Vault"><select value={recoveryDraft.vaultId} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, vaultId: event.target.value })}>{vaults.map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name}</option>)}</select></FormField>
<FormField label="Requested scope"><input value={recoveryDraft.requestedScope} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, requestedScope: event.target.value })} /></FormField>
<FormField label="Reason"><textarea rows={3} value={recoveryDraft.reason} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, reason: event.target.value })} /></FormField>
<FormField label="Expires at"><input type="datetime-local" value={recoveryDraft.expiresAt} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, expiresAt: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={recoveryDraft.policyRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={recoveryDraft.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, assuranceRef: event.target.value })} /></FormField>
</div>
</Dialog>
<Dialog open={Boolean(recoveryDecision)} title={`${humanize(recoveryDecision?.decision ?? "decide")} recovery`} onClose={() => !busy && setRecoveryDecision(null)} closeDisabled={busy} footer={<><Button onClick={() => setRecoveryDecision(null)} disabled={busy}>Cancel</Button><Button variant={recoveryDecision?.decision === "reject" ? "danger" : "primary"} onClick={() => void submitRecoveryDecision()} disabled={busy || !recoveryDecision?.reason.trim() || !recoveryDecision?.assuranceRef.trim()}>Record {recoveryDecision?.decision}</Button></>}>
{recoveryDecision && <>
<p>{recoveryDecision.decision === "approve" ? "Approval counts only if you are a distinct, high-assurance custodian. Quorum authorization still releases no key material." : "One rejection terminates this recovery ceremony. A new recovery needs a new governed request."}</p>
<FormField label="Reason"><textarea rows={4} value={recoveryDecision.reason} disabled={busy} onChange={(event) => setRecoveryDecision({ ...recoveryDecision, reason: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={recoveryDecision.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDecision({ ...recoveryDecision, assuranceRef: event.target.value })} /></FormField>
</>}
</Dialog>
</AdminPageLayout>;
}
function lifecycleText(action: LifecycleAction): string {
if (action === "rotate") return "Rotation creates a new current key version. Existing envelopes remain bound to their recorded key version until explicitly migrated.";
if (action === "revoke") return "Revocation blocks future provider use. It cannot recall plaintext or key material already obtained, and protected content may become unavailable.";
return "Destruction is irreversible once the provider performs it. Any content still bound to this key becomes permanently unreadable unless it was migrated first.";
}
function futureLocalTime(hours: number): string {
const value = new Date(Date.now() + hours * 60 * 60 * 1000);
value.setMinutes(value.getMinutes() - value.getTimezoneOffset());
return value.toISOString().slice(0, 16);
}
function humanize(value: string): string {
return value.replace(/[_-]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function formatDateTime(value?: string | null): string {
if (!value) return "-";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}