Implement governed function assignment workflows
This commit is contained in:
@@ -99,10 +99,90 @@ export type OrganizationFunctionAssignmentPayload = {
|
||||
is_active?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
change_request_id?: string | null;
|
||||
governance_override_reason?: string | null;
|
||||
governance_override_evidence?: string[];
|
||||
};
|
||||
|
||||
export type IdmSettingsPayload = Partial<Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings">>;
|
||||
|
||||
export type FunctionAssignmentChangeKind = "request" | "grant";
|
||||
export type FunctionAssignmentChangeAction = "approve" | "reject" | "accept" | "request_changes" | "respond" | "withdraw" | "recover";
|
||||
|
||||
export type FunctionAssignmentChangeEvent = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
action: string;
|
||||
from_state?: string | null;
|
||||
to_state: string;
|
||||
actor_account_id?: string | null;
|
||||
actor_identity_id?: string | null;
|
||||
comment?: string | null;
|
||||
evidence: string[];
|
||||
policy_decision: Record<string, unknown>;
|
||||
workflow_step_id?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChange = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
state: string;
|
||||
profile: string;
|
||||
function_id: string;
|
||||
organization_unit_id: string;
|
||||
candidate_identity_id: string;
|
||||
candidate_account_id?: string | null;
|
||||
initiator_account_id: string;
|
||||
initiator_identity_id?: string | null;
|
||||
justification: string;
|
||||
evidence: string[];
|
||||
requested_valid_from?: string | null;
|
||||
requested_valid_until?: string | null;
|
||||
required_steps: string[];
|
||||
completed_steps: string[];
|
||||
policy_decision: Record<string, unknown>;
|
||||
workflow_definition_revision?: number | null;
|
||||
workflow_definition_hash?: string | null;
|
||||
workflow_instance_id?: string | null;
|
||||
resulting_assignment_id?: string | null;
|
||||
expires_at?: string | null;
|
||||
outcome_reason?: string | null;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
metadata: Record<string, unknown>;
|
||||
events: FunctionAssignmentChangeEvent[];
|
||||
available_actions: FunctionAssignmentChangeAction[];
|
||||
availability_reason?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChangeList = {
|
||||
changes: FunctionAssignmentChange[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChangePayload = {
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
function_id: string;
|
||||
candidate_identity_id: string;
|
||||
candidate_account_id?: string | null;
|
||||
justification: string;
|
||||
evidence?: string[];
|
||||
requested_valid_from?: string | null;
|
||||
requested_valid_until?: string | null;
|
||||
applies_to_subunits?: boolean;
|
||||
assignment_source?: "governance" | "delegated";
|
||||
represented_assignment_id?: string | null;
|
||||
idempotency_key: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
||||
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
@@ -168,3 +248,40 @@ export function patchOrganizationFunctionAssignment(
|
||||
): Promise<OrganizationFunctionAssignmentItem> {
|
||||
return patch(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
|
||||
}
|
||||
|
||||
export function getFunctionAssignmentChanges(settings: ApiSettings): Promise<FunctionAssignmentChangeList> {
|
||||
return apiFetch<FunctionAssignmentChangeList>(settings, "/api/v1/idm/function-assignment-changes?page_size=200");
|
||||
}
|
||||
|
||||
export function getFunctionAssignmentChange(settings: ApiSettings, id: string): Promise<FunctionAssignmentChange> {
|
||||
return apiFetch<FunctionAssignmentChange>(settings, `/api/v1/idm/function-assignment-changes/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function createFunctionAssignmentChange(
|
||||
settings: ApiSettings,
|
||||
payload: FunctionAssignmentChangePayload
|
||||
): Promise<FunctionAssignmentChange> {
|
||||
return post(settings, "/api/v1/idm/function-assignment-changes", payload);
|
||||
}
|
||||
|
||||
export function actOnFunctionAssignmentChange(
|
||||
settings: ApiSettings,
|
||||
change: FunctionAssignmentChange,
|
||||
action: FunctionAssignmentChangeAction,
|
||||
comment?: string
|
||||
): Promise<FunctionAssignmentChange> {
|
||||
return apiFetch<FunctionAssignmentChange>(
|
||||
settings,
|
||||
`/api/v1/idm/function-assignment-changes/${encodeURIComponent(change.id)}/actions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "If-Match": change.etag },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
base_revision: change.resource_revision,
|
||||
comment: comment?.trim() || null,
|
||||
evidence: []
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||
import {
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
hasScope,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
actOnFunctionAssignmentChange,
|
||||
createFunctionAssignmentChange,
|
||||
getFunctionAssignmentChange,
|
||||
getFunctionAssignmentChanges,
|
||||
type FunctionAssignmentChange,
|
||||
type FunctionAssignmentChangeAction,
|
||||
type FunctionAssignmentChangeKind,
|
||||
type IdentityOption,
|
||||
type OrganizationModel
|
||||
} from "../api/idm";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
model: OrganizationModel;
|
||||
identities: IdentityOption[];
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
functionId: string;
|
||||
identityId: string;
|
||||
accountId: string;
|
||||
justification: string;
|
||||
evidence: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
};
|
||||
|
||||
function emptyDraft(auth: AuthInfo, kind: FunctionAssignmentChangeKind): Draft {
|
||||
return {
|
||||
kind,
|
||||
functionId: "",
|
||||
identityId: kind === "request" ? auth.principal?.identity_id ?? "" : "",
|
||||
accountId: kind === "request" ? auth.principal?.account_id ?? "" : "",
|
||||
justification: "",
|
||||
evidence: "",
|
||||
validFrom: "",
|
||||
validUntil: ""
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
try {
|
||||
const body = JSON.parse(error.body) as { detail?: string | { message?: string } };
|
||||
if (typeof body.detail === "string") return body.detail;
|
||||
if (body.detail?.message) return body.detail.message;
|
||||
} catch {
|
||||
// Use the transport message.
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function dateTimeValue(value: string): string | null {
|
||||
return value ? new Date(value).toISOString() : 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";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function actionLabel(action: FunctionAssignmentChangeAction): string {
|
||||
return {
|
||||
approve: "Approve",
|
||||
reject: "Reject",
|
||||
accept: "Accept",
|
||||
request_changes: "Request changes",
|
||||
respond: "Respond",
|
||||
withdraw: "Withdraw",
|
||||
recover: "Recheck"
|
||||
}[action];
|
||||
}
|
||||
|
||||
function actionIcon(action: FunctionAssignmentChangeAction): JSX.Element {
|
||||
const props = { size: 16, "aria-hidden": true as const };
|
||||
if (action === "approve" || action === "accept") return <Check {...props} />;
|
||||
if (action === "reject") return <X {...props} />;
|
||||
if (action === "request_changes") return <Undo2 {...props} />;
|
||||
if (action === "recover") return <RotateCcw {...props} />;
|
||||
return <Undo2 {...props} />;
|
||||
}
|
||||
|
||||
export default function FunctionAssignmentChangesPanel({ settings, auth, model, identities }: Props) {
|
||||
const canRequest = hasScope(auth, "idm:function_request:create");
|
||||
const canGrant = hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:organization_assignment:write");
|
||||
const visible = canRequest || canGrant || hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
|
||||
const initialKind: FunctionAssignmentChangeKind = canRequest ? "request" : "grant";
|
||||
const [changes, setChanges] = useState<FunctionAssignmentChange[]>([]);
|
||||
const [draft, setDraft] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||
const [selected, setSelected] = useState<FunctionAssignmentChange | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const initialChangeId = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("change") ?? "";
|
||||
}, []);
|
||||
const initialChangeOpened = useRef(false);
|
||||
const functionById = useMemo(() => new Map(model.functions.map((item) => [item.id, item])), [model.functions]);
|
||||
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
|
||||
const selectedIdentity = identityById.get(draft.identityId);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!visible) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getFunctionAssignmentChanges(settings);
|
||||
setChanges(response.changes);
|
||||
if (initialChangeId && !initialChangeOpened.current) {
|
||||
initialChangeOpened.current = true;
|
||||
const detail = await getFunctionAssignmentChange(settings, initialChangeId);
|
||||
setSelected(detail);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialChangeId, settings, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
function setKind(kind: FunctionAssignmentChangeKind) {
|
||||
setDraft(emptyDraft(auth, kind));
|
||||
}
|
||||
|
||||
function setIdentity(identityId: string) {
|
||||
const identity = identityById.get(identityId);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
identityId,
|
||||
accountId: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
|
||||
}));
|
||||
}
|
||||
|
||||
async function openDetail(item: FunctionAssignmentChange) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const detail = await getFunctionAssignmentChange(settings, item.id);
|
||||
setSelected(detail);
|
||||
setComment("");
|
||||
setDetailOpen(true);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createFunctionAssignmentChange(settings, {
|
||||
kind: draft.kind,
|
||||
function_id: draft.functionId,
|
||||
candidate_identity_id: draft.identityId,
|
||||
candidate_account_id: draft.accountId || null,
|
||||
justification: draft.justification.trim(),
|
||||
evidence: draft.evidence.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
requested_valid_from: dateTimeValue(draft.validFrom),
|
||||
requested_valid_until: dateTimeValue(draft.validUntil),
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
metadata: {}
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setDraft(emptyDraft(auth, initialKind));
|
||||
setSelected(created);
|
||||
setDetailOpen(true);
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function performAction(action: FunctionAssignmentChangeAction) {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await actOnFunctionAssignmentChange(settings, selected, action, comment);
|
||||
setSelected(updated);
|
||||
setComment("");
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataGridColumn<FunctionAssignmentChange>[] = [
|
||||
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, value: (row) => row.kind },
|
||||
{
|
||||
id: "function",
|
||||
header: "Function",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => functionById.get(row.function_id)?.name ?? row.function_id,
|
||||
render: (row) => functionById.get(row.function_id)?.name ?? row.function_id
|
||||
},
|
||||
{
|
||||
id: "candidate",
|
||||
header: "Candidate",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id,
|
||||
render: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id
|
||||
},
|
||||
{ id: "state", header: "State", width: 170, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={statusTone(row.state)} label={row.state.replaceAll("_", " ")} /> },
|
||||
{ id: "progress", header: "Decisions", width: 140, value: (row) => `${row.completed_steps.length}/${row.required_steps.length}`, render: (row) => `${row.completed_steps.length} / ${row.required_steps.length}` },
|
||||
{ id: "updated", header: "Updated", width: 170, sortable: true, value: (row) => row.updated_at, render: (row) => new Date(row.updated_at).toLocaleString() },
|
||||
{ id: "actions", header: "Actions", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "view", label: "Open change", icon: <Eye size={16} aria-hidden="true" />, onClick: () => void openDetail(row) }]} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<Card
|
||||
title="Function requests and grants"
|
||||
collapsible
|
||||
collapseKey="idm.function-assignment-changes"
|
||||
actions={(canRequest || canGrant) ? <AdminIconButton label="Start governed change" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={busy} onClick={() => setCreateOpen(true)} /> : undefined}
|
||||
>
|
||||
<DataGrid id="idm-function-assignment-changes" rows={changes} columns={columns} getRowKey={(row) => row.id} emptyText={loading ? "Loading changes..." : "No governed function changes"} initialFit="container" />
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Start governed function change"
|
||||
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
||||
onClose={() => !busy && setCreateOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<><Button type="button" onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button><Button type="submit" form="idm-change-create" variant="primary" disabled={busy || !draft.functionId || !draft.identityId || !draft.justification.trim()}>Submit</Button></>}
|
||||
>
|
||||
<form id="idm-change-create" className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||
<div className="wide">
|
||||
<SegmentedControl
|
||||
ariaLabel="Function change kind"
|
||||
value={draft.kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ id: "request", label: "Request function", disabled: !canRequest },
|
||||
{ id: "grant", label: "Grant function", disabled: !canGrant }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FormField label="Function">
|
||||
<select value={draft.functionId} onChange={(event) => setDraft((current) => ({ ...current, functionId: event.target.value }))} disabled={busy}>
|
||||
<option value="">Select function</option>
|
||||
{model.functions.filter((item) => item.is_active).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Candidate identity">
|
||||
<select value={draft.identityId} onChange={(event) => setIdentity(event.target.value)} disabled={busy || draft.kind === "request"}>
|
||||
<option value="">Select identity</option>
|
||||
{identities.map((item) => <option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Candidate account">
|
||||
<select value={draft.accountId} onChange={(event) => setDraft((current) => ({ ...current, accountId: event.target.value }))} disabled={busy}>
|
||||
<option value="">No linked account</option>
|
||||
{(selectedIdentity?.account_ids ?? []).map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Valid from">
|
||||
<input type="datetime-local" value={draft.validFrom} onChange={(event) => setDraft((current) => ({ ...current, validFrom: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
<FormField label="Valid until">
|
||||
<input type="datetime-local" value={draft.validUntil} onChange={(event) => setDraft((current) => ({ ...current, validUntil: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
<div className="wide">
|
||||
<FormField label="Justification">
|
||||
<textarea rows={4} value={draft.justification} onChange={(event) => setDraft((current) => ({ ...current, justification: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<FormField label="Evidence references (one per line)">
|
||||
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={detailOpen && selected !== null}
|
||||
title={selected ? `${selected.kind === "request" ? "Function request" : "Function grant"}: ${functionById.get(selected.function_id)?.name ?? selected.function_id}` : "Function change"}
|
||||
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
||||
onClose={() => !busy && setDetailOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy}>Close</Button>}
|
||||
>
|
||||
{selected && (
|
||||
<div className="idm-change-detail">
|
||||
<dl className="idm-change-summary">
|
||||
<div><dt>State</dt><dd><StatusBadge status={statusTone(selected.state)} label={selected.state.replaceAll("_", " ")} /></dd></div>
|
||||
<div><dt>Candidate</dt><dd>{identityById.get(selected.candidate_identity_id)?.display_name ?? selected.candidate_identity_id}</dd></div>
|
||||
<div><dt>Profile</dt><dd>{selected.profile}</dd></div>
|
||||
<div><dt>Workflow revision</dt><dd>{selected.workflow_definition_revision ?? "-"}</dd></div>
|
||||
<div><dt>Required decisions</dt><dd>{selected.required_steps.join(", ") || "None"}</dd></div>
|
||||
<div><dt>Completed decisions</dt><dd>{selected.completed_steps.join(", ") || "None"}</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>
|
||||
{selected.available_actions.length > 0 && (
|
||||
<div className="idm-change-actions">
|
||||
<FormField label="Decision comment">
|
||||
<textarea rows={2} value={comment} onChange={(event) => setComment(event.target.value)} disabled={busy} />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
{selected.available_actions.map((action) => (
|
||||
<Button key={action} type="button" variant={action === "reject" ? "danger" : action === "approve" || action === "accept" ? "primary" : "secondary"} disabled={busy} onClick={() => void performAction(action)}>
|
||||
{actionIcon(action)} {actionLabel(action)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.availability_reason && selected.available_actions.length === 0 && <p className="idm-muted">{selected.availability_reason}</p>}
|
||||
<div>
|
||||
<h3>History</h3>
|
||||
<ol className="idm-change-history">
|
||||
{selected.events.map((event) => <li key={event.id}><strong>{event.action}</strong><span>{new Date(event.created_at).toLocaleString()}</span><span>{event.from_state ? `${event.from_state} -> ` : ""}{event.to_state}</span>{event.comment && <p>{event.comment}</p>}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type OrganizationModel,
|
||||
type OrganizationUnitItem
|
||||
} from "../api/idm";
|
||||
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||
|
||||
type IdmPageProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -52,6 +53,8 @@ type AssignmentDraft = {
|
||||
delegated_from_assignment_id: string;
|
||||
acting_for_account_id: string;
|
||||
is_active: boolean;
|
||||
governance_override_reason: string;
|
||||
governance_override_evidence: string;
|
||||
};
|
||||
|
||||
type SettingsDraft = {
|
||||
@@ -91,7 +94,9 @@ function emptyAssignmentDraft(): AssignmentDraft {
|
||||
source: "direct",
|
||||
delegated_from_assignment_id: "",
|
||||
acting_for_account_id: "",
|
||||
is_active: true
|
||||
is_active: true,
|
||||
governance_override_reason: "",
|
||||
governance_override_evidence: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +115,12 @@ function assignmentPayload(draft: AssignmentDraft): OrganizationFunctionAssignme
|
||||
delegated_from_assignment_id: textOrNull(draft.delegated_from_assignment_id),
|
||||
acting_for_account_id: textOrNull(draft.acting_for_account_id),
|
||||
is_active: draft.is_active,
|
||||
settings: {}
|
||||
settings: {},
|
||||
governance_override_reason: textOrNull(draft.governance_override_reason),
|
||||
governance_override_evidence: draft.governance_override_evidence
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +133,9 @@ function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): Assignme
|
||||
source: item.source || "direct",
|
||||
delegated_from_assignment_id: item.delegated_from_assignment_id ?? "",
|
||||
acting_for_account_id: item.acting_for_account_id ?? "",
|
||||
is_active: item.is_active
|
||||
is_active: item.is_active,
|
||||
governance_override_reason: "",
|
||||
governance_override_evidence: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +148,8 @@ function isAssignmentDirty(draft: AssignmentDraft): boolean {
|
||||
draft.source !== "direct" ||
|
||||
draft.delegated_from_assignment_id ||
|
||||
draft.acting_for_account_id ||
|
||||
draft.governance_override_reason ||
|
||||
draft.governance_override_evidence ||
|
||||
!draft.is_active
|
||||
);
|
||||
}
|
||||
@@ -224,6 +238,16 @@ function sourceLabel(value: string): string {
|
||||
return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function isGovernedFunction(item: OrganizationFunctionItem | undefined): boolean {
|
||||
const governance = item?.settings.assignment_governance;
|
||||
if (!governance || typeof governance !== "object" || Array.isArray(governance)) return false;
|
||||
const values = governance as Record<string, unknown>;
|
||||
return ["request_profile", "grant_profile"].some((key) => {
|
||||
const value = String(values[key] ?? "unavailable").trim().toLowerCase();
|
||||
return Boolean(value && value !== "unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
function idmInitialQuery(): { assignmentId: string; functionId: string } {
|
||||
if (typeof window === "undefined") return { assignmentId: "", functionId: "" };
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -265,6 +289,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]);
|
||||
const actingForOptionById = useMemo(() => mapById(actingForOptions), [actingForOptions]);
|
||||
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
|
||||
const selectedFunctionIsGoverned = isGovernedFunction(functionById.get(assignmentDraft.function_id));
|
||||
|
||||
const identitySelectOptions = useMemo(() => {
|
||||
if (!assignmentDraft.identity_id || identityOptionById.has(assignmentDraft.identity_id)) return identityOptions;
|
||||
@@ -450,6 +475,10 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
setError("i18n:govoplan-idm.function_is_required.5cce5b41");
|
||||
return false;
|
||||
}
|
||||
if (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim()) {
|
||||
setError("Direct changes to this governed function require an emergency override reason.");
|
||||
return false;
|
||||
}
|
||||
const requestId = textOrNull(assignmentChangeRequestId);
|
||||
const payload = requestId ? { ...assignmentPayload(assignmentDraft), change_request_id: requestId } : assignmentPayload(assignmentDraft);
|
||||
const ok = await runAction(
|
||||
@@ -458,7 +487,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
);
|
||||
if (ok) discardAssignmentDraft();
|
||||
return ok;
|
||||
}, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]);
|
||||
}, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, selectedFunctionIsGoverned, settings]);
|
||||
|
||||
const saveDrafts = useCallback(async (): Promise<boolean> => {
|
||||
if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false;
|
||||
@@ -625,6 +654,13 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<FunctionAssignmentChangesPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
model={model}
|
||||
identities={identityOptions}
|
||||
/>
|
||||
|
||||
<Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy} onClick={openCreateAssignment} />}>
|
||||
<DataGrid
|
||||
id="idm-organization-function-assignments"
|
||||
@@ -655,7 +691,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" onClick={discardAssignmentDraft} disabled={busy}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id}>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())}>
|
||||
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||
</Button>
|
||||
</>
|
||||
@@ -727,6 +763,29 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<ToggleSwitch label="i18n:govoplan-idm.applies_to_subunits.2e31b50b" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(applies_to_subunits) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-idm.active.7bd0e9f8" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(is_active) => setAssignmentDraft({ ...assignmentDraft, is_active })} />
|
||||
</div>
|
||||
{selectedFunctionIsGoverned && (
|
||||
<div className="wide idm-governance-override">
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.
|
||||
</DismissibleAlert>
|
||||
<FormField label="Emergency override reason">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={assignmentDraft.governance_override_reason}
|
||||
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_reason: event.target.value })}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Override evidence references (one per line)">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={assignmentDraft.governance_override_evidence}
|
||||
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_evidence: event.target.value })}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
<div className="wide idm-dialog-change-request">
|
||||
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db">
|
||||
<input value={assignmentChangeRequestId} onChange={(event) => setAssignmentChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
||||
|
||||
@@ -66,3 +66,87 @@
|
||||
.idm-dialog-change-request {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.idm-governance-override {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.idm-change-detail {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.idm-change-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.idm-change-summary > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.idm-change-summary > .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.idm-change-summary dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.idm-change-summary dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.idm-change-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-block: 14px;
|
||||
border-block: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.idm-change-history {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.idm-change-history li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.6fr) minmax(150px, 0.7fr) minmax(180px, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.idm-change-history p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.idm-change-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.idm-change-summary > .wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.idm-change-history li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user