import { useEffect, useMemo, useRef, useState } from "react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateUser, 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 { PasswordField } 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"; import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { loadDeltaRows } from "./utils/deltaRows"; const emptyDraft = { email: "", displayName: "", password: "", passwordResetRequired: true, isActive: true, groupIds: [] as string[], roleIds: [] as string[] }; export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise;}) { const [users, setUsers] = useState([]); const [groups, setGroups] = useState([]); const [roles, setRoles] = useState([]); const usersRef = useRef([]); const groupsRef = useRef([]); const rolesRef = useRef([]); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); const [deactivating, setDeactivating] = useState(null); const [draft, setDraft] = useState(emptyDraft); const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null); 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 { const [nextUsers, nextGroups, nextRoles] = await Promise.all([ loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers), loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups), loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)] ); usersRef.current = nextUsers; groupsRef.current = nextGroups; rolesRef.current = nextRoles; setUsers(nextUsers); setGroups(nextGroups); setRoles(nextRoles.filter((role) => role.is_assignable)); } catch (err) {setError(adminErrorMessage(err));} finally {setLoading(false);} } useEffect(() => { usersRef.current = []; groupsRef.current = []; rolesRef.current = []; resetDeltaWatermark(); void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]); function openCreate() { setDraft(emptyDraft); setSavedDraftKey(draftKey(emptyDraft)); setEditing("new"); setError(""); } function openEdit(user: UserAdminItem) { const nextDraft = { email: user.email, displayName: user.display_name || "", password: "", passwordResetRequired: user.password_reset_required, isActive: user.is_active, groupIds: user.groups.map((group) => group.id), roleIds: user.roles.map((role) => role.id) }; setDraft(nextDraft); setSavedDraftKey(draftKey(nextDraft)); setEditing(user); setError(""); } function closeEditor() { setEditing(null); setDraft(emptyDraft); setSavedDraftKey(draftKey(emptyDraft)); } async function save(): Promise { setBusy(true); setError(""); try { if (editing === "new") { const response = await createUser(settings, { email: draft.email, display_name: draft.displayName || null, password: draft.password || null, password_reset_required: draft.passwordResetRequired, is_active: draft.isActive, group_ids: canManageGroups ? draft.groupIds : [], role_ids: canAssignRoles ? draft.roleIds : [] }); setSuccess(i18nMessage("i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6", { value0: response.user.email })); if (response.temporary_password) setTemporaryPassword({ email: response.user.email, password: response.temporary_password }); } else if (editing) { const payload: Parameters[2] = {}; if (canUpdate) payload.display_name = draft.displayName || null; if (canSuspend) payload.is_active = draft.isActive; if (canManageGroups) payload.group_ids = draft.groupIds; if (canAssignRoles) payload.role_ids = draft.roleIds; await updateUser(settings, editing.id, payload); setSuccess(i18nMessage("i18n:govoplan-access.access_updated_for_value.87f22245", { value0: editing.email })); if (editing.id === auth.user.id) await onAuthRefresh(); } setEditing(null); await load(); return true; } catch (err) {setError(adminErrorMessage(err));return false;} finally {setBusy(false);} } async function deactivate() { if (!deactivating) return; setBusy(true); setError(""); try { await updateUser(settings, deactivating.id, { is_active: false }); setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2", { value0: deactivating.email })); if (deactivating.id === auth.user.id) await onAuthRefresh(); setDeactivating(null); await load(); } catch (err) {setError(adminErrorMessage(err));} finally {setBusy(false);} } const columns = useMemo[]>(() => [ { id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) =>
{row.display_name || row.email}
{row.email}
}, { id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) }, { id: "roles", header: "i18n:govoplan-access.direct_roles.c4db7156", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, { id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) }, { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => }, { 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) =>
} onClick={() => setViewing(row)} /> } onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} /> } variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
}], [canAssignRoles, canManageGroups, canSuspend, canUpdate]); return ( <> } variant="primary" onClick={openCreate} disabled={!canCreate} />}>
row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" />
!busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<>}>
setDraft({ ...draft, email: event.target.value })} /> setDraft({ ...draft, displayName: event.target.value })} /> {editing === "new" && setDraft({ ...draft, password })} /> }
{editing === "new" && } {editing && editing !== "new" && editing.is_last_active_owner &&

i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f

}
i18n:govoplan-access.groups.ae9629f4 group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" />
i18n:govoplan-access.direct_roles.c4db7156 ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" />

i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16

setViewing(null)} className="admin-dialog admin-dialog-wide" footer={}> {viewing &&
i18n:govoplan-access.user.9f8a2389
{viewing.display_name || viewing.email}
i18n:govoplan-access.email.84add5b2
{viewing.email}
i18n:govoplan-access.membership.53bc9670
{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}
i18n:govoplan-access.global_account.e1b00cf5
{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}
i18n:govoplan-access.groups.ae9629f4
{joinLabels(viewing.groups)}
i18n:govoplan-access.direct_roles.c4db7156
{joinLabels(viewing.roles)}
i18n:govoplan-access.effective_permissions.17c0fe8a
{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}
i18n:govoplan-access.last_login.43dab84f
{formatDateTime(viewing.last_login_at)}
}
setTemporaryPassword(null)} className="admin-dialog" footer={}> {temporaryPassword && <>

i18n:govoplan-access.this_is_shown_once_for.b0f0f526 {temporaryPassword.email}.

{temporaryPassword.password}

i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b

}
setDeactivating(null)} onConfirm={() => void deactivate()} /> ); } function draftKey(draft: typeof emptyDraft): string { return JSON.stringify(draft); } function sortUsers(left: UserAdminItem, right: UserAdminItem): number { return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email); } function sortGroups(left: GroupSummary, right: GroupSummary): number { return left.name.localeCompare(right.name); } function sortTenantRoles(left: RoleSummary, right: RoleSummary): number { const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin); if (builtinDelta !== 0) return builtinDelta; return left.name.localeCompare(right.name); }