Migrate IDM interface patterns

This commit is contained in:
2026-08-03 12:36:49 +02:00
parent 6f6c45f6e2
commit d8643174d7
11 changed files with 766 additions and 84 deletions
+4
View File
@@ -91,6 +91,10 @@ planning.
The WebUI exposed by this repository is a normal module UI at `/idm`. It is the
editing surface for identity-to-organization-function assignment links.
Its interface archetypes, consequence classes, contextual-help contract, and
accessibility evidence are recorded in
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
## Migration And Permission Transition
The initial IDM migration creates new IDM-owned tables only. No data is migrated
+51
View File
@@ -0,0 +1,51 @@
# IDM Interface Pattern Migration
This document records the bounded migration of IDM-owned WebUI surfaces to the
GovOPlaN interface pattern language. Identity owns people and account links,
Organizations owns functions and units, Access owns application authority, and
IDM owns effective identity-to-function facts and their governed lifecycle.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/idm` assignments | Repeated administration and governed relationship directory | Create, change, deactivate, delegate, or act for an assignment | Shared DataGrid/dialog/actions, accurate draft baseline, explicit permission and organization prerequisites, contextual field help |
| IDM governance settings | Effective tenant configuration | Require change evidence and alter audit retention | Shared card/form/toggles, dirty-state guard, write blocker, consequence help |
| Function request/grant list | Governed work queue | Start or inspect a function change | Shared grid/loading/status/action slot, localized state and workflow vocabulary |
| Request/grant editor | Guided consequential editor | Submit a governed assignment change | Shared segmented control/dialog/forms, guarded draft, effective dates, justification and evidence help |
| Function-change detail | Decision and provenance record | Approve, reject, accept, request changes, withdraw, respond, or recover | Shared confirmation, available-action contract, retained actor/policy/workflow/history evidence |
| `idm.action.view-function-assignments` | Contextual cross-module action | Navigate with function context | Declared capability surface, permission guard, no Organizations-private import |
## Consequence And Availability Rules
- Opening an existing assignment does not mark it dirty. The guard compares the
editor to the loaded baseline and protects only actual changes.
- Direct assignment changes require IDM write authority. Governed functions use
request/grant workflows for normal changes; direct edits are emergency
overrides with a retained reason and optional evidence references.
- Delegated and acting-for assignments remain tied to a valid source assignment
and the Organizations flags that permit those semantics.
- Assignment and governance settings do not create application permissions.
Access must separately map accepted institutional facts to assignable roles.
- Decisions are confirmed before execution. The resulting actor, comment,
policy decision, workflow revision, state transition, and evidence remain in
the governed record.
- Deactivation and expiry remove a fact from effective resolution while
retaining provenance and lifecycle evidence.
- Missing permission, identity search, and organization functions identify the
required action, responsible administrator, and destination.
## State And Accessibility Evidence
The module uses Core page/card/grid/dialog/loading/alert/status/action-blocker,
field-help, disabled-reason, confirmation, and unsaved-change controls. Shared
dialogs retain focus containment and return behavior; stable grid actions remain
keyboard reachable. Existing responsive CSS collapses summaries and histories
to one column at narrow widths.
English and German catalogues cover route metadata, assignment fields, governed
states, workflow steps, decisions, confirmations, and accessible labels. Dates
follow the selected platform locale. Manifest topics provide stable route,
field, blocker, workflow, and consequence references without importing optional
Policy, Audit, Notifications, Access, or Workflow Engine implementations.
+111
View File
@@ -29,6 +29,8 @@ from govoplan_core.core.organizations import (
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -327,6 +329,34 @@ manifest = ModuleManifest(
documentation_types=("admin",),
audience=("tenant_admin", "access_admin", "operator"),
related_modules=("identity", "organizations", "access", "audit", "policy"),
links=(
DocumentationLink(
label="IDM workspace",
href="/idm",
kind="runtime",
),
DocumentationLink(
label="IDM settings API",
href="/api/v1/idm/settings",
kind="api",
),
),
metadata={
"kind": "reference",
"help_contexts": [
"idm.governance.settings",
"idm.assignment.change-request",
"idm.assignment.emergency-override",
"idm.function-change.request",
"idm.function-change.grant",
"idm.function-change.decision",
],
"consequence_classes": {
"governance_settings": "Changes whether direct assignment mutations require approved change evidence.",
"emergency_override": "Bypasses the normal governed request or grant path and requires retained reason and evidence.",
"function_decision": "Advances or terminates a governed change and retains actor, comment, policy, and workflow evidence.",
},
},
order=27,
),
DocumentationTopic(
@@ -359,8 +389,89 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator", "user"),
related_modules=("identity", "organizations", "access"),
conditions=(
DocumentationCondition(any_scopes=IDM_READ_SCOPES),
),
links=(
DocumentationLink(
label="IDM assignments",
href="/idm",
kind="runtime",
),
DocumentationLink(
label="Organization function assignments API",
href="/api/v1/idm/organization-function-assignments",
kind="api",
),
),
metadata={
"kind": "workflow",
"help_contexts": [
"idm.route.assignments",
"idm.action.view-function-assignments",
"idm.blocker.permission",
"idm.blocker.no-functions",
"idm.blocker.identity-search",
],
},
order=28,
),
DocumentationTopic(
id="idm.reference.fields-and-consequences",
title="IDM assignment fields and consequences",
summary=(
"Reference for direct assignments, delegation, acting-for, "
"effective dates, governed changes, evidence, and retention."
),
body=(
"Identity and account select who receives the institutional fact; "
"function and unit are owned by Organizations. Source distinguishes "
"direct, delegated, acting-for, directory, governance, and system facts. "
"Delegation and acting-for require a valid source assignment and the "
"corresponding function permission. Subunit scope broadens the fact's "
"organizational reach. Deactivation and expiry preserve provenance while "
"removing the assignment from effective resolution. Governed request and "
"grant decisions retain actor, policy, workflow revision, comments, and "
"evidence. An emergency override is not the normal process and must carry "
"an explicit reason. An IDM assignment alone never grants application "
"permissions; Access requires an explicit mapping."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator", "user"),
related_modules=("identity", "organizations", "access", "policy", "audit", "workflow_engine"),
links=(
DocumentationLink(
label="Function assignment workflows",
href="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
kind="repository",
),
),
metadata={
"kind": "reference",
"help_contexts": [
"idm.field.identity",
"idm.field.account",
"idm.field.function",
"idm.field.source",
"idm.field.delegation",
"idm.field.acting-for",
"idm.field.subunits",
"idm.field.effective-dates",
"idm.field.justification",
"idm.field.evidence",
"idm.field.retention",
],
"consequence_classes": {
"assignment": "Changes the effective institutional function fact consumed by optional downstream capabilities.",
"deactivate_or_expire": "Removes the fact from effective resolution while retaining provenance and lifecycle evidence.",
"delegation": "Creates a bounded derived assignment that remains tied to its source assignment.",
"acting_for": "Allows a bounded account to act in place of a source assignment when Organizations permits it.",
"retention": "Changes how long detailed assignment-change evidence remains available.",
},
},
order=29,
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
@@ -0,0 +1,39 @@
from __future__ import annotations
import unittest
from govoplan_idm.backend.manifest import manifest
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
def test_route_and_contributed_action_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual(
{"/idm"},
{route.path for route in frontend.routes}, # type: ignore[union-attr]
)
self.assertEqual(
{"idm.action.view-function-assignments"},
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
)
self.assertTrue(all(item.icon == "users" for item in manifest.nav_items))
def test_topics_publish_workflow_blocker_and_consequence_metadata(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
self.assertIn("idm.workflow.assign-function-to-identity", topics)
self.assertIn("idm.reference.assignment-governance", topics)
self.assertIn("idm.reference.fields-and-consequences", topics)
workflow = topics["idm.workflow.assign-function-to-identity"]
self.assertIn("idm.blocker.permission", workflow.metadata["help_contexts"])
governance = topics["idm.reference.assignment-governance"]
self.assertIn("function_decision", governance.metadata["consequence_classes"])
reference = topics["idm.reference.fields-and-consequences"]
self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"])
self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"])
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -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
View File
@@ -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);
}
+48
View File
@@ -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;
}
+180 -2
View File
@@ -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
View File
@@ -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
}
],