feat(admin): consolidate user actions into keyboard menus
This commit is contained in:
@@ -91,7 +91,8 @@ test('[P7-10 Users A01 E09] real admin creation/role persistence revoke sessions
|
||||
expect((await call(session.requests, other, 'PUT', `/users/${administrator.id}/roles`, before.headers, { roles: [] })).status()).toBe(403);
|
||||
const card=page.locator('.MuiPaper-root').filter({ has: page.getByText(email, { exact: true }).first() }).last();
|
||||
const changed=response(page, 'PUT', `/users/${created.id}/roles`);
|
||||
await keyboard(card.getByRole('button', { name: copy('adminUsersMakeAdmin'), exact: true })); expect((await changed).status()).toBe(204);
|
||||
await keyboard(card.getByRole('button', { name: copy('adminUsersActionsFor').replace('{name}', email), exact: true }));
|
||||
await keyboard(page.getByRole('menuitem', { name: copy('adminUsersMakeAdmin'), exact: true })); expect((await changed).status()).toBe(204);
|
||||
await expect.poll(async () => (await json<Array<{ id: string; roles: string[] }>>(await call(session.requests, page.request, 'GET', '/users'))).find(value => value.id===created.id)?.roles).toEqual(['Admin']);
|
||||
expect((await call(session.requests, other, 'GET', '/auth/me')).status()).toBe(401);
|
||||
expect((await page.request.delete(`${apiUrl}/users/${created.id}`, { headers: { ...administrator.headers, 'X-Confirm-Account-Deletion': email } })).status()).toBe(503);
|
||||
@@ -145,7 +146,7 @@ test('[P7-10 Users A01] finite bilingual light/dark widths retain long identitie
|
||||
await expect(page.getByText(email, { exact: true }).first()).toBeVisible();
|
||||
if (sample.width>=768) {
|
||||
const region=page.getByRole('region', { name: copy('adminUsersTitle', sample.language), exact: true }); await expect(region).toHaveAttribute('tabindex', '0');
|
||||
const action=region.getByRole('button', { name: copy('adminUsersSendReset', sample.language), exact: true }).first(); await action.focus(); await expect(action).toBeFocused();
|
||||
const action=region.getByRole('button', { name: copy('adminUsersActionsFor', sample.language).replace('{name}', email), exact: true }); await action.focus(); await expect(action).toBeFocused();
|
||||
const box=await action.boundingBox(); expect(box).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x+box!.width).toBeLessThanOrEqual(sample.width);
|
||||
const clipped=await region.locator('.MuiDataGrid-row').evaluateAll(rows => rows.flatMap(row => {
|
||||
const bounds=row.getBoundingClientRect(); return Array.from(row.querySelectorAll('button')).filter(button => { const rect=button.getBoundingClientRect(); return rect.top < bounds.top-1 || rect.bottom > bounds.bottom+1; }).map(button => button.textContent);
|
||||
|
||||
@@ -55,6 +55,11 @@ 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!' } }); }
|
||||
|
||||
async function openUserActions() {
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^Actions for / }));
|
||||
return screen.findByRole("menu");
|
||||
}
|
||||
|
||||
test.each([['en', 'Rows per page:'], ['nb', 'Rader per side:']])('desktop user-grid pagination follows %s UI language', async (language, label) => {
|
||||
window.localStorage.setItem('uiLanguage', language);
|
||||
(window.matchMedia as jest.Mock).mockImplementation(() => ({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn() }));
|
||||
@@ -113,14 +118,14 @@ test("requires explicit confirmation before removing your own admin role", async
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Create user", level: 2 })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove admin" }));
|
||||
fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Remove admin" }));
|
||||
expect(await screen.findByText(/you will immediately lose access/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove admin" }));
|
||||
fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Remove admin" }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Remove admin" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/users/me/roles", { roles: [] }));
|
||||
@@ -129,7 +134,7 @@ test("requires explicit confirmation before removing your own admin role", async
|
||||
test("warns before demoting another administrator", async () => {
|
||||
renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: ["Admin"], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove admin" }));
|
||||
fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Remove admin" }));
|
||||
|
||||
expect(await screen.findByText(/they will immediately lose access/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
@@ -139,18 +144,17 @@ test("warns before demoting another administrator", async () => {
|
||||
test("disables demotion and deletion for the final administrator", async () => {
|
||||
renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: false }]);
|
||||
|
||||
const demote = await screen.findByRole("button", { name: "Remove admin" });
|
||||
expect(demote).toBeDisabled();
|
||||
expect(demote.parentElement).not.toHaveAttribute("aria-label");
|
||||
fireEvent.mouseOver(demote.parentElement!);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent("The final administrator cannot be demoted or deleted.");
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
const menu = within(await openUserActions());
|
||||
expect(menu.getByRole("menuitem", { name: "Remove admin" })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(menu.getByRole("menuitem", { name: "Delete" })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(menu.getByText(/The final administrator cannot be demoted or deleted/)).toBeVisible();
|
||||
expect(menu.getByRole("menuitem", { name: "Send reset" })).not.toHaveAttribute("aria-disabled");
|
||||
});
|
||||
|
||||
test("sends the exact account email after confirming deletion", async () => {
|
||||
renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||
fireEvent.click(within(await openUserActions()).getByRole("menuitem", { name: "Delete" }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/users/other", {
|
||||
@@ -161,7 +165,7 @@ test("sends the exact account email after confirming deletion", async () => {
|
||||
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 openUserActions()).getByRole('menuitem', { 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();
|
||||
|
||||
@@ -2231,6 +2231,7 @@ export const translations = {
|
||||
adminUsers2faDisabled: "2FA disabled",
|
||||
adminUsers2faUnavailable: "2FA status unavailable",
|
||||
adminUsersActions: "Actions",
|
||||
adminUsersActionsFor: "Actions for {name}",
|
||||
adminUsersNoUsers: "No users.",
|
||||
adminUsersRolesUpdated: "Roles updated.",
|
||||
adminUsersRolesUpdateFailed: "Failed to update roles.",
|
||||
@@ -5472,6 +5473,7 @@ export const translations = {
|
||||
adminUsers2faDisabled: "2FA deaktivert",
|
||||
adminUsers2faUnavailable: "2FA-status utilgjengelig",
|
||||
adminUsersActions: "Handlinger",
|
||||
adminUsersActionsFor: "Handlinger for {name}",
|
||||
adminUsersNoUsers: "Ingen brukere.",
|
||||
adminUsersRolesUpdated: "Roller oppdatert.",
|
||||
adminUsersRolesUpdateFailed: "Kunne ikke oppdatere roller.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -7,10 +8,11 @@ import {
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -49,6 +51,71 @@ function UserStatus({ user }: { user: UserDto }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UserActions({ user, busy, tabIndex, setAdminRole, sendReset, remove }: {
|
||||
user: UserDto;
|
||||
busy: boolean;
|
||||
tabIndex?: number;
|
||||
setAdminRole: (user: UserDto, grant: boolean) => Promise<void>;
|
||||
sendReset: (user: UserDto) => Promise<void>;
|
||||
remove: (user: UserDto) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
const id = useId();
|
||||
const isAdmin = user.roles?.includes("Admin") ?? false;
|
||||
const lastAdmin = isAdmin && !user.canRemoveAdmin;
|
||||
const label = t("adminUsersActionsFor", { name: user.email || user.userName || user.id });
|
||||
const run = (action: () => Promise<void>) => {
|
||||
// Close the menu before an action moves focus or opens its confirmation dialog.
|
||||
flushSync(() => setAnchor(null));
|
||||
anchor?.focus();
|
||||
const group = anchor?.closest('[role="group"][aria-busy]');
|
||||
void action().finally(() => {
|
||||
const closingDialog = document.querySelector('[role="dialog"]');
|
||||
const restore = (frames: number) => {
|
||||
if (!anchor?.isConnected) return;
|
||||
if (closingDialog?.isConnected && frames > 0) {
|
||||
requestAnimationFrame(() => restore(frames - 1));
|
||||
} else if (document.activeElement === document.body || document.activeElement === group) {
|
||||
anchor.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => restore(60));
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
id={`${id}-trigger`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={busy}
|
||||
tabIndex={tabIndex}
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={Boolean(anchor)}
|
||||
aria-controls={anchor ? id : undefined}
|
||||
onClick={event => setAnchor(event.currentTarget)}
|
||||
>
|
||||
{t("adminUsersActions")}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchor}
|
||||
open={Boolean(anchor)}
|
||||
onClose={() => setAnchor(null)}
|
||||
slotProps={{ list: { id, "aria-labelledby": `${id}-trigger` } }}
|
||||
>
|
||||
<MenuItem disabled={busy || lastAdmin} onClick={() => run(() => setAdminRole(user, !isAdmin))}>
|
||||
{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={busy} onClick={() => run(() => sendReset(user))}>{t("adminUsersSendReset")}</MenuItem>
|
||||
<MenuItem disabled={busy || lastAdmin} onClick={() => run(() => remove(user))} sx={{ color: "error.main" }}>{t("adminUsersDelete")}</MenuItem>
|
||||
{lastAdmin && <Typography component="li" role="presentation" variant="body2" sx={{ px: 2, py: 1, maxWidth: 240, color: "text.secondary" }}>{t("adminUsersLastAdminHelp")}</Typography>}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const { toast } = useToast();
|
||||
@@ -203,31 +270,10 @@ export default function AdminUsersPage() {
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("adminUsersActions"),
|
||||
minWidth: 300,
|
||||
flex: 1.4,
|
||||
minWidth: 125,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
renderCell: (params) => {
|
||||
const user = params.row.raw as UserDto;
|
||||
const isAdmin = (user.roles || []).includes("Admin");
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", py: 0.5 }}>
|
||||
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span>
|
||||
<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" disabled={busy} onClick={() => void sendReset(user)}>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span><Button size="small" color="error" variant="outlined" disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)}>{t("adminUsersDelete")}</Button></span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
renderCell: (params) => <UserActions user={params.row.raw as UserDto} busy={busy} tabIndex={params.hasFocus ? 0 : -1} setAdminRole={setAdminRole} sendReset={sendReset} remove={remove} />,
|
||||
},
|
||||
], [busy, remove, sendReset, setAdminRole, t]);
|
||||
|
||||
@@ -264,7 +310,6 @@ export default function AdminUsersPage() {
|
||||
) : null}
|
||||
{rows.map((row) => {
|
||||
const user = row.raw as UserDto;
|
||||
const isAdmin = user.roles.includes("Admin");
|
||||
return (
|
||||
<Paper key={row.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}>
|
||||
<Stack spacing={1.25}>
|
||||
@@ -285,17 +330,7 @@ export default function AdminUsersPage() {
|
||||
</Box>
|
||||
<UserStatus user={user} />
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<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" disabled={busy} onClick={() => void sendReset(user)} fullWidth>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span><Button color="error" variant="outlined" disabled={busy || isAdmin && !user.canRemoveAdmin} onClick={() => void remove(user)} fullWidth>{t("adminUsersDelete")}</Button></span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<UserActions user={user} busy={busy} setAdminRole={setAdminRole} sendReset={sendReset} remove={remove} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user