Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent f37c6668e5
commit efb69b3d2d
43 changed files with 6464 additions and 192 deletions

View File

@@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRoles, 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 } from "@govoplan/core-webui";
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 [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
const [viewing, setViewing] = useState<RoleSummary | null>(null);
const [draft, setDraft] = useState(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("");
async function load() {
setLoading(true);
setError("");
try {
const [nextRoles, nextPermissions] = await Promise.all([fetchRoles(settings), fetchPermissionCatalog(settings)]);
setRoles(nextRoles);
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
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); setEditing("new"); setError(""); }
function openEdit(role: RoleSummary) {
if (role.is_builtin || role.system_template_id) return;
setDraft({ slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable });
setEditing(role);
setError("");
}
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() {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
setSuccess(`Role ${draft.name} created.`);
} else if (editing) {
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
setSuccess(`Role ${draft.name} updated.`);
}
setEditing(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
async function remove() {
if (!deleting) return;
setBusy(true);
setError("");
try {
await deleteRole(settings, deleting.id);
setSuccess(`Role ${deleting.name} deleted.`);
setDeleting(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{ id: "role", header: "Role", 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">System{row.system_required ? " · required" : ""}</span>}</div></div> },
{ id: "permissions", header: "Permissions", 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: "Assignments", 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: "Type", 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 ? "Built-in" : row.system_template_id ? "System" : "Custom"} /> },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <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={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
<AdminIconButton label={`Delete ${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="Tenant roles" description="Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add role" 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="No roles found." /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create role" : "Edit 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={!canDefine || 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="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
{editing !== "new" && <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>}
</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="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>Role</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
<div><dt>Type</dt><dd>{viewing.is_builtin ? "Built-in" : viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant custom"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div>
<div><dt>User assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Group assignments</dt><dd>{viewing.group_assignments}</dd></div>
</dl><h3>Permissions</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title="Delete role" message={`Delete ${deleting?.name}? Only unassigned tenant-defined roles can be deleted.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>
);
}