feat(admin): migrate Users with safe recovery and action state
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
import { api } from "./api";
|
||||
@@ -13,7 +13,7 @@ import AdminUsersPage from "./views/AdminUsersPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() },
|
||||
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
getApiErrorMessage: (error: any, fallback: string) => error?.response?.data?.detail || fallback,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
@@ -22,7 +22,7 @@ function renderPage(users: unknown[]) {
|
||||
mockedApi.get.mockResolvedValue({ data: users } as any);
|
||||
mockedApi.put.mockResolvedValue({ data: null } as any);
|
||||
mockedApi.delete.mockResolvedValue({ data: null } as any);
|
||||
render(
|
||||
return render(
|
||||
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
@@ -39,6 +39,7 @@ function renderPage(users: unknown[]) {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockReset(); mockedApi.post.mockReset(); window.localStorage.clear();
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
@@ -51,6 +52,38 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function held<T>() { let resolve!: (value: T) => void; const promise=new Promise<T>(yes => { resolve=yes; }); return { promise, resolve }; }
|
||||
function createDraft(email='new@example.test') { fireEvent.change(screen.getByLabelText('Email'), { target: { value: email } }); fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'TestPassword123!' } }); }
|
||||
|
||||
test('failed users read stays unavailable and Retry only reads', async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error('offline')); renderPage([]);
|
||||
const retry=await screen.findByRole('button', { name: 'Retry' });
|
||||
expect(screen.queryByText('No users.')).not.toBeInTheDocument(); fireEvent.click(retry);
|
||||
expect(await screen.findByText('No users.')).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('create failure presents structured feedback and retains the entered fields', async () => {
|
||||
mockedApi.post.mockRejectedValue({ response: { status: 400, data: { detail: 'The email is already registered.' } } }); renderPage([]); await screen.findByText('No users.'); createDraft();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' })); expect(await screen.findByText('The email is already registered.')).toBeInTheDocument(); expect(screen.getByLabelText('Email')).toHaveValue('new@example.test');
|
||||
});
|
||||
|
||||
test('create admission serializes competing clicks and acknowledgement retains newer form values', async () => {
|
||||
const pending=held<any>(); mockedApi.post.mockReturnValue(pending.promise); renderPage([]); await screen.findByText('No users.'); createDraft();
|
||||
const create=screen.getByRole('button', { name: 'Create' }); act(() => { create.click(); create.click(); }); expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'next@example.test' } });
|
||||
await act(async () => pending.resolve({ data: { id: 'created' } })); expect(screen.getByLabelText('Email')).toHaveValue('next@example.test');
|
||||
});
|
||||
|
||||
test('successful create followed by failed list refresh keeps read recovery without replaying create', async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: { id: 'created' } }); renderPage([]); await screen.findByText('No users.'); mockedApi.get.mockRejectedValueOnce(new Error('offline')); createDraft();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' })); const retry=await screen.findByRole('button', { name: 'Retry' }); fireEvent.click(retry); await screen.findByText('No users.'); expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('disposed create acknowledgement cannot start a new private list read', async () => {
|
||||
const pending=held<any>(); mockedApi.post.mockReturnValue(pending.promise); const view=renderPage([]); await screen.findByText('No users.'); createDraft();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' })); view.unmount(); await act(async () => pending.resolve({ data: { id: 'created' } })); expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("requires explicit confirmation before removing your own admin role", async () => {
|
||||
renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: true }]);
|
||||
|
||||
@@ -96,3 +129,12 @@ test("sends the exact account email after confirming deletion", async () => {
|
||||
headers: { "X-Confirm-Account-Deletion": "other@example.com" },
|
||||
}));
|
||||
});
|
||||
|
||||
test('accepted deletion is reported as queued rather than completed', async () => {
|
||||
renderPage([{ id: 'other', email: 'other@example.com', userName: 'other', roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]);
|
||||
mockedApi.delete.mockResolvedValueOnce({ status: 202, data: { status: 'pending' } } as any);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' }));
|
||||
expect(await screen.findByText('Account deletion request queued. Deletion has not completed yet.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('User deleted.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1798,6 +1798,7 @@ export const translations = {
|
||||
adminUsersResetFailed: "Failed to send reset.",
|
||||
adminUsersDeleteConfirmTitle: "Delete user",
|
||||
adminUsersDeleted: "User deleted.",
|
||||
adminUsersDeletionQueued: "Account deletion request queued. Deletion has not completed yet.",
|
||||
adminUsersDeleteFailed: "Failed to delete user.",
|
||||
adminUsersCreated: "User created.",
|
||||
adminUsersCreateFailed: "Failed to create user.",
|
||||
@@ -4570,6 +4571,7 @@ export const translations = {
|
||||
adminUsersResetFailed: "Kunne ikke sende tilbakestilling.",
|
||||
adminUsersDeleteConfirmTitle: "Slett bruker",
|
||||
adminUsersDeleted: "Bruker slettet.",
|
||||
adminUsersDeletionQueued: "Forespørselen om kontosletting er satt i kø. Slettingen er ikke fullført ennå.",
|
||||
adminUsersDeleteFailed: "Kunne ikke slette bruker.",
|
||||
adminUsersCreated: "Bruker opprettet.",
|
||||
adminUsersCreateFailed: "Kunne ikke opprette bruker.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -20,6 +20,10 @@ 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;
|
||||
@@ -36,30 +40,47 @@ export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const { t } = useI18n();
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const owner = useRef({ mounted: true, generation: 0, busy: false });
|
||||
const body = useRef<HTMLDivElement>(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<UserDto[]>("/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;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<UserDto[]>("/users");
|
||||
setUsers(res.data ?? []);
|
||||
} catch {
|
||||
setUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
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 canCreate = useMemo(() => newEmail.trim().length > 3 && newPassword.length >= 6, [newEmail, newPassword]);
|
||||
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,
|
||||
@@ -71,6 +92,8 @@ export default function AdminUsersPage() {
|
||||
})), [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(
|
||||
@@ -83,44 +106,50 @@ export default function AdminUsersPage() {
|
||||
destructive: true,
|
||||
},
|
||||
);
|
||||
if (!confirmed) return;
|
||||
if (!active() || !confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
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 });
|
||||
toast(t("adminUsersRolesUpdated"), "success");
|
||||
if (!active()) return;
|
||||
toast(currentTranslation.current("adminUsersRolesUpdated"), "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(getApiErrorMessage(e, t("adminUsersRolesUpdateFailed")), "error");
|
||||
if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersRolesUpdateFailed")), "error");
|
||||
}
|
||||
}, [confirmAction, t, toast]);
|
||||
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`);
|
||||
toast(t("adminUsersResetSent"), "success");
|
||||
if (active()) toast(currentTranslation.current("adminUsersResetSent"), "success");
|
||||
} catch (e: any) {
|
||||
toast(getApiErrorMessage(e, t("adminUsersResetFailed")), "error");
|
||||
if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersResetFailed")), "error");
|
||||
}
|
||||
}, [t, toast]);
|
||||
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 }))) return;
|
||||
try {
|
||||
await api.delete(`/users/${u.id}`, { headers: { "X-Confirm-Account-Deletion": u.email || u.userName || "" } });
|
||||
toast(t("adminUsersDeleted"), "info");
|
||||
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) {
|
||||
toast(getApiErrorMessage(e, t("adminUsersDeleteFailed")), "error");
|
||||
if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersDeleteFailed")), "error");
|
||||
}
|
||||
}, [confirmAction, t, toast]);
|
||||
finally { finishAction(active); }
|
||||
}, [beginAction, confirmAction, finishAction, load, t, toast]);
|
||||
|
||||
const columns = useMemo<GridColDef[]>(() => [
|
||||
{
|
||||
@@ -173,33 +202,30 @@ export default function AdminUsersPage() {
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", py: 0.5 }}>
|
||||
<Tooltip title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span>
|
||||
<Button size="small" variant={isAdmin ? "contained" : "outlined"} disabled={isAdmin && !user.canRemoveAdmin} onClick={() => void setAdminRole(user, !isAdmin)}>
|
||||
<Button size="small" variant={isAdmin ? "contained" : "outlined"} disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void setAdminRole(user, !isAdmin)}>
|
||||
{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button size="small" variant="outlined" onClick={() => void sendReset(user)}>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void sendReset(user)}>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Tooltip title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span><Button size="small" color="error" variant="outlined" disabled={isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)}>{t("adminUsersDelete")}</Button></span>
|
||||
<span><Button size="small" color="error" variant="outlined" disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)}>{t("adminUsersDelete")}</Button></span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [remove, sendReset, setAdminRole, t]);
|
||||
], [busy, remove, sendReset, setAdminRole, t]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||
<Typography component="h2" variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
|
||||
{t("adminUsersTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</Typography>
|
||||
<Box ref={body} role="group" aria-label={t("adminUsersTitle")} tabIndex={-1} aria-busy={busy} sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0, "& button": { whiteSpace: "normal", overflowWrap: "anywhere", "@media (pointer: coarse)": { minHeight: 44 } } }}>
|
||||
<PageHeader level="h2" title={t("adminUsersTitle")} description={t("adminUsersSubtitle")} />
|
||||
<Alert severity="info" sx={{ mb: 2 }}>{t("adminUsersSafetyNotice")}</Alert>
|
||||
|
||||
<Paper sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}>
|
||||
<Typography sx={{ fontWeight: 900, mb: 1 }}>{t("adminUsersCreateUser")}</Typography>
|
||||
<PageSection level="h3" surface title={t("adminUsersCreateUser")}>
|
||||
<Box component="form" onSubmit={event => { event.preventDefault(); void createUser(); }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<TextField label={t("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} fullWidth />
|
||||
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
|
||||
@@ -208,28 +234,19 @@ export default function AdminUsersPage() {
|
||||
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!canCreate || loading}
|
||||
disabled={!canCreate || loading || busy}
|
||||
type="submit"
|
||||
sx={{ width: { xs: "100%", sm: "auto" } }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.post("/users", { email: newEmail, password: newPassword, roles: newIsAdmin ? ["Admin"] : [] });
|
||||
setNewEmail("");
|
||||
setNewPassword("");
|
||||
setNewIsAdmin(false);
|
||||
toast(t("adminUsersCreated"), "success");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("adminUsersCreateFailed");
|
||||
toast(String(msg), "error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</PageSection>
|
||||
|
||||
{isMobile ? (
|
||||
<ViewStateNotice loading={userResource.loading} error={userResource.error} title={t("adminUsersLoadFailed")} onRetry={async () => { body.current?.focus(); await load(); }} />
|
||||
|
||||
{!userResource.error ? isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
@@ -238,7 +255,7 @@ export default function AdminUsersPage() {
|
||||
const user = row.raw as UserDto;
|
||||
const isAdmin = user.roles.includes("Admin");
|
||||
return (
|
||||
<Paper key={row.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Paper key={row.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
@@ -259,13 +276,13 @@ export default function AdminUsersPage() {
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Tooltip title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span><Button variant={isAdmin ? "contained" : "outlined"} disabled={isAdmin && !user.canRemoveAdmin} onClick={() => void setAdminRole(user, !isAdmin)} fullWidth>{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}</Button></span>
|
||||
<span><Button variant={isAdmin ? "contained" : "outlined"} disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void setAdminRole(user, !isAdmin)} fullWidth>{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}</Button></span>
|
||||
</Tooltip>
|
||||
<Button variant="outlined" onClick={() => void sendReset(user)} fullWidth>
|
||||
<Button variant="outlined" disabled={busy} onClick={() => void sendReset(user)} fullWidth>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Tooltip title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span><Button color="error" variant="outlined" disabled={isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)} fullWidth>{t("adminUsersDelete")}</Button></span>
|
||||
<span><Button color="error" variant="outlined" disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)} fullWidth>{t("adminUsersDelete")}</Button></span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -274,9 +291,10 @@ export default function AdminUsersPage() {
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<Paper sx={{ borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", overflow: "hidden" }}>
|
||||
<Paper variant="outlined" role="region" aria-label={t("adminUsersTitle")} tabIndex={0} sx={{ borderRadius: 1, minWidth: 0, overflow: "auto" }}>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
getRowHeight={() => "auto"}
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
disableRowSelectionOnClick
|
||||
@@ -301,7 +319,7 @@ export default function AdminUsersPage() {
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
)}
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user