233 lines
12 KiB
TypeScript
233 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
|
import type { ApiSettings } from "../../types";
|
|
import Button from "../../components/Button";
|
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
|
import Dialog from "../../components/Dialog";
|
|
import FormField from "../../components/FormField";
|
|
import StatusBadge from "../../components/StatusBadge";
|
|
import {
|
|
createGovernanceTemplate,
|
|
deleteGovernanceTemplate,
|
|
fetchGovernanceTemplates,
|
|
fetchPermissionCatalog,
|
|
fetchTenants,
|
|
updateGovernanceTemplate,
|
|
type GovernanceAssignment,
|
|
type GovernanceTemplateItem,
|
|
type PermissionItem,
|
|
type TenantAdminItem
|
|
} from "../../api/admin";
|
|
import AdminIconButton from "./components/AdminIconButton";
|
|
import AdminPageLayout from "./components/AdminPageLayout";
|
|
import AdminSelectionList from "./components/AdminSelectionList";
|
|
import { adminErrorMessage, joinLabels } from "./adminUtils";
|
|
|
|
const emptyDraft = {
|
|
slug: "",
|
|
name: "",
|
|
description: "",
|
|
isActive: true,
|
|
permissions: [] as string[],
|
|
assignments: [] as GovernanceAssignment[]
|
|
};
|
|
|
|
export default function GovernanceTemplatesPanel({
|
|
settings,
|
|
kind,
|
|
canWrite,
|
|
onAuthRefresh
|
|
}: {
|
|
settings: ApiSettings;
|
|
kind: "group" | "role";
|
|
canWrite: boolean;
|
|
onAuthRefresh: () => Promise<void>;
|
|
}) {
|
|
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
|
|
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
|
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
|
const [editing, setEditing] = useState<GovernanceTemplateItem | "new" | null>(null);
|
|
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
|
|
const [deleting, setDeleting] = useState<GovernanceTemplateItem | 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 [nextItems, nextTenants, nextPermissions] = await Promise.all([
|
|
fetchGovernanceTemplates(settings, kind),
|
|
fetchTenants(settings),
|
|
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])
|
|
]);
|
|
setItems(nextItems);
|
|
setTenants(nextTenants);
|
|
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
|
|
} catch (err) { setError(adminErrorMessage(err)); }
|
|
finally { setLoading(false); }
|
|
}
|
|
|
|
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]);
|
|
|
|
function openCreate() {
|
|
setDraft(emptyDraft);
|
|
setEditing("new");
|
|
}
|
|
|
|
function openEdit(item: GovernanceTemplateItem) {
|
|
setDraft({
|
|
slug: item.slug,
|
|
name: item.name,
|
|
description: item.description || "",
|
|
isActive: item.is_active,
|
|
permissions: item.permissions,
|
|
assignments: item.assignments
|
|
});
|
|
setEditing(item);
|
|
}
|
|
|
|
function assignmentMode(tenantId: string): "none" | "available" | "required" {
|
|
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
|
|
}
|
|
|
|
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
|
|
setDraft((current) => ({
|
|
...current,
|
|
assignments: mode === "none"
|
|
? current.assignments.filter((item) => item.tenant_id !== tenantId)
|
|
: [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
|
|
}));
|
|
}
|
|
|
|
async function save() {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
if (editing === "new") {
|
|
await createGovernanceTemplate(settings, {
|
|
kind,
|
|
slug: draft.slug,
|
|
name: draft.name,
|
|
description: draft.description || null,
|
|
permissions: kind === "role" ? draft.permissions : [],
|
|
is_active: draft.isActive,
|
|
assignments: draft.assignments
|
|
});
|
|
setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`);
|
|
} else if (editing) {
|
|
await updateGovernanceTemplate(settings, editing.id, {
|
|
name: draft.name,
|
|
description: draft.description || null,
|
|
permissions: kind === "role" ? draft.permissions : [],
|
|
is_active: draft.isActive,
|
|
assignments: draft.assignments
|
|
});
|
|
setSuccess(`${draft.name} updated and synchronized to assigned tenants.`);
|
|
}
|
|
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 deleteGovernanceTemplate(settings, deleting.id);
|
|
setSuccess(`${deleting.name} deleted.`);
|
|
setDeleting(null);
|
|
await load();
|
|
await onAuthRefresh();
|
|
} catch (err) { setError(adminErrorMessage(err)); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
|
|
{
|
|
id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, 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: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
|
|
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
|
|
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
|
|
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
|
|
return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`;
|
|
}).join(", ") : "—"
|
|
},
|
|
...(kind === "role" ? [{
|
|
id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
|
|
value: (row: GovernanceTemplateItem) => row.effective_permission_count
|
|
}] : []),
|
|
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
|
{
|
|
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={!canWrite} />
|
|
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
|
</div>
|
|
}
|
|
], [canWrite, kind, tenants]);
|
|
|
|
const title = kind === "group" ? "Central groups" : "Tenant roles";
|
|
const description = kind === "group"
|
|
? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local."
|
|
: "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions.";
|
|
|
|
return (
|
|
<>
|
|
<AdminPageLayout
|
|
title={title}
|
|
description={description}
|
|
loading={loading}
|
|
error={error}
|
|
success={success}
|
|
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label={kind === "group" ? "Add group template" : "Add tenant role"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
|
|
>
|
|
<div className="admin-table-surface">
|
|
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={`No central ${kind} templates found.`} />
|
|
</div>
|
|
</AdminPageLayout>
|
|
|
|
<Dialog
|
|
open={editing !== null}
|
|
title={editing === "new" ? (kind === "group" ? "Create group template" : "Create tenant role") : (kind === "group" ? "Edit group template" : "Edit tenant 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…" : kind === "group" ? "Save template" : "Save tenant 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="Status"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
|
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
|
</div>
|
|
{kind === "role" && <div className="form-field"><span className="form-label">Tenant permissions</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
|
|
<div className="form-field">
|
|
<span className="form-label">Tenant availability</span>
|
|
<div className="admin-selection-list admin-governance-mode">
|
|
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">Not available</option><option value="available">Available</option><option value="required">Required</option></select></div>)}
|
|
</div>
|
|
<p className="muted small-note">Required means the definition must remain present and system-controlled. It does not automatically assign users or grant permissions.</p>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(viewing)} title={viewing?.name || "Template 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>Kind</dt><dd>{viewing.kind}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Tenants</dt><dd>{viewing.assignments.length || "None"}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Permissions</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
|
</Dialog>
|
|
|
|
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "Delete group template" : "Delete tenant role"} message={`Delete ${deleting?.name}? Removal is blocked while a materialized tenant definition still has members or assignments.`} confirmLabel={kind === "group" ? "Delete template" : "Delete tenant role"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
|
</>
|
|
);
|
|
}
|