Files
jobtrackingapp/job-tracker-ui/src/views/AdminUsersPage.tsx
T
cesnimda 15e5464da7
CI and Deploy / test (pull_request) Failing after 4m31s
CI and Deploy / deploy (pull_request) Has been skipped
fix(admin): protect administrator access
Reject final-admin demotion and deletion at the API boundary. Require explicit confirmation before any administrator role removal.
2026-08-15 13:11:54 +02:00

308 lines
12 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, 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 { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
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 } = useI18n();
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(false);
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newIsAdmin, setNewIsAdmin] = useState(false);
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 canCreate = useMemo(() => newEmail.trim().length > 3 && newPassword.length >= 6, [newEmail, newPassword]);
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) => {
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 {
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) {
toast(getApiErrorMessage(e, t("adminUsersRolesUpdateFailed")), "error");
}
}, [confirmAction, t, toast]);
const sendReset = useCallback(async (u: UserDto) => {
try {
await api.post(`/users/${u.id}/send-password-reset`);
toast(t("adminUsersResetSent"), "success");
} catch (e: any) {
toast(getApiErrorMessage(e, t("adminUsersResetFailed")), "error");
}
}, [t, toast]);
const remove = useCallback(async (u: UserDto) => {
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}`);
toast(t("adminUsersDeleted"), "info");
await load();
} 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,
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",
headerName: t("adminUsersRolesLabel"),
flex: 1,
minWidth: 180,
sortable: false,
renderCell: (params) => {
const roles = params.row.roles as string[];
return (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", py: 0.5 }}>
{roles.length ? roles.map((role) => <Chip key={role} size="small" label={role} variant="outlined" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}></Typography>}
</Box>
);
},
},
{
field: "emailConfirmed",
headerName: t("adminUsersConfirmed"),
width: 130,
renderCell: (params) => (
<Chip size="small" label={params.value ? t("yes") : t("noWord")} color={params.value ? "success" : "default"} variant={params.value ? "filled" : "outlined"} />
),
},
{
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 (
<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)}>
{isAdmin ? t("adminUsersRemoveAdmin") : t("adminUsersMakeAdmin")}
</Button>
</span>
</Tooltip>
<Button size="small" variant="outlined" 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>
</Tooltip>
</Box>
);
},
},
], [remove, sendReset, setAdminRole, t]);
return (
<Paper sx={{ p: { xs: 1.5, sm: 2 } }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
{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>
<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 />
</Box>
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 2, mt: 1.5, flexWrap: "wrap" }}>
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} />
<Button
variant="contained"
disabled={!canCreate || loading}
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>
{isMobile ? (
<Stack spacing={1.5}>
{!loading && rows.length === 0 ? (
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
) : null}
{rows.map((row) => {
const user = row.raw as UserDto;
const isAdmin = user.roles.includes("Admin");
return (
<Paper key={row.id} sx={{ p: 1.5, borderRadius: 3 }}>
<Stack spacing={1.25}>
<Box>
<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>
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
{(row.roles as string[]).length ? (row.roles as string[]).map((role) => (
<Chip key={role} size="small" label={role} variant="outlined" />
)) : <Chip size="small" label="—" variant="outlined" />}
<Chip size="small" label={row.emailConfirmed ? t("yes") : t("noWord")} color={row.emailConfirmed ? "success" : "default"} variant={row.emailConfirmed ? "filled" : "outlined"} />
</Box>
<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>
</Tooltip>
<Button variant="outlined" 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>
</Tooltip>
</Stack>
</Stack>
</Paper>
);
})}
</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" }}>
<DataGrid
autoHeight
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 ? (
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
) : null}
</Paper>
)}
</Paper>
);
}