290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
|
import type { ApiSettings } from "@govoplan/core-webui";
|
|
import {
|
|
createSystemRole,
|
|
deleteSystemRole,
|
|
fetchPermissionCatalog,
|
|
fetchSystemRolesDelta,
|
|
updateSystemRole,
|
|
type PermissionItem,
|
|
type RoleSummary } from
|
|
"../../api/admin";
|
|
import { Button } from "@govoplan/core-webui";
|
|
import { ConfirmDialog } 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 { StatusBadge } from "@govoplan/core-webui";
|
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
|
import { loadDeltaRows } from "./utils/deltaRows";
|
|
|
|
const emptyDraft = {
|
|
slug: "",
|
|
name: "",
|
|
description: "",
|
|
permissions: [] as string[],
|
|
isAssignable: true
|
|
};
|
|
|
|
export default function SystemRolesPanel({
|
|
settings,
|
|
canWrite,
|
|
onAuthRefresh
|
|
|
|
|
|
|
|
|
|
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
|
|
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
|
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
|
const rolesRef = useRef<RoleSummary[]>([]);
|
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
|
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
|
|
const [viewing, setViewing] = useState<RoleSummary | null>(null);
|
|
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
|
const [draft, setDraft] = useState(emptyDraft);
|
|
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty,
|
|
onSave: save,
|
|
onDiscard: closeEditor
|
|
});
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [nextRoles, catalogue] = await Promise.all([
|
|
loadDeltaRows(rolesRef.current, "access:system-roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchSystemRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_system_role", sortSystemRoles),
|
|
fetchPermissionCatalog(settings)]
|
|
);
|
|
rolesRef.current = nextRoles;
|
|
setRoles(nextRoles);
|
|
setPermissions(catalogue.filter((item) => item.level === "system"));
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
rolesRef.current = [];
|
|
resetDeltaWatermark();
|
|
void load();
|
|
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
|
|
|
function openCreate() {
|
|
setDraft(emptyDraft);
|
|
setSavedDraftKey(draftKey(emptyDraft));
|
|
setEditing("new");
|
|
}
|
|
|
|
function openEdit(role: RoleSummary) {
|
|
const nextDraft = {
|
|
slug: role.slug,
|
|
name: role.name,
|
|
description: role.description || "",
|
|
permissions: role.permissions,
|
|
isAssignable: role.is_assignable
|
|
};
|
|
setDraft(nextDraft);
|
|
setSavedDraftKey(draftKey(nextDraft));
|
|
setEditing(role);
|
|
}
|
|
|
|
function closeEditor() {
|
|
setEditing(null);
|
|
setDraft(emptyDraft);
|
|
setSavedDraftKey(draftKey(emptyDraft));
|
|
}
|
|
|
|
async function save(): Promise<boolean> {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
if (editing === "new") {
|
|
await createSystemRole(settings, {
|
|
slug: draft.slug,
|
|
name: draft.name,
|
|
description: draft.description || null,
|
|
permissions: draft.permissions
|
|
});
|
|
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_created.9c1a6bc8", { value0: draft.name }));
|
|
} else if (editing) {
|
|
await updateSystemRole(settings, editing.id, {
|
|
name: draft.name,
|
|
description: draft.description || null,
|
|
permissions: draft.permissions,
|
|
is_assignable: draft.isAssignable
|
|
});
|
|
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_updated.3a3f8862", { value0: draft.name }));
|
|
}
|
|
setEditing(null);
|
|
await load();
|
|
await onAuthRefresh();
|
|
return true;
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
if (!deleting) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await deleteSystemRole(settings, deleting.id);
|
|
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_deleted.7b18a6f8", { value0: deleting.name }));
|
|
setDeleting(null);
|
|
await load();
|
|
await onAuthRefresh();
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
|
{
|
|
id: "role",
|
|
header: "i18n:govoplan-access.system_role.91762640",
|
|
width: 240,
|
|
minWidth: 180,
|
|
maxWidth: 380,
|
|
resizable: true,
|
|
sticky: "start",
|
|
sortable: true,
|
|
filterable: true,
|
|
value: (row) => `${row.name} ${row.slug}`,
|
|
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
|
|
},
|
|
{
|
|
id: "description",
|
|
header: "i18n:govoplan-access.description.55f8ebc8",
|
|
width: 360,
|
|
fill: true,
|
|
minWidth: 220,
|
|
maxWidth: 640,
|
|
resizable: true,
|
|
sortable: true,
|
|
filterable: true,
|
|
value: (row) => row.description || "",
|
|
render: (row) => row.description || "—"
|
|
},
|
|
{
|
|
id: "permissions",
|
|
header: "i18n:govoplan-access.permissions.d06d5557",
|
|
width: 120,
|
|
resizable: false,
|
|
sortable: true,
|
|
filterable: true,
|
|
filterType: "integer",
|
|
value: (row) => row.effective_permission_count,
|
|
render: (row) => String(row.effective_permission_count)
|
|
},
|
|
{
|
|
id: "assignable",
|
|
header: "i18n:govoplan-access.assignable.a88debc5",
|
|
width: 120,
|
|
resizable: false,
|
|
sortable: true,
|
|
filterable: true,
|
|
value: (row) => row.is_assignable ? "yes" : "no",
|
|
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"} />
|
|
},
|
|
{
|
|
id: "assignments",
|
|
header: "i18n:govoplan-access.accounts.36bae316",
|
|
width: 110,
|
|
resizable: false,
|
|
sortable: true,
|
|
filterable: true,
|
|
filterType: "integer",
|
|
value: (row) => row.user_assignments
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "i18n:govoplan-access.actions.c3cd636a",
|
|
width: 150,
|
|
sticky: "end",
|
|
resizable: false,
|
|
align: "right",
|
|
render: (row) => {
|
|
const protectedOwner = row.slug === "system_owner";
|
|
return <div className="admin-icon-actions">
|
|
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
|
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
|
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
|
</div>;
|
|
}
|
|
}],
|
|
[canWrite]);
|
|
|
|
return (
|
|
<>
|
|
<AdminPageLayout
|
|
title="i18n:govoplan-access.system_roles.a9461aa6"
|
|
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
|
|
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_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}>
|
|
|
|
<div className="admin-table-surface">
|
|
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
|
</div>
|
|
</AdminPageLayout>
|
|
|
|
<Dialog
|
|
open={editing !== null}
|
|
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
|
onClose={() => !busy && setEditing(null)}
|
|
className="admin-dialog admin-dialog-wide"
|
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
|
|
|
<div className="admin-form-grid two-columns">
|
|
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
|
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
|
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
|
</div>
|
|
<div className="form-field">
|
|
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
|
<AdminSelectionList
|
|
options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))}
|
|
selected={draft.permissions}
|
|
onChange={(next) => setDraft({ ...draft, permissions: next })} />
|
|
|
|
<p className="muted small-note">i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45</p>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
|
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-access.protected.28531336</dt><dd>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.account_assignments.f5a91f2a</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>i18n:govoplan-access.assigned_scopes.c7b09b12</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
|
</Dialog>
|
|
|
|
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
|
</>);
|
|
|
|
}
|
|
|
|
function draftKey(draft: typeof emptyDraft): string {
|
|
return JSON.stringify(draft);
|
|
}
|
|
|
|
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
|
|
return left.name.localeCompare(right.name);
|
|
}
|