170 lines
13 KiB
TypeScript
170 lines
13 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
|
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, 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 { StatusBadge } from "@govoplan/core-webui";
|
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
|
import { hasTenantWildcard, 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 RolesPanel({ settings, auth, canDefine, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canDefine: 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 [draft, setDraft] = useState(emptyDraft);
|
|
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
|
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
|
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, nextPermissions] = await Promise.all([
|
|
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles),
|
|
fetchPermissionCatalog(settings)]
|
|
);
|
|
rolesRef.current = nextRoles;
|
|
setRoles(nextRoles);
|
|
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
|
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
|
{setLoading(false);}
|
|
}
|
|
|
|
useEffect(() => {
|
|
rolesRef.current = [];
|
|
resetDeltaWatermark();
|
|
void load();
|
|
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
|
|
|
|
const permissionGroups = useMemo(() => {
|
|
const groups = new Map<string, PermissionItem[]>();
|
|
for (const permission of permissions) groups.set(permission.category, [...(groups.get(permission.category) ?? []), permission]);
|
|
return Array.from(groups.entries());
|
|
}, [permissions]);
|
|
|
|
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
|
|
function openEdit(role: RoleSummary) {
|
|
if (role.is_builtin || role.system_template_id) return;
|
|
const nextDraft = { slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable };
|
|
setDraft(nextDraft);
|
|
setSavedDraftKey(draftKey(nextDraft));
|
|
setEditing(role);
|
|
setError("");
|
|
}
|
|
|
|
function closeEditor() {
|
|
setEditing(null);
|
|
setDraft(emptyDraft);
|
|
setSavedDraftKey(draftKey(emptyDraft));
|
|
}
|
|
function togglePermission(scope: string, checked: boolean) {
|
|
const next = new Set(draft.permissions);
|
|
if (checked) next.add(scope);else next.delete(scope);
|
|
setDraft({ ...draft, permissions: Array.from(next) });
|
|
}
|
|
|
|
async function save(): Promise<boolean> {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
if (editing === "new") {
|
|
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
|
|
setSuccess(i18nMessage("i18n:govoplan-access.role_value_created.2a964899", { value0: draft.name }));
|
|
} else if (editing) {
|
|
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
|
|
setSuccess(i18nMessage("i18n:govoplan-access.role_value_updated.ec386eb0", { value0: draft.name }));
|
|
}
|
|
setEditing(null);
|
|
await onAuthRefresh();
|
|
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 deleteRole(settings, deleting.id);
|
|
setSuccess(i18nMessage("i18n:govoplan-access.role_value_deleted.3bd819cf", { value0: deleting.name }));
|
|
setDeleting(null);
|
|
await onAuthRefresh();
|
|
await load();
|
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
|
{setBusy(false);}
|
|
}
|
|
|
|
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
|
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, 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} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
|
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
|
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
|
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <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={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
|
|
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
|
|
</div> }],
|
|
[canDefine, permissions]);
|
|
|
|
return (
|
|
<>
|
|
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" 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_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
|
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
|
</AdminPageLayout>
|
|
|
|
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} 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={!canDefine || 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.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
|
{editing !== "new" && <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>}
|
|
</div>
|
|
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" 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.role.c3f104d1</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
|
<div><dt>i18n:govoplan-access.type.3deb7456</dt><dd>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</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.user_assignments.bc7cc801</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.group_assignments.e534bb56</dt><dd>{viewing.group_assignments}</dd></div>
|
|
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
|
</Dialog>
|
|
|
|
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { 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 sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
|
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
|
|
if (builtinDelta !== 0) return builtinDelta;
|
|
return left.name.localeCompare(right.name);
|
|
}
|