feat(idm): govern delegation chains and timed escalation
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user