Add identity trust administration surfaces

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent 0ffc8b6b0f
commit 9640ec29dd
16 changed files with 1143 additions and 10 deletions
+25
View File
@@ -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"
}
}
+123
View File
@@ -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");
}
+332
View File
@@ -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);
}
+19
View File
@@ -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 };
+2
View File
@@ -0,0 +1,2 @@
export { default, identityTrustModule } from "./module";
export * from "./api/identityTrust";
+61
View File
@@ -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;
+64
View File
@@ -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.");