feat(idm): govern delegation chains and timed escalation
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-08-22 03:12:30 +02:00
parent b0eda35195
commit 21e8f0bc39
21 changed files with 1931 additions and 95 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
+4
View File
@@ -259,6 +259,10 @@ export type FunctionAssignmentChange = {
workflow_instance_id?: string | null;
resulting_assignment_id?: string | null;
expires_at?: string | null;
review_deadline_at?: string | null;
escalated_at?: string | null;
escalation_from_state?: string | null;
escalation_target_function_id?: string | null;
outcome_reason?: string | null;
resource_revision: number;
etag: string;
@@ -94,7 +94,7 @@ function dateTimeValue(value: string): string | null {
function statusTone(state: string): string {
if (state === "applied") return "success";
if (["rejected", "expired", "withdrawn", "cancelled"].includes(state)) return "inactive";
if (["blocked", "failed_manual_review"].includes(state)) return "danger";
if (["blocked", "failed_manual_review", "escalated"].includes(state)) return "danger";
return "warning";
}
@@ -113,6 +113,8 @@ const DOMAIN_LABELS: Record<string, string> = {
cancelled: "i18n:govoplan-idm.state_cancelled",
blocked: "i18n:govoplan-idm.state_blocked",
failed_manual_review: "i18n:govoplan-idm.state_failed_manual_review",
escalated: "Escalated",
escalated_review: "Escalated review",
approve_holder: "i18n:govoplan-idm.step_approve_holder",
approve_authority: "i18n:govoplan-idm.step_approve_authority",
accept_recipient: "i18n:govoplan-idm.step_accept_recipient",
@@ -441,6 +443,10 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
<div><dt>Workflow revision</dt><dd>{selected.workflow_definition_revision ?? "-"}</dd></div>
<div><dt>Required decisions</dt><dd>{selected.required_steps.map(domainLabel).join(", ") || "None"}</dd></div>
<div><dt>Completed decisions</dt><dd>{selected.completed_steps.map(domainLabel).join(", ") || "None"}</dd></div>
{selected.review_deadline_at && <div><dt>Review deadline</dt><dd>{new Date(selected.review_deadline_at).toLocaleString(language)}</dd></div>}
{selected.escalated_at && <div><dt>Escalated</dt><dd>{new Date(selected.escalated_at).toLocaleString(language)}</dd></div>}
{selected.escalation_from_state && <div><dt>Escalated from</dt><dd>{domainLabel(selected.escalation_from_state)}</dd></div>}
{selected.escalation_target_function_id && <div><dt>Escalation target</dt><dd>{functionById.get(selected.escalation_target_function_id)?.name ?? selected.escalation_target_function_id}</dd></div>}
<div className="wide"><dt>Justification</dt><dd>{selected.justification}</dd></div>
{selected.outcome_reason && <div className="wide"><dt>Explanation</dt><dd>{selected.outcome_reason}</dd></div>}
</dl>
+105 -5
View File
@@ -72,6 +72,15 @@ type SettingsDraft = {
require_assignment_change_requests: boolean;
audit_detail_level: "summary" | "standard" | "full";
change_retention_days: string;
delegation_allowed: boolean;
maximum_delegation_depth: string;
maximum_delegated_validity_days: string;
holder_escalation_target: string;
holder_escalation_hours: string;
authority_escalation_target: string;
authority_escalation_hours: string;
recipient_escalation_target: string;
recipient_escalation_hours: string;
};
const EMPTY_MODEL: OrganizationModel = {
@@ -152,20 +161,66 @@ function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): Assignme
function settingsDraftFrom(item: IdmSettings | null): SettingsDraft {
const source = item ?? DEFAULT_IDM_SETTINGS;
const defaults = source.settings.function_assignment_governance_defaults;
const governance = defaults && typeof defaults === "object" && !Array.isArray(defaults)
? defaults as Record<string, unknown>
: {};
const escalationValue = governance.escalation;
const escalation = escalationValue && typeof escalationValue === "object" && !Array.isArray(escalationValue)
? escalationValue as Record<string, unknown>
: {};
const escalationRule = (step: string): Record<string, unknown> => {
const value = escalation[step];
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
};
const holder = escalationRule("holder");
const authority = escalationRule("authority");
const recipient = escalationRule("recipient");
return {
require_assignment_change_requests: source.require_assignment_change_requests,
audit_detail_level: source.audit_detail_level,
change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days)
change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days),
delegation_allowed: governance.delegation_allowed === true,
maximum_delegation_depth: governance.maximum_delegation_depth == null ? "1" : String(governance.maximum_delegation_depth),
maximum_delegated_validity_days: governance.maximum_delegated_validity_days == null ? "" : String(governance.maximum_delegated_validity_days),
holder_escalation_target: String(holder.target_function_id ?? ""),
holder_escalation_hours: holder.timeout_hours == null ? "" : String(holder.timeout_hours),
authority_escalation_target: String(authority.target_function_id ?? ""),
authority_escalation_hours: authority.timeout_hours == null ? "" : String(authority.timeout_hours),
recipient_escalation_target: String(recipient.target_function_id ?? ""),
recipient_escalation_hours: recipient.timeout_hours == null ? "" : String(recipient.timeout_hours)
};
}
function settingsPayload(draft: SettingsDraft, item: IdmSettings | null): Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings"> {
const trimmedDays = draft.change_retention_days.trim();
const sourceSettings = item?.settings ?? {};
const sourceDefaults = sourceSettings.function_assignment_governance_defaults;
const existingDefaults = sourceDefaults && typeof sourceDefaults === "object" && !Array.isArray(sourceDefaults)
? sourceDefaults as Record<string, unknown>
: {};
const escalation: Record<string, { target_function_id: string; timeout_hours: number }> = {};
for (const [step, target, hours] of [
["holder", draft.holder_escalation_target, draft.holder_escalation_hours],
["authority", draft.authority_escalation_target, draft.authority_escalation_hours],
["recipient", draft.recipient_escalation_target, draft.recipient_escalation_hours]
] as const) {
if (target && hours) escalation[step] = { target_function_id: target, timeout_hours: Number(hours) };
}
return {
require_assignment_change_requests: draft.require_assignment_change_requests,
audit_detail_level: draft.audit_detail_level,
change_retention_days: trimmedDays ? Number(trimmedDays) : null,
settings: item?.settings ?? {}
settings: {
...sourceSettings,
function_assignment_governance_defaults: {
...existingDefaults,
delegation_allowed: draft.delegation_allowed,
maximum_delegation_depth: Number(draft.maximum_delegation_depth || "1"),
maximum_delegated_validity_days: draft.maximum_delegated_validity_days ? Number(draft.maximum_delegated_validity_days) : null,
escalation
}
}
};
}
@@ -174,10 +229,26 @@ function isSettingsDirty(draft: SettingsDraft, item: IdmSettings | null): boolea
return (
draft.require_assignment_change_requests !== baseline.require_assignment_change_requests ||
draft.audit_detail_level !== baseline.audit_detail_level ||
draft.change_retention_days.trim() !== baseline.change_retention_days.trim()
draft.change_retention_days.trim() !== baseline.change_retention_days.trim() ||
draft.delegation_allowed !== baseline.delegation_allowed ||
draft.maximum_delegation_depth !== baseline.maximum_delegation_depth ||
draft.maximum_delegated_validity_days !== baseline.maximum_delegated_validity_days ||
draft.holder_escalation_target !== baseline.holder_escalation_target ||
draft.holder_escalation_hours !== baseline.holder_escalation_hours ||
draft.authority_escalation_target !== baseline.authority_escalation_target ||
draft.authority_escalation_hours !== baseline.authority_escalation_hours ||
draft.recipient_escalation_target !== baseline.recipient_escalation_target ||
draft.recipient_escalation_hours !== baseline.recipient_escalation_hours
);
}
function settingsDraftInvalid(draft: SettingsDraft): boolean {
const incompleteRule = (target: string, hours: string) => Boolean(target) !== Boolean(hours);
return incompleteRule(draft.holder_escalation_target, draft.holder_escalation_hours)
|| incompleteRule(draft.authority_escalation_target, draft.authority_escalation_hours)
|| incompleteRule(draft.recipient_escalation_target, draft.recipient_escalation_hours);
}
function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
@@ -710,12 +781,41 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
/>
</FormField>
<div className="wide"><h3>Delegation and timed escalation defaults</h3><p>Function-specific policy may tighten these tenant defaults. A timeout changes the review to a visible escalated state; it never approves automatically.</p></div>
<div className="idm-check-list wide">
<ToggleSwitch label="Allow governed delegation" checked={settingsDraft.delegation_allowed} disabled={!canManageSettings || busy} help={idmDisabledReason(false, busy, canManageSettings)} onChange={(delegation_allowed) => setSettingsDraft({ ...settingsDraft, delegation_allowed })} />
</div>
<FormField label="Maximum delegation-chain depth" documentation={IDM_FIELD_DOCUMENTATION}>
<input type="number" min="1" max="20" value={settingsDraft.maximum_delegation_depth} disabled={!canManageSettings || busy || !settingsDraft.delegation_allowed} onChange={(event) => setSettingsDraft({ ...settingsDraft, maximum_delegation_depth: event.target.value })} />
</FormField>
<FormField label="Maximum delegated validity (days)" documentation={IDM_FIELD_DOCUMENTATION}>
<input type="number" min="1" max="3650" value={settingsDraft.maximum_delegated_validity_days} placeholder="No additional ceiling" disabled={!canManageSettings || busy || !settingsDraft.delegation_allowed} onChange={(event) => setSettingsDraft({ ...settingsDraft, maximum_delegated_validity_days: event.target.value })} />
</FormField>
{([
["Holder review", "holder_escalation_target", "holder_escalation_hours"],
["Authority review", "authority_escalation_target", "authority_escalation_hours"],
["Recipient review", "recipient_escalation_target", "recipient_escalation_hours"]
] as const).map(([label, targetKey, hoursKey]) => (
<div className="wide" key={targetKey}>
<FormLayout columns={2} gap="small" collapseAt="workspace" className="">
<FormField label={`${label} escalation target`} documentation={IDM_FIELD_DOCUMENTATION}>
<select value={settingsDraft[targetKey]} disabled={!canManageSettings || busy} onChange={(event) => setSettingsDraft({ ...settingsDraft, [targetKey]: event.target.value })}>
<option value="">No timed escalation</option>
{model.functions.filter((item) => item.is_active).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label={`${label} timeout (hours)`} documentation={IDM_FIELD_DOCUMENTATION}>
<input type="number" min="1" max="8760" value={settingsDraft[hoursKey]} disabled={!canManageSettings || busy || !settingsDraft[targetKey]} onChange={(event) => setSettingsDraft({ ...settingsDraft, [hoursKey]: event.target.value })} />
</FormField>
</FormLayout>
</div>
))}
<div className="button-row compact-actions wide">
<Button
type="submit"
variant="primary"
disabled={!canManageSettings || busy || !hasDirtySettingsDraft}
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (!hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
disabled={!canManageSettings || busy || !hasDirtySettingsDraft || settingsDraftInvalid(settingsDraft)}
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (settingsDraftInvalid(settingsDraft) ? "Each escalation rule needs both a target and timeout." : !hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
>
i18n:govoplan-idm.save_settings.4602c430
</Button>