Files
jobtrackingapp/job-tracker-ui/src/pages/AdminUsersPage.tsx
T

227 lines
8.1 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Checkbox,
FormControlLabel,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { api } from "../api";
import { useToast } from "../toast";
type UserDto = {
id: string;
email?: string | null;
userName?: string | null;
emailConfirmed: boolean;
roles: string[];
};
export default function AdminUsersPage() {
const { toast } = useToast();
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(false);
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailSubject, setTestEmailSubject] = useState("Job Tracker SMTP test");
const [testEmailMessage, setTestEmailMessage] = useState("This is a test email from the Job Tracker admin panel.");
const [sendingTestEmail, setSendingTestEmail] = 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 setAdminRole = async (u: UserDto, isAdmin: boolean) => {
try {
await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] });
toast("Roles updated.", "success");
await load();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update roles.";
toast(String(msg), "error");
}
};
const sendReset = async (u: UserDto) => {
try {
await api.post(`/users/${u.id}/send-password-reset`);
toast("Password reset email sent.", "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to send reset.";
toast(String(msg), "error");
}
};
const sendTestEmail = async () => {
setSendingTestEmail(true);
try {
await api.post("/users/send-test-email", {
toEmail: testEmailTo.trim() || null,
subject: testEmailSubject.trim() || null,
message: testEmailMessage.trim() || null,
});
toast("Test email sent.", "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to send test email.";
toast(String(msg), "error");
} finally {
setSendingTestEmail(false);
}
};
const remove = async (u: UserDto) => {
if (!confirmAction(`Delete user ${u.email || u.userName || u.id}?`)) return;
try {
await api.delete(`/users/${u.id}`);
toast("User deleted.", "info");
await load();
} catch {
toast("Failed to delete user.", "error");
}
};
return (
<Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
Users
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>Admin-only user management.</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography sx={{ fontWeight: 900, mb: 1 }}>SMTP test email</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>
Send a quick delivery check using the configured SMTP settings. Leave the recipient blank to use your admin email.
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField
label="Recipient email"
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
placeholder="Uses your admin email if left blank"
/>
<TextField label="Subject" value={testEmailSubject} onChange={(e) => setTestEmailSubject(e.target.value)} />
<TextField
label="Message"
multiline
minRows={3}
value={testEmailMessage}
onChange={(e) => setTestEmailMessage(e.target.value)}
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
/>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5 }}>
<Button variant="contained" disabled={sendingTestEmail} onClick={() => void sendTestEmail()}>
{sendingTestEmail ? "Sending..." : "Send test email"}
</Button>
</Box>
</Paper>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography sx={{ fontWeight: 900, mb: 1 }}>Create user</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField label="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
<TextField label="Password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</Box>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mt: 1.5, flexWrap: "wrap" }}>
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label="Admin" />
<Button
variant="contained"
disabled={!canCreate || loading}
onClick={async () => {
try {
await api.post("/users", { email: newEmail, password: newPassword, roles: newIsAdmin ? ["Admin"] : [] });
setNewEmail("");
setNewPassword("");
setNewIsAdmin(false);
toast("User created.", "success");
await load();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to create user.";
toast(String(msg), "error");
}
}}
>
Create
</Button>
</Box>
</Paper>
<TableContainer sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Username</TableCell>
<TableCell>Roles</TableCell>
<TableCell>Confirmed</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => {
const isAdmin = (u.roles || []).includes("Admin");
return (
<TableRow key={u.id} hover>
<TableCell sx={{ fontWeight: 850 }}>{u.email || ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{u.userName || ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{u.roles?.length ? u.roles.join(", ") : "-"}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{u.emailConfirmed ? "Yes" : "No"}</TableCell>
<TableCell align="right">
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(u, !isAdmin)}>
Admin
</Button>
<Button size="small" variant="outlined" onClick={() => void sendReset(u)}>
Send reset
</Button>
<Button size="small" color="error" variant="outlined" onClick={() => void remove(u)}>
Delete
</Button>
</Box>
</TableCell>
</TableRow>
);
})}
{!loading && users.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>No users.</Typography>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
}