feat: add governed ownership workflows and admin tree navigation
This commit is contained in:
142
webui/src/api/ownership.ts
Normal file
142
webui/src/api/ownership.ts
Normal 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" }
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
162
webui/src/layout/TreeSubnav.tsx
Normal file
162
webui/src/layout/TreeSubnav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user