286 lines
17 KiB
TypeScript
286 lines
17 KiB
TypeScript
import { useEffect, useMemo, useRef, 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,
|
|
fetchSystemAccountsDelta,
|
|
fetchTenants,
|
|
updateSystemAccount,
|
|
updateSystemAccountRoles,
|
|
updateSystemMemberships,
|
|
type RoleSummary,
|
|
type SystemAccountItem,
|
|
type SystemMembershipDraft,
|
|
type TenantAdminItem
|
|
} from "../../api/admin";
|
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } 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 accountsRef = useRef<SystemAccountItem[]>([]);
|
|
const rolesRef = useRef<RoleSummary[]>([]);
|
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
|
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 [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty,
|
|
onSave: save,
|
|
onDiscard: closeEditor
|
|
});
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
let nextWatermark = getDeltaWatermark("access:system-accounts");
|
|
let nextAccounts = accountsRef.current;
|
|
let nextRoles = rolesRef.current;
|
|
let hasMore = false;
|
|
do {
|
|
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
|
|
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
|
nextAccounts = response.full
|
|
? continuingFullSnapshot
|
|
? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts })
|
|
: response.accounts
|
|
: mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
|
|
nextRoles = response.full
|
|
? continuingFullSnapshot
|
|
? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles })
|
|
: response.roles
|
|
: mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
|
nextWatermark = response.watermark ?? null;
|
|
hasMore = response.has_more;
|
|
} while (hasMore);
|
|
setDeltaWatermark("access:system-accounts", nextWatermark);
|
|
const nextTenants = await fetchTenants(settings);
|
|
accountsRef.current = nextAccounts;
|
|
rolesRef.current = nextRoles;
|
|
setAccounts(nextAccounts);
|
|
setRoles(nextRoles);
|
|
setTenants(nextTenants);
|
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
|
{setLoading(false);}
|
|
}
|
|
|
|
useEffect(() => {
|
|
accountsRef.current = [];
|
|
rolesRef.current = [];
|
|
resetDeltaWatermark();
|
|
void load();
|
|
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
|
|
|
function openCreate() {
|
|
setDraft(emptyDraft);
|
|
setSavedDraftKey(draftKey(emptyDraft));
|
|
setEditing("new");
|
|
}
|
|
|
|
function openEdit(item: SystemAccountItem) {
|
|
const nextDraft = {
|
|
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
|
|
}))
|
|
};
|
|
setDraft(nextDraft);
|
|
setSavedDraftKey(draftKey(nextDraft));
|
|
setEditing(item);
|
|
}
|
|
|
|
function closeEditor() {
|
|
setEditing(null);
|
|
setDraft(emptyDraft);
|
|
setSavedDraftKey(draftKey(emptyDraft));
|
|
}
|
|
|
|
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(): Promise<boolean> {
|
|
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(i18nMessage("i18n:govoplan-access.global_account_value_created.5100e467", { value0: response.account.email }));
|
|
} 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(i18nMessage("i18n:govoplan-access.global_account_value_updated.0de76b0e", { value0: editing.email }));
|
|
}
|
|
setEditing(null);
|
|
await load();
|
|
await onAuthRefresh();
|
|
return true;
|
|
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
|
{setBusy(false);}
|
|
}
|
|
|
|
async function deactivate() {
|
|
if (!deactivating) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
|
|
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated.ed3d027c", { value0: deactivating.email }));
|
|
setDeactivating(null);
|
|
await load();
|
|
await onAuthRefresh();
|
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
|
{setBusy(false);}
|
|
}
|
|
|
|
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
|
|
{ id: "account", header: "i18n:govoplan-access.account.85dfa32c", 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: "i18n:govoplan-access.tenant_memberships.451de736", 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: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", 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: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
|
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
|
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
|
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: 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="i18n:govoplan-access.central_users.91ac1b51"
|
|
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
|
loading={loading}
|
|
error={error}
|
|
success={success}
|
|
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" 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="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
|
</AdminPageLayout>
|
|
|
|
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
|
<div className="admin-form-grid two-columns">
|
|
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
|
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
|
{editing === "new" &&
|
|
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
|
<PasswordField
|
|
value={draft.password}
|
|
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
|
autoComplete="new-password"
|
|
onValueChange={(password) => setDraft({ ...draft, password })} />
|
|
|
|
</FormField>
|
|
}
|
|
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><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">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
|
</div>
|
|
<div className="admin-assignment-grid">
|
|
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</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">i18n:govoplan-access.tenant_memberships.451de736</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">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
|
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
|
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{viewing.email}</dd></div><div><dt>i18n:govoplan-access.display_name.c7874aaa</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>i18n:govoplan-access.system_roles.a9461aa6</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>i18n:govoplan-access.tenants.1f7ae776</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
|
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
|
</Dialog>
|
|
|
|
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_global_account.66d92736" message={i18nMessage("i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_account.fd9fd676" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
|
</>);
|
|
|
|
}
|
|
|
|
function draftKey(draft: typeof emptyDraft): string {
|
|
return JSON.stringify(draft);
|
|
}
|
|
|
|
function sortSystemAccounts(left: SystemAccountItem, right: SystemAccountItem): number {
|
|
return left.email.localeCompare(right.email);
|
|
}
|
|
|
|
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
|
|
return left.name.localeCompare(right.name);
|
|
}
|