Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:20 +02:00
parent c71de86a1f
commit a7c486788e
18 changed files with 1527 additions and 137 deletions

View File

@@ -6,7 +6,8 @@ import type {
ApiSettings,
AuthInfo,
FilesConnectorsUiCapability,
MailProfilesUiCapability
MailProfilesUiCapability,
OrganizationFunctionPickerUiCapability
} from "@govoplan/core-webui";
import { fetchMe } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
@@ -19,6 +20,7 @@ import TenantsPanel from "./TenantsPanel";
import UsersPanel from "./UsersPanel";
import GroupsPanel from "./GroupsPanel";
import RolesPanel from "./RolesPanel";
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
import ApiKeysPanel from "./ApiKeysPanel";
import AdminAuditPanel from "./AdminAuditPanel";
import FileConnectorsPanel from "./FileConnectorsPanel";
@@ -46,6 +48,7 @@ const handledAdminSectionIds = new Set<string>([
"system-retention",
"tenant-settings",
"tenant-roles",
"tenant-function-role-mappings",
"tenant-groups",
"tenant-users",
"tenant-file-connectors",
@@ -72,6 +75,7 @@ export default function AdminPage({
}) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
const mailProfilesAvailable = Boolean(mailProfilesUi);
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
@@ -108,6 +112,7 @@ export default function AdminPage({
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
sections.add("tenant-mail-servers");
@@ -126,7 +131,7 @@ export default function AdminPage({
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit");
return sections;
}, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, policyAvailable]);
}, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, policyAvailable]);
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null;
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
@@ -198,14 +203,15 @@ export default function AdminPage({
items: asSubnavItems(
sortNavItems([
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 20),
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 30),
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 40),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 50),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 60),
visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 70),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 80),
visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 90),
visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 80),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 100),
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
])
)
@@ -265,6 +271,7 @@ export default function AdminPage({
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}

View File

@@ -0,0 +1,377 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
import {
createExternalFunctionRoleMapping,
deleteExternalFunctionRoleMapping,
fetchExternalFunctionRoleMappingsDelta,
fetchRolesDelta,
updateExternalFunctionRoleMapping,
type ExternalFunctionRoleMappingItem,
type RoleSummary
} from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = {
sourceModule: "organizations",
functionId: "",
functionLabel: "",
organizationUnitLabel: "",
roleId: ""
};
export default function ExternalFunctionRoleMappingsPanel({
settings,
auth,
functionPicker,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
auth: AuthInfo;
functionPicker: OrganizationFunctionPickerUiCapability;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
const [mappings, setMappings] = useState<ExternalFunctionRoleMappingItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const mappingsRef = useRef<ExternalFunctionRoleMappingItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<ExternalFunctionRoleMappingItem | "new" | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [deleting, setDeleting] = useState<ExternalFunctionRoleMappingItem | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextMappings, nextRoles] = await Promise.all([
loadDeltaRows(
mappingsRef.current,
"access:external-function-role-mappings",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchExternalFunctionRoleMappingsDelta(settings, { since }),
(response) => response.mappings,
(mapping) => mapping.id,
"access_external_function_role_mapping",
sortMappings
),
loadDeltaRows(
rolesRef.current,
"access:roles-for-external-function-mappings",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchRolesDelta(settings, { since }),
(response) => response.roles,
(role) => role.id,
"access_role",
sortRoles
)
]);
mappingsRef.current = nextMappings;
rolesRef.current = nextRoles;
setMappings(nextMappings);
setRoles(nextRoles.filter((role) => role.level === "tenant"));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
mappingsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, tenantId, resetDeltaWatermark]);
const roleById = useMemo(() => new Map(roles.map((role) => [role.id, role])), [roles]);
const assignableRoles = useMemo(() => roles.filter((role) => role.is_assignable), [roles]);
function openCreate() {
const initial = { ...emptyDraft, roleId: assignableRoles[0]?.id ?? "" };
setDraft(initial);
setSavedDraftKey(draftKey(initial));
setEditing("new");
setError("");
}
function openEdit(mapping: ExternalFunctionRoleMappingItem) {
const nextDraft = {
sourceModule: mapping.source_module,
functionId: mapping.function_id,
functionLabel: "",
organizationUnitLabel: "",
roleId: mapping.role_id
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(mapping);
setError("");
}
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createExternalFunctionRoleMapping(settings, {
source_module: functionPicker.sourceModule,
function_id: draft.functionId.trim(),
role_id: draft.roleId
});
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_created.7a25eb5a"));
} else if (editing) {
await updateExternalFunctionRoleMapping(settings, editing.id, { role_id: draft.roleId });
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_updated.76020443"));
}
closeEditor();
await onAuthRefresh();
mappingsRef.current = [];
resetDeltaWatermark("access:external-function-role-mappings");
await load();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function remove() {
if (!deleting) return;
setBusy(true);
setError("");
try {
await deleteExternalFunctionRoleMapping(settings, deleting.id);
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_deleted.fb180786"));
setDeleting(null);
await onAuthRefresh();
mappingsRef.current = [];
resetDeltaWatermark("access:external-function-role-mappings");
await load();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
const columns = useMemo<DataGridColumn<ExternalFunctionRoleMappingItem>[]>(
() => [
{
id: "function",
header: "i18n:govoplan-access.function_fact.4f7435e4",
width: "minmax(260px, 1fr)",
minWidth: 220,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => `${row.source_module} ${row.function_id}`,
render: (row) => (
<>
{functionPicker.renderLabel?.({
settings,
auth,
sourceModule: row.source_module,
functionId: row.function_id,
fallback: (
<div>
<strong>{row.source_module}</strong>
<div className="muted small-note">{row.function_id}</div>
</div>
)
}) ?? (
<div>
<strong>{row.source_module}</strong>
<div className="muted small-note">{row.function_id}</div>
</div>
)}
</>
)
},
{
id: "role",
header: "i18n:govoplan-access.role.c3f104d1",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
sortable: true,
filterable: true,
value: (row) => roleById.get(row.role_id)?.name ?? row.role_id,
render: (row) => {
const role = roleById.get(row.role_id);
return (
<div>
<strong>{role?.name ?? row.role_id}</strong>
{role && <div className="muted small-note">{role.slug}</div>}
</div>
);
}
},
{
id: "updated",
header: "i18n:govoplan-access.updated.f2f8570d",
width: 180,
minWidth: 150,
resizable: true,
sortable: true,
value: (row) => row.updated_at,
render: (row) => formatDateTime(row.updated_at)
},
{
id: "actions",
header: "i18n:govoplan-access.actions.c3cd636a",
width: 130,
sticky: "end",
resizable: false,
align: "right",
render: (row) => (
<div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
</div>
)
}
],
[auth, canWrite, functionPicker, roleById, settings]
);
return (
<>
<AdminPageLayout
title="i18n:govoplan-access.function_role_mappings.2b64e9c3"
description="i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf"
loading={loading}
error={error}
success={success}
actions={
<>
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} />
</>
}
>
<div className="admin-table-surface">
<DataGrid
id="admin-external-function-role-mappings-v1"
rows={mappings}
columns={columns}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="i18n:govoplan-access.no_function_role_mappings_found.f735ff54"
/>
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
onClose={() => !busy && closeEditor()}
className="admin-dialog"
footer={
<>
<Button onClick={closeEditor} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.functionId.trim() || !draft.roleId}>
{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_mapping.a4ac90e9"}
</Button>
</>
}
>
<div className="admin-form-grid">
<FormField label="i18n:govoplan-access.function_id.e5e08937">
{functionPicker.renderPicker({
settings,
auth,
disabled: editing !== "new",
value: draft.functionId ? draftSelection(draft) : null,
onChange: (selection) => setDraft({ ...draft, ...selectionDraft(selection) })
})}
</FormField>
<FormField label="i18n:govoplan-access.role.c3f104d1">
<select value={draft.roleId} onChange={(event) => setDraft({ ...draft, roleId: event.target.value })}>
<option value="">i18n:govoplan-access.select_role.c543f191</option>
{assignableRoles.map((role) => (
<option key={role.id} value={role.id}>{role.name} ({role.slug})</option>
))}
</select>
</FormField>
</div>
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
</Dialog>
<ConfirmDialog
open={Boolean(deleting)}
title="i18n:govoplan-access.delete_function_role_mapping.0c0eec6e"
message={i18nMessage("i18n:govoplan-access.delete_function_role_mapping_value.419da2aa", { value0: deleting?.function_id })}
confirmLabel="i18n:govoplan-access.delete_mapping.0d27d92a"
tone="danger"
busy={busy}
onCancel={() => setDeleting(null)}
onConfirm={() => void remove()}
/>
</>
);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function draftSelection(draft: typeof emptyDraft): OrganizationFunctionSelection {
return {
sourceModule: "organizations",
functionId: draft.functionId,
label: draft.functionLabel || null,
organizationUnitLabel: draft.organizationUnitLabel || null
};
}
function selectionDraft(selection: OrganizationFunctionSelection | null): Pick<typeof emptyDraft, "sourceModule" | "functionId" | "functionLabel" | "organizationUnitLabel"> {
return {
sourceModule: selection?.sourceModule ?? "organizations",
functionId: selection?.functionId ?? "",
functionLabel: selection?.label ?? "",
organizationUnitLabel: selection?.organizationUnitLabel ?? ""
};
}
function sortMappings(left: ExternalFunctionRoleMappingItem, right: ExternalFunctionRoleMappingItem): number {
const sourceDelta = left.source_module.localeCompare(right.source_module);
return sourceDelta !== 0 ? sourceDelta : left.function_id.localeCompare(right.function_id);
}
function sortRoles(left: RoleSummary, right: RoleSummary): number {
return left.name.localeCompare(right.name);
}