import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, Button, Checkbox, Chip, FormControlLabel, Paper, Stack, TextField, Tooltip, Typography, } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { nbNO } from "@mui/x-data-grid/locales"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; import { useViewResource } from "../hooks/useViewResource"; import ViewStateNotice from "../components/ViewStateNotice"; import { PageHeader, PageSection } from "../layout/PageLayout"; import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary"; type UserDto = { id: string; email?: string | null; userName?: string | null; emailConfirmed: boolean; roles: string[]; isCurrentUser: boolean; canRemoveAdmin: boolean; }; export default function AdminUsersPage() { const isMobile = useMediaQuery("(max-width:767.95px)"); const { toast } = useToast(); const { confirmAction } = useDialogActions(); const { t, language } = useI18n(); const [busy, setBusy] = useState(false); const owner = useRef({ mounted: true, generation: 0, busy: false }); const body = useRef(null); const currentTranslation = useRef(t); useEffect(() => { currentTranslation.current=t; }, [t]); useEffect(() => { const lifetime=owner.current; lifetime.mounted=true; lifetime.generation+=1; return () => { lifetime.mounted=false; lifetime.generation+=1; }; }, []); const userResource=useViewResource(async () => (await api.get("/users")).data ?? [], { initialData: [], errorMessage: t("adminUsersLoadFailed") }); const users=userResource.data; const loading=userResource.loading || userResource.refreshing; const load=userResource.reload; const [newEmail, setNewEmail] = useState(""); const [newPassword, setNewPassword] = useState(""); const [newIsAdmin, setNewIsAdmin] = useState(false); const canCreate = newEmail.trim().length > 3 && newPassword.length >= 6; const beginAction=useCallback(() => { checkSessionCookie(); const lifetime=owner.current; if (!lifetime.mounted || lifetime.busy || isSessionInvalidated()) return null; lifetime.busy=true; setBusy(true); if (body.current?.contains(document.activeElement)) body.current.focus(); const context=getSessionContext(); const generation=lifetime.generation; return () => { checkSessionCookie(); return lifetime.mounted && lifetime.generation===generation && !isSessionInvalidated() && getSessionContext()===context; }; }, []); const finishAction=useCallback((active: () => boolean) => { owner.current.busy=false; if (active()) setBusy(false); }, []); const createUser=async () => { if (!canCreate) return; const active=beginAction(); if (!active) return; const submitted={ email: newEmail, password: newPassword, isAdmin: newIsAdmin }; try { await api.post("/users", { email: submitted.email, password: submitted.password, roles: submitted.isAdmin ? ["Admin"] : [] }); if (!active()) return; setNewEmail(current => current===submitted.email ? "" : current); setNewPassword(current => current===submitted.password ? "" : current); setNewIsAdmin(current => current===submitted.isAdmin ? false : current); toast(currentTranslation.current("adminUsersCreated"), "success"); await load(); } catch (error) { if (active()) toast(getApiErrorMessage(error, currentTranslation.current("adminUsersCreateFailed")), "error"); } finally { finishAction(active); } }; const rows = useMemo(() => users.map((u) => ({ id: u.id, email: u.email || "", userName: u.userName || "", roles: u.roles || [], emailConfirmed: u.emailConfirmed, raw: u, })), [users]); const setAdminRole = useCallback(async (u: UserDto, grantAdmin: boolean) => { const active=beginAction(); if (!active) return; try { if (!grantAdmin) { const name = u.userName || u.email || u.id; const confirmed = await confirmAction( u.isCurrentUser ? t("adminUsersSelfDemoteWarning") : t("adminUsersDemoteWarning", { name }), { title: u.isCurrentUser ? t("adminUsersSelfDemoteTitle") : t("adminUsersDemoteTitle"), confirmLabel: t("adminUsersRemoveAdmin"), destructive: true, }, ); if (!active() || !confirmed) return; } const roles = grantAdmin ? Array.from(new Set([...(u.roles || []), "Admin"])) : (u.roles || []).filter((role) => role.toLowerCase() !== "admin"); await api.put(`/users/${u.id}/roles`, { roles }); if (!active()) return; toast(currentTranslation.current("adminUsersRolesUpdated"), "success"); await load(); } catch (e) { if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersRolesUpdateFailed")), "error"); } finally { finishAction(active); } }, [beginAction, confirmAction, finishAction, load, t, toast]); const sendReset = useCallback(async (u: UserDto) => { const active=beginAction(); if (!active) return; try { await api.post(`/users/${u.id}/send-password-reset`); if (active()) toast(currentTranslation.current("adminUsersResetSent"), "success"); } catch (e: any) { if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersResetFailed")), "error"); } finally { finishAction(active); } }, [beginAction, finishAction, toast]); const remove = useCallback(async (u: UserDto) => { const active=beginAction(); if (!active) return; try { const name = u.userName || u.email || u.id; const message = u.isCurrentUser ? t("adminUsersDeleteSelfWarning") : t("adminUsersDeleteConfirmNamed", { name }); if (!(await confirmAction(message, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true })) || !active()) return; const response=await api.delete(`/users/${u.id}`, { headers: { "X-Confirm-Account-Deletion": u.email || u.userName || "" } }); if (!active()) return; toast(currentTranslation.current(response.status===202 ? "adminUsersDeletionQueued" : "adminUsersDeleted"), "info"); await load(); } catch (e) { if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersDeleteFailed")), "error"); } finally { finishAction(active); } }, [beginAction, confirmAction, finishAction, load, t, toast]); const columns = useMemo(() => [ { field: "email", headerName: t("profileEmail"), flex: 1.2, minWidth: 220, renderCell: (params) => ( {params.value} {(params.row.raw as UserDto).isCurrentUser ? : null} ), }, { field: "userName", headerName: t("profileUsername"), flex: 1, minWidth: 180 }, { field: "roles", headerName: t("adminUsersRolesLabel"), flex: 1, minWidth: 180, sortable: false, renderCell: (params) => { const roles = params.row.roles as string[]; return ( {roles.length ? roles.map((role) => ) : —} ); }, }, { field: "emailConfirmed", headerName: t("adminUsersConfirmed"), width: 130, renderCell: (params) => ( ), }, { field: "actions", headerName: t("adminUsersActions"), minWidth: 300, flex: 1.4, sortable: false, filterable: false, renderCell: (params) => { const user = params.row.raw as UserDto; const isAdmin = (user.roles || []).includes("Admin"); return ( ); }, }, ], [busy, remove, sendReset, setAdminRole, t]); return ( {t("adminUsersSafetyNotice")} { event.preventDefault(); void createUser(); }}> setNewEmail(e.target.value)} fullWidth /> setNewPassword(e.target.value)} fullWidth /> setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} /> { body.current?.focus(); await load(); }} /> {!userResource.error ? isMobile ? ( {!loading && rows.length === 0 ? ( {t("adminUsersNoUsers")} ) : null} {rows.map((row) => { const user = row.raw as UserDto; const isAdmin = user.roles.includes("Admin"); return ( {row.userName || row.email || row.id} {user.isCurrentUser ? : null} {row.email || "—"} {(row.roles as string[]).length ? (row.roles as string[]).map((role) => ( )) : } ); })} ) : ( "auto"} rows={rows} columns={columns} disableRowSelectionOnClick loading={loading} pageSizeOptions={[5, 10, 25]} initialState={{ pagination: { paginationModel: { pageSize: 10, page: 0 } }, sorting: { sortModel: [{ field: "email", sort: "asc" }] }, }} sx={{ border: 0, '& .MuiDataGrid-columnHeaders': { backgroundColor: 'action.hover', fontWeight: 800, }, '& .MuiDataGrid-cell': { alignItems: 'center', }, }} /> {!loading && rows.length === 0 ? ( {t("adminUsersNoUsers")} ) : null} ) : null} ); }