fix(admin): protect administrator access
CI and Deploy / test (pull_request) Failing after 4m31s
CI and Deploy / deploy (pull_request) Has been skipped

Reject final-admin demotion and deletion at the API boundary. Require explicit confirmation before any administrator role removal.
This commit is contained in:
cesnimda
2026-08-15 13:11:54 +02:00
parent 896746319b
commit 15e5464da7
9 changed files with 411 additions and 45 deletions
+66 -25
View File
@@ -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>