acf60c2a07
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while keeping the app's actual routing/rendering model unchanged -- the app is almost entirely behind auth with no proven SSR/SEO need, so a real App Router rewrite would touch ~90 files for zero user-visible benefit. - next.config.js: output:'export' (static HTML+JS, same "single index.html served by nginx with try_files fallback" deploy as CRA). - app/layout.tsx + app/page.tsx: root shell ports public/index.html's <head>, mounts the whole existing App tree client-only (ssr:false) since it reads window/localStorage during initial render and Next's static prerender would otherwise execute that on the server. - Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects any `pages/` dir under the app root and tried to build our React Router page components as its own routes). - REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development, Dockerfile, docker-compose.yml build args. - Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported under Turbopack) with a small inline JobbjaktMark component. - TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax 4.9's parser rejects; CRA never hit this because babel doesn't type-check). - Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts, public/index.html); kept react-scripts as the Jest test runner only (next/jest migration not needed -- the existing config already works). Verified: `next build` static export succeeds, `next dev` serves the landing page and client-side routes (login etc.) correctly, all 57 frontend tests + 172 backend tests still green. Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s in `next dev` since there's no server route for it -- the app only ever mounts at "/". Production is unaffected: nginx's existing try_files fallback still serves index.html for any path.
267 lines
9.9 KiB
TypeScript
267 lines
9.9 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import {
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Chip,
|
|
FormControlLabel,
|
|
Paper,
|
|
Stack,
|
|
TextField,
|
|
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[];
|
|
};
|
|
|
|
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, isAdmin: boolean) => {
|
|
try {
|
|
await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] });
|
|
toast(t("adminUsersRolesUpdated"), "success");
|
|
await load();
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data || e?.message || t("adminUsersRolesUpdateFailed");
|
|
toast(String(msg), "error");
|
|
}
|
|
}, [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;
|
|
if (!(await confirmAction(t("adminUsersDeleteConfirmNamed", { name }), { 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");
|
|
}
|
|
}, [confirmAction, t, toast]);
|
|
|
|
const columns = useMemo<GridColDef[]>(() => [
|
|
{ field: "email", headerName: t("profileEmail"), flex: 1.2, minWidth: 220 },
|
|
{ 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 }}>
|
|
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(user, !isAdmin)}>
|
|
{t("adminUsersAdmin")}
|
|
</Button>
|
|
<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>
|
|
</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>
|
|
|
|
<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>
|
|
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>
|
|
{row.userName || row.email || row.id}
|
|
</Typography>
|
|
<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}>
|
|
<Button variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(user, !isAdmin)} fullWidth>
|
|
{t("adminUsersAdmin")}
|
|
</Button>
|
|
<Button variant="outlined" onClick={() => void sendReset(user)} fullWidth>
|
|
{t("adminUsersSendReset")}
|
|
</Button>
|
|
<Button color="error" variant="outlined" onClick={() => void remove(user)} fullWidth>
|
|
{t("adminUsersDelete")}
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
})}
|
|
</Stack>
|
|
) : (
|
|
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", 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>
|
|
);
|
|
}
|