5 Commits
v0.1.6 ... main

Author SHA1 Message Date
74652686ca intermittent commit 2026-07-14 13:22:11 +02:00
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
13 changed files with 463 additions and 183 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

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any, TypeVar from typing import Any, TypeVar
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -158,6 +159,18 @@ def _ensure_assignment_workflow(
*, *,
tenant_id: str, tenant_id: str,
) -> None: ) -> None:
_ensure_assignment_shape(item)
function = _get_tenant_row(session, OrganizationFunction, item.function_id, tenant_id, "Organization function")
base = _assignment_source_assignment(session, item, tenant_id=tenant_id)
_ensure_assignment_source_rules(
item,
function=function,
base=base,
account_linked_to_identity=lambda identity_id, account_id: _account_linked_to_identity(session, identity_id, account_id),
)
def _ensure_assignment_shape(item: IdmOrganizationFunctionAssignment) -> None:
if item.source not in ASSIGNMENT_SOURCES: if item.source not in ASSIGNMENT_SOURCES:
raise _invalid("Assignment source is not supported.") raise _invalid("Assignment source is not supported.")
if item.valid_from is not None and item.valid_until is not None and item.valid_until <= item.valid_from: if item.valid_from is not None and item.valid_until is not None and item.valid_until <= item.valid_from:
@@ -165,43 +178,81 @@ def _ensure_assignment_workflow(
if item.delegated_from_assignment_id is not None and item.delegated_from_assignment_id == item.id: if item.delegated_from_assignment_id is not None and item.delegated_from_assignment_id == item.id:
raise _invalid("A function assignment cannot delegate from itself.") raise _invalid("A function assignment cannot delegate from itself.")
function = _get_tenant_row(session, OrganizationFunction, item.function_id, tenant_id, "Organization function")
base: IdmOrganizationFunctionAssignment | None = None
if item.delegated_from_assignment_id is not None:
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
if not base.is_active:
raise _invalid("Delegation source assignment must be active.")
if base.function_id != item.function_id:
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
def _assignment_source_assignment(
session: Session,
item: IdmOrganizationFunctionAssignment,
*,
tenant_id: str,
) -> IdmOrganizationFunctionAssignment | None:
if item.delegated_from_assignment_id is None:
return None
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
if not base.is_active:
raise _invalid("Delegation source assignment must be active.")
if base.function_id != item.function_id:
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
return base
def _ensure_assignment_source_rules(
item: IdmOrganizationFunctionAssignment,
*,
function: OrganizationFunction,
base: IdmOrganizationFunctionAssignment | None,
account_linked_to_identity: Callable[[str, str], bool],
) -> None:
if item.source == "delegated": if item.source == "delegated":
if base is None: _ensure_delegated_assignment(item, function=function, base=base)
raise _invalid("Delegated assignments require a source assignment.") return
if not function.delegable: if item.source == "acting_for":
raise _invalid("This organization function does not allow delegation.") _ensure_acting_for_assignment(item, function=function, base=base, account_linked_to_identity=account_linked_to_identity)
if item.acting_for_account_id is not None: return
raise _invalid("Delegated assignments cannot set an acting-for account.") _ensure_direct_assignment_shape(item)
if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
raise _invalid("A delegated assignment must target another identity or account.")
elif item.source == "acting_for": def _ensure_delegated_assignment(
if base is None: item: IdmOrganizationFunctionAssignment,
raise _invalid("Acting-for assignments require a source assignment.") *,
if not function.act_in_place_allowed: function: OrganizationFunction,
raise _invalid("This organization function does not allow acting in place.") base: IdmOrganizationFunctionAssignment | None,
if item.acting_for_account_id is None: ) -> None:
raise _invalid("Acting-for assignments require an acting-for account.") if base is None:
if base.account_id is not None: raise _invalid("Delegated assignments require a source assignment.")
if item.acting_for_account_id != base.account_id: if not function.delegable:
raise _invalid("Acting-for account must match the source assignment account.") raise _invalid("This organization function does not allow delegation.")
elif not _account_linked_to_identity(session, base.identity_id, item.acting_for_account_id): if item.acting_for_account_id is not None:
raise _invalid("Acting-for account must belong to the source assignment identity.") raise _invalid("Delegated assignments cannot set an acting-for account.")
if item.account_id == item.acting_for_account_id: if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
raise _invalid("The acting account and acting-for account must be different.") raise _invalid("A delegated assignment must target another identity or account.")
else:
if item.delegated_from_assignment_id is not None:
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.") def _ensure_acting_for_assignment(
if item.acting_for_account_id is not None: item: IdmOrganizationFunctionAssignment,
raise _invalid("Only acting-for assignments can set an acting-for account.") *,
function: OrganizationFunction,
base: IdmOrganizationFunctionAssignment | None,
account_linked_to_identity: Callable[[str, str], bool],
) -> None:
if base is None:
raise _invalid("Acting-for assignments require a source assignment.")
if not function.act_in_place_allowed:
raise _invalid("This organization function does not allow acting in place.")
if item.acting_for_account_id is None:
raise _invalid("Acting-for assignments require an acting-for account.")
if base.account_id is not None and item.acting_for_account_id != base.account_id:
raise _invalid("Acting-for account must match the source assignment account.")
if base.account_id is None and not account_linked_to_identity(base.identity_id, item.acting_for_account_id):
raise _invalid("Acting-for account must belong to the source assignment identity.")
if item.account_id == item.acting_for_account_id:
raise _invalid("The acting account and acting-for account must be different.")
def _ensure_direct_assignment_shape(item: IdmOrganizationFunctionAssignment) -> None:
if item.delegated_from_assignment_id is not None:
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.")
if item.acting_for_account_id is not None:
raise _invalid("Only acting-for assignments can set an acting-for account.")
def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool: def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool:

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

@@ -0,0 +1,132 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi import HTTPException
from govoplan_idm.backend.api.v1.routes import _ensure_assignment_shape, _ensure_assignment_source_rules
def assignment(**overrides: object) -> SimpleNamespace:
values = {
"id": "assignment-1",
"identity_id": "identity-1",
"account_id": "account-1",
"function_id": "function-1",
"source": "direct",
"delegated_from_assignment_id": None,
"acting_for_account_id": None,
"valid_from": None,
"valid_until": None,
}
values.update(overrides)
return SimpleNamespace(**values)
def function(**overrides: object) -> SimpleNamespace:
values = {"delegable": True, "act_in_place_allowed": True}
values.update(overrides)
return SimpleNamespace(**values)
class AssignmentWorkflowTests(unittest.TestCase):
def assert_invalid(self, expected_detail: str, callback: object) -> None:
with self.assertRaises(HTTPException) as captured:
callback()
self.assertEqual(captured.exception.detail, expected_detail)
def test_direct_assignment_accepts_plain_shape(self) -> None:
item = assignment()
_ensure_assignment_shape(item) # type: ignore[arg-type]
_ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(),
base=None,
account_linked_to_identity=lambda _identity_id, _account_id: False,
)
def test_shape_rejects_backwards_validity_window(self) -> None:
now = datetime.now(timezone.utc)
item = assignment(valid_from=now, valid_until=now - timedelta(days=1))
self.assert_invalid("Valid until must be after valid from.", lambda: _ensure_assignment_shape(item)) # type: ignore[arg-type]
def test_shape_rejects_self_delegation(self) -> None:
item = assignment(id="assignment-1", delegated_from_assignment_id="assignment-1")
self.assert_invalid("A function assignment cannot delegate from itself.", lambda: _ensure_assignment_shape(item)) # type: ignore[arg-type]
def test_delegated_assignment_accepts_different_target(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
item = assignment(
id="assignment-2",
identity_id="identity-2",
account_id="account-2",
source="delegated",
delegated_from_assignment_id="source-1",
)
_ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(delegable=True),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
)
def test_delegated_assignment_rejects_same_target(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
item = assignment(source="delegated", delegated_from_assignment_id="source-1")
self.assert_invalid(
"A delegated assignment must target another identity or account.",
lambda: _ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(delegable=True),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
),
)
def test_acting_for_assignment_accepts_identity_linked_account(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
item = assignment(
id="assignment-2",
source="acting_for",
delegated_from_assignment_id="source-1",
account_id="acting-account",
acting_for_account_id="represented-account",
)
_ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(act_in_place_allowed=True),
base=base,
account_linked_to_identity=lambda identity_id, account_id: identity_id == "identity-1" and account_id == "represented-account",
)
def test_acting_for_assignment_rejects_unlinked_represented_account(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
item = assignment(
id="assignment-2",
source="acting_for",
delegated_from_assignment_id="source-1",
account_id="acting-account",
acting_for_account_id="represented-account",
)
self.assert_invalid(
"Acting-for account must belong to the source assignment identity.",
lambda: _ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(act_in_place_allowed=True),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
),
)
if __name__ == "__main__":
unittest.main()

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,98 +589,9 @@ 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)}>
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf">
<input
value={identitySearch}
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
disabled={!canSearchIdentities || busy}
onChange={(event) => setIdentitySearch(event.target.value)}
/>
</FormField>
<FormField label="i18n:govoplan-idm.select_identity.91d31615">
<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) => (
<option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>
))}
</select>
</FormField>
<FormField label="i18n:govoplan-idm.account.2b2936f8">
<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">
<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">
<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">
<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) => (
<option key={item.id} value={item.id}>{assignmentOptionLabel(item, identityOptionById, functionById, unitById)}</option>
))}
</select>
</FormField>
)}
{assignmentDraft.source === "acting_for" && (
<>
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7">
<input
value={actingForSearch}
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
disabled={!canSearchIdentities || busy}
onChange={(event) => setActingForSearch(event.target.value)}
/>
</FormField>
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b">
<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>)}
</select>
</FormField>
</>
)}
<div className="idm-check-list">
<label>
<input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} />
<span>i18n:govoplan-idm.applies_to_subunits.2e31b50b</span>
</label>
<label>
<input type="checkbox" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} />
<span>i18n:govoplan-idm.active.7bd0e9f8</span>
</label>
</div>
{!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>}
{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>}
<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>
</Card>
{canReadSettings && (
<Card title="i18n:govoplan-idm.idm_governance.6e4f3251">
<form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}> <form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
<div className="idm-check-list wide"> <div className="idm-check-list wide">
<label> <label>
@@ -710,9 +632,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
</form> </form>
</Card> </Card>
)} )}
</div>
<Card title="i18n:govoplan-idm.assignments.a0d19ec5"> <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 <DataGrid
id="idm-organization-function-assignments" id="idm-organization-function-assignments"
rows={assignments} rows={assignments}
@@ -725,6 +646,112 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
</Card> </Card>
</div> </div>
</LoadingFrame> </LoadingFrame>
{renderAssignmentDialog()}
</div> </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">
<input
value={identitySearch}
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
disabled={!canSearchIdentities || busy}
onChange={(event) => setIdentitySearch(event.target.value)}
/>
</FormField>
<FormField label="i18n:govoplan-idm.select_identity.91d31615">
<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) => (
<option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>
))}
</select>
</FormField>
<FormField label="i18n:govoplan-idm.account.2b2936f8">
<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">
<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">
<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">
<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) => (
<option key={item.id} value={item.id}>{assignmentOptionLabel(item, identityOptionById, functionById, unitById)}</option>
))}
</select>
</FormField>
)}
{assignmentDraft.source === "acting_for" && (
<>
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7">
<input
value={actingForSearch}
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
disabled={!canSearchIdentities || busy}
onChange={(event) => setActingForSearch(event.target.value)}
/>
</FormField>
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b">
<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>)}
</select>
</FormField>
</>
)}
<div className="idm-check-list">
<label>
<input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} />
<span>i18n:govoplan-idm.applies_to_subunits.2e31b50b</span>
</label>
<label>
<input type="checkbox" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} />
<span>i18n:govoplan-idm.active.7bd0e9f8</span>
</label>
</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>}
{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>}
</form>
</Dialog>
);
}
} }

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) {