fix(admin): protect administrator access
Reject final-admin demotion and deletion at the API boundary. Require explicit confirmation before any administrator role removal.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
import { api } from "./api";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { getTheme } from "./theme";
|
||||
import { ToastProvider } from "./toast";
|
||||
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,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage(users: unknown[]) {
|
||||
mockedApi.get.mockResolvedValue({ data: users } as any);
|
||||
mockedApi.put.mockResolvedValue({ data: null } as any);
|
||||
render(
|
||||
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<AdminUsersPage />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</CssVarsProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
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 }]);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { 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 screen.findByRole("dialog")).getByRole("button", { name: "Remove admin" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/users/me/roles", { roles: [] }));
|
||||
});
|
||||
|
||||
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" }));
|
||||
|
||||
expect(await screen.findByText(/they will immediately lose access/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 }]);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Remove admin" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
});
|
||||
@@ -527,6 +527,16 @@ export const translations = {
|
||||
adminUsersAdminNo: "Admin: No",
|
||||
adminUsersDeleteConfirmBody: "Delete this user?",
|
||||
adminUsersDeleteConfirmNamed: "Delete user {name}?",
|
||||
adminUsersYou: "You",
|
||||
adminUsersMakeAdmin: "Make admin",
|
||||
adminUsersRemoveAdmin: "Remove admin",
|
||||
adminUsersDemoteTitle: "Remove administrator access",
|
||||
adminUsersDemoteWarning: "Remove administrator access from {name}? They will immediately lose access to administration features.",
|
||||
adminUsersSelfDemoteTitle: "Remove your own administrator access?",
|
||||
adminUsersSelfDemoteWarning: "You will immediately lose access to user management and all other administration features. Another administrator must restore the role for you.",
|
||||
adminUsersDeleteSelfWarning: "Delete your own account? You will be signed out and permanently lose access to this account and its data.",
|
||||
adminUsersLastAdminHelp: "The final administrator cannot be demoted or deleted. Make another user an administrator first.",
|
||||
adminUsersSafetyNotice: "The final administrator is protected. Removing your own administrator role requires explicit confirmation.",
|
||||
adminUsersPassword: "Password",
|
||||
kanbanHint: "Drag cards between columns to move a job forward. Use the card menu to set an exact stage.",
|
||||
kanbanDropHere: "Drop here",
|
||||
@@ -1658,6 +1668,16 @@ export const translations = {
|
||||
adminUsersAdminNo: "Admin: Nei",
|
||||
adminUsersDeleteConfirmBody: "Slette denne brukeren?",
|
||||
adminUsersDeleteConfirmNamed: "Slette bruker {name}?",
|
||||
adminUsersYou: "Deg",
|
||||
adminUsersMakeAdmin: "Gjør til admin",
|
||||
adminUsersRemoveAdmin: "Fjern admin",
|
||||
adminUsersDemoteTitle: "Fjern administratortilgang",
|
||||
adminUsersDemoteWarning: "Fjerne administratortilgang fra {name}? Brukeren mister umiddelbart tilgang til administrasjonsfunksjoner.",
|
||||
adminUsersSelfDemoteTitle: "Fjern din egen administratortilgang?",
|
||||
adminUsersSelfDemoteWarning: "Du mister umiddelbart tilgang til brukeradministrasjon og alle andre administrasjonsfunksjoner. En annen administrator må gjenopprette rollen for deg.",
|
||||
adminUsersDeleteSelfWarning: "Slette din egen konto? Du blir logget ut og mister permanent tilgang til kontoen og dataene.",
|
||||
adminUsersLastAdminHelp: "Den siste administratoren kan ikke nedgraderes eller slettes. Gjør en annen bruker til administrator først.",
|
||||
adminUsersSafetyNotice: "Den siste administratoren er beskyttet. Fjerning av din egen administratorrolle krever uttrykkelig bekreftelse.",
|
||||
adminUsersPassword: "Passord",
|
||||
kanbanHint: "Dra kort mellom kolonnene for å flytte en jobb videre. Bruk kortmenyen for å sette et eksakt trinn.",
|
||||
kanbanDropHere: "Slipp her",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -25,6 +27,8 @@ type UserDto = {
|
||||
userName?: string | null;
|
||||
emailConfirmed: boolean;
|
||||
roles: string[];
|
||||
isCurrentUser: boolean;
|
||||
canRemoveAdmin: boolean;
|
||||
};
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
@@ -66,16 +70,33 @@ export default function AdminUsersPage() {
|
||||
raw: u,
|
||||
})), [users]);
|
||||
|
||||
const setAdminRole = useCallback(async (u: UserDto, isAdmin: boolean) => {
|
||||
const setAdminRole = useCallback(async (u: UserDto, grantAdmin: boolean) => {
|
||||
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 (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] });
|
||||
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");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("adminUsersRolesUpdateFailed");
|
||||
toast(String(msg), "error");
|
||||
} catch (e) {
|
||||
toast(getApiErrorMessage(e, t("adminUsersRolesUpdateFailed")), "error");
|
||||
}
|
||||
}, [t, toast]);
|
||||
}, [confirmAction, t, toast]);
|
||||
|
||||
const sendReset = useCallback(async (u: UserDto) => {
|
||||
try {
|
||||
@@ -88,18 +109,32 @@ export default function AdminUsersPage() {
|
||||
|
||||
const remove = useCallback(async (u: UserDto) => {
|
||||
const name = u.userName || u.email || u.id;
|
||||
if (!(await confirmAction(t("adminUsersDeleteConfirmNamed", { name }), { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
||||
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}`);
|
||||
toast(t("adminUsersDeleted"), "info");
|
||||
await load();
|
||||
} catch {
|
||||
toast(t("adminUsersDeleteFailed"), "error");
|
||||
} catch (e) {
|
||||
toast(getApiErrorMessage(e, t("adminUsersDeleteFailed")), "error");
|
||||
}
|
||||
}, [confirmAction, t, toast]);
|
||||
|
||||
const columns = useMemo<GridColDef[]>(() => [
|
||||
{ field: "email", headerName: t("profileEmail"), flex: 1.2, minWidth: 220 },
|
||||
{
|
||||
field: "email",
|
||||
headerName: t("profileEmail"),
|
||||
flex: 1.2,
|
||||
minWidth: 220,
|
||||
renderCell: (params) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.75, minWidth: 0 }}>
|
||||
<Typography variant="body2" noWrap>{params.value}</Typography>
|
||||
{(params.row.raw as UserDto).isCurrentUser ? <Chip size="small" label={t("adminUsersYou")} color="primary" variant="outlined" /> : null}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{ field: "userName", headerName: t("profileUsername"), flex: 1, minWidth: 180 },
|
||||
{
|
||||
field: "roles",
|
||||
@@ -136,15 +171,19 @@ export default function AdminUsersPage() {
|
||||
const isAdmin = (user.roles || []).includes("Admin");
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", py: 0.5 }}>
|
||||
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(user, !isAdmin)}>
|
||||
{t("adminUsersAdmin")}
|
||||
</Button>
|
||||
<Tooltip title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||
<span>
|
||||
<Button size="small" variant={isAdmin ? "contained" : "outlined"} disabled={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)}>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Button size="small" color="error" variant="outlined" onClick={() => void remove(user)}>
|
||||
{t("adminUsersDelete")}
|
||||
</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>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
@@ -157,6 +196,7 @@ export default function AdminUsersPage() {
|
||||
{t("adminUsersTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</Typography>
|
||||
<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>
|
||||
@@ -201,9 +241,10 @@ export default function AdminUsersPage() {
|
||||
<Paper key={row.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>
|
||||
{row.userName || row.email || row.id}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography component="span" sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{row.userName || row.email || row.id}</Typography>
|
||||
{user.isCurrentUser ? <Chip size="small" label={t("adminUsersYou")} color="primary" variant="outlined" /> : null}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
|
||||
{row.email || "—"}
|
||||
</Typography>
|
||||
@@ -217,15 +258,15 @@ export default function AdminUsersPage() {
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(user, !isAdmin)} fullWidth>
|
||||
{t("adminUsersAdmin")}
|
||||
</Button>
|
||||
<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>
|
||||
</Tooltip>
|
||||
<Button variant="outlined" onClick={() => void sendReset(user)} fullWidth>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Button color="error" variant="outlined" onClick={() => void remove(user)} fullWidth>
|
||||
{t("adminUsersDelete")}
|
||||
</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>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
Reference in New Issue
Block a user