diff --git a/README.md b/README.md index 5392a18..cfc8fce 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..1a785b4 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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. + diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index 8561b2e..3bfab93 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -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", diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..87f399e --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -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() + diff --git a/webui/package.json b/webui/package.json index f2d2622..de389e0 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs new file mode 100644 index 0000000..b2a34dc --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -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."); + diff --git a/webui/src/features/FunctionAssignmentChangesPanel.tsx b/webui/src/features/FunctionAssignmentChangesPanel.tsx index 91eed58..8ff1a0e 100644 --- a/webui/src/features/FunctionAssignmentChangesPanel.tsx +++ b/webui/src/features/FunctionAssignmentChangesPanel.tsx @@ -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 = { + 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([]); const [draft, setDraft] = useState(() => emptyDraft(auth, initialKind)); + const [draftBaseline, setDraftBaseline] = useState(() => emptyDraft(auth, initialKind)); const [selected, setSelected] = useState(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(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 { + 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[] = [ - { 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) => }, + { id: "state", header: "State", width: 170, sortable: true, filterable: true, value: (row) => row.state, render: (row) => }, { 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) =>