import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { Edit3, Plus, RefreshCw } from "lucide-react"; import { AdminIconButton, ApiError, Button, Card, DataGrid, Dialog, 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 { 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(items: T[]): Map { 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 {fallbackId}; const label = identity.display_name || identity.external_subject || identity.id; return ( {label} {identity.id} ); } function functionLabel(item: OrganizationFunctionItem, unitById: ReadonlyMap): string { const unitName = unitById.get(item.organization_unit_id)?.name; return unitName ? `${item.name} (${unitName})` : item.name; } function assignmentOptionLabel( item: OrganizationFunctionAssignmentItem, identityById: ReadonlyMap, functionById: ReadonlyMap, unitById: ReadonlyMap ): 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 ; } function sourceLabel(value: string): string { 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) { const [model, setModel] = useState(EMPTY_MODEL); const [assignments, setAssignments] = useState([]); const [idmSettings, setIdmSettings] = useState(null); const [identityOptions, setIdentityOptions] = useState([]); const [identitySearch, setIdentitySearch] = useState(""); const [identityLoading, setIdentityLoading] = useState(false); const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true); const [actingForSearch, setActingForSearch] = useState(""); const [actingForOptions, setActingForOptions] = useState([]); const [actingForLoading, setActingForLoading] = useState(false); const [assignmentDraft, setAssignmentDraft] = useState(() => emptyAssignmentDraft()); const [settingsDraft, setSettingsDraft] = useState(() => settingsDraftFrom(null)); const [editingAssignmentId, setEditingAssignmentId] = useState(null); const [assignmentEditorOpen, setAssignmentEditorOpen] = useState(false); const [assignmentChangeRequestId, setAssignmentChangeRequestId] = useState(""); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = 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 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(); 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 = initialQuery.functionId; const hasDirtyAssignmentDraft = assignmentEditorOpen && 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 (!appliedInitialQueryRef.current && initialQuery.assignmentId) { 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) { 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, initialQuery.assignmentId, 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, successMessage = ""): Promise => { 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); setAssignmentEditorOpen(false); setAssignmentChangeRequestId(""); }, []); const discardDrafts = useCallback(() => { discardAssignmentDraft(); setSettingsDraft(settingsDraftFrom(idmSettings)); }, [discardAssignmentDraft, idmSettings]); const submitSettings = useCallback(async (): Promise => { 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 => { 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 requestId = textOrNull(assignmentChangeRequestId); const payload = requestId ? { ...assignmentPayload(assignmentDraft), change_request_id: requestId } : 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; }, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]); const saveDrafts = useCallback(async (): Promise => { 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 openCreateAssignment = useCallback(() => { setEditingAssignmentId(null); setAssignmentDraft({ ...emptyAssignmentDraft(), function_id: initialFunctionFilter && functionById.has(initialFunctionFilter) ? initialFunctionFilter : "" }); setAssignmentChangeRequestId(""); setAssignmentEditorOpen(true); }, [functionById, initialFunctionFilter]); const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => { setEditingAssignmentId(item.id); setAssignmentDraft(assignmentDraftFrom(item)); setAssignmentChangeRequestId(""); setAssignmentEditorOpen(true); }, []); 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[] = [ { 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 ? {row.account_id} : i18n:govoplan-idm.none.2baf5c66 }, { 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) : {row.function_id}; } }, { id: "unit", header: "i18n:govoplan-idm.unit.a94c2fbd", minWidth: 180, render: (row) => unitById.get(row.organization_unit_id)?.name ?? {row.organization_unit_id} }, { 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: 88, sticky: "end", render: (row) => (
) } ]; return (
i18n:govoplan-idm.idm.61f4a7a2

i18n:govoplan-idm.identity_links_intro.45fed9dd

{error && {error}} {success && !error && {success}} {!canManage && i18n:govoplan-idm.write_permission_required.c7dde7c6}
{canReadSettings && (
{ event.preventDefault(); void submitSettings(); }}>
setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })} />
)}
{renderAssignmentDialog()}
); function renderAssignmentDialog() { const formId = "idm-assignment-editor"; return ( !busy && discardAssignmentDraft()} closeDisabled={busy} className="admin-dialog admin-dialog-wide idm-editor-dialog" footer={( <> )} >
void submitAssignment(event)}> setIdentitySearch(event.target.value)} /> {(assignmentDraft.source === "delegated" || assignmentDraft.source === "acting_for") && ( )} {assignmentDraft.source === "acting_for" && ( <> setActingForSearch(event.target.value)} /> )}
setAssignmentChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />

i18n:govoplan-idm.change_request_id_help.cc7de508

{!identityLookupAvailable &&

i18n:govoplan-idm.identity_lookup_unavailable.b76f7714

} {identityLoading &&

i18n:govoplan-idm.loading_identities.f3b84693

} {actingForLoading &&

i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e

} {!model.functions.length &&

i18n:govoplan-idm.no_functions_available.51ba08eb

}
); } }