Migrate IDM interface patterns
This commit is contained in:
@@ -6,6 +6,9 @@
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const page = source("../src/features/IdmPage.tsx");
|
||||
const changes = source("../src/features/FunctionAssignmentChangesPanel.tsx");
|
||||
const patterns = source("../src/features/interfacePatterns.ts");
|
||||
const moduleSource = source("../src/module.ts");
|
||||
const translations = source("../src/i18n/generatedTranslations.ts");
|
||||
|
||||
assert(page.includes("ActionBlockerHint") && page.includes("DocumentationHelpLink"), "IDM permissions and prerequisites expose actionable help");
|
||||
assert(page.includes("assignmentBaseline") && page.includes("draftKey(assignmentDraft) !== draftKey(assignmentBaseline)"), "Existing assignments compare against their loaded draft baseline");
|
||||
assert(page.includes("requestDiscard(() => void loadData())") && page.includes("closeAssignmentEditor"), "Reload and dialog close preserve actual dirty drafts");
|
||||
assert(page.includes("disabledReason") && changes.includes("disabledReason"), "Unavailable assignment and decision actions explain their state");
|
||||
assert(changes.includes("ConfirmDialog") && changes.includes("confirm_function_action"), "Governed function decisions require explicit shared confirmation");
|
||||
assert(changes.includes("useUnsavedDraftGuard") && changes.includes("usePlatformLanguage"), "Governed editors protect drafts and format dates with the platform locale");
|
||||
assert(patterns.includes('topicId: "idm.reference.fields-and-consequences"'), "IDM fields use manifest-backed consequence help");
|
||||
assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-idm.view_assignments.2d40d6a5"'), "WebUI metadata matches the module release and localizes its action surface");
|
||||
assert(translations.includes('"i18n:govoplan-idm.state_awaiting_authority"'), "Governed states and decisions are in the translation catalogue");
|
||||
assert(!page.includes("window.confirm") && !changes.includes("window.confirm"), "IDM does not use browser-native consequential confirmation");
|
||||
|
||||
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
||||
|
||||
@@ -5,14 +5,21 @@ import {
|
||||
ApiError,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
@@ -28,6 +35,12 @@ import {
|
||||
type IdentityOption,
|
||||
type OrganizationModel
|
||||
} from "../api/idm";
|
||||
import {
|
||||
IDM_FIELD_DOCUMENTATION,
|
||||
IDM_GOVERNANCE_DOCUMENTATION,
|
||||
IDM_INTERFACE_I18N,
|
||||
idmDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
@@ -85,15 +98,49 @@ function statusTone(state: string): string {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
request: "i18n:govoplan-idm.kind_request",
|
||||
grant: "i18n:govoplan-idm.kind_grant",
|
||||
submitted: "i18n:govoplan-idm.state_submitted",
|
||||
awaiting_holder: "i18n:govoplan-idm.state_awaiting_holder",
|
||||
awaiting_authority: "i18n:govoplan-idm.state_awaiting_authority",
|
||||
awaiting_recipient: "i18n:govoplan-idm.state_awaiting_recipient",
|
||||
changes_requested: "i18n:govoplan-idm.state_changes_requested",
|
||||
applied: "i18n:govoplan-idm.state_applied",
|
||||
rejected: "i18n:govoplan-idm.state_rejected",
|
||||
expired: "i18n:govoplan-idm.state_expired",
|
||||
withdrawn: "i18n:govoplan-idm.state_withdrawn",
|
||||
cancelled: "i18n:govoplan-idm.state_cancelled",
|
||||
blocked: "i18n:govoplan-idm.state_blocked",
|
||||
failed_manual_review: "i18n:govoplan-idm.state_failed_manual_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",
|
||||
holder_review: "i18n:govoplan-idm.profile_holder_review",
|
||||
authority_review: "i18n:govoplan-idm.profile_authority_review",
|
||||
recipient_review: "i18n:govoplan-idm.profile_recipient_review",
|
||||
approve: "i18n:govoplan-idm.action_approve",
|
||||
reject: "i18n:govoplan-idm.action_reject",
|
||||
accept: "i18n:govoplan-idm.action_accept",
|
||||
request_changes: "i18n:govoplan-idm.action_request_changes",
|
||||
respond: "i18n:govoplan-idm.action_respond",
|
||||
withdraw: "i18n:govoplan-idm.action_withdraw",
|
||||
recover: "i18n:govoplan-idm.action_recheck"
|
||||
};
|
||||
|
||||
function domainLabel(value: string): string {
|
||||
return DOMAIN_LABELS[value] ?? value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function actionLabel(action: FunctionAssignmentChangeAction): string {
|
||||
return {
|
||||
approve: "Approve",
|
||||
reject: "Reject",
|
||||
accept: "Accept",
|
||||
request_changes: "Request changes",
|
||||
respond: "Respond",
|
||||
withdraw: "Withdraw",
|
||||
recover: "Recheck"
|
||||
approve: "i18n:govoplan-idm.action_approve",
|
||||
reject: "i18n:govoplan-idm.action_reject",
|
||||
accept: "i18n:govoplan-idm.action_accept",
|
||||
request_changes: "i18n:govoplan-idm.action_request_changes",
|
||||
respond: "i18n:govoplan-idm.action_respond",
|
||||
withdraw: "i18n:govoplan-idm.action_withdraw",
|
||||
recover: "i18n:govoplan-idm.action_recheck"
|
||||
}[action];
|
||||
}
|
||||
|
||||
@@ -113,6 +160,7 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
const initialKind: FunctionAssignmentChangeKind = canRequest ? "request" : "grant";
|
||||
const [changes, setChanges] = useState<FunctionAssignmentChange[]>([]);
|
||||
const [draft, setDraft] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||
const [draftBaseline, setDraftBaseline] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||
const [selected, setSelected] = useState<FunctionAssignmentChange | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
@@ -120,6 +168,9 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pendingAction, setPendingAction] = useState<FunctionAssignmentChangeAction | null>(null);
|
||||
const { language } = usePlatformLanguage();
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const initialChangeId = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("change") ?? "";
|
||||
@@ -128,6 +179,15 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
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 draftDirty = createOpen && draftKey(draft) !== draftKey(draftBaseline);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
title: "Unsaved function change",
|
||||
message: "Save or discard the function request or grant before leaving this surface.",
|
||||
onSave: submit,
|
||||
onDiscard: discardCreateDraft
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!visible) return;
|
||||
@@ -156,7 +216,29 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
if (!visible) return null;
|
||||
|
||||
function setKind(kind: FunctionAssignmentChangeKind) {
|
||||
setDraft(emptyDraft(auth, kind));
|
||||
const next = emptyDraft(auth, kind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
const next = emptyDraft(auth, initialKind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
function discardCreateDraft() {
|
||||
const next = emptyDraft(auth, initialKind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
setCreateOpen(false);
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
if (busy) return;
|
||||
if (draftDirty) requestDiscard(discardCreateDraft);
|
||||
else discardCreateDraft();
|
||||
}
|
||||
|
||||
function setIdentity(identityId: string) {
|
||||
@@ -183,8 +265,9 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
async function submit(event?: FormEvent): Promise<boolean> {
|
||||
event?.preventDefault();
|
||||
if (!draft.functionId || !draft.identityId || !draft.justification.trim()) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -201,12 +284,16 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
metadata: {}
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setDraft(emptyDraft(auth, initialKind));
|
||||
const next = emptyDraft(auth, initialKind);
|
||||
setDraft(next);
|
||||
setDraftBaseline(next);
|
||||
setSelected(created);
|
||||
setDetailOpen(true);
|
||||
await load();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -229,7 +316,7 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
}
|
||||
|
||||
const columns: DataGridColumn<FunctionAssignmentChange>[] = [
|
||||
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, value: (row) => row.kind },
|
||||
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, value: (row) => row.kind, render: (row) => domainLabel(row.kind) },
|
||||
{
|
||||
id: "function",
|
||||
header: "Function",
|
||||
@@ -248,9 +335,9 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
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: "state", header: "State", width: 170, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={statusTone(row.state)} label={domainLabel(row.state)} /> },
|
||||
{ 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: "updated", header: "Updated", width: 170, sortable: true, value: (row) => row.updated_at, render: (row) => new Date(row.updated_at).toLocaleString(language) },
|
||||
{ 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) }]} /> }
|
||||
];
|
||||
|
||||
@@ -261,18 +348,25 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
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}
|
||||
actions={(
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
|
||||
{(canRequest || canGrant) ? <AdminIconButton label="Start governed change" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={busy} disabledReason={idmDisabledReason(false, busy)} onClick={openCreate} /> : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<DataGrid id="idm-function-assignment-changes" rows={changes} columns={columns} getRowKey={(row) => row.id} emptyText={loading ? "Loading changes..." : "No governed function changes"} initialFit="container" />
|
||||
<LoadingFrame loading={loading} label="Loading governed function changes">
|
||||
<DataGrid id="idm-function-assignment-changes" rows={changes} columns={columns} getRowKey={(row) => row.id} emptyText="No governed function changes" initialFit="container" />
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Start governed function change"
|
||||
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
||||
onClose={() => !busy && setCreateOpen(false)}
|
||||
onClose={closeCreate}
|
||||
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></>}
|
||||
footer={<><Button type="button" onClick={closeCreate} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Cancel</Button><Button type="submit" form="idm-change-create" variant="primary" disabled={busy || !draft.functionId || !draft.identityId || !draft.justification.trim()} disabledReason={idmDisabledReason(false, busy) ?? ((!draft.functionId || !draft.identityId || !draft.justification.trim()) ? IDM_INTERFACE_I18N.incomplete : undefined)}>Submit</Button></>}
|
||||
>
|
||||
<form id="idm-change-create" className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||
<div className="wide">
|
||||
@@ -286,37 +380,37 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FormField label="Function">
|
||||
<FormField label="Function" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<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">
|
||||
<FormField label="Candidate identity" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<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">
|
||||
<FormField label="Candidate account" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<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">
|
||||
<FormField label="Valid from" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input type="datetime-local" value={draft.validFrom} onChange={(event) => setDraft((current) => ({ ...current, validFrom: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
<FormField label="Valid until">
|
||||
<FormField label="Valid until" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<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">
|
||||
<FormField label="Justification" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<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)">
|
||||
<FormField label="Evidence references (one per line)" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -325,32 +419,39 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
|
||||
<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"}
|
||||
title={selected
|
||||
? i18nMessage(
|
||||
selected.kind === "request"
|
||||
? "i18n:govoplan-idm.function_request_title"
|
||||
: "i18n:govoplan-idm.function_grant_title",
|
||||
{ function: 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>}
|
||||
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy} disabledReason={idmDisabledReason(false, 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>State</dt><dd><StatusBadge status={statusTone(selected.state)} label={domainLabel(selected.state)} /></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>Profile</dt><dd>{domainLabel(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><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>
|
||||
<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">
|
||||
<FormField label="Decision comment" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<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)}>
|
||||
<Button key={action} type="button" variant={action === "reject" ? "danger" : action === "approve" || action === "accept" ? "primary" : "secondary"} disabled={busy} disabledReason={idmDisabledReason(false, busy)} onClick={() => setPendingAction(action)}>
|
||||
{actionIcon(action)} {actionLabel(action)}
|
||||
</Button>
|
||||
))}
|
||||
@@ -361,12 +462,36 @@ export default function FunctionAssignmentChangesPanel({ settings, auth, model,
|
||||
<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>)}
|
||||
{selected.events.map((event) => <li key={event.id}><strong>{domainLabel(event.action)}</strong><span>{new Date(event.created_at).toLocaleString(language)}</span><span>{event.from_state ? `${domainLabel(event.from_state)} -> ` : ""}{domainLabel(event.to_state)}</span>{event.comment && <p>{event.comment}</p>}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingAction && selected)}
|
||||
title="Confirm function decision"
|
||||
message={pendingAction && selected
|
||||
? i18nMessage("i18n:govoplan-idm.confirm_function_action", {
|
||||
action: actionLabel(pendingAction),
|
||||
function: functionById.get(selected.function_id)?.name ?? selected.function_id
|
||||
})
|
||||
: ""}
|
||||
confirmLabel={pendingAction ? actionLabel(pendingAction) : "Confirm"}
|
||||
tone={pendingAction === "reject" || pendingAction === "withdraw" ? "danger" : "default"}
|
||||
busy={busy}
|
||||
onConfirm={() => {
|
||||
if (!pendingAction) return;
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
void performAction(action);
|
||||
}}
|
||||
onCancel={() => setPendingAction(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
+140
-46
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
PageScrollViewport,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
@@ -38,6 +41,13 @@ import {
|
||||
type OrganizationUnitItem
|
||||
} from "../api/idm";
|
||||
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||
import {
|
||||
IDM_DOCUMENTATION,
|
||||
IDM_FIELD_DOCUMENTATION,
|
||||
IDM_GOVERNANCE_DOCUMENTATION,
|
||||
IDM_INTERFACE_I18N,
|
||||
idmDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type IdmPageProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -139,21 +149,6 @@ function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): Assignme
|
||||
};
|
||||
}
|
||||
|
||||
function isAssignmentDirty(draft: AssignmentDraft): boolean {
|
||||
return Boolean(
|
||||
draft.identity_id ||
|
||||
draft.account_id ||
|
||||
draft.function_id ||
|
||||
draft.applies_to_subunits ||
|
||||
draft.source !== "direct" ||
|
||||
draft.delegated_from_assignment_id ||
|
||||
draft.acting_for_account_id ||
|
||||
draft.governance_override_reason ||
|
||||
draft.governance_override_evidence ||
|
||||
!draft.is_active
|
||||
);
|
||||
}
|
||||
|
||||
function settingsDraftFrom(item: IdmSettings | null): SettingsDraft {
|
||||
const source = item ?? DEFAULT_IDM_SETTINGS;
|
||||
return {
|
||||
@@ -269,6 +264,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const [actingForOptions, setActingForOptions] = useState<IdentityOption[]>([]);
|
||||
const [actingForLoading, setActingForLoading] = useState(false);
|
||||
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
|
||||
const [assignmentBaseline, setAssignmentBaseline] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
|
||||
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null));
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
|
||||
const [assignmentEditorOpen, setAssignmentEditorOpen] = useState(false);
|
||||
@@ -279,6 +275,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const [success, setSuccess] = useState("");
|
||||
const initialQuery = useMemo(() => idmInitialQuery(), []);
|
||||
const appliedInitialQueryRef = useRef(false);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
|
||||
@@ -330,7 +327,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
return Array.from(values).sort((left, right) => left.localeCompare(right));
|
||||
}, [actingForOptionById, actingForOptions, assignmentDraft.acting_for_account_id, identityOptionById, sourceAssignment]);
|
||||
const initialFunctionFilter = initialQuery.functionId;
|
||||
const hasDirtyAssignmentDraft = assignmentEditorOpen && isAssignmentDirty(assignmentDraft);
|
||||
const hasDirtyAssignmentDraft = assignmentEditorOpen && draftKey(assignmentDraft) !== draftKey(assignmentBaseline);
|
||||
const hasDirtySettingsDraft = canReadSettings && isSettingsDirty(settingsDraft, idmSettings);
|
||||
const hasDirtyDraft = hasDirtyAssignmentDraft || hasDirtySettingsDraft;
|
||||
|
||||
@@ -352,7 +349,9 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
if (initialAssignment) {
|
||||
appliedInitialQueryRef.current = true;
|
||||
setEditingAssignmentId(initialAssignment.id);
|
||||
setAssignmentDraft(assignmentDraftFrom(initialAssignment));
|
||||
const nextDraft = assignmentDraftFrom(initialAssignment);
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setAssignmentEditorOpen(true);
|
||||
}
|
||||
}
|
||||
@@ -437,7 +436,9 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
}, [loadData]);
|
||||
|
||||
const discardAssignmentDraft = useCallback(() => {
|
||||
setAssignmentDraft(emptyAssignmentDraft());
|
||||
const nextDraft = emptyAssignmentDraft();
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setEditingAssignmentId(null);
|
||||
setAssignmentEditorOpen(false);
|
||||
setAssignmentChangeRequestId("");
|
||||
@@ -505,18 +506,28 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
|
||||
const openCreateAssignment = useCallback(() => {
|
||||
setEditingAssignmentId(null);
|
||||
setAssignmentDraft({ ...emptyAssignmentDraft(), function_id: initialFunctionFilter && functionById.has(initialFunctionFilter) ? initialFunctionFilter : "" });
|
||||
const nextDraft = { ...emptyAssignmentDraft(), function_id: initialFunctionFilter && functionById.has(initialFunctionFilter) ? initialFunctionFilter : "" };
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setAssignmentChangeRequestId("");
|
||||
setAssignmentEditorOpen(true);
|
||||
}, [functionById, initialFunctionFilter]);
|
||||
|
||||
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
|
||||
const nextDraft = assignmentDraftFrom(item);
|
||||
setEditingAssignmentId(item.id);
|
||||
setAssignmentDraft(assignmentDraftFrom(item));
|
||||
setAssignmentDraft(nextDraft);
|
||||
setAssignmentBaseline(nextDraft);
|
||||
setAssignmentChangeRequestId("");
|
||||
setAssignmentEditorOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeAssignmentEditor = useCallback(() => {
|
||||
if (busy) return;
|
||||
if (hasDirtyAssignmentDraft) requestDiscard(discardAssignmentDraft);
|
||||
else discardAssignmentDraft();
|
||||
}, [busy, discardAssignmentDraft, hasDirtyAssignmentDraft, requestDiscard]);
|
||||
|
||||
function onIdentityChange(identityId: string) {
|
||||
const identity = identityOptionById.get(identityId);
|
||||
setAssignmentDraft({
|
||||
@@ -594,7 +605,14 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
header: "Actions",
|
||||
width: 72,
|
||||
sticky: "end",
|
||||
render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-idm.edit.a5a0f3cc", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canManage || busy, onClick: () => editAssignment(row) }]} />
|
||||
render: (row) => <TableActionGroup actions={[{
|
||||
id: "edit",
|
||||
label: "i18n:govoplan-idm.edit.a5a0f3cc",
|
||||
icon: <Edit3 size={16} aria-hidden="true" />,
|
||||
disabled: !canManage || busy,
|
||||
disabledReason: idmDisabledReason(false, busy, canManage),
|
||||
onClick: () => editAssignment(row)
|
||||
}]} />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -607,7 +625,14 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
|
||||
</div>
|
||||
<div className="idm-toolbar">
|
||||
<Button type="button" onClick={() => void loadData()} disabled={loading || busy} title="i18n:govoplan-idm.reload.870ca3ec">
|
||||
<DocumentationHelpLink reference={IDM_DOCUMENTATION} />
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => requestDiscard(() => void loadData())}
|
||||
disabled={loading || busy}
|
||||
disabledReason={idmDisabledReason(loading, busy)}
|
||||
title="i18n:govoplan-idm.reload.870ca3ec"
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
|
||||
</Button>
|
||||
</div>
|
||||
@@ -615,17 +640,53 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
{!canManage && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-idm.write_permission_required.c7dde7c6</DismissibleAlert>}
|
||||
{!canManage && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: IDM_INTERFACE_I18N.writeReason,
|
||||
requiredAction: IDM_INTERFACE_I18N.permissionAction,
|
||||
actor: IDM_INTERFACE_I18N.permissionActor,
|
||||
target: IDM_INTERFACE_I18N.permissionDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||
actor: IDM_INTERFACE_I18N.actor,
|
||||
target: IDM_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={IDM_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
{!model.functions.length && !loading && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: IDM_INTERFACE_I18N.noFunctions,
|
||||
requiredAction: IDM_INTERFACE_I18N.functionAction,
|
||||
actor: IDM_INTERFACE_I18N.functionActor,
|
||||
target: IDM_INTERFACE_I18N.functionDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||
actor: IDM_INTERFACE_I18N.actor,
|
||||
target: IDM_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={IDM_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd">
|
||||
<div className="idm-table-stack">
|
||||
{canReadSettings && (
|
||||
<Card title="i18n:govoplan-idm.idm_governance.6e4f3251" collapsible collapseKey="idm.governance">
|
||||
<Card
|
||||
title="i18n:govoplan-idm.idm_governance.6e4f3251"
|
||||
collapsible
|
||||
collapseKey="idm.governance"
|
||||
actions={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
|
||||
>
|
||||
<form className="admin-form-grid two-columns" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
|
||||
<div className="idm-check-list wide">
|
||||
<ToggleSwitch label="i18n:govoplan-idm.require_assignment_change_requests.697718a1" checked={settingsDraft.require_assignment_change_requests} disabled={!canManageSettings || busy} onChange={(require_assignment_change_requests) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests })} />
|
||||
<ToggleSwitch label="i18n:govoplan-idm.require_assignment_change_requests.697718a1" checked={settingsDraft.require_assignment_change_requests} disabled={!canManageSettings || busy} help={idmDisabledReason(false, busy, canManageSettings)} onChange={(require_assignment_change_requests) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests })} />
|
||||
</div>
|
||||
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2">
|
||||
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={settingsDraft.audit_detail_level}
|
||||
disabled={!canManageSettings || busy}
|
||||
@@ -636,7 +697,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3">
|
||||
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -646,7 +707,12 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
/>
|
||||
</FormField>
|
||||
<div className="button-row compact-actions wide">
|
||||
<Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canManageSettings || busy || !hasDirtySettingsDraft}
|
||||
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (!hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
|
||||
>
|
||||
i18n:govoplan-idm.save_settings.4602c430
|
||||
</Button>
|
||||
</div>
|
||||
@@ -661,7 +727,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
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} />}>
|
||||
<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 || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
|
||||
<DataGrid
|
||||
id="idm-organization-function-assignments"
|
||||
rows={assignments}
|
||||
@@ -685,20 +751,26 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<Dialog
|
||||
open={assignmentEditorOpen}
|
||||
title={editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||
onClose={() => !busy && discardAssignmentDraft()}
|
||||
onClose={closeAssignmentEditor}
|
||||
closeDisabled={busy}
|
||||
className="admin-dialog admin-dialog-wide idm-editor-dialog"
|
||||
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 || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())}>
|
||||
<Button type="button" onClick={closeAssignmentEditor} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="primary"
|
||||
disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())}
|
||||
disabledReason={idmDisabledReason(false, busy, canManage) ?? ((!assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())) ? IDM_INTERFACE_I18N.incomplete : undefined)}
|
||||
>
|
||||
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submitAssignment(event)}>
|
||||
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf">
|
||||
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={identitySearch}
|
||||
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
|
||||
@@ -706,7 +778,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
onChange={(event) => setIdentitySearch(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.select_identity.91d31615">
|
||||
<FormField label="i18n:govoplan-idm.select_identity.91d31615" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.identity_id} disabled={!canManage || busy} onChange={(event) => onIdentityChange(event.target.value)}>
|
||||
<option value="">i18n:govoplan-idm.select_identity.91d31615</option>
|
||||
{identitySelectOptions.map((item) => (
|
||||
@@ -714,25 +786,25 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.account.2b2936f8">
|
||||
<FormField label="i18n:govoplan-idm.account.2b2936f8" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })}>
|
||||
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
|
||||
{accountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.select_function.2bec86e0">
|
||||
<FormField label="i18n:govoplan-idm.select_function.2bec86e0" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.function_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, function_id: event.target.value })}>
|
||||
<option value="">i18n:govoplan-idm.select_function.2bec86e0</option>
|
||||
{model.functions.map((item) => <option key={item.id} value={item.id}>{functionLabel(item, unitById)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.source.d15b50c9">
|
||||
<FormField label="i18n:govoplan-idm.source.d15b50c9" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.source} disabled={!canManage || busy} onChange={(event) => onSourceChange(event.target.value)}>
|
||||
{SOURCE_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
{(assignmentDraft.source === "delegated" || assignmentDraft.source === "acting_for") && (
|
||||
<FormField label="i18n:govoplan-idm.delegated_from_assignment_id.20d4a548">
|
||||
<FormField label="i18n:govoplan-idm.delegated_from_assignment_id.20d4a548" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.delegated_from_assignment_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, delegated_from_assignment_id: event.target.value, acting_for_account_id: "" })}>
|
||||
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
|
||||
{assignmentOptions.map((item) => (
|
||||
@@ -743,7 +815,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
)}
|
||||
{assignmentDraft.source === "acting_for" && (
|
||||
<>
|
||||
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7">
|
||||
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={actingForSearch}
|
||||
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
|
||||
@@ -751,7 +823,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
onChange={(event) => setActingForSearch(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b">
|
||||
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<select value={assignmentDraft.acting_for_account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, acting_for_account_id: event.target.value })}>
|
||||
<option value="">i18n:govoplan-idm.select_account.982ee1ad</option>
|
||||
{actingForAccountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
@@ -760,15 +832,16 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</>
|
||||
)}
|
||||
<div className="idm-check-list">
|
||||
<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 })} />
|
||||
<ToggleSwitch label="i18n:govoplan-idm.applies_to_subunits.2e31b50b" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} help={idmDisabledReason(false, busy, canManage)} onChange={(applies_to_subunits) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-idm.active.7bd0e9f8" checked={assignmentDraft.is_active} disabled={!canManage || busy} help={idmDisabledReason(false, busy, canManage)} 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.
|
||||
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
|
||||
</DismissibleAlert>
|
||||
<FormField label="Emergency override reason">
|
||||
<FormField label="Emergency override reason" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={assignmentDraft.governance_override_reason}
|
||||
@@ -776,7 +849,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Override evidence references (one per line)">
|
||||
<FormField label="Override evidence references (one per line)" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={assignmentDraft.governance_override_evidence}
|
||||
@@ -787,12 +860,29 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="wide idm-dialog-change-request">
|
||||
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db">
|
||||
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||
<input value={assignmentChangeRequestId} onChange={(event) => setAssignmentChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
||||
</FormField>
|
||||
<p className="idm-muted">i18n:govoplan-idm.change_request_id_help.cc7de508</p>
|
||||
</div>
|
||||
{!identityLookupAvailable && <p className="idm-muted wide">i18n:govoplan-idm.identity_lookup_unavailable.b76f7714</p>}
|
||||
{!identityLookupAvailable && (
|
||||
<div className="wide">
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "i18n:govoplan-idm.identity_lookup_unavailable.b76f7714",
|
||||
requiredAction: IDM_INTERFACE_I18N.permissionAction,
|
||||
actor: IDM_INTERFACE_I18N.permissionActor,
|
||||
target: IDM_INTERFACE_I18N.permissionDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||
actor: IDM_INTERFACE_I18N.actor,
|
||||
target: IDM_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={IDM_DOCUMENTATION}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{identityLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_identities.f3b84693</p>}
|
||||
{actingForLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e</p>}
|
||||
{!model.functions.length && <p className="idm-muted wide">i18n:govoplan-idm.no_functions_available.51ba08eb</p>}
|
||||
@@ -801,3 +891,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const IDM_DOCUMENTATION = {
|
||||
topicId: "idm.workflow.assign-function-to-identity",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_GOVERNANCE_DOCUMENTATION = {
|
||||
topicId: "idm.reference.assignment-governance",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_FIELD_DOCUMENTATION = {
|
||||
topicId: "idm.reference.fields-and-consequences",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_INTERFACE_I18N = {
|
||||
loading: "i18n:govoplan-idm.loading_reason",
|
||||
busy: "i18n:govoplan-idm.busy_reason",
|
||||
writeReason: "i18n:govoplan-idm.write_permission_required.c7dde7c6",
|
||||
settingsReason: "i18n:govoplan-idm.settings_write_permission_required.37f37efa",
|
||||
searchReason: "i18n:govoplan-idm.identity_search_permission_reason",
|
||||
incomplete: "i18n:govoplan-idm.incomplete_draft_reason",
|
||||
noChanges: "i18n:govoplan-idm.no_changes_reason",
|
||||
noFunctions: "i18n:govoplan-idm.no_functions_available.51ba08eb",
|
||||
requiredAction: "i18n:govoplan-idm.required_action",
|
||||
actor: "i18n:govoplan-idm.responsible_actor",
|
||||
destination: "i18n:govoplan-idm.destination",
|
||||
permissionAction: "i18n:govoplan-idm.permission_required_action",
|
||||
permissionActor: "i18n:govoplan-idm.permission_responsible_actor",
|
||||
permissionDestination: "i18n:govoplan-idm.permission_destination",
|
||||
functionAction: "i18n:govoplan-idm.function_required_action",
|
||||
functionActor: "i18n:govoplan-idm.function_responsible_actor",
|
||||
functionDestination: "i18n:govoplan-idm.function_destination"
|
||||
} as const;
|
||||
|
||||
export function idmDisabledReason(
|
||||
loading: boolean,
|
||||
busy: boolean,
|
||||
permitted = true
|
||||
): string | undefined {
|
||||
if (loading) return IDM_INTERFACE_I18N.loading;
|
||||
if (busy) return IDM_INTERFACE_I18N.busy;
|
||||
if (!permitted) return IDM_INTERFACE_I18N.writeReason;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,96 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.unit.a94c2fbd": "Unit",
|
||||
"i18n:govoplan-idm.update_assignment.e20f52aa": "Update assignment",
|
||||
"i18n:govoplan-idm.view_assignments.2d40d6a5": "View assignments",
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "You do not have permission to manage IDM organization assignments."
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "You do not have permission to manage IDM organization assignments.",
|
||||
"i18n:govoplan-idm.loading_reason": "IDM data is still loading.",
|
||||
"i18n:govoplan-idm.busy_reason": "Another IDM action is still running.",
|
||||
"i18n:govoplan-idm.identity_search_permission_reason": "Your account may not search identity candidates.",
|
||||
"i18n:govoplan-idm.incomplete_draft_reason": "Complete all required fields before submitting.",
|
||||
"i18n:govoplan-idm.no_changes_reason": "There are no settings changes to save.",
|
||||
"i18n:govoplan-idm.required_action": "Required action",
|
||||
"i18n:govoplan-idm.responsible_actor": "Responsible actor",
|
||||
"i18n:govoplan-idm.destination": "Destination",
|
||||
"i18n:govoplan-idm.permission_required_action": "Ask for the corresponding IDM management permission.",
|
||||
"i18n:govoplan-idm.permission_responsible_actor": "An Access or tenant administrator",
|
||||
"i18n:govoplan-idm.permission_destination": "Access role assignments",
|
||||
"i18n:govoplan-idm.function_required_action": "Create or activate an organization function first.",
|
||||
"i18n:govoplan-idm.function_responsible_actor": "An organization administrator",
|
||||
"i18n:govoplan-idm.function_destination": "Organizations",
|
||||
"i18n:govoplan-idm.confirm_function_action": "{action} the governed change for {function}? The decision and actor are retained as evidence.",
|
||||
"i18n:govoplan-idm.function_request_title": "Function request: {function}",
|
||||
"i18n:govoplan-idm.function_grant_title": "Function grant: {function}",
|
||||
"i18n:govoplan-idm.action_approve": "Approve",
|
||||
"i18n:govoplan-idm.action_reject": "Reject",
|
||||
"i18n:govoplan-idm.action_accept": "Accept",
|
||||
"i18n:govoplan-idm.action_request_changes": "Request changes",
|
||||
"i18n:govoplan-idm.action_respond": "Respond",
|
||||
"i18n:govoplan-idm.action_withdraw": "Withdraw",
|
||||
"i18n:govoplan-idm.action_recheck": "Recheck",
|
||||
"i18n:govoplan-idm.kind_request": "Request",
|
||||
"i18n:govoplan-idm.kind_grant": "Grant",
|
||||
"i18n:govoplan-idm.state_submitted": "Submitted",
|
||||
"i18n:govoplan-idm.state_awaiting_holder": "Awaiting holder",
|
||||
"i18n:govoplan-idm.state_awaiting_authority": "Awaiting authority",
|
||||
"i18n:govoplan-idm.state_awaiting_recipient": "Awaiting recipient",
|
||||
"i18n:govoplan-idm.state_changes_requested": "Changes requested",
|
||||
"i18n:govoplan-idm.state_applied": "Applied",
|
||||
"i18n:govoplan-idm.state_rejected": "Rejected",
|
||||
"i18n:govoplan-idm.state_expired": "Expired",
|
||||
"i18n:govoplan-idm.state_withdrawn": "Withdrawn",
|
||||
"i18n:govoplan-idm.state_cancelled": "Cancelled",
|
||||
"i18n:govoplan-idm.state_blocked": "Blocked",
|
||||
"i18n:govoplan-idm.state_failed_manual_review": "Manual review failed",
|
||||
"i18n:govoplan-idm.step_approve_holder": "Holder approval",
|
||||
"i18n:govoplan-idm.step_approve_authority": "Authority approval",
|
||||
"i18n:govoplan-idm.step_accept_recipient": "Recipient acceptance",
|
||||
"i18n:govoplan-idm.profile_holder_review": "Holder review",
|
||||
"i18n:govoplan-idm.profile_authority_review": "Authority review",
|
||||
"i18n:govoplan-idm.profile_recipient_review": "Recipient review",
|
||||
"Actions": "Actions",
|
||||
"Direct changes to this governed function require an emergency override reason.": "Direct changes to this governed function require an emergency override reason.",
|
||||
"Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.": "Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.",
|
||||
"Emergency override reason": "Emergency override reason",
|
||||
"Override evidence references (one per line)": "Override evidence references (one per line)",
|
||||
"Unsaved function change": "Unsaved function change",
|
||||
"Save or discard the function request or grant before leaving this surface.": "Save or discard the function request or grant before leaving this surface.",
|
||||
"Kind": "Kind",
|
||||
"Function": "Function",
|
||||
"Candidate": "Candidate",
|
||||
"State": "State",
|
||||
"Decisions": "Decisions",
|
||||
"Updated": "Updated",
|
||||
"Open change": "Open change",
|
||||
"Function requests and grants": "Function requests and grants",
|
||||
"Start governed change": "Start governed change",
|
||||
"Loading governed function changes": "Loading governed function changes",
|
||||
"No governed function changes": "No governed function changes",
|
||||
"Start governed function change": "Start governed function change",
|
||||
"Cancel": "Cancel",
|
||||
"Submit": "Submit",
|
||||
"Function change kind": "Function change kind",
|
||||
"Request function": "Request function",
|
||||
"Grant function": "Grant function",
|
||||
"Select function": "Select function",
|
||||
"Candidate identity": "Candidate identity",
|
||||
"Select identity": "Select identity",
|
||||
"Candidate account": "Candidate account",
|
||||
"No linked account": "No linked account",
|
||||
"Valid from": "Valid from",
|
||||
"Valid until": "Valid until",
|
||||
"Justification": "Justification",
|
||||
"Evidence references (one per line)": "Evidence references (one per line)",
|
||||
"Function change": "Function change",
|
||||
"Close": "Close",
|
||||
"Profile": "Profile",
|
||||
"Workflow revision": "Workflow revision",
|
||||
"Required decisions": "Required decisions",
|
||||
"Completed decisions": "Completed decisions",
|
||||
"Explanation": "Explanation",
|
||||
"Decision comment": "Decision comment",
|
||||
"History": "History",
|
||||
"Confirm function decision": "Confirm function decision",
|
||||
"Confirm": "Confirm",
|
||||
"None": "None"
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-idm.account.2b2936f8": "Konto",
|
||||
@@ -125,6 +214,95 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.unit.a94c2fbd": "Einheit",
|
||||
"i18n:govoplan-idm.update_assignment.e20f52aa": "Zuordnung aktualisieren",
|
||||
"i18n:govoplan-idm.view_assignments.2d40d6a5": "Zuordnungen anzeigen",
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "Du hast keine Berechtigung, IDM-Organisationszuordnungen zu verwalten."
|
||||
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "Du hast keine Berechtigung, IDM-Organisationszuordnungen zu verwalten.",
|
||||
"i18n:govoplan-idm.loading_reason": "IDM-Daten werden noch geladen.",
|
||||
"i18n:govoplan-idm.busy_reason": "Eine andere IDM-Aktion läuft noch.",
|
||||
"i18n:govoplan-idm.identity_search_permission_reason": "Ihr Konto darf keine Identitätskandidaten suchen.",
|
||||
"i18n:govoplan-idm.incomplete_draft_reason": "Füllen Sie vor dem Absenden alle Pflichtfelder aus.",
|
||||
"i18n:govoplan-idm.no_changes_reason": "Es gibt keine Einstellungsänderungen zu speichern.",
|
||||
"i18n:govoplan-idm.required_action": "Erforderliche Aktion",
|
||||
"i18n:govoplan-idm.responsible_actor": "Verantwortliche Stelle",
|
||||
"i18n:govoplan-idm.destination": "Ziel",
|
||||
"i18n:govoplan-idm.permission_required_action": "Fordern Sie die entsprechende IDM-Verwaltungsberechtigung an.",
|
||||
"i18n:govoplan-idm.permission_responsible_actor": "Access- oder Mandantenadministration",
|
||||
"i18n:govoplan-idm.permission_destination": "Access-Rollenzuordnungen",
|
||||
"i18n:govoplan-idm.function_required_action": "Erstellen oder aktivieren Sie zuerst eine Organisationsfunktion.",
|
||||
"i18n:govoplan-idm.function_responsible_actor": "Organisationsadministration",
|
||||
"i18n:govoplan-idm.function_destination": "Organisationen",
|
||||
"i18n:govoplan-idm.confirm_function_action": "{action} der gesteuerten Änderung für {function}? Entscheidung und handelnde Person werden als Nachweis aufbewahrt.",
|
||||
"i18n:govoplan-idm.function_request_title": "Funktionsantrag: {function}",
|
||||
"i18n:govoplan-idm.function_grant_title": "Funktionsvergabe: {function}",
|
||||
"i18n:govoplan-idm.action_approve": "Genehmigen",
|
||||
"i18n:govoplan-idm.action_reject": "Ablehnen",
|
||||
"i18n:govoplan-idm.action_accept": "Annehmen",
|
||||
"i18n:govoplan-idm.action_request_changes": "Änderungen anfordern",
|
||||
"i18n:govoplan-idm.action_respond": "Antworten",
|
||||
"i18n:govoplan-idm.action_withdraw": "Zurückziehen",
|
||||
"i18n:govoplan-idm.action_recheck": "Erneut prüfen",
|
||||
"i18n:govoplan-idm.kind_request": "Antrag",
|
||||
"i18n:govoplan-idm.kind_grant": "Vergabe",
|
||||
"i18n:govoplan-idm.state_submitted": "Eingereicht",
|
||||
"i18n:govoplan-idm.state_awaiting_holder": "Wartet auf Funktionsinhaber",
|
||||
"i18n:govoplan-idm.state_awaiting_authority": "Wartet auf zuständige Stelle",
|
||||
"i18n:govoplan-idm.state_awaiting_recipient": "Wartet auf Empfänger",
|
||||
"i18n:govoplan-idm.state_changes_requested": "Änderungen angefordert",
|
||||
"i18n:govoplan-idm.state_applied": "Angewendet",
|
||||
"i18n:govoplan-idm.state_rejected": "Abgelehnt",
|
||||
"i18n:govoplan-idm.state_expired": "Abgelaufen",
|
||||
"i18n:govoplan-idm.state_withdrawn": "Zurückgezogen",
|
||||
"i18n:govoplan-idm.state_cancelled": "Abgebrochen",
|
||||
"i18n:govoplan-idm.state_blocked": "Blockiert",
|
||||
"i18n:govoplan-idm.state_failed_manual_review": "Manuelle Prüfung fehlgeschlagen",
|
||||
"i18n:govoplan-idm.step_approve_holder": "Genehmigung durch Funktionsinhaber",
|
||||
"i18n:govoplan-idm.step_approve_authority": "Genehmigung durch zuständige Stelle",
|
||||
"i18n:govoplan-idm.step_accept_recipient": "Annahme durch Empfänger",
|
||||
"i18n:govoplan-idm.profile_holder_review": "Prüfung durch Funktionsinhaber",
|
||||
"i18n:govoplan-idm.profile_authority_review": "Prüfung durch zuständige Stelle",
|
||||
"i18n:govoplan-idm.profile_recipient_review": "Prüfung durch Empfänger",
|
||||
"Actions": "Aktionen",
|
||||
"Direct changes to this governed function require an emergency override reason.": "Direkte Änderungen an dieser gesteuerten Funktion erfordern eine Begründung für die Notfallübersteuerung.",
|
||||
"Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.": "Direkte Änderungen an dieser gesteuerten Funktion sind Notfallübersteuerungen. Verwenden Sie für den regulären Prozess einen Antrag oder eine Vergabe.",
|
||||
"Emergency override reason": "Begründung der Notfallübersteuerung",
|
||||
"Override evidence references (one per line)": "Nachweisreferenzen der Übersteuerung (eine pro Zeile)",
|
||||
"Unsaved function change": "Ungespeicherte Funktionsänderung",
|
||||
"Save or discard the function request or grant before leaving this surface.": "Speichern oder verwerfen Sie den Funktionsantrag oder die Vergabe, bevor Sie diesen Bereich verlassen.",
|
||||
"Kind": "Art",
|
||||
"Function": "Funktion",
|
||||
"Candidate": "Kandidat",
|
||||
"State": "Status",
|
||||
"Decisions": "Entscheidungen",
|
||||
"Updated": "Aktualisiert",
|
||||
"Open change": "Änderung öffnen",
|
||||
"Function requests and grants": "Funktionsanträge und -vergaben",
|
||||
"Start governed change": "Gesteuerte Änderung starten",
|
||||
"Loading governed function changes": "Gesteuerte Funktionsänderungen werden geladen",
|
||||
"No governed function changes": "Keine gesteuerten Funktionsänderungen",
|
||||
"Start governed function change": "Gesteuerte Funktionsänderung starten",
|
||||
"Cancel": "Abbrechen",
|
||||
"Submit": "Absenden",
|
||||
"Function change kind": "Art der Funktionsänderung",
|
||||
"Request function": "Funktion beantragen",
|
||||
"Grant function": "Funktion vergeben",
|
||||
"Select function": "Funktion auswählen",
|
||||
"Candidate identity": "Kandidatenidentität",
|
||||
"Select identity": "Identität auswählen",
|
||||
"Candidate account": "Kandidatenkonto",
|
||||
"No linked account": "Kein verknüpftes Konto",
|
||||
"Valid from": "Gültig ab",
|
||||
"Valid until": "Gültig bis",
|
||||
"Justification": "Begründung",
|
||||
"Evidence references (one per line)": "Nachweisreferenzen (eine pro Zeile)",
|
||||
"Function change": "Funktionsänderung",
|
||||
"Close": "Schließen",
|
||||
"Profile": "Profil",
|
||||
"Workflow revision": "Workflow-Revision",
|
||||
"Required decisions": "Erforderliche Entscheidungen",
|
||||
"Completed decisions": "Abgeschlossene Entscheidungen",
|
||||
"Explanation": "Erläuterung",
|
||||
"Decision comment": "Entscheidungskommentar",
|
||||
"History": "Verlauf",
|
||||
"Confirm function decision": "Funktionsentscheidung bestätigen",
|
||||
"Confirm": "Bestätigen",
|
||||
"None": "Keine"
|
||||
}
|
||||
};
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ const organizationFunctionActions: OrganizationFunctionActionsUiCapability = {
|
||||
export const idmModule: PlatformWebModule = {
|
||||
id: "idm",
|
||||
label: "i18n:govoplan-idm.idm.61f4a7a2",
|
||||
version: "0.1.6",
|
||||
version: "0.1.8",
|
||||
dependencies: ["identity", "organizations"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
@@ -46,7 +46,7 @@ export const idmModule: PlatformWebModule = {
|
||||
id: "idm.action.view-function-assignments",
|
||||
moduleId: "idm",
|
||||
kind: "action",
|
||||
label: "View function assignments",
|
||||
label: "i18n:govoplan-idm.view_assignments.2d40d6a5",
|
||||
order: 40
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user