Release v0.1.5
This commit is contained in:
254
webui/src/features/admin/SystemRolesPanel.tsx
Normal file
254
webui/src/features/admin/SystemRolesPanel.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemRole,
|
||||
deleteSystemRole,
|
||||
fetchPermissionCatalog,
|
||||
fetchSystemRoles,
|
||||
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 } from "@govoplan/core-webui";
|
||||
|
||||
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 [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 [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextRoles, catalogue] = await Promise.all([
|
||||
fetchSystemRoles(settings),
|
||||
fetchPermissionCatalog(settings)
|
||||
]);
|
||||
setRoles(nextRoles);
|
||||
setPermissions(catalogue.filter((item) => item.level === "system"));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(role: RoleSummary) {
|
||||
setDraft({
|
||||
slug: role.slug,
|
||||
name: role.name,
|
||||
description: role.description || "",
|
||||
permissions: role.permissions,
|
||||
isAssignable: role.is_assignable
|
||||
});
|
||||
setEditing(role);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createSystemRole(settings, {
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: draft.permissions
|
||||
});
|
||||
setSuccess(`System role ${draft.name} created.`);
|
||||
} else if (editing) {
|
||||
await updateSystemRole(settings, editing.id, {
|
||||
name: draft.name,
|
||||
description: draft.description || null,
|
||||
permissions: draft.permissions,
|
||||
is_assignable: draft.isAssignable
|
||||
});
|
||||
setSuccess(`System role ${draft.name} updated.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteSystemRole(settings, deleting.id);
|
||||
setSuccess(`System role ${deleting.name} deleted.`);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{
|
||||
id: "role",
|
||||
header: "System role",
|
||||
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: "Description",
|
||||
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: "Permissions",
|
||||
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: "Assignable",
|
||||
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 ? "Yes" : "No"} />
|
||||
},
|
||||
{
|
||||
id: "assignments",
|
||||
header: "Accounts",
|
||||
width: 110,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.user_assignments
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 150,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => {
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
||||
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
], [canWrite]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="System roles"
|
||||
description="Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add system role" 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="No system roles found." />
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? "Create system role" : "Edit system role"}
|
||||
onClose={() => !busy && setEditing(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}
|
||||
>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<span className="form-label">System permissions</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">A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "System role details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Protected</dt><dd>{viewing.slug === "system_owner" ? "Yes" : "No"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div><div><dt>Account assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Effective permissions</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>Assigned scopes</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="Delete system role" message={`Delete ${deleting?.name}? The role must have no account assignments.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user