feat: add governed ownership workflows and admin tree navigation

This commit is contained in:
2026-07-30 17:42:05 +02:00
parent 9b88ae388b
commit f0898fcdee
17 changed files with 2857 additions and 1 deletions

142
webui/src/api/ownership.ts Normal file
View File

@@ -0,0 +1,142 @@
import { apiFetch } from "./client";
import type { ApiSettings } from "../types";
export type OwnershipSubject = {
type: string;
id: string;
};
export type OwnershipResource = {
module_id: string;
resource_type: string;
resource_id: string;
};
export type OwnershipDecision = {
sequence: number;
action: string;
actor_type?: string | null;
actor_id?: string | null;
decided_at: string;
status: string;
details: Record<string, unknown>;
};
export type OwnershipTransfer = {
id: string;
tenant_id: string;
resource_module: string;
resource_type: string;
resource_id: string;
kind:
| "owner_initiated"
| "target_requested"
| "administrative_recovery";
status: string;
current_owner: OwnershipSubject;
target_owner: OwnershipSubject;
initiated_by: OwnershipSubject;
owner_approved_by?: OwnershipSubject | null;
target_accepted_by?: OwnershipSubject | null;
reason?: string | null;
assurance_profile?: string | null;
required_approvals: number;
approvals: Array<Record<string, unknown>>;
decisions: OwnershipDecision[];
expires_at: string;
execute_after?: string | null;
completed_at?: string | null;
declined_at?: string | null;
cancelled_at?: string | null;
expired_at?: string | null;
revision: number;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OwnershipTransferStart = {
resource: OwnershipResource;
target_owner: OwnershipSubject;
idempotency_key: string;
reason?: string | null;
expiry_days?: number | null;
};
export type OwnershipRequestStart = {
resource: OwnershipResource;
target_owner?: OwnershipSubject | null;
idempotency_key: string;
reason?: string | null;
expiry_days?: number | null;
};
export async function listOwnershipTransfers(
settings: ApiSettings,
filters: {
resourceType?: string;
resourceId?: string;
status?: string;
limit?: number;
} = {}
): Promise<OwnershipTransfer[]> {
const query = new URLSearchParams();
if (filters.resourceType) {
query.set("resource_type", filters.resourceType);
}
if (filters.resourceId) query.set("resource_id", filters.resourceId);
if (filters.status) query.set("status", filters.status);
if (filters.limit) query.set("limit", String(filters.limit));
const suffix = query.toString();
const result = await apiFetch<{ transfers: OwnershipTransfer[] }>(
settings,
`/api/v1/ownership/transfers${suffix ? `?${suffix}` : ""}`
);
return result.transfers;
}
export function startOwnershipTransfer(
settings: ApiSettings,
payload: OwnershipTransferStart
): Promise<OwnershipTransfer> {
return apiFetch<OwnershipTransfer>(
settings,
"/api/v1/ownership/transfers",
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function requestResourceOwnership(
settings: ApiSettings,
payload: OwnershipRequestStart
): Promise<OwnershipTransfer> {
return apiFetch<OwnershipTransfer>(
settings,
"/api/v1/ownership/transfers/requests",
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export function decideOwnershipTransfer(
settings: ApiSettings,
transferId: string,
action:
| "owner-approval"
| "acceptance"
| "decline"
| "cancel"
| "recovery-approval"
| "recovery-execution"
): Promise<OwnershipTransfer> {
return apiFetch<OwnershipTransfer>(
settings,
`/api/v1/ownership/transfers/${transferId}/${action}`,
{ method: "POST" }
);
}

View File

@@ -183,4 +183,24 @@ export { default as IconRail } from "./layout/IconRail";
export { default as LanguageMenu } from "./layout/LanguageMenu";
export { default as ModuleSubnav } from "./layout/ModuleSubnav";
export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav";
export { default as TreeSubnav } from "./layout/TreeSubnav";
export type {
TreeSubnavBranch,
TreeSubnavItem,
TreeSubnavNode
} from "./layout/TreeSubnav";
export {
decideOwnershipTransfer,
listOwnershipTransfers,
requestResourceOwnership,
startOwnershipTransfer
} from "./api/ownership";
export type {
OwnershipDecision,
OwnershipRequestStart,
OwnershipResource,
OwnershipSubject,
OwnershipTransfer,
OwnershipTransferStart
} from "./api/ownership";
export { default as Titlebar } from "./layout/Titlebar";

View File

@@ -0,0 +1,162 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type TreeSubnavItem<T extends string> = {
id: T;
label: string;
subtle?: boolean;
};
export type TreeSubnavBranch<T extends string> = {
branchId: string;
label: string;
children: TreeSubnavNode<T>[];
defaultExpanded?: boolean;
};
export type TreeSubnavNode<T extends string> =
| TreeSubnavItem<T>
| TreeSubnavBranch<T>;
function isBranch<T extends string>(
node: TreeSubnavNode<T>
): node is TreeSubnavBranch<T> {
return "branchId" in node;
}
function branchIdsContaining<T extends string>(
nodes: TreeSubnavNode<T>[],
active: T
): string[] {
const result: string[] = [];
for (const node of nodes) {
if (!isBranch(node)) continue;
const nested = branchIdsContaining(node.children, active);
if (
nested.length > 0 ||
node.children.some((child) => !isBranch(child) && child.id === active)
) {
result.push(node.branchId, ...nested);
}
}
return result;
}
function defaultExpandedBranchIds<T extends string>(
nodes: TreeSubnavNode<T>[]
): string[] {
return nodes.flatMap((node) => {
if (!isBranch(node)) return [];
return [
...(node.defaultExpanded === false ? [] : [node.branchId]),
...defaultExpandedBranchIds(node.children)
];
});
}
export default function TreeSubnav<T extends string>({
active,
nodes,
onSelect,
ariaLabel,
className = ""
}: {
active: T;
nodes: TreeSubnavNode<T>[];
onSelect: (section: T) => void;
ariaLabel: string;
className?: string;
}) {
const { translateText } = usePlatformLanguage();
const initialExpanded = useMemo(
() => defaultExpandedBranchIds(nodes),
[nodes]
);
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(initialExpanded)
);
useEffect(() => {
const activeBranchIds = branchIdsContaining(nodes, active);
setExpanded((current) => {
if (activeBranchIds.every((branchId) => current.has(branchId))) {
return current;
}
const next = new Set(current);
activeBranchIds.forEach((branchId) => next.add(branchId));
return next;
});
}, [active, nodes]);
function toggleBranch(branchId: string) {
setExpanded((current) => {
const next = new Set(current);
if (next.has(branchId)) next.delete(branchId);
else next.add(branchId);
return next;
});
}
function renderNodes(
currentNodes: TreeSubnavNode<T>[],
depth: number
) {
return currentNodes.map((node) => {
const depthStyle = { "--tree-depth": depth } as CSSProperties;
if (isBranch(node)) {
const isExpanded = expanded.has(node.branchId);
const containsActive = branchIdsContaining([node], active).length > 0;
return (
<div
className={`tree-subnav-branch${containsActive ? " contains-active" : ""}`}
key={node.branchId}
>
<button
type="button"
className="tree-subnav-disclosure"
style={depthStyle}
aria-expanded={isExpanded}
onClick={() => toggleBranch(node.branchId)}
>
{isExpanded ? (
<ChevronDown aria-hidden="true" size={16} />
) : (
<ChevronRight aria-hidden="true" size={16} />
)}
<span>{translateText(node.label)}</span>
</button>
{isExpanded && (
<div className="tree-subnav-children">
{renderNodes(node.children, depth + 1)}
</div>
)}
</div>
);
}
return (
<button
type="button"
key={node.id}
className={`tree-subnav-link${node.subtle ? " subtle" : ""}${
active === node.id ? " active" : ""
}`}
style={depthStyle}
aria-current={active === node.id ? "page" : undefined}
onClick={() => onSelect(node.id)}
>
{translateText(node.label)}
</button>
);
});
}
return (
<aside
className={`section-sidebar tree-subnav ${className}`.trim()}
aria-label={translateText(ariaLabel)}
>
{renderNodes(nodes, 0)}
</aside>
);
}

View File

@@ -73,6 +73,48 @@
.section-link:hover, .section-link.active { background: var(--sidebar-hover-bg); color: var(--text-hover-strong); }
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
.section-link.subtle { font-size: 13px; }
.tree-subnav { padding: 10px 0; }
.tree-subnav-disclosure,
.tree-subnav-link {
width: calc(100% + 3px);
min-height: 38px;
box-sizing: border-box;
border: 0;
margin-left: -3px;
padding: 7px 10px 7px calc(12px + (var(--tree-depth, 0) * 14px));
background: transparent;
color: var(--text-subtle);
font: inherit;
text-align: left;
cursor: pointer;
}
.tree-subnav-disclosure {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-strong);
font-size: 13px;
font-weight: 700;
}
.tree-subnav-disclosure svg { flex: 0 0 auto; }
.tree-subnav-link {
padding-left: calc(34px + (var(--tree-depth, 0) * 14px));
font-size: 13px;
}
.tree-subnav-disclosure:hover,
.tree-subnav-link:hover,
.tree-subnav-link.active {
background: var(--sidebar-hover-bg);
color: var(--text-hover-strong);
}
.tree-subnav-link.active {
border-left: 3px solid var(--accent);
font-weight: 700;
}
.tree-subnav-link.subtle { color: var(--muted); }
.tree-subnav-branch.contains-active > .tree-subnav-disclosure {
color: var(--text-strong);
}
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
.page-scroll-viewport { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: auto; }
.ui-no-sticky-section-sidebars .app-content { overflow: auto; }

View File

@@ -283,6 +283,12 @@ export type AdminSectionRenderContext = PlatformRouteContext & {
export type AdminSectionContribution = {
id: string;
label: string;
/** Owning module, used to group module-specific administration. */
moduleId?: string;
/** Distinguishes operational management pages from configurable settings. */
kind?: "management" | "settings";
/** Optional parent section for future embedded or nested settings surfaces. */
parentId?: string;
group?: AdminSectionGroup;
order?: number;
anyOf?: string[];