Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:26 +02:00
parent 2751849af1
commit b09c1449b9
25 changed files with 2809 additions and 0 deletions

31
webui/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/idm.css": "./src/styles/idm.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

147
webui/src/api/idm.ts Normal file
View File

@@ -0,0 +1,147 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type OrganizationUnitItem = {
id: string;
tenant_id: string;
unit_type_id?: string | null;
parent_id?: string | null;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationFunctionItem = {
id: string;
tenant_id: string;
function_type_id?: string | null;
organization_unit_id: string;
slug: string;
name: string;
description?: string | null;
delegable: boolean;
act_in_place_allowed: boolean;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationModel = {
units: OrganizationUnitItem[];
functions: OrganizationFunctionItem[];
};
export type IdentityOption = {
id: string;
display_name?: string | null;
external_subject?: string | null;
source: string;
primary_account_id?: string | null;
account_ids: string[];
status: string;
};
export type IdentityListResponse = {
identities: IdentityOption[];
};
export type OrganizationFunctionAssignmentItem = {
id: string;
tenant_id: string;
identity_id: string;
account_id?: string | null;
function_id: string;
organization_unit_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id?: string | null;
acting_for_account_id?: string | null;
valid_from?: string | null;
valid_until?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationFunctionAssignmentList = {
assignments: OrganizationFunctionAssignmentItem[];
};
export type IdmSettings = {
tenant_id: string;
require_assignment_change_requests: boolean;
audit_detail_level: "summary" | "standard" | "full";
change_retention_days?: number | null;
settings: Record<string, unknown>;
created_at?: string | null;
updated_at?: string | null;
};
export type OrganizationFunctionAssignmentPayload = {
identity_id: string;
function_id: string;
account_id?: string | null;
applies_to_subunits?: boolean;
source?: string;
delegated_from_assignment_id?: string | null;
acting_for_account_id?: string | null;
valid_from?: string | null;
valid_until?: string | null;
is_active?: boolean;
settings?: Record<string, unknown>;
change_request_id?: string | null;
};
export type IdmSettingsPayload = Partial<Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings">>;
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
}
function patch<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
return apiFetch<T>(settings, path, { method: "PATCH", body: JSON.stringify(payload) });
}
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
}
export function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
return apiFetch<OrganizationFunctionAssignmentList>(settings, "/api/v1/idm/organization-function-assignments");
}
export function getIdmSettings(settings: ApiSettings): Promise<IdmSettings> {
return apiFetch<IdmSettings>(settings, "/api/v1/idm/settings");
}
export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayload): Promise<IdmSettings> {
return patch(settings, "/api/v1/idm/settings", payload);
}
export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise<IdentityListResponse> {
const params = new URLSearchParams();
const trimmed = query.trim();
if (trimmed) params.set("query", trimmed);
params.set("limit", String(limit));
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`);
}
export function createOrganizationFunctionAssignment(
settings: ApiSettings,
payload: OrganizationFunctionAssignmentPayload
): Promise<OrganizationFunctionAssignmentItem> {
return post(settings, "/api/v1/idm/organization-function-assignments", payload);
}
export function patchOrganizationFunctionAssignment(
settings: ApiSettings,
id: string,
payload: Partial<OrganizationFunctionAssignmentPayload>
): Promise<OrganizationFunctionAssignmentItem> {
return patch(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
}

View File

@@ -0,0 +1,730 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { Edit3, RefreshCw, Save, XCircle } from "lucide-react";
import {
ApiError,
Button,
Card,
DataGrid,
DismissibleAlert,
FormField,
LoadingFrame,
PageTitle,
StatusBadge,
hasScope,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
createOrganizationFunctionAssignment,
getIdmSettings,
getOrganizationFunctionAssignments,
getOrganizationModel,
patchIdmSettings,
patchOrganizationFunctionAssignment,
searchOrganizationIdentityOptions,
type IdmSettings,
type IdentityOption,
type OrganizationFunctionAssignmentItem,
type OrganizationFunctionAssignmentPayload,
type OrganizationFunctionItem,
type OrganizationModel,
type OrganizationUnitItem
} from "../api/idm";
type IdmPageProps = {
settings: ApiSettings;
auth: AuthInfo;
};
type AssignmentDraft = {
identity_id: string;
account_id: string;
function_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id: string;
acting_for_account_id: string;
is_active: boolean;
};
type SettingsDraft = {
require_assignment_change_requests: boolean;
audit_detail_level: "summary" | "standard" | "full";
change_retention_days: string;
};
const EMPTY_MODEL: OrganizationModel = {
units: [],
functions: []
};
const DEFAULT_IDM_SETTINGS: IdmSettings = {
tenant_id: "",
require_assignment_change_requests: false,
audit_detail_level: "standard",
change_retention_days: null,
settings: {}
};
const SOURCE_OPTIONS = [
{ value: "direct", label: "i18n:govoplan-idm.direct.7b2b1f51" },
{ value: "delegated", label: "i18n:govoplan-idm.delegated.b189f4b6" },
{ value: "acting_for", label: "i18n:govoplan-idm.acting_for.8650e6a6" },
{ value: "directory", label: "i18n:govoplan-idm.directory.12e2e859" },
{ value: "governance", label: "i18n:govoplan-idm.governance.b989a277" },
{ value: "system", label: "i18n:govoplan-idm.system.70c07e3f" }
];
function emptyAssignmentDraft(): AssignmentDraft {
return {
identity_id: "",
account_id: "",
function_id: "",
applies_to_subunits: false,
source: "direct",
delegated_from_assignment_id: "",
acting_for_account_id: "",
is_active: true
};
}
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function assignmentPayload(draft: AssignmentDraft): OrganizationFunctionAssignmentPayload {
return {
identity_id: draft.identity_id,
account_id: textOrNull(draft.account_id),
function_id: draft.function_id,
applies_to_subunits: draft.applies_to_subunits,
source: draft.source,
delegated_from_assignment_id: textOrNull(draft.delegated_from_assignment_id),
acting_for_account_id: textOrNull(draft.acting_for_account_id),
is_active: draft.is_active,
settings: {}
};
}
function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): AssignmentDraft {
return {
identity_id: item.identity_id,
account_id: item.account_id ?? "",
function_id: item.function_id,
applies_to_subunits: item.applies_to_subunits,
source: item.source || "direct",
delegated_from_assignment_id: item.delegated_from_assignment_id ?? "",
acting_for_account_id: item.acting_for_account_id ?? "",
is_active: item.is_active
};
}
function isAssignmentDirty(draft: AssignmentDraft): boolean {
return Boolean(
draft.identity_id ||
draft.account_id ||
draft.function_id ||
draft.applies_to_subunits ||
draft.source !== "direct" ||
draft.delegated_from_assignment_id ||
draft.acting_for_account_id ||
!draft.is_active
);
}
function settingsDraftFrom(item: IdmSettings | null): SettingsDraft {
const source = item ?? DEFAULT_IDM_SETTINGS;
return {
require_assignment_change_requests: source.require_assignment_change_requests,
audit_detail_level: source.audit_detail_level,
change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days)
};
}
function settingsPayload(draft: SettingsDraft, item: IdmSettings | null): Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings"> {
const trimmedDays = draft.change_retention_days.trim();
return {
require_assignment_change_requests: draft.require_assignment_change_requests,
audit_detail_level: draft.audit_detail_level,
change_retention_days: trimmedDays ? Number(trimmedDays) : null,
settings: item?.settings ?? {}
};
}
function isSettingsDirty(draft: SettingsDraft, item: IdmSettings | null): boolean {
const baseline = settingsDraftFrom(item);
return (
draft.require_assignment_change_requests !== baseline.require_assignment_change_requests ||
draft.audit_detail_level !== baseline.audit_detail_level ||
draft.change_retention_days.trim() !== baseline.change_retention_days.trim()
);
}
function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
function apiErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
try {
const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } };
if (typeof parsed.detail === "string") return parsed.detail;
if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message;
} catch {
// Fall back to the transport message below.
}
return error.message;
}
if (error instanceof Error) return error.message;
return String(error);
}
function identityDisplay(identity: IdentityOption | undefined, fallbackId: string): JSX.Element {
if (!identity) return <span className="idm-id">{fallbackId}</span>;
const label = identity.display_name || identity.external_subject || identity.id;
return (
<span className="idm-identity">
<span>{label}</span>
<span className="idm-id">{identity.id}</span>
</span>
);
}
function functionLabel(item: OrganizationFunctionItem, unitById: ReadonlyMap<string, OrganizationUnitItem>): string {
const unitName = unitById.get(item.organization_unit_id)?.name;
return unitName ? `${item.name} (${unitName})` : item.name;
}
function assignmentOptionLabel(
item: OrganizationFunctionAssignmentItem,
identityById: ReadonlyMap<string, IdentityOption>,
functionById: ReadonlyMap<string, OrganizationFunctionItem>,
unitById: ReadonlyMap<string, OrganizationUnitItem>
): string {
const identity = identityById.get(item.identity_id);
const identityLabel = identity?.display_name || identity?.external_subject || item.identity_id;
const functionItem = functionById.get(item.function_id);
const functionText = functionItem ? functionLabel(functionItem, unitById) : item.function_id;
return `${identityLabel} - ${functionText}`;
}
function activeStatus(active: boolean): JSX.Element {
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-idm.active.7bd0e9f8" : "i18n:govoplan-idm.inactive.1baa5fba"} />;
}
function sourceLabel(value: string): string {
return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value;
}
export default function IdmPage({ settings, auth }: IdmPageProps) {
const [model, setModel] = useState<OrganizationModel>(EMPTY_MODEL);
const [assignments, setAssignments] = useState<OrganizationFunctionAssignmentItem[]>([]);
const [idmSettings, setIdmSettings] = useState<IdmSettings | null>(null);
const [identityOptions, setIdentityOptions] = useState<IdentityOption[]>([]);
const [identitySearch, setIdentitySearch] = useState("");
const [identityLoading, setIdentityLoading] = useState(false);
const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true);
const [actingForSearch, setActingForSearch] = useState("");
const [actingForOptions, setActingForOptions] = useState<IdentityOption[]>([]);
const [actingForLoading, setActingForLoading] = useState(false);
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null));
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
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 canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write");
const canManageSettings = hasScope(auth, "idm:settings:write");
const unitById = useMemo(() => mapById(model.units), [model.units]);
const functionById = useMemo(() => mapById(model.functions), [model.functions]);
const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]);
const actingForOptionById = useMemo(() => mapById(actingForOptions), [actingForOptions]);
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
const identitySelectOptions = useMemo(() => {
if (!assignmentDraft.identity_id || identityOptionById.has(assignmentDraft.identity_id)) return identityOptions;
return [
{
id: assignmentDraft.identity_id,
display_name: assignmentDraft.identity_id,
external_subject: null,
source: "local",
primary_account_id: assignmentDraft.account_id || null,
account_ids: assignmentDraft.account_id ? [assignmentDraft.account_id] : [],
status: "active"
},
...identityOptions
];
}, [assignmentDraft.account_id, assignmentDraft.identity_id, identityOptionById, identityOptions]);
const accountIds = useMemo(() => {
const values = new Set(selectedIdentity?.account_ids ?? []);
if (assignmentDraft.account_id) values.add(assignmentDraft.account_id);
return Array.from(values).sort((left, right) => left.localeCompare(right));
}, [assignmentDraft.account_id, selectedIdentity]);
const assignmentOptions = useMemo(
() => assignments.filter((item) => item.id !== editingAssignmentId && (!assignmentDraft.function_id || item.function_id === assignmentDraft.function_id)),
[assignmentDraft.function_id, assignments, editingAssignmentId]
);
const assignmentById = useMemo(() => mapById(assignments), [assignments]);
const sourceAssignment = assignmentDraft.delegated_from_assignment_id ? assignmentById.get(assignmentDraft.delegated_from_assignment_id) : undefined;
const actingForAccountIds = useMemo(() => {
const values = new Set<string>();
if (sourceAssignment?.account_id) values.add(sourceAssignment.account_id);
const sourceIdentity = sourceAssignment ? identityOptionById.get(sourceAssignment.identity_id) ?? actingForOptionById.get(sourceAssignment.identity_id) : undefined;
for (const accountId of sourceIdentity?.account_ids ?? []) values.add(accountId);
for (const identity of actingForOptions) {
for (const accountId of identity.account_ids) values.add(accountId);
}
if (assignmentDraft.acting_for_account_id) values.add(assignmentDraft.acting_for_account_id);
return Array.from(values).sort((left, right) => left.localeCompare(right));
}, [actingForOptionById, actingForOptions, assignmentDraft.acting_for_account_id, identityOptionById, sourceAssignment]);
const initialFunctionFilter = useMemo(() => {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("function_id") || "";
}, []);
const hasDirtyAssignmentDraft = isAssignmentDirty(assignmentDraft);
const hasDirtySettingsDraft = canReadSettings && isSettingsDirty(settingsDraft, idmSettings);
const hasDirtyDraft = hasDirtyAssignmentDraft || hasDirtySettingsDraft;
const loadData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextModel, nextAssignments, nextSettings] = await Promise.all([
getOrganizationModel(settings),
getOrganizationFunctionAssignments(settings),
canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null)
]);
setModel(nextModel);
setAssignments(nextAssignments.assignments);
setIdmSettings(nextSettings);
if (nextSettings) setSettingsDraft(settingsDraftFrom(nextSettings));
if (initialFunctionFilter && nextModel.functions.some((item) => item.id === initialFunctionFilter)) {
setAssignmentDraft((draft) => draft.function_id ? draft : { ...draft, function_id: initialFunctionFilter });
}
if (canSearchIdentities) {
try {
const identities = await searchOrganizationIdentityOptions(settings, "", 100);
setIdentityOptions(identities.identities);
setIdentityLookupAvailable(true);
} catch {
setIdentityLookupAvailable(false);
}
} else {
setIdentityLookupAvailable(false);
}
} catch (caught) {
setError(apiErrorMessage(caught));
} finally {
setLoading(false);
}
}, [canReadSettings, canSearchIdentities, initialFunctionFilter, settings]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
if (!canSearchIdentities) return;
let disposed = false;
setIdentityLoading(true);
searchOrganizationIdentityOptions(settings, identitySearch, 50)
.then((payload) => {
if (!disposed) {
setIdentityOptions(payload.identities);
setIdentityLookupAvailable(true);
}
})
.catch(() => {
if (!disposed) setIdentityLookupAvailable(false);
})
.finally(() => {
if (!disposed) setIdentityLoading(false);
});
return () => {
disposed = true;
};
}, [canSearchIdentities, identitySearch, settings]);
useEffect(() => {
if (!canSearchIdentities || assignmentDraft.source !== "acting_for") return;
let disposed = false;
setActingForLoading(true);
searchOrganizationIdentityOptions(settings, actingForSearch, 50)
.then((payload) => {
if (!disposed) setActingForOptions(payload.identities);
})
.catch(() => {
if (!disposed) setActingForOptions([]);
})
.finally(() => {
if (!disposed) setActingForLoading(false);
});
return () => {
disposed = true;
};
}, [actingForSearch, assignmentDraft.source, canSearchIdentities, settings]);
const runAction = useCallback(async (action: () => Promise<unknown>, successMessage = ""): Promise<boolean> => {
setBusy(true);
setError("");
setSuccess("");
try {
await action();
if (successMessage) setSuccess(successMessage);
await loadData();
return true;
} catch (caught) {
setError(apiErrorMessage(caught));
return false;
} finally {
setBusy(false);
}
}, [loadData]);
const discardAssignmentDraft = useCallback(() => {
setAssignmentDraft(emptyAssignmentDraft());
setEditingAssignmentId(null);
}, []);
const discardDrafts = useCallback(() => {
discardAssignmentDraft();
setSettingsDraft(settingsDraftFrom(idmSettings));
}, [discardAssignmentDraft, idmSettings]);
const submitSettings = useCallback(async (): Promise<boolean> => {
if (!hasDirtySettingsDraft) return true;
if (!canManageSettings) {
setError("i18n:govoplan-idm.settings_write_permission_required.37f37efa");
return false;
}
const ok = await runAction(
() => patchIdmSettings(settings, settingsPayload(settingsDraft, idmSettings)),
"i18n:govoplan-idm.settings_updated.9bbd0867"
);
return ok;
}, [canManageSettings, hasDirtySettingsDraft, idmSettings, runAction, settings, settingsDraft]);
const submitAssignment = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canManage) {
setError("i18n:govoplan-idm.write_permission_required.c7dde7c6");
return false;
}
if (!assignmentDraft.identity_id) {
setError("i18n:govoplan-idm.identity_is_required.6ad4ee23");
return false;
}
if (!assignmentDraft.function_id) {
setError("i18n:govoplan-idm.function_is_required.5cce5b41");
return false;
}
const payload = assignmentPayload(assignmentDraft);
const ok = await runAction(
() => editingAssignmentId ? patchOrganizationFunctionAssignment(settings, editingAssignmentId, payload) : createOrganizationFunctionAssignment(settings, payload),
editingAssignmentId ? "i18n:govoplan-idm.assignment_updated.fbbf9bd6" : "i18n:govoplan-idm.assignment_added.91f1ee42"
);
if (ok) discardAssignmentDraft();
return ok;
}, [assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]);
const saveDrafts = useCallback(async (): Promise<boolean> => {
if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false;
if (hasDirtySettingsDraft && !(await submitSettings())) return false;
return true;
}, [hasDirtyAssignmentDraft, hasDirtySettingsDraft, submitAssignment, submitSettings]);
useUnsavedDraftGuard({
dirty: hasDirtyDraft,
onSave: saveDrafts,
onDiscard: discardDrafts,
title: "i18n:govoplan-core.unsaved_changes.29267269",
message: "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"
});
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
setEditingAssignmentId(item.id);
setAssignmentDraft(assignmentDraftFrom(item));
}, []);
const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
void runAction(() => patchOrganizationFunctionAssignment(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
function onIdentityChange(identityId: string) {
const identity = identityOptionById.get(identityId);
setAssignmentDraft({
...assignmentDraft,
identity_id: identityId,
account_id: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
});
}
function onSourceChange(source: string) {
setAssignmentDraft({
...assignmentDraft,
source,
delegated_from_assignment_id: source === "delegated" || source === "acting_for" ? assignmentDraft.delegated_from_assignment_id : "",
acting_for_account_id: source === "acting_for" ? assignmentDraft.acting_for_account_id : ""
});
}
const assignmentColumns: DataGridColumn<OrganizationFunctionAssignmentItem>[] = [
{
id: "identity",
header: "i18n:govoplan-idm.identity.544a8347",
minWidth: 220,
sortable: true,
filterable: true,
filterValue: (row) => identityOptionById.get(row.identity_id)?.display_name ?? row.identity_id,
render: (row) => identityDisplay(identityOptionById.get(row.identity_id), row.identity_id)
},
{
id: "account",
header: "i18n:govoplan-idm.account.2b2936f8",
minWidth: 180,
value: (row) => row.account_id ?? "",
render: (row) => row.account_id ? <span className="idm-id">{row.account_id}</span> : <span className="idm-empty">i18n:govoplan-idm.none.2baf5c66</span>
},
{
id: "function",
header: "i18n:govoplan-idm.function.e67d5aba",
minWidth: 220,
sortable: true,
filterable: true,
filterValue: (row) => `${functionById.get(row.function_id)?.name ?? ""} ${row.function_id}`.trim(),
render: (row) => {
const item = functionById.get(row.function_id);
return item ? functionLabel(item, unitById) : <span className="idm-id">{row.function_id}</span>;
}
},
{
id: "unit",
header: "i18n:govoplan-idm.unit.a94c2fbd",
minWidth: 180,
render: (row) => unitById.get(row.organization_unit_id)?.name ?? <span className="idm-id">{row.organization_unit_id}</span>
},
{
id: "subunits",
header: "i18n:govoplan-idm.applies_to_subunits.2e31b50b",
width: 150,
render: (row) => activeStatus(row.applies_to_subunits)
},
{
id: "source",
header: "i18n:govoplan-idm.source.d15b50c9",
width: 130,
value: (row) => row.source,
render: (row) => sourceLabel(row.source)
},
{
id: "status",
header: "i18n:govoplan-idm.status.9acb445d",
width: 120,
render: (row) => activeStatus(row.is_active)
},
{
id: "actions",
header: "",
width: 220,
sticky: "end",
render: (row) => (
<div className="idm-row-actions">
<Button type="button" variant="ghost" disabled={!canManage || busy} onClick={() => editAssignment(row)} title="i18n:govoplan-idm.edit.a5a0f3cc">
<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>
)
}
];
return (
<div className="content-pad idm-page">
<div className="page-heading split idm-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
</div>
<div className="idm-toolbar">
<Button type="button" onClick={() => void loadData()} disabled={loading || busy} title="i18n:govoplan-idm.reload.870ca3ec">
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
</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>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{!canManage && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-idm.write_permission_required.c7dde7c6</DismissibleAlert>}
<LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd">
<div className="idm-layout">
<div className="idm-editor-stack">
<Card title="i18n:govoplan-idm.assignment_editor.e20598e7">
<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(); }}>
<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

@@ -0,0 +1,126 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-idm.account.2b2936f8": "Account",
"i18n:govoplan-idm.active.7bd0e9f8": "Active",
"i18n:govoplan-idm.acting_for.8650e6a6": "acting for",
"i18n:govoplan-idm.acting_for_account_id.5d7ade5b": "Acting for account ID",
"i18n:govoplan-idm.acting_for_search.b7c526c7": "Acting-for account search",
"i18n:govoplan-idm.add_assignment.08f2a0d5": "Add assignment",
"i18n:govoplan-idm.applies_to_subunits.2e31b50b": "Applies to subunits",
"i18n:govoplan-idm.assignment_added.91f1ee42": "Assignment added.",
"i18n:govoplan-idm.assignment_editor.e20598e7": "Assignment editor",
"i18n:govoplan-idm.assignment_updated.fbbf9bd6": "Assignment updated.",
"i18n:govoplan-idm.assignments.a0d19ec5": "Assignments",
"i18n:govoplan-idm.cancel_edit.ea4781e0": "Cancel edit",
"i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit detail level",
"i18n:govoplan-idm.change_retention_days.4a91f7d3": "Change retention days",
"i18n:govoplan-idm.deactivate.585777c8": "Deactivate",
"i18n:govoplan-idm.delegated.b189f4b6": "delegated",
"i18n:govoplan-idm.delegated_from_assignment_id.20d4a548": "Delegated from assignment",
"i18n:govoplan-idm.direct.7b2b1f51": "direct",
"i18n:govoplan-idm.directory.12e2e859": "directory",
"i18n:govoplan-idm.discard_drafts.c0a86816": "Discard drafts",
"i18n:govoplan-idm.edit.a5a0f3cc": "Edit",
"i18n:govoplan-idm.function.e67d5aba": "Function",
"i18n:govoplan-idm.function_is_required.5cce5b41": "Function is required.",
"i18n:govoplan-idm.full.7f021a14": "Full",
"i18n:govoplan-idm.governance.b989a277": "governance",
"i18n:govoplan-idm.identity.544a8347": "Identity",
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Link identities to organization functions. Organizations defines the functions; IDM owns who holds them.",
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.",
"i18n:govoplan-idm.identity_search.d3460fcf": "Identity search",
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance",
"i18n:govoplan-idm.idm.61f4a7a2": "IDM",
"i18n:govoplan-idm.inactive.1baa5fba": "Inactive",
"i18n:govoplan-idm.loading_idm_assignments.0b1501bd": "Loading IDM assignments...",
"i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e": "Loading acting-for accounts...",
"i18n:govoplan-idm.loading_identities.f3b84693": "Loading identities...",
"i18n:govoplan-idm.no_assignments.41193ce8": "No identity/function assignments found.",
"i18n:govoplan-idm.no_functions_available.51ba08eb": "No organization functions are available yet.",
"i18n:govoplan-idm.none.2baf5c66": "None",
"i18n:govoplan-idm.reactivate.e4871a43": "Reactivate",
"i18n:govoplan-idm.reload.870ca3ec": "Reload",
"i18n:govoplan-idm.require_assignment_change_requests.697718a1": "Require assignment change requests",
"i18n:govoplan-idm.save_drafts.32a0d60a": "Save drafts",
"i18n:govoplan-idm.save_settings.4602c430": "Save settings",
"i18n:govoplan-idm.search_identities.88a9ef15": "Search by name, subject, account, or ID",
"i18n:govoplan-idm.select_account.982ee1ad": "Select account",
"i18n:govoplan-idm.select_function.2bec86e0": "Select function",
"i18n:govoplan-idm.select_identity.91d31615": "Select identity",
"i18n:govoplan-idm.settings_updated.9bbd0867": "Settings updated.",
"i18n:govoplan-idm.settings_write_permission_required.37f37efa": "You do not have permission to manage IDM settings.",
"i18n:govoplan-idm.source.d15b50c9": "Source",
"i18n:govoplan-idm.standard.6edc51aa": "Standard",
"i18n:govoplan-idm.status.9acb445d": "Status",
"i18n:govoplan-idm.summary.f1c5b7ab": "Summary",
"i18n:govoplan-idm.system.70c07e3f": "system",
"i18n:govoplan-idm.unit.a94c2fbd": "Unit",
"i18n:govoplan-idm.update_assignment.e20f52aa": "Update assignment",
"i18n:govoplan-idm.view_assignments.2d40d6a5": "View assignments",
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "You do not have permission to manage IDM organization assignments."
},
de: {
"i18n:govoplan-idm.account.2b2936f8": "Konto",
"i18n:govoplan-idm.active.7bd0e9f8": "Aktiv",
"i18n:govoplan-idm.acting_for.8650e6a6": "in Vertretung",
"i18n:govoplan-idm.acting_for_account_id.5d7ade5b": "Konto-ID der Vertretung",
"i18n:govoplan-idm.acting_for_search.b7c526c7": "Suche nach vertretenem Konto",
"i18n:govoplan-idm.add_assignment.08f2a0d5": "Zuordnung hinzufügen",
"i18n:govoplan-idm.applies_to_subunits.2e31b50b": "Gilt für Untereinheiten",
"i18n:govoplan-idm.assignment_added.91f1ee42": "Zuordnung hinzugefügt.",
"i18n:govoplan-idm.assignment_editor.e20598e7": "Zuordnungseditor",
"i18n:govoplan-idm.assignment_updated.fbbf9bd6": "Zuordnung aktualisiert.",
"i18n:govoplan-idm.assignments.a0d19ec5": "Zuordnungen",
"i18n:govoplan-idm.cancel_edit.ea4781e0": "Bearbeitung abbrechen",
"i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit-Detailgrad",
"i18n:govoplan-idm.change_retention_days.4a91f7d3": "Änderungsaufbewahrung in Tagen",
"i18n:govoplan-idm.deactivate.585777c8": "Deaktivieren",
"i18n:govoplan-idm.delegated.b189f4b6": "delegiert",
"i18n:govoplan-idm.delegated_from_assignment_id.20d4a548": "Delegiert von Zuordnung",
"i18n:govoplan-idm.direct.7b2b1f51": "direkt",
"i18n:govoplan-idm.directory.12e2e859": "Verzeichnis",
"i18n:govoplan-idm.discard_drafts.c0a86816": "Entwürfe verwerfen",
"i18n:govoplan-idm.edit.a5a0f3cc": "Bearbeiten",
"i18n:govoplan-idm.function.e67d5aba": "Funktion",
"i18n:govoplan-idm.function_is_required.5cce5b41": "Funktion ist erforderlich.",
"i18n:govoplan-idm.full.7f021a14": "Vollständig",
"i18n:govoplan-idm.governance.b989a277": "Governance",
"i18n:govoplan-idm.identity.544a8347": "Identität",
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.",
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verknüpfe Identitäten mit Organisationsfunktionen. Organisationen definiert die Funktionen; IDM verwaltet, wer sie innehat.",
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.",
"i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche",
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance",
"i18n:govoplan-idm.idm.61f4a7a2": "IDM",
"i18n:govoplan-idm.inactive.1baa5fba": "Inaktiv",
"i18n:govoplan-idm.loading_idm_assignments.0b1501bd": "IDM-Zuordnungen werden geladen...",
"i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e": "Vertretene Konten werden geladen...",
"i18n:govoplan-idm.loading_identities.f3b84693": "Identitäten werden geladen...",
"i18n:govoplan-idm.no_assignments.41193ce8": "Keine Identitäts-/Funktionszuordnungen gefunden.",
"i18n:govoplan-idm.no_functions_available.51ba08eb": "Es sind noch keine Organisationsfunktionen verfügbar.",
"i18n:govoplan-idm.none.2baf5c66": "Keine",
"i18n:govoplan-idm.reactivate.e4871a43": "Reaktivieren",
"i18n:govoplan-idm.reload.870ca3ec": "Neu laden",
"i18n:govoplan-idm.require_assignment_change_requests.697718a1": "Änderungsanträge für Zuordnungen verlangen",
"i18n:govoplan-idm.save_drafts.32a0d60a": "Entwürfe speichern",
"i18n:govoplan-idm.save_settings.4602c430": "Einstellungen speichern",
"i18n:govoplan-idm.search_identities.88a9ef15": "Nach Name, Subject, Konto oder ID suchen",
"i18n:govoplan-idm.select_account.982ee1ad": "Konto auswählen",
"i18n:govoplan-idm.select_function.2bec86e0": "Funktion auswählen",
"i18n:govoplan-idm.select_identity.91d31615": "Identität auswählen",
"i18n:govoplan-idm.settings_updated.9bbd0867": "Einstellungen aktualisiert.",
"i18n:govoplan-idm.settings_write_permission_required.37f37efa": "Du hast keine Berechtigung, IDM-Einstellungen zu verwalten.",
"i18n:govoplan-idm.source.d15b50c9": "Quelle",
"i18n:govoplan-idm.standard.6edc51aa": "Standard",
"i18n:govoplan-idm.status.9acb445d": "Status",
"i18n:govoplan-idm.summary.f1c5b7ab": "Zusammenfassung",
"i18n:govoplan-idm.system.70c07e3f": "System",
"i18n:govoplan-idm.unit.a94c2fbd": "Einheit",
"i18n:govoplan-idm.update_assignment.e20f52aa": "Zuordnung aktualisieren",
"i18n:govoplan-idm.view_assignments.2d40d6a5": "Zuordnungen anzeigen",
"i18n:govoplan-idm.write_permission_required.c7dde7c6": "Du hast keine Berechtigung, IDM-Organisationszuordnungen zu verwalten."
}
};

1
webui/src/index.ts Normal file
View File

@@ -0,0 +1 @@
export { idmModule, default } from "./module";

72
webui/src/module.ts Normal file
View File

@@ -0,0 +1,72 @@
import { createElement, lazy } from "react";
import { Button, type OrganizationFunctionActionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/idm.css";
const IdmPage = lazy(() => import("./features/IdmPage"));
const idmReadScopes = [
"idm:organization_assignment:read",
"idm:organization_assignment:write",
"organizations:function:assign"
];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const organizationFunctionActions: OrganizationFunctionActionsUiCapability = {
actions: [
{
id: "idm.view-function-assignments",
label: "i18n:govoplan-idm.assignments.a0d19ec5",
anyOf: idmReadScopes,
order: 40,
render: ({ function: item }) => createElement(
Button,
{
type: "button",
variant: "ghost",
title: "i18n:govoplan-idm.view_assignments.2d40d6a5",
onClick: () => {
if (typeof window !== "undefined") {
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
}
}
},
"i18n:govoplan-idm.assignments.a0d19ec5"
)
}
]
};
export const idmModule: PlatformWebModule = {
id: "idm",
label: "i18n:govoplan-idm.idm.61f4a7a2",
version: "0.1.6",
dependencies: ["identity", "organizations"],
translations,
navItems: [
{
to: "/idm",
label: "i18n:govoplan-idm.idm.61f4a7a2",
iconName: "users",
anyOf: idmReadScopes,
order: 72
}
],
routes: [
{
path: "/idm",
anyOf: idmReadScopes,
order: 72,
render: ({ settings, auth }) => createElement(IdmPage, { settings, auth })
}
],
uiCapabilities: {
"organizations.functionActions": organizationFunctionActions
}
};
export default idmModule;

100
webui/src/styles/idm.css Normal file
View File

@@ -0,0 +1,100 @@
.idm-page {
display: grid;
gap: 18px;
width: 100%;
max-width: 1480px;
}
.idm-heading {
margin-bottom: 4px;
}
.idm-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.idm-layout {
display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.idm-editor-stack {
display: grid;
gap: 18px;
}
.idm-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.idm-form-grid .wide {
grid-column: 1 / -1;
}
.idm-check-list {
display: grid;
gap: 10px;
grid-column: 1 / -1;
}
.idm-check-list label {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font-weight: 600;
}
.idm-form-actions,
.idm-row-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.idm-row-actions {
justify-content: flex-end;
}
.idm-identity {
display: grid;
gap: 2px;
min-width: 0;
}
.idm-id {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--muted);
}
.idm-muted {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.idm-empty {
color: var(--muted);
}
@media (max-width: 1100px) {
.idm-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.idm-form-grid {
grid-template-columns: 1fr;
}
}