Add identity trust administration surfaces
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@govoplan/identity-trust-webui",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": { "types": "./src/index.ts", "import": "./src/index.ts" },
|
||||
"./styles/identity-trust.css": "./src/styles/identity-trust.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": { "optional": true }
|
||||
},
|
||||
"scripts": {
|
||||
"test:identity-trust-ui": "node tests/identity-trust-ui-structure.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiReferenceOptionProvider,
|
||||
type ApiSettings,
|
||||
type ReferenceOptionProvider
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type DeviceKey = {
|
||||
tenant_id: string;
|
||||
identity_id: string;
|
||||
account_id: string;
|
||||
device_id: string;
|
||||
key_id: string;
|
||||
algorithm: string;
|
||||
purpose: string;
|
||||
assurance_level: string;
|
||||
status: string;
|
||||
epoch: number;
|
||||
registered_at: string;
|
||||
attestation_ref?: string | null;
|
||||
expires_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
revocation_reason?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AssuranceEvidence = {
|
||||
id: string;
|
||||
account_id: string;
|
||||
device_key_id?: string | null;
|
||||
evidence_ref: string;
|
||||
assurance_level: string;
|
||||
provider_id: string;
|
||||
verified_at: string;
|
||||
expires_at: string;
|
||||
active: boolean;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type KeyEpoch = {
|
||||
tenant_id: string;
|
||||
subject_kind: string;
|
||||
subject_id: string;
|
||||
epoch: number;
|
||||
state: string;
|
||||
history_policy: string;
|
||||
effective_at: string;
|
||||
previous_epoch?: number | null;
|
||||
reason?: string | null;
|
||||
access_decision_ref?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type KeyAccessDecision = {
|
||||
id: string;
|
||||
decision_ref: string;
|
||||
account_id: string;
|
||||
device_key_id: string;
|
||||
subject_kind: string;
|
||||
subject_id: string;
|
||||
key_epoch: number;
|
||||
access_decision_ref: string;
|
||||
purpose: string;
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
resource_ref?: string | null;
|
||||
function_assignment_id?: string | null;
|
||||
delegation_id?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type EpochRotatePayload = {
|
||||
subject_kind: "identity" | "account" | "function" | "postbox" | "external_recipient";
|
||||
subject_id: string;
|
||||
reason: string;
|
||||
access_decision_ref: string;
|
||||
idempotency_key: string;
|
||||
history_policy: string;
|
||||
previous_epoch?: number | null;
|
||||
};
|
||||
|
||||
export async function listDeviceKeys(settings: ApiSettings, accountId: string, activeOnly = false): Promise<DeviceKey[]> {
|
||||
const params = new URLSearchParams({ account_id: accountId, active_only: String(activeOnly) });
|
||||
const response = await apiFetch<{ keys: DeviceKey[] }>(settings, `/api/v1/identity-trust/device-keys?${params}`);
|
||||
return response.keys;
|
||||
}
|
||||
|
||||
export async function revokeDeviceKey(settings: ApiSettings, keyId: string, expectedEpoch: number, reason: string): Promise<DeviceKey> {
|
||||
return apiFetch<DeviceKey>(settings, `/api/v1/identity-trust/device-keys/${encodeURIComponent(keyId)}/revoke`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_epoch: expectedEpoch, reason })
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAssuranceEvidence(settings: ApiSettings, accountId: string, activeOnly = false): Promise<AssuranceEvidence[]> {
|
||||
const params = new URLSearchParams({ account_id: accountId, active_only: String(activeOnly) });
|
||||
const response = await apiFetch<{ evidence: AssuranceEvidence[] }>(settings, `/api/v1/identity-trust/assurance/evidence?${params}`);
|
||||
return response.evidence;
|
||||
}
|
||||
|
||||
export async function listEpochs(settings: ApiSettings, subjectKind: string, subjectId: string): Promise<KeyEpoch[]> {
|
||||
const params = new URLSearchParams({ subject_kind: subjectKind, subject_id: subjectId });
|
||||
const response = await apiFetch<{ epochs: KeyEpoch[] }>(settings, `/api/v1/identity-trust/epochs?${params}`);
|
||||
return response.epochs;
|
||||
}
|
||||
|
||||
export async function rotateEpoch(settings: ApiSettings, payload: EpochRotatePayload): Promise<KeyEpoch> {
|
||||
return apiFetch<KeyEpoch>(settings, "/api/v1/identity-trust/epochs/rotate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listKeyAccessDecisions(settings: ApiSettings, accountId: string): Promise<KeyAccessDecision[]> {
|
||||
const params = new URLSearchParams({ account_id: accountId });
|
||||
const response = await apiFetch<{ decisions: KeyAccessDecision[] }>(settings, `/api/v1/identity-trust/key-access/decisions?${params}`);
|
||||
return response.decisions;
|
||||
}
|
||||
|
||||
export function identityTrustAccountProvider(settings: ApiSettings): ReferenceOptionProvider {
|
||||
return apiReferenceOptionProvider(settings, "/api/v1/identity-trust/account-options");
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Eye, RefreshCw, RotateCw, ShieldOff } from "lucide-react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
ReferenceSelect,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
identityTrustAccountProvider,
|
||||
listAssuranceEvidence,
|
||||
listDeviceKeys,
|
||||
listEpochs,
|
||||
listKeyAccessDecisions,
|
||||
revokeDeviceKey,
|
||||
rotateEpoch,
|
||||
type AssuranceEvidence,
|
||||
type DeviceKey,
|
||||
type KeyAccessDecision,
|
||||
type KeyEpoch
|
||||
} from "../api/identityTrust";
|
||||
|
||||
type IdentityTrustPanelProps = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
administrative?: boolean;
|
||||
};
|
||||
|
||||
type EpochDraft = {
|
||||
subjectKind: "identity" | "account" | "function" | "postbox" | "external_recipient";
|
||||
subjectId: string;
|
||||
reason: string;
|
||||
accessDecisionRef: string;
|
||||
};
|
||||
|
||||
const EMPTY_EPOCH_DRAFT: EpochDraft = {
|
||||
subjectKind: "postbox",
|
||||
subjectId: "",
|
||||
reason: "",
|
||||
accessDecisionRef: ""
|
||||
};
|
||||
|
||||
export default function IdentityTrustPanel({ settings, auth, administrative = false }: IdentityTrustPanelProps) {
|
||||
const ownAccountId = auth.principal?.account_id || auth.user.account_id;
|
||||
const canRevokeDevice = hasScope(auth, "identity_trust:device:write")
|
||||
|| hasScope(auth, "identity_trust:device:admin");
|
||||
const [accountId, setAccountId] = useState(ownAccountId);
|
||||
const [keys, setKeys] = useState<DeviceKey[]>([]);
|
||||
const [evidence, setEvidence] = useState<AssuranceEvidence[]>([]);
|
||||
const [decisions, setDecisions] = useState<KeyAccessDecision[]>([]);
|
||||
const [epochs, setEpochs] = useState<KeyEpoch[]>([]);
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [revoking, setRevoking] = useState<DeviceKey | null>(null);
|
||||
const [revocationReason, setRevocationReason] = useState("");
|
||||
const [selectedEvidence, setSelectedEvidence] = useState<AssuranceEvidence | null>(null);
|
||||
const [selectedDecision, setSelectedDecision] = useState<KeyAccessDecision | null>(null);
|
||||
const [epochDraft, setEpochDraft] = useState<EpochDraft>(EMPTY_EPOCH_DRAFT);
|
||||
const accountProvider = useMemo(() => identityTrustAccountProvider(settings), [settings]);
|
||||
|
||||
const loadAccount = useCallback(async () => {
|
||||
if (!accountId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextKeys, nextEvidence, nextDecisions] = await Promise.all([
|
||||
listDeviceKeys(settings, accountId, false),
|
||||
listAssuranceEvidence(settings, accountId, false),
|
||||
administrative ? listKeyAccessDecisions(settings, accountId) : Promise.resolve([])
|
||||
]);
|
||||
setKeys(nextKeys);
|
||||
setEvidence(nextEvidence);
|
||||
setDecisions(nextDecisions);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
setKeys([]);
|
||||
setEvidence([]);
|
||||
setDecisions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accountId, administrative, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccount();
|
||||
}, [loadAccount]);
|
||||
|
||||
const visibleKeys = showRevoked ? keys : keys.filter((key) => key.status === "active");
|
||||
const activeEvidence = evidence.filter((item) => item.active);
|
||||
const highestAssurance = activeEvidence
|
||||
.map((item) => item.assurance_level)
|
||||
.sort((left, right) => assuranceRank(right) - assuranceRank(left))[0] ?? "None";
|
||||
|
||||
const keyColumns = useMemo<DataGridColumn<DeviceKey>[]>(() => [
|
||||
{ id: "device", header: "Device", width: 180, sortable: true, filterable: true, render: (row) => row.device_id, value: (row) => row.device_id },
|
||||
{ id: "key", header: "Public key", width: 220, sortable: true, filterable: true, render: (row) => row.key_id, value: (row) => row.key_id },
|
||||
{ id: "purpose", header: "Purpose", width: 170, sortable: true, filterable: true, render: (row) => humanize(row.purpose), value: (row) => row.purpose },
|
||||
{ id: "algorithm", header: "Algorithm", width: 130, sortable: true, filterable: true, render: (row) => row.algorithm, value: (row) => row.algorithm },
|
||||
{ id: "assurance", header: "Assurance", width: 140, sortable: true, filterable: true, render: (row) => humanize(row.assurance_level), value: (row) => row.assurance_level },
|
||||
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
|
||||
{ id: "epoch", header: "Revision", width: 100, sortable: true, filterable: true, filterType: "integer", render: (row) => row.epoch, value: (row) => row.epoch },
|
||||
{ id: "expiry", header: "Expiry", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at ?? "" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 90,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "revoke",
|
||||
label: "Revoke device key",
|
||||
icon: <ShieldOff aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
applicable: row.status === "active",
|
||||
disabled: !canRevokeDevice,
|
||||
disabledReason: !canRevokeDevice
|
||||
? "Device-key write permission is required."
|
||||
: undefined,
|
||||
onClick: () => {
|
||||
setRevocationReason("");
|
||||
setRevoking(row);
|
||||
}
|
||||
}
|
||||
]} />
|
||||
}
|
||||
], [canRevokeDevice]);
|
||||
|
||||
const evidenceColumns = useMemo<DataGridColumn<AssuranceEvidence>[]>(() => [
|
||||
{ id: "level", header: "Level", width: 130, sortable: true, filterable: true, render: (row) => humanize(row.assurance_level), value: (row) => row.assurance_level },
|
||||
{ id: "provider", header: "Provider", width: 160, sortable: true, filterable: true, render: (row) => row.provider_id, value: (row) => row.provider_id },
|
||||
{ id: "evidence", header: "Evidence reference", width: 260, sortable: true, filterable: true, render: (row) => row.evidence_ref, value: (row) => row.evidence_ref },
|
||||
{ id: "device", header: "Device key", width: 190, sortable: true, filterable: true, render: (row) => row.device_key_id || "Any registered device", value: (row) => row.device_key_id ?? "" },
|
||||
{ id: "verified", header: "Verified", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.verified_at), value: (row) => row.verified_at },
|
||||
{ id: "expires", header: "Expires", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at },
|
||||
{ id: "state", header: "State", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.active ? "active" : "expired"} />, value: (row) => row.active ? "active" : "expired" },
|
||||
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "details", label: "View provenance", icon: <Eye aria-hidden="true" />, onClick: () => setSelectedEvidence(row) }]} /> }
|
||||
], []);
|
||||
|
||||
const decisionColumns = useMemo<DataGridColumn<KeyAccessDecision>[]>(() => [
|
||||
{ id: "time", header: "Recorded", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.created_at), value: (row) => row.created_at },
|
||||
{ id: "purpose", header: "Purpose", width: 220, sortable: true, filterable: true, render: (row) => row.purpose, value: (row) => row.purpose },
|
||||
{ id: "subject", header: "Subject", width: 230, sortable: true, filterable: true, render: (row) => `${humanize(row.subject_kind)}: ${row.subject_id}`, value: (row) => `${row.subject_kind}:${row.subject_id}` },
|
||||
{ id: "device", header: "Device key", width: 180, sortable: true, filterable: true, render: (row) => row.device_key_id, value: (row) => row.device_key_id },
|
||||
{ id: "decision", header: "Decision", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.allowed ? "allowed" : "denied"} />, value: (row) => row.allowed ? "allowed" : "denied" },
|
||||
{ id: "reason", header: "Reason", width: 320, render: (row) => row.reason, value: (row) => row.reason },
|
||||
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "details", label: "View decision provenance", icon: <Eye aria-hidden="true" />, onClick: () => setSelectedDecision(row) }]} /> }
|
||||
], []);
|
||||
|
||||
const epochColumns = useMemo<DataGridColumn<KeyEpoch>[]>(() => [
|
||||
{ id: "epoch", header: "Epoch", width: 90, sortable: true, filterable: true, filterType: "integer", render: (row) => row.epoch, value: (row) => row.epoch },
|
||||
{ id: "state", header: "State", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
|
||||
{ id: "history", header: "History access", width: 170, sortable: true, filterable: true, render: (row) => humanize(row.history_policy), value: (row) => row.history_policy },
|
||||
{ id: "effective", header: "Effective", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.effective_at), value: (row) => row.effective_at },
|
||||
{ id: "access", header: "Access decision", width: 260, render: (row) => row.access_decision_ref || "-", value: (row) => row.access_decision_ref ?? "" },
|
||||
{ id: "reason", header: "Reason", width: 320, render: (row) => row.reason || "-", value: (row) => row.reason ?? "" }
|
||||
], []);
|
||||
|
||||
async function applyRevoke() {
|
||||
if (!revoking || !revocationReason.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeDeviceKey(settings, revoking.key_id, revoking.epoch, revocationReason.trim());
|
||||
setRevoking(null);
|
||||
setSuccess("The device key was revoked. Existing plaintext or exported keys cannot be recalled.");
|
||||
await loadAccount();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEpochHistory() {
|
||||
if (!epochDraft.subjectId.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setEpochs(await listEpochs(settings, epochDraft.subjectKind, epochDraft.subjectId.trim()));
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
setEpochs([]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyEpochRotation() {
|
||||
if (!epochDraft.subjectId.trim() || !epochDraft.reason.trim() || !epochDraft.accessDecisionRef.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const current = epochs.find((epoch) => epoch.state === "active");
|
||||
await rotateEpoch(settings, {
|
||||
subject_kind: epochDraft.subjectKind,
|
||||
subject_id: epochDraft.subjectId.trim(),
|
||||
reason: epochDraft.reason.trim(),
|
||||
access_decision_ref: epochDraft.accessDecisionRef.trim(),
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
history_policy: "all_retained",
|
||||
previous_epoch: current?.epoch ?? null
|
||||
});
|
||||
setSuccess("The key epoch was rotated. Existing device copies and previously obtained plaintext cannot be revoked retroactively.");
|
||||
setEpochDraft((currentDraft) => ({ ...currentDraft, reason: "", accessDecisionRef: "" }));
|
||||
await loadEpochHistory();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const content = <>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||
|
||||
{administrative && <Card title="Account" compact>
|
||||
<div className="identity-trust-account-selector">
|
||||
<FormField label="Account">
|
||||
<ReferenceSelect
|
||||
value={accountId}
|
||||
provider={accountProvider}
|
||||
onChange={(value) => setAccountId(value)}
|
||||
createCustomOption={(value) => value.trim() ? { value: value.trim(), label: value.trim(), description: "Explicit account reference" } : null}
|
||||
placeholder="Select or enter an account"
|
||||
searchPlaceholder="Search accounts" />
|
||||
</FormField>
|
||||
<Button onClick={() => void loadAccount()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>
|
||||
</div>
|
||||
</Card>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading identity trust state">
|
||||
<div className="metric-grid compact">
|
||||
<MetricCard label="Active device keys" value={keys.filter((key) => key.status === "active").length} tone="good" />
|
||||
<MetricCard label="Revoked or expired" value={keys.filter((key) => key.status !== "active").length} tone="warning" />
|
||||
<MetricCard label="Active assurance evidence" value={activeEvidence.length} tone={activeEvidence.length ? "good" : "warning"} />
|
||||
<MetricCard label="Highest assurance" value={humanize(highestAssurance)} tone={highestAssurance === "None" ? "warning" : "info"} />
|
||||
</div>
|
||||
|
||||
<Card title="Device keys" actions={<ToggleSwitch label="Show revoked and expired" checked={showRevoked} onChange={setShowRevoked} />}>
|
||||
<p className="muted small-note">Only public key and trust metadata are stored. Revocation blocks future server-mediated use but cannot erase plaintext or key material already obtained by a device.</p>
|
||||
<div className="admin-table-surface"><DataGrid id={`identity-trust-device-keys-${administrative ? "admin" : "self"}`} rows={visibleKeys} columns={keyColumns} initialFit="container" getRowKey={(row) => row.key_id} emptyText="No device keys found." /></div>
|
||||
</Card>
|
||||
|
||||
<Card title="Assurance evidence">
|
||||
<p className="muted small-note">Evidence is bounded by provider, assurance level, device, verification time, and expiry. It does not grant resource access on its own.</p>
|
||||
<div className="admin-table-surface"><DataGrid id={`identity-trust-assurance-${administrative ? "admin" : "self"}`} rows={evidence} columns={evidenceColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No assurance evidence found." /></div>
|
||||
</Card>
|
||||
|
||||
{administrative && <>
|
||||
<Card title="Key epoch administration">
|
||||
<p className="muted small-note">Rotation supersedes the active epoch and retains history for newly authorized incumbents. It does not grant Access permission, recall exported material, or transfer private keys.</p>
|
||||
<div className="identity-trust-epoch-form">
|
||||
<FormField label="Subject type"><select value={epochDraft.subjectKind} disabled={busy} onChange={(event) => { setEpochDraft({ ...epochDraft, subjectKind: event.target.value as EpochDraft["subjectKind"] }); setEpochs([]); }}><option value="identity">Identity</option><option value="account">Account</option><option value="function">Function</option><option value="postbox">Postbox</option><option value="external_recipient">External recipient</option></select></FormField>
|
||||
<FormField label="Subject reference"><input value={epochDraft.subjectId} disabled={busy} onChange={(event) => { setEpochDraft({ ...epochDraft, subjectId: event.target.value }); setEpochs([]); }} /></FormField>
|
||||
<Button onClick={() => void loadEpochHistory()} disabled={busy || !epochDraft.subjectId.trim()}><RefreshCw aria-hidden="true" /> Load history</Button>
|
||||
<FormField label="History access"><input value="All retained history" disabled /></FormField>
|
||||
<FormField label="Authorizing Access decision"><input value={epochDraft.accessDecisionRef} disabled={busy} onChange={(event) => setEpochDraft({ ...epochDraft, accessDecisionRef: event.target.value })} /></FormField>
|
||||
<FormField label="Rotation reason"><input value={epochDraft.reason} disabled={busy} onChange={(event) => setEpochDraft({ ...epochDraft, reason: event.target.value })} /></FormField>
|
||||
<Button variant="danger" onClick={() => void applyEpochRotation()} disabled={busy || !epochDraft.subjectId.trim() || !epochDraft.accessDecisionRef.trim() || !epochDraft.reason.trim()}><RotateCw aria-hidden="true" /> Rotate epoch</Button>
|
||||
</div>
|
||||
<div className="admin-table-surface"><DataGrid id="identity-trust-epochs-admin" rows={epochs} columns={epochColumns} initialFit="container" getRowKey={(row) => `${row.subject_kind}:${row.subject_id}:${row.epoch}`} emptyText="Load a subject to inspect its epoch history." /></div>
|
||||
</Card>
|
||||
|
||||
<Card title="Key-access decisions">
|
||||
<p className="muted small-note">These immutable decisions combine an upstream Access decision with the acting account, current public device key, active epoch, purpose, and resource reference. No cryptographic material is returned by Identity Trust.</p>
|
||||
<div className="admin-table-surface"><DataGrid id="identity-trust-decisions-admin" rows={decisions} columns={decisionColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No key-access decisions found for this account." /></div>
|
||||
</Card>
|
||||
</>}
|
||||
</LoadingFrame>
|
||||
|
||||
<Dialog open={Boolean(revoking)} title="Revoke device key" onClose={() => !busy && setRevoking(null)} closeDisabled={busy} footer={<><Button onClick={() => setRevoking(null)} disabled={busy}>Cancel</Button><Button variant="danger" onClick={() => void applyRevoke()} disabled={busy || !revocationReason.trim()}>Revoke key</Button></>}>
|
||||
<p>Revoke <strong>{revoking?.key_id}</strong>? Future key-access decisions will reject this device. Plaintext, exports, and keys already obtained by the device cannot be recalled.</p>
|
||||
<FormField label="Reason"><textarea rows={4} value={revocationReason} disabled={busy} onChange={(event) => setRevocationReason(event.target.value)} /></FormField>
|
||||
</Dialog>
|
||||
|
||||
<ProvenanceDialog title="Assurance evidence provenance" value={selectedEvidence} onClose={() => setSelectedEvidence(null)} />
|
||||
<ProvenanceDialog title="Key-access decision provenance" value={selectedDecision} onClose={() => setSelectedDecision(null)} />
|
||||
</>;
|
||||
|
||||
if (administrative) {
|
||||
return <AdminPageLayout title="Identity trust" description="Inspect public device trust, assurance provenance, epoch history, and immutable key-access decisions." loading={false} error="" success="" actions={<Button onClick={() => void loadAccount()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>{content}</AdminPageLayout>;
|
||||
}
|
||||
return <div className="identity-trust-panel">{content}</div>;
|
||||
}
|
||||
|
||||
function ProvenanceDialog({ title, value, onClose }: { title: string; value: AssuranceEvidence | KeyAccessDecision | null; onClose: () => void }) {
|
||||
return <Dialog open={Boolean(value)} title={title} onClose={onClose} footer={<Button onClick={onClose}>Close</Button>}>
|
||||
{value && <div className="identity-trust-provenance">
|
||||
<dl>
|
||||
{"evidence_ref" in value && <><dt>Evidence reference</dt><dd>{value.evidence_ref}</dd><dt>Provider</dt><dd>{value.provider_id}</dd></>}
|
||||
{"decision_ref" in value && <><dt>Decision reference</dt><dd>{value.decision_ref}</dd><dt>Upstream Access decision</dt><dd>{value.access_decision_ref}</dd><dt>Resource</dt><dd>{value.resource_ref || "Not bound"}</dd></>}
|
||||
</dl>
|
||||
<pre>{JSON.stringify(value.provenance, null, 2)}</pre>
|
||||
</div>}
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
function assuranceRank(value: string): number {
|
||||
return ({ none: 0, software: 1, mfa: 2, hardware: 3, high: 4 } as Record<string, number>)[value.toLowerCase()] ?? 0;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
const en = {
|
||||
"i18n:govoplan-identity-trust.identity_trust": "Identity trust",
|
||||
"i18n:govoplan-identity-trust.device_trust": "Device trust",
|
||||
"i18n:govoplan-identity-trust.identity_trust_administration": "Identity trust administration",
|
||||
"i18n:govoplan-identity-trust.key_epochs": "Key epochs",
|
||||
"i18n:govoplan-identity-trust.key_access_decisions": "Key-access decisions"
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
"i18n:govoplan-identity-trust.identity_trust": "Identitaetsvertrauen",
|
||||
"i18n:govoplan-identity-trust.device_trust": "Geraetevertrauen",
|
||||
"i18n:govoplan-identity-trust.identity_trust_administration": "Administration des Identitaetsvertrauens",
|
||||
"i18n:govoplan-identity-trust.key_epochs": "Schluesselepochen",
|
||||
"i18n:govoplan-identity-trust.key_access_decisions": "Schluesselzugriffsentscheidungen"
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, identityTrustModule } from "./module";
|
||||
export * from "./api/identityTrust";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
AdminSectionsUiCapability,
|
||||
PlatformWebModule,
|
||||
SettingsSectionsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/identity-trust.css";
|
||||
|
||||
const IdentityTrustPanel = lazy(() => import("./features/IdentityTrustPanel"));
|
||||
|
||||
const settingsSections: SettingsSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "identity-trust",
|
||||
surfaceId: "identity_trust.settings.devices",
|
||||
label: "i18n:govoplan-identity-trust.device_trust",
|
||||
group: "account",
|
||||
order: 45,
|
||||
anyOf: ["identity_trust:device:read", "identity_trust:assurance:read"],
|
||||
render: ({ settings, auth }) => createElement(IdentityTrustPanel, { settings, auth })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "tenant-identity-trust",
|
||||
moduleId: "identity_trust",
|
||||
kind: "management",
|
||||
surfaceId: "identity_trust.admin.trust",
|
||||
label: "i18n:govoplan-identity-trust.identity_trust",
|
||||
group: "TENANT",
|
||||
order: 75,
|
||||
anyOf: ["identity_trust:device:admin"],
|
||||
render: ({ settings, auth }) => createElement(IdentityTrustPanel, { settings, auth, administrative: true })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const identityTrustModule: PlatformWebModule = {
|
||||
id: "identity_trust",
|
||||
label: "i18n:govoplan-identity-trust.identity_trust",
|
||||
version: "0.1.14",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["access", "audit", "policy", "encryption", "postbox"],
|
||||
translations: generatedTranslations,
|
||||
viewSurfaces: [
|
||||
{ id: "identity_trust.settings.devices", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.device_trust", order: 10 },
|
||||
{ id: "identity_trust.admin.trust", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.identity_trust_administration", order: 20 },
|
||||
{ id: "identity_trust.admin.epochs", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.key_epochs", parentId: "identity_trust.admin.trust", order: 30 },
|
||||
{ id: "identity_trust.admin.decisions", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.key_access_decisions", parentId: "identity_trust.admin.trust", order: 40 }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"settings.sections": settingsSections,
|
||||
"admin.sections": adminSections
|
||||
}
|
||||
};
|
||||
|
||||
export default identityTrustModule;
|
||||
@@ -0,0 +1,64 @@
|
||||
.identity-trust-panel,
|
||||
.identity-trust-panel > .loading-frame,
|
||||
.identity-trust-panel .loading-frame-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-trust-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.identity-trust-account-selector {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.identity-trust-epoch-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.identity-trust-panel .admin-table-surface {
|
||||
max-height: 380px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.identity-trust-provenance dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, auto) minmax(0, 1fr);
|
||||
gap: 8px 14px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.identity-trust-provenance dt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.identity-trust-provenance dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.identity-trust-provenance pre {
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line-subtle);
|
||||
background: var(--surface-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.identity-trust-account-selector,
|
||||
.identity-trust-epoch-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const moduleSource = readFileSync("src/module.ts", "utf8");
|
||||
const panel = readFileSync("src/features/IdentityTrustPanel.tsx", "utf8");
|
||||
const api = readFileSync("src/api/identityTrust.ts", "utf8");
|
||||
|
||||
assert.match(moduleSource, /"settings.sections": settingsSections/);
|
||||
assert.match(moduleSource, /"admin.sections": adminSections/);
|
||||
assert.match(moduleSource, /identity_trust\.settings\.devices/);
|
||||
assert.match(moduleSource, /identity_trust\.admin\.epochs/);
|
||||
assert.match(panel, /<ReferenceSelect/);
|
||||
assert.match(panel, /revoking\.epoch/);
|
||||
assert.match(panel, /disabled: !canRevokeDevice/);
|
||||
assert.match(panel, /identity_trust:device:write/);
|
||||
assert.match(panel, /cannot be recalled/);
|
||||
assert.match(panel, /listKeyAccessDecisions/);
|
||||
assert.match(panel, /rotateEpoch/);
|
||||
assert.doesNotMatch(panel, /public_jwk/);
|
||||
assert.match(api, /expected_epoch: expectedEpoch/);
|
||||
assert.match(api, /identity-trust\/account-options/);
|
||||
|
||||
console.log("Identity Trust user and administration UI structural contract passed.");
|
||||
Reference in New Issue
Block a user