Release v0.1.5
This commit is contained in:
224
webui/src/features/admin/SystemUsersPanel.tsx
Normal file
224
webui/src/features/admin/SystemUsersPanel.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
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 { PasswordField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemAccount,
|
||||
fetchSystemAccounts,
|
||||
fetchTenants,
|
||||
updateSystemAccount,
|
||||
updateSystemAccountRoles,
|
||||
updateSystemMemberships,
|
||||
type RoleSummary,
|
||||
type SystemAccountItem,
|
||||
type SystemMembershipDraft,
|
||||
type TenantAdminItem
|
||||
} from "../../api/admin";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
displayName: "",
|
||||
password: "",
|
||||
passwordResetRequired: true,
|
||||
isActive: true,
|
||||
roleIds: [] as string[],
|
||||
memberships: [] as SystemMembershipDraft[]
|
||||
};
|
||||
|
||||
export default function SystemUsersPanel({
|
||||
settings,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
canAssignRoles,
|
||||
canManageMemberships,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canSuspend: boolean;
|
||||
canAssignRoles: boolean;
|
||||
canManageMemberships: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [editing, setEditing] = useState<SystemAccountItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; value: string } | 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 [access, nextTenants] = await Promise.all([fetchSystemAccounts(settings), fetchTenants(settings)]);
|
||||
setAccounts(access.accounts);
|
||||
setRoles(access.roles);
|
||||
setTenants(nextTenants);
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(item: SystemAccountItem) {
|
||||
setDraft({
|
||||
email: item.email,
|
||||
displayName: item.display_name || "",
|
||||
password: "",
|
||||
passwordResetRequired: false,
|
||||
isActive: item.is_active,
|
||||
roleIds: item.roles.map((role) => role.id),
|
||||
memberships: item.memberships.map((membership) => ({
|
||||
tenant_id: membership.tenant_id,
|
||||
is_active: membership.is_active,
|
||||
role_ids: membership.role_ids || [],
|
||||
group_ids: membership.group_ids || [],
|
||||
is_owner: membership.is_owner,
|
||||
is_last_active_owner: membership.is_last_active_owner
|
||||
}))
|
||||
});
|
||||
setEditing(item);
|
||||
}
|
||||
|
||||
function membership(tenantId: string) {
|
||||
return draft.memberships.find((item) => item.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
function setMembership(tenantId: string, enabled: boolean) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
memberships: enabled
|
||||
? [...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }]
|
||||
: current.memberships.filter((item) => item.tenant_id !== tenantId)
|
||||
}));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const response = await createSystemAccount(settings, {
|
||||
email: draft.email,
|
||||
display_name: draft.displayName || null,
|
||||
password: draft.password || null,
|
||||
password_reset_required: draft.passwordResetRequired,
|
||||
is_active: draft.isActive,
|
||||
role_ids: canAssignRoles ? draft.roleIds : [],
|
||||
memberships: canManageMemberships ? draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })) : []
|
||||
});
|
||||
if (response.temporary_password) setTemporaryPassword({ email: response.account.email, value: response.temporary_password });
|
||||
setSuccess(`Global account ${response.account.email} created.`);
|
||||
} else if (editing) {
|
||||
const accountChanges: { display_name?: string | null; is_active?: boolean } = {};
|
||||
if (canUpdate) accountChanges.display_name = draft.displayName || null;
|
||||
if (canSuspend) accountChanges.is_active = draft.isActive;
|
||||
if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges);
|
||||
if (canAssignRoles) await updateSystemAccountRoles(settings, editing.account_id, draft.roleIds);
|
||||
if (canManageMemberships) await updateSystemMemberships(settings, editing.account_id, draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })));
|
||||
setSuccess(`Global account ${editing.email} updated.`);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!deactivating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
|
||||
setSuccess(`${deactivating.email} deactivated.`);
|
||||
setDeactivating(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
|
||||
{ id: "account", header: "Account", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "tenants", header: "Tenant memberships", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
|
||||
{ id: "roles", header: "System roles", width: 220, minWidth: 170, maxWidth: 420, resizable: 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: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
||||
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
|
||||
</div> }
|
||||
], [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="Central users"
|
||||
description="Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add global account" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
|
||||
>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="No global accounts found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create global account" : "Edit global account"} 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.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "Saving…" : "Save account"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" && (
|
||||
<FormField label="Initial password">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="Leave empty to generate"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Account status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">System 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 })} /></div>
|
||||
<div><span className="form-label">Tenant memberships</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
|
||||
</div>
|
||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.</p>}
|
||||
<p className="muted small-note">Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Global account 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>Account</dt><dd>{viewing.email}</dd></div><div><dt>Display name</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>System roles</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>Tenants</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
|
||||
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate global account" message={`Deactivate ${deactivating?.email}? All sessions and tenant access will stop. Historical ownership remains intact.`} confirmLabel="Deactivate account" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user