Release v0.1.5
This commit is contained in:
139
webui/src/features/admin/GroupsPanel.tsx
Normal file
139
webui/src/features/admin/GroupsPanel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createGroup, fetchGroups, fetchRoles, fetchUsers, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } 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, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||
|
||||
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canDefine: boolean;
|
||||
canManageMembers: boolean;
|
||||
canAssignRoles: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [editing, setEditing] = useState<GroupSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<GroupSummary | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<GroupSummary | 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 [nextGroups, nextUsers, nextRoles] = await Promise.all([fetchGroups(settings), fetchUsers(settings), fetchRoles(settings)]);
|
||||
setGroups(nextGroups);
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles.filter((role) => role.is_assignable));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
|
||||
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
|
||||
function openEdit(group: GroupSummary) {
|
||||
setDraft({ slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) });
|
||||
setEditing(group);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] });
|
||||
setSuccess(`Group ${draft.name} created.`);
|
||||
} else if (editing) {
|
||||
const managed = Boolean(editing.system_template_id);
|
||||
await updateGroup(settings, editing.id, {
|
||||
...(canDefine && !managed ? { name: draft.name, description: draft.description || null } : {}),
|
||||
...(canDefine && !editing.system_required ? { is_active: draft.isActive } : {}),
|
||||
...(canManageMembers ? { member_ids: draft.memberIds } : {}),
|
||||
...(canAssignRoles ? { role_ids: draft.roleIds } : {})
|
||||
});
|
||||
setSuccess(`Group ${draft.name} updated.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateGroup(settings, deactivating.id, { is_active: false });
|
||||
setSuccess(`Group ${deactivating.name} deactivated.`);
|
||||
setDeactivating(null);
|
||||
if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
||||
{ id: "group", header: "Group", 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: "members", header: "Members", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||
{ id: "roles", header: "Inherited roles", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ 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={!(canDefine || canManageMembers || canAssignRoles)} />
|
||||
<AdminIconButton label={`Deactivate ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
|
||||
</div> }
|
||||
], [canAssignRoles, canDefine, canManageMembers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant groups" description="Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add group" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No groups found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create group" : "Edit group"} 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() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "Saving…" : "Save group"}</Button></>}>
|
||||
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.</p>}
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_required))} 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} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">Members</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="No tenant users exist." /></div>
|
||||
<div><span className="form-label">Inherited roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="No assignable roles exist." /></div>
|
||||
</div>
|
||||
<p className="muted small-note">The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Group 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>Group</dt><dd>{viewing.name}</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>Management</dt><dd>{viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant managed"}</dd></div>
|
||||
<div><dt>Members</dt><dd>{viewing.member_count}</dd></div><div><dt>Roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
</dl>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate group" message={`Deactivate ${deactivating?.name}? Memberships remain stored, but role inheritance stops.`} confirmLabel="Deactivate group" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user