4 Commits

Author SHA1 Message Date
b47f71916d Release v0.1.8 2026-07-11 16:49:02 +02:00
b9b371a704 Polish IDM assignment table layout 2026-07-11 03:02:46 +02:00
3b0e8bc600 Release v0.1.7 2026-07-11 03:00:44 +02:00
05a10846a8 Support deep links into IDM assignments 2026-07-11 00:55:23 +02:00
11 changed files with 245 additions and 148 deletions

View File

@@ -1,5 +1,9 @@
# GovOPlaN IDM # GovOPlaN IDM
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-idm` is the planned integration module for external identity `govoplan-idm` is the planned integration module for external identity
management systems. It does not own GovOPlaN's internal identity, organization, management systems. It does not own GovOPlaN's internal identity, organization,
account, role, or tenant tables; those remain with `govoplan-identity`, account, role, or tenant tables; those remain with `govoplan-identity`,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-idm" name = "govoplan-idm"
version = "0.1.6" version = "0.1.8"
description = "GovOPlaN identity management bridge module." description = "GovOPlaN identity management bridge module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.6", "govoplan-core>=0.1.8",
"govoplan-identity>=0.1.6", "govoplan-identity>=0.1.8",
"govoplan-organizations>=0.1.6", "govoplan-organizations>=0.1.8",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -102,7 +102,7 @@ def _idm_directory(context: ModuleContext) -> object:
manifest = ModuleManifest( manifest = ModuleManifest(
id="idm", id="idm",
name="IDM", name="IDM",
version="0.1.6", version="0.1.8",
dependencies=("identity", "organizations"), dependencies=("identity", "organizations"),
optional_dependencies=("access", "audit"), optional_dependencies=("access", "audit"),
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),

View File

@@ -0,0 +1 @@
"""IDM migration revisions."""

View File

@@ -0,0 +1,65 @@
"""v0.1.7 idm baseline
Revision ID: 8f9a0b1c2d3e
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '8f9a0b1c2d3e'
down_revision = None
branch_labels = None
depends_on = ('5c6d7e8f9a10', '6d7e8f9a0b1c')
def upgrade() -> None:
op.create_table('idm_tenant_settings',
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('require_assignment_change_requests', sa.Boolean(), nullable=False),
sa.Column('audit_detail_level', sa.String(length=20), nullable=False),
sa.Column('change_retention_days', sa.Integer(), nullable=True),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('tenant_id', name=op.f('pk_idm_tenant_settings'))
)
op.create_table('idm_organization_function_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('identity_id', sa.String(length=36), nullable=False),
sa.Column('account_id', sa.String(length=36), nullable=True),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['idm_organization_function_assignments.id'], name=op.f('fk_idm_organization_function_assignments_delegated_from_assignment_id_idm_organization_function_assignments'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['function_id'], ['organizations_functions.id'], name=op.f('fk_idm_organization_function_assignments_function_id_organizations_functions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['identity_id'], ['identity_identities.id'], name=op.f('fk_idm_organization_function_assignments_identity_id_identity_identities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['organization_unit_id'], ['organizations_units.id'], name=op.f('fk_idm_organization_function_assignments_organization_unit_id_organizations_units'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_idm_organization_function_assignments')),
sa.UniqueConstraint('tenant_id', 'identity_id', 'function_id', 'organization_unit_id', name='uq_idm_org_function_assignments_identity_scope')
)
op.create_index(op.f('ix_idm_organization_function_assignments_account_id'), 'idm_organization_function_assignments', ['account_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_acting_for_account_id'), 'idm_organization_function_assignments', ['acting_for_account_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_delegated_from_assignment_id'), 'idm_organization_function_assignments', ['delegated_from_assignment_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_function_id'), 'idm_organization_function_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_identity_id'), 'idm_organization_function_assignments', ['identity_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_organization_unit_id'), 'idm_organization_function_assignments', ['organization_unit_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_tenant_id'), 'idm_organization_function_assignments', ['tenant_id'], unique=False)
def downgrade() -> None:
op.drop_table('idm_organization_function_assignments')
op.drop_table('idm_tenant_settings')

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/idm.css": "./src/styles/idm.css" "./styles/idm.css": "./src/styles/idm.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { Edit3, RefreshCw, Save, XCircle } from "lucide-react"; import { Edit3, Plus, RefreshCw } from "lucide-react";
import { import {
AdminIconButton,
ApiError, ApiError,
Button, Button,
Card, Card,
DataGrid, DataGrid,
Dialog,
DismissibleAlert, DismissibleAlert,
FormField, FormField,
LoadingFrame, LoadingFrame,
@@ -219,6 +221,15 @@ function sourceLabel(value: string): string {
return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value; return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value;
} }
function idmInitialQuery(): { assignmentId: string; functionId: string } {
if (typeof window === "undefined") return { assignmentId: "", functionId: "" };
const params = new URLSearchParams(window.location.search);
return {
assignmentId: params.get("assignment_id") || "",
functionId: params.get("function_id") || ""
};
}
export default function IdmPage({ settings, auth }: IdmPageProps) { export default function IdmPage({ settings, auth }: IdmPageProps) {
const [model, setModel] = useState<OrganizationModel>(EMPTY_MODEL); const [model, setModel] = useState<OrganizationModel>(EMPTY_MODEL);
const [assignments, setAssignments] = useState<OrganizationFunctionAssignmentItem[]>([]); const [assignments, setAssignments] = useState<OrganizationFunctionAssignmentItem[]>([]);
@@ -233,10 +244,14 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft()); const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null)); const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null));
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null); const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
const [assignmentEditorOpen, setAssignmentEditorOpen] = useState(false);
const [assignmentChangeRequestId, setAssignmentChangeRequestId] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const initialQuery = useMemo(() => idmInitialQuery(), []);
const appliedInitialQueryRef = useRef(false);
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign"); 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"); const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
@@ -286,11 +301,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
if (assignmentDraft.acting_for_account_id) values.add(assignmentDraft.acting_for_account_id); if (assignmentDraft.acting_for_account_id) values.add(assignmentDraft.acting_for_account_id);
return Array.from(values).sort((left, right) => left.localeCompare(right)); return Array.from(values).sort((left, right) => left.localeCompare(right));
}, [actingForOptionById, actingForOptions, assignmentDraft.acting_for_account_id, identityOptionById, sourceAssignment]); }, [actingForOptionById, actingForOptions, assignmentDraft.acting_for_account_id, identityOptionById, sourceAssignment]);
const initialFunctionFilter = useMemo(() => { const initialFunctionFilter = initialQuery.functionId;
if (typeof window === "undefined") return ""; const hasDirtyAssignmentDraft = assignmentEditorOpen && isAssignmentDirty(assignmentDraft);
return new URLSearchParams(window.location.search).get("function_id") || "";
}, []);
const hasDirtyAssignmentDraft = isAssignmentDirty(assignmentDraft);
const hasDirtySettingsDraft = canReadSettings && isSettingsDirty(settingsDraft, idmSettings); const hasDirtySettingsDraft = canReadSettings && isSettingsDirty(settingsDraft, idmSettings);
const hasDirtyDraft = hasDirtyAssignmentDraft || hasDirtySettingsDraft; const hasDirtyDraft = hasDirtyAssignmentDraft || hasDirtySettingsDraft;
@@ -307,8 +319,14 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
setAssignments(nextAssignments.assignments); setAssignments(nextAssignments.assignments);
setIdmSettings(nextSettings); setIdmSettings(nextSettings);
if (nextSettings) setSettingsDraft(settingsDraftFrom(nextSettings)); if (nextSettings) setSettingsDraft(settingsDraftFrom(nextSettings));
if (initialFunctionFilter && nextModel.functions.some((item) => item.id === initialFunctionFilter)) { if (!appliedInitialQueryRef.current && initialQuery.assignmentId) {
setAssignmentDraft((draft) => draft.function_id ? draft : { ...draft, function_id: initialFunctionFilter }); const initialAssignment = nextAssignments.assignments.find((item) => item.id === initialQuery.assignmentId);
if (initialAssignment) {
appliedInitialQueryRef.current = true;
setEditingAssignmentId(initialAssignment.id);
setAssignmentDraft(assignmentDraftFrom(initialAssignment));
setAssignmentEditorOpen(true);
}
} }
if (canSearchIdentities) { if (canSearchIdentities) {
try { try {
@@ -326,7 +344,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canReadSettings, canSearchIdentities, initialFunctionFilter, settings]); }, [canReadSettings, canSearchIdentities, initialFunctionFilter, initialQuery.assignmentId, settings]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -393,6 +411,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
const discardAssignmentDraft = useCallback(() => { const discardAssignmentDraft = useCallback(() => {
setAssignmentDraft(emptyAssignmentDraft()); setAssignmentDraft(emptyAssignmentDraft());
setEditingAssignmentId(null); setEditingAssignmentId(null);
setAssignmentEditorOpen(false);
setAssignmentChangeRequestId("");
}, []); }, []);
const discardDrafts = useCallback(() => { const discardDrafts = useCallback(() => {
@@ -427,14 +447,15 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
setError("i18n:govoplan-idm.function_is_required.5cce5b41"); setError("i18n:govoplan-idm.function_is_required.5cce5b41");
return false; return false;
} }
const payload = assignmentPayload(assignmentDraft); const requestId = textOrNull(assignmentChangeRequestId);
const payload = requestId ? { ...assignmentPayload(assignmentDraft), change_request_id: requestId } : assignmentPayload(assignmentDraft);
const ok = await runAction( const ok = await runAction(
() => editingAssignmentId ? patchOrganizationFunctionAssignment(settings, editingAssignmentId, payload) : createOrganizationFunctionAssignment(settings, payload), () => editingAssignmentId ? patchOrganizationFunctionAssignment(settings, editingAssignmentId, payload) : createOrganizationFunctionAssignment(settings, payload),
editingAssignmentId ? "i18n:govoplan-idm.assignment_updated.fbbf9bd6" : "i18n:govoplan-idm.assignment_added.91f1ee42" editingAssignmentId ? "i18n:govoplan-idm.assignment_updated.fbbf9bd6" : "i18n:govoplan-idm.assignment_added.91f1ee42"
); );
if (ok) discardAssignmentDraft(); if (ok) discardAssignmentDraft();
return ok; return ok;
}, [assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]); }, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]);
const saveDrafts = useCallback(async (): Promise<boolean> => { const saveDrafts = useCallback(async (): Promise<boolean> => {
if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false; if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false;
@@ -450,15 +471,20 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
message: "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b" message: "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"
}); });
const openCreateAssignment = useCallback(() => {
setEditingAssignmentId(null);
setAssignmentDraft({ ...emptyAssignmentDraft(), function_id: initialFunctionFilter && functionById.has(initialFunctionFilter) ? initialFunctionFilter : "" });
setAssignmentChangeRequestId("");
setAssignmentEditorOpen(true);
}, [functionById, initialFunctionFilter]);
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => { const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
setEditingAssignmentId(item.id); setEditingAssignmentId(item.id);
setAssignmentDraft(assignmentDraftFrom(item)); setAssignmentDraft(assignmentDraftFrom(item));
setAssignmentChangeRequestId("");
setAssignmentEditorOpen(true);
}, []); }, []);
const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
void runAction(() => patchOrganizationFunctionAssignment(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
function onIdentityChange(identityId: string) { function onIdentityChange(identityId: string) {
const identity = identityOptionById.get(identityId); const identity = identityOptionById.get(identityId);
setAssignmentDraft({ setAssignmentDraft({
@@ -534,16 +560,11 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
{ {
id: "actions", id: "actions",
header: "", header: "",
width: 220, width: 88,
sticky: "end", sticky: "end",
render: (row) => ( render: (row) => (
<div className="idm-row-actions"> <div className="idm-row-actions">
<Button type="button" variant="ghost" disabled={!canManage || busy} onClick={() => editAssignment(row)} title="i18n:govoplan-idm.edit.a5a0f3cc"> <AdminIconButton label="i18n:govoplan-idm.edit.a5a0f3cc" icon={<Edit3 size={16} aria-hidden="true" />} disabled={!canManage || busy} onClick={() => editAssignment(row)} />
<Edit3 size={16} aria-hidden="true" /> i18n:govoplan-idm.edit.a5a0f3cc
</Button>
<Button type="button" variant={row.is_active ? "secondary" : "primary"} disabled={!canManage || busy} onClick={() => toggleAssignment(row)}>
{row.is_active ? "i18n:govoplan-idm.deactivate.585777c8" : "i18n:govoplan-idm.reactivate.e4871a43"}
</Button>
</div> </div>
) )
} }
@@ -560,16 +581,6 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
<Button type="button" onClick={() => void loadData()} disabled={loading || busy} title="i18n:govoplan-idm.reload.870ca3ec"> <Button type="button" onClick={() => void loadData()} disabled={loading || busy} title="i18n:govoplan-idm.reload.870ca3ec">
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec <RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
</Button> </Button>
{hasDirtyDraft && (
<>
<Button type="button" onClick={() => void saveDrafts()} disabled={busy} title="i18n:govoplan-idm.save_drafts.32a0d60a">
<Save size={16} aria-hidden="true" /> i18n:govoplan-idm.save_drafts.32a0d60a
</Button>
<Button type="button" variant="ghost" onClick={discardDrafts} disabled={busy} title="i18n:govoplan-idm.discard_drafts.c0a86816">
<XCircle size={16} aria-hidden="true" /> i18n:govoplan-idm.discard_drafts.c0a86816
</Button>
</>
)}
</div> </div>
</div> </div>
@@ -578,10 +589,86 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
{!canManage && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-idm.write_permission_required.c7dde7c6</DismissibleAlert>} {!canManage && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-idm.write_permission_required.c7dde7c6</DismissibleAlert>}
<LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd"> <LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd">
<div className="idm-layout"> <div className="idm-table-stack">
<div className="idm-editor-stack"> {canReadSettings && (
<Card title="i18n:govoplan-idm.assignment_editor.e20598e7"> <Card title="i18n:govoplan-idm.idm_governance.6e4f3251" collapsible collapseKey="idm.governance">
<form className="idm-form-grid" onSubmit={(event) => void submitAssignment(event)}> <form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
<div className="idm-check-list wide">
<label>
<input
type="checkbox"
checked={settingsDraft.require_assignment_change_requests}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests: event.target.checked })}
/>
<span>i18n:govoplan-idm.require_assignment_change_requests.697718a1</span>
</label>
</div>
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2">
<select
value={settingsDraft.audit_detail_level}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, audit_detail_level: event.target.value as SettingsDraft["audit_detail_level"] })}
>
<option value="summary">i18n:govoplan-idm.summary.f1c5b7ab</option>
<option value="standard">i18n:govoplan-idm.standard.6edc51aa</option>
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
</select>
</FormField>
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3">
<input
type="number"
min="0"
value={settingsDraft.change_retention_days}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
/>
</FormField>
<div className="idm-form-actions wide">
<Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}>
i18n:govoplan-idm.save_settings.4602c430
</Button>
</div>
</form>
</Card>
)}
<Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy} onClick={openCreateAssignment} />}>
<DataGrid
id="idm-organization-function-assignments"
rows={assignments}
columns={assignmentColumns}
getRowKey={(row) => row.id}
emptyText="i18n:govoplan-idm.no_assignments.41193ce8"
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
initialFit="container"
/>
</Card>
</div>
</LoadingFrame>
{renderAssignmentDialog()}
</div>
);
function renderAssignmentDialog() {
const formId = "idm-assignment-editor";
return (
<Dialog
open={assignmentEditorOpen}
title={editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
onClose={() => !busy && discardAssignmentDraft()}
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}>
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
</Button>
</>
)}
>
<form id={formId} className="idm-form-grid" onSubmit={(event) => void submitAssignment(event)}>
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf"> <FormField label="i18n:govoplan-idm.identity_search.d3460fcf">
<input <input
value={identitySearch} value={identitySearch}
@@ -653,78 +740,18 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
<span>i18n:govoplan-idm.active.7bd0e9f8</span> <span>i18n:govoplan-idm.active.7bd0e9f8</span>
</label> </label>
</div> </div>
<div className="wide idm-dialog-change-request">
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db">
<input value={assignmentChangeRequestId} onChange={(event) => setAssignmentChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
</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 && <p className="idm-muted wide">i18n:govoplan-idm.identity_lookup_unavailable.b76f7714</p>}
{identityLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_identities.f3b84693</p>} {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>} {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>} {!model.functions.length && <p className="idm-muted wide">i18n:govoplan-idm.no_functions_available.51ba08eb</p>}
<div className="idm-form-actions wide">
<Button type="submit" variant="primary" disabled={!canManage || busy}>
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
</Button>
{editingAssignmentId && (
<Button type="button" variant="ghost" onClick={discardAssignmentDraft} disabled={busy}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
)}
</div>
</form> </form>
</Card> </Dialog>
{canReadSettings && (
<Card title="i18n:govoplan-idm.idm_governance.6e4f3251">
<form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
<div className="idm-check-list wide">
<label>
<input
type="checkbox"
checked={settingsDraft.require_assignment_change_requests}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests: event.target.checked })}
/>
<span>i18n:govoplan-idm.require_assignment_change_requests.697718a1</span>
</label>
</div>
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2">
<select
value={settingsDraft.audit_detail_level}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, audit_detail_level: event.target.value as SettingsDraft["audit_detail_level"] })}
>
<option value="summary">i18n:govoplan-idm.summary.f1c5b7ab</option>
<option value="standard">i18n:govoplan-idm.standard.6edc51aa</option>
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
</select>
</FormField>
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3">
<input
type="number"
min="0"
value={settingsDraft.change_retention_days}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
/>
</FormField>
<div className="idm-form-actions wide">
<Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}>
i18n:govoplan-idm.save_settings.4602c430
</Button>
</div>
</form>
</Card>
)}
</div>
<Card title="i18n:govoplan-idm.assignments.a0d19ec5">
<DataGrid
id="idm-organization-function-assignments"
rows={assignments}
columns={assignmentColumns}
getRowKey={(row) => row.id}
emptyText="i18n:govoplan-idm.no_assignments.41193ce8"
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
initialFit="container"
/>
</Card>
</div>
</LoadingFrame>
</div>
); );
}
} }

View File

@@ -15,6 +15,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-idm.assignments.a0d19ec5": "Assignments", "i18n:govoplan-idm.assignments.a0d19ec5": "Assignments",
"i18n:govoplan-idm.cancel_edit.ea4781e0": "Cancel edit", "i18n:govoplan-idm.cancel_edit.ea4781e0": "Cancel edit",
"i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit detail level", "i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit detail level",
"i18n:govoplan-idm.change_request_id.b7d816db": "Change request ID",
"i18n:govoplan-idm.change_request_id_help.cc7de508": "Only required when IDM assignment governance requires an approved recorded change request.",
"i18n:govoplan-idm.change_retention_days.4a91f7d3": "Change retention days", "i18n:govoplan-idm.change_retention_days.4a91f7d3": "Change retention days",
"i18n:govoplan-idm.deactivate.585777c8": "Deactivate", "i18n:govoplan-idm.deactivate.585777c8": "Deactivate",
"i18n:govoplan-idm.delegated.b189f4b6": "delegated", "i18n:govoplan-idm.delegated.b189f4b6": "delegated",
@@ -76,6 +78,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-idm.assignments.a0d19ec5": "Zuordnungen", "i18n:govoplan-idm.assignments.a0d19ec5": "Zuordnungen",
"i18n:govoplan-idm.cancel_edit.ea4781e0": "Bearbeitung abbrechen", "i18n:govoplan-idm.cancel_edit.ea4781e0": "Bearbeitung abbrechen",
"i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit-Detailgrad", "i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit-Detailgrad",
"i18n:govoplan-idm.change_request_id.b7d816db": "Änderungsantrags-ID",
"i18n:govoplan-idm.change_request_id_help.cc7de508": "Nur erforderlich, wenn die IDM-Zuordnungs-Governance einen genehmigten erfassten Änderungsantrag verlangt.",
"i18n:govoplan-idm.change_retention_days.4a91f7d3": "Änderungsaufbewahrung in Tagen", "i18n:govoplan-idm.change_retention_days.4a91f7d3": "Änderungsaufbewahrung in Tagen",
"i18n:govoplan-idm.deactivate.585777c8": "Deaktivieren", "i18n:govoplan-idm.deactivate.585777c8": "Deaktivieren",
"i18n:govoplan-idm.delegated.b189f4b6": "delegiert", "i18n:govoplan-idm.delegated.b189f4b6": "delegiert",

View File

@@ -17,16 +17,14 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.idm-layout { .idm-table-stack {
display: grid; display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 18px; gap: 18px;
align-items: start; width: 100%;
} }
.idm-editor-stack { .idm-table-stack > .card {
display: grid; width: 100%;
gap: 18px;
} }
.idm-form-grid { .idm-form-grid {
@@ -57,8 +55,8 @@
.idm-row-actions { .idm-row-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
flex-wrap: wrap; flex-wrap: nowrap;
} }
.idm-row-actions { .idm-row-actions {
@@ -87,10 +85,8 @@
color: var(--muted); color: var(--muted);
} }
@media (max-width: 1100px) { .idm-dialog-change-request {
.idm-layout { padding-top: 2px;
grid-template-columns: 1fr;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {