227 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|