378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
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);
|
|
}
|