First Commit

This commit is contained in:
cesnimda
2026-03-21 11:55:27 +01:00
commit 2e8a29b4d0
1757 changed files with 166084 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { api } from "../api";
import { useToast } from "../toast";
type AuditItem = {
id: number;
type: string;
oldValue?: string | null;
newValue?: string | null;
note?: string | null;
at: string;
jobApplicationId: number;
jobTitle?: string | null;
companyName?: string | null;
ownerUserId?: string | null;
ownerEmail?: string | null;
ownerUserName?: string | null;
};
function canUndo(type: string) {
return ["StatusChanged", "FollowUpSet", "ResponseUpdated", "Deleted", "Restored"].includes(type);
}
export default function AdminAuditPage() {
const { toast } = useToast();
const [items, setItems] = useState<AuditItem[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const r = await api.get<AuditItem[]>("/admin/audit?take=200");
setItems(r.data ?? []);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const undo = async (e: AuditItem) => {
if (!canUndo(e.type)) return;
setBusyId(e.id);
try {
const res = await api.post<{ ok: boolean; message: string }>(`/admin/audit/${e.id}/undo`, {});
toast(res.data?.message || "Undone.", "success");
await load();
} catch (err: any) {
const msg = err?.response?.data?.message || err?.response?.data || err?.message || "Undo failed.";
toast(String(msg), "error");
} finally {
setBusyId(null);
}
};
const restore = async (e: AuditItem) => {
setBusyId(e.id);
try {
await api.post(`/jobapplications/${e.jobApplicationId}/restore`, {});
toast("Restored.", "success");
await load();
} catch (err: any) {
const msg = err?.response?.data || err?.message || "Restore failed.";
toast(String(msg), "error");
} finally {
setBusyId(null);
}
};
const rows = useMemo(() => items, [items]);
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 1 }}>
Audit log
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Admin-only.
</Typography>
<TableContainer sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 165 }}>At</TableCell>
<TableCell sx={{ width: 160 }}>Type</TableCell>
<TableCell>Job</TableCell>
<TableCell sx={{ width: 220 }}>User</TableCell>
<TableCell>Details</TableCell>
<TableCell sx={{ width: 170 }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6}>
<Typography sx={{ color: "text.secondary" }}>Loading...</Typography>
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>
<Typography sx={{ color: "text.secondary" }}>No events.</Typography>
</TableCell>
</TableRow>
) : (
rows.map((e) => {
const jobLabel = e.jobTitle || `Job #${e.jobApplicationId}`;
const ownerLabel = e.ownerEmail || e.ownerUserName || e.ownerUserId || "-";
const details = e.oldValue || e.newValue ? `${e.oldValue ?? ""} -> ${e.newValue ?? ""}` : "";
const disabled = busyId === e.id;
const showRestore = e.type === "Deleted";
return (
<TableRow key={e.id} hover>
<TableCell>{e.at ? new Date(e.at).toLocaleString() : ""}</TableCell>
<TableCell>
<Chip label={e.type} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
{e.companyName ? `${e.companyName} - ` : ""}
{jobLabel}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
jobId={e.jobApplicationId}
</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: 700 }}>{ownerLabel}</Typography>
</TableCell>
<TableCell>
<Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>
{details}
{e.note ? `${details ? "\n" : ""}${e.note}` : ""}
</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{canUndo(e.type) ? (
<Button size="small" variant="outlined" disabled={disabled} onClick={() => void undo(e)}>
Undo
</Button>
) : null}
{showRestore ? (
<Button size="small" variant="contained" disabled={disabled} onClick={() => void restore(e)}>
Restore
</Button>
) : null}
</Box>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
}
+176
View File
@@ -0,0 +1,176 @@
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);
async function load() {
setLoading(true);
try {
const res = await api.get<UserDto[]>("/users");
setUsers(res.data ?? []);
} catch {
setUsers([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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 remove = async (u: UserDto) => {
if (!window.confirm(`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 }}>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>
);
}
+139
View File
@@ -0,0 +1,139 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../api";
import { setAuthToken } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import { useToast } from "../toast";
type AuthConfig = {
requireAuth: boolean;
googleEnabled: boolean;
localEnabled: boolean;
allowRegistration: boolean;
};
export default function LoginPage() {
const { toast } = useToast();
const navigate = useNavigate();
const location = useLocation() as any;
const [tab, setTab] = useState(0);
const [cfg, setCfg] = useState<AuthConfig | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
useEffect(() => {
api
.get<AuthConfig>("/auth/config")
.then((r) => setCfg(r.data))
.catch(() => setCfg(null));
}, []);
async function submit(mode: "login" | "register") {
setLoading(true);
try {
const url = mode === "register" ? "/auth/register" : "/auth/login";
const res = await api.post<{ accessToken: string; tokenType: string }>(url, {
email,
password,
});
setAuthToken(res.data.accessToken);
toast("Signed in.", "success");
navigate(nextPath, { replace: true });
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Login failed.";
toast(String(msg), "error");
} finally {
setLoading(false);
}
}
const allowReg = cfg?.allowRegistration ?? false;
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background:
"radial-gradient(1200px 700px at 20% 0%, rgba(79,140,255,0.14), transparent 55%), radial-gradient(900px 600px at 80% 20%, rgba(245,158,11,0.10), transparent 55%)",
}}
>
<Paper sx={{ width: "min(520px, 100%)", p: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
Sign in
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{cfg?.requireAuth ? "Authentication is required to use this app." : "Authentication is optional in this environment."}
</Typography>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Email & password" />
<Tab label="Google" />
</Tabs>
{tab === 0 && (
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
void submit("login");
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
<TextField
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
fullWidth
/>
<TextField
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={allowReg ? "new-password" : "current-password"}
type="password"
fullWidth
/>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
{allowReg && (
<Button
type="button"
variant="outlined"
disabled={loading}
onClick={() => void submit("register")}
>
Create account
</Button>
)}
<Button type="submit" variant="contained" disabled={loading}>
Sign in
</Button>
</Box>
</Box>
)}
{tab === 1 && (
<GoogleAuthCard
onSignedIn={() => {
navigate(nextPath, { replace: true });
}}
/>
)}
</Paper>
</Box>
);
}
+164
View File
@@ -0,0 +1,164 @@
import React, { useEffect, useMemo, useState } from "react";
import { Avatar, Box, Button, Paper, TextField, Typography } from "@mui/material";
import { api } from "../api";
import { useToast } from "../toast";
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
email?: string;
userName?: string;
roles?: string[];
};
function initialsFrom(s?: string) {
const v = (s ?? "").trim();
if (!v) return "?";
const parts = v.split(/[\s@._-]+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
export default function ProfilePage() {
const { toast } = useToast();
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState("");
const [userName, setUserName] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
useEffect(() => {
api
.get<MeResponse>("/auth/me")
.then((r) => {
setMe(r.data);
setEmail(r.data?.email ?? "");
setUserName(r.data?.userName ?? "");
})
.catch(() => setMe(null));
}, []);
const initials = useMemo(() => initialsFrom(me?.userName || me?.email), [me]);
const isLocal = me?.provider === "local";
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Avatar sx={{ width: 44, height: 44, fontWeight: 900 }}>{initials}</Avatar>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
Profile
</Typography>
<Typography sx={{ color: "text.secondary" }}>
{me?.email ? me.email : "—"} {me?.provider ? `(${me.provider})` : ""}
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="h6">Account</Typography>
{!isLocal ? (
<Typography sx={{ color: "text.secondary" }}>
This account is authenticated via Google; profile updates are read-only in this build.
</Typography>
) : null}
</Box>
<TextField
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<TextField
label="Username"
value={userName}
onChange={(e) => setUserName(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
variant="contained"
disabled={!isLocal || loading}
onClick={async () => {
setLoading(true);
try {
await api.put("/auth/profile", { email, userName });
toast("Profile updated.", "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update profile.";
toast(String(msg), "error");
} finally {
setLoading(false);
}
}}
>
Save changes
</Button>
</Box>
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Typography variant="h6">Change password</Typography>
{!isLocal ? (
<Typography sx={{ color: "text.secondary" }}>
Password changes are only available for local accounts.
</Typography>
) : null}
</Box>
<TextField
label="Current password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<TextField
label="New password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
variant="outlined"
disabled={!isLocal || loading}
onClick={async () => {
setLoading(true);
try {
await api.post("/auth/change-password", {
currentPassword,
newPassword,
});
setCurrentPassword("");
setNewPassword("");
toast("Password updated.", "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to change password.";
toast(String(msg), "error");
} finally {
setLoading(false);
}
}}
>
Update password
</Button>
</Box>
</Box>
</Paper>
);
}
@@ -0,0 +1,85 @@
import React, { useMemo, useState } from "react";
import { Box, Button, Paper, TextField, Typography } from "@mui/material";
import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../api";
import { useToast } from "../toast";
function useQuery() {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}
export default function ResetPasswordPage() {
const { toast } = useToast();
const navigate = useNavigate();
const q = useQuery();
const email = q.get("email") || "";
const token = q.get("token") || "";
const [newPassword, setNewPassword] = useState("");
const [loading, setLoading] = useState(false);
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background:
"radial-gradient(1200px 700px at 20% 0%, rgba(79,140,255,0.14), transparent 55%), radial-gradient(900px 600px at 80% 20%, rgba(245,158,11,0.10), transparent 55%)",
}}
>
<Paper sx={{ width: "min(520px, 100%)", p: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
Reset password
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Set a new password for your account.
</Typography>
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
if (!email || !token) {
toast("Missing email/token in link.", "error");
return;
}
setLoading(true);
api
.post("/auth/reset-password", { email, token, newPassword })
.then(() => {
toast("Password reset. Please sign in.", "success");
navigate("/login", { replace: true });
})
.catch((e2: any) => {
const msg = e2?.response?.data || e2?.message || "Reset failed.";
toast(String(msg), "error");
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
<TextField label="Email" value={email} disabled fullWidth />
<TextField label="New password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
Back to login
</Button>
<Button type="submit" variant="contained" disabled={loading}>
Update password
</Button>
</Box>
</Box>
</Paper>
</Box>
);
}