Polish UI, harden company creation, and add error pages

This commit is contained in:
cesnimda
2026-03-23 19:34:29 +01:00
parent 8f5eab2fe4
commit fcafda6f52
38 changed files with 2293 additions and 1269 deletions
+62 -23
View File
@@ -7,10 +7,12 @@ import {
Chip,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type SummarizerMetrics = {
healthy: boolean;
@@ -111,10 +113,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
}
export default function AdminSystemPage() {
const { t } = useI18n();
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(false);
const [runningProbe, setRunningProbe] = useState(false);
const [error, setError] = useState<string | null>(null);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailSubject, setTestEmailSubject] = useState("Jobbjakt SMTP test");
const [testEmailMessage, setTestEmailMessage] = useState("This is a test email from the Jobbjakt system panel.");
const [sendingTestEmail, setSendingTestEmail] = useState(false);
const load = async () => {
setLoading(true);
@@ -148,12 +155,27 @@ export default function AdminSystemPage() {
return "success" as const;
}, [status]);
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,
});
} catch (e: any) {
setError(e?.response?.data || e?.message || "Failed to send test email.");
} finally {
setSendingTestEmail(false);
}
};
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 950 }}>System status</Typography>
<Typography sx={{ color: "text.secondary" }}>Production diagnostics for runtime, database, auth, email, and summarizer health.</Typography>
<Typography variant="h5" sx={{ fontWeight: 950 }}>{t("adminSystemTitle")}</Typography>
<Typography sx={{ color: "text.secondary" }}>{t("adminSystemSubtitle")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
@@ -172,10 +194,10 @@ export default function AdminSystemPage() {
}}
disabled={loading || runningProbe}
>
{runningProbe ? "Running probe..." : "Run probe now"}
{runningProbe ? t("adminSystemRunningProbe") : t("adminSystemRunProbe")}
</Button>
<Button variant="contained" onClick={() => void load()} disabled={loading}>
{loading ? "Refreshing..." : "Refresh"}
{loading ? t("adminSystemRefreshing") : t("adminSystemRefresh")}
</Button>
</Box>
</Box>
@@ -186,37 +208,37 @@ export default function AdminSystemPage() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
<SummaryCard
title="Environment"
title={t("adminSystemEnvironment")}
value={status?.environment ?? "-"}
subtitle={`Version ${displayMetadata(status?.version)} · Commit ${displayMetadata(status?.commitSha)}`}
/>
<SummaryCard
title="Database"
value={status ? (status.database.canConnect ? "Connected" : "Offline") : "-"}
title={t("adminSystemDatabase")}
value={status ? (status.database.canConnect ? t("adminSystemConnected") : t("adminSystemOffline")) : "-"}
subtitle={status ? `${status.database.provider} · ${status.database.target || "No target"}` : "-"}
tone={dbTone}
/>
<SummaryCard
title="SMTP"
value={status?.email.enabled ? "Enabled" : "Disabled"}
subtitle={status?.email.host || "No SMTP host configured"}
title={t("adminSystemSmtp")}
value={status?.email.enabled ? t("adminSystemEnabled") : t("adminSystemDisabled")}
subtitle={status?.email.host || t("adminSystemNoSmtpHost")}
tone={status?.email.enabled ? "success" : "default"}
/>
<SummaryCard
title="Summarizer"
value={status?.summarizer.healthy ? "Healthy" : "Offline"}
title={t("adminSystemSummarizer")}
value={status?.summarizer.healthy ? t("adminSystemHealthy") : t("adminSystemOffline")}
subtitle={status?.summarizer.probeLatencyMs != null
? `${status.summarizer.probeLatencyMs} ms probe · ${status.summarizer.device || "unknown device"}`
: status?.summarizer.healthLatencyMs != null
? `${status.summarizer.healthLatencyMs} ms health · ${status.summarizer.device || "unknown device"}`
: "No latency data"}
: t("adminSystemNoLatencyData")}
tone={summarizerTone}
/>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Database and storage</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemDatabaseStorage")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Provider" value={status?.database.provider || "-"} />
<DetailRow label="Target" value={status?.database.target || "-"} />
@@ -234,7 +256,7 @@ export default function AdminSystemPage() {
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Runtime and auth</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemRuntimeAuth")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Framework" value={status?.runtime.framework || "-"} />
<DetailRow label="OS" value={status?.runtime.osDescription || "-"} />
@@ -252,7 +274,7 @@ export default function AdminSystemPage() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Email configuration</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Enabled" value={status?.email.enabled ? "Yes" : "No"} />
<DetailRow label="From" value={status?.email.from || "-"} />
@@ -264,7 +286,7 @@ export default function AdminSystemPage() {
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer runtime</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography>
<Stack spacing={0.75}>
<DetailRow label="Model" value={status?.summarizer.model || "-"} />
<DetailRow label="Device" value={status?.summarizer.device || "-"} />
@@ -280,7 +302,24 @@ export default function AdminSystemPage() {
</Box>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSmtpTest")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>
{t("adminSystemSmtpTestBody")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<TextField label={t("adminSystemRecipientEmail")} value={testEmailTo} onChange={(e) => setTestEmailTo(e.target.value)} placeholder={t("adminSystemRecipientPlaceholder")} />
<TextField label={t("adminSystemSubject")} value={testEmailSubject} onChange={(e) => setTestEmailSubject(e.target.value)} />
<TextField label={t("adminSystemMessage")} 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 ? t("adminSystemSending") : t("adminSystemSendTestEmail")}
</Button>
</Box>
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(6, 1fr)" }, gap: 2 }}>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography></Box>
@@ -290,11 +329,11 @@ export default function AdminSystemPage() {
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography></Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}>
<Chip label={status?.database.canConnect ? "Database connected" : "Database issue"} color={status?.database.canConnect ? "success" : "error"} size="small" />
<Chip label={status?.auth.required ? "Auth enforced" : "Auth optional"} color={status?.auth.required ? "success" : "warning"} size="small" />
<Chip label={status?.auth.googleConfigured ? "Google sign-in ready" : "Google sign-in off"} variant="outlined" size="small" />
<Chip label={status?.auth.gmailConfigured ? "Gmail ready" : "Gmail incomplete"} variant="outlined" size="small" />
<Chip label={status?.summarizer.gpuAvailable ? "GPU visible" : "CPU mode"} color={status?.summarizer.gpuAvailable ? "success" : "default"} size="small" />
<Chip label={status?.database.canConnect ? t("adminSystemDatabaseConnected") : t("adminSystemDatabaseIssue")} color={status?.database.canConnect ? "success" : "error"} size="small" />
<Chip label={status?.auth.required ? t("adminSystemAuthEnforced") : t("adminSystemAuthOptional")} color={status?.auth.required ? "success" : "warning"} size="small" />
<Chip label={status?.auth.googleConfigured ? t("adminSystemGoogleReady") : t("adminSystemGoogleOff")} variant="outlined" size="small" />
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
<Chip label={status?.summarizer.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.summarizer.gpuAvailable ? "success" : "default"} size="small" />
</Box>
</Paper>
</Box>
+26 -63
View File
@@ -19,6 +19,7 @@ import {
import { api } from "../api";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
type UserDto = {
id: string;
@@ -31,6 +32,7 @@ type UserDto = {
export default function AdminUsersPage() {
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const { t } = useI18n();
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(false);
@@ -38,11 +40,6 @@ export default function AdminUsersPage() {
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 {
@@ -64,10 +61,10 @@ export default function AdminUsersPage() {
const setAdminRole = async (u: UserDto, isAdmin: boolean) => {
try {
await api.put(`/users/${u.id}/roles`, { roles: isAdmin ? ["Admin"] : [] });
toast("Roles updated.", "success");
toast(t("adminUsersRolesUpdated"), "success");
await load();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update roles.";
const msg = e?.response?.data || e?.message || t("adminUsersRolesUpdateFailed");
toast(String(msg), "error");
}
};
@@ -75,73 +72,39 @@ export default function AdminUsersPage() {
const sendReset = async (u: UserDto) => {
try {
await api.post(`/users/${u.id}/send-password-reset`);
toast("Password reset email sent.", "success");
toast(t("adminUsersResetSent"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to send reset.";
const msg = e?.response?.data || e?.message || t("adminUsersResetFailed");
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 (!(await confirmAction(`Delete user ${u.email || u.userName || u.id}?`, { title: "Delete user", confirmLabel: "Delete", destructive: true }))) return;
if (!(await confirmAction(`Delete user ${u.email || u.userName || u.id}?`, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
try {
await api.delete(`/users/${u.id}`);
toast("User deleted.", "info");
toast(t("adminUsersDeleted"), "info");
await load();
} catch {
toast("Failed to delete user.", "error");
toast(t("adminUsersDeleteFailed"), "error");
}
};
return (
<Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
Users
{t("adminUsersTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>Admin-only user management.</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</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>
<Typography sx={{ fontWeight: 900, mb: 1 }}>{t("adminUsersCreateUser")}</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)} />
<TextField label={t("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
<TextField label={t("profileNewPassword")} 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" />
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} />
<Button
variant="contained"
disabled={!canCreate || loading}
@@ -151,15 +114,15 @@ export default function AdminUsersPage() {
setNewEmail("");
setNewPassword("");
setNewIsAdmin(false);
toast("User created.", "success");
toast(t("adminUsersCreated"), "success");
await load();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to create user.";
const msg = e?.response?.data || e?.message || t("adminUsersCreateFailed");
toast(String(msg), "error");
}
}}
>
Create
{t("create")}
</Button>
</Box>
</Paper>
@@ -168,11 +131,11 @@ export default function AdminUsersPage() {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Username</TableCell>
<TableCell>{t("profileEmail")}</TableCell>
<TableCell>{t("profileUsername")}</TableCell>
<TableCell>Roles</TableCell>
<TableCell>Confirmed</TableCell>
<TableCell align="right">Actions</TableCell>
<TableCell>{t("adminUsersConfirmed")}</TableCell>
<TableCell align="right">{t("adminUsersActions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -187,13 +150,13 @@ export default function AdminUsersPage() {
<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
{t("adminUsersAdmin")}
</Button>
<Button size="small" variant="outlined" onClick={() => void sendReset(u)}>
Send reset
{t("adminUsersSendReset")}
</Button>
<Button size="small" color="error" variant="outlined" onClick={() => void remove(u)}>
Delete
{t("adminUsersDelete")}
</Button>
</Box>
</TableCell>
@@ -204,7 +167,7 @@ export default function AdminUsersPage() {
{!loading && users.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>No users.</Typography>
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
</TableCell>
</TableRow>
) : null}
+16 -48
View File
@@ -8,6 +8,7 @@ import { api } from "../api";
import { setAuthToken } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type AuthConfig = {
requireAuth: boolean;
@@ -18,6 +19,7 @@ type AuthConfig = {
export default function LoginPage() {
const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate();
const location = useLocation() as any;
@@ -41,15 +43,12 @@ export default function LoginPage() {
setLoading(true);
try {
const url = mode === "register" ? "/auth/register" : "/auth/login";
const res = await api.post<{ accessToken: string; tokenType: string }>(url, {
email,
password,
});
const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password });
setAuthToken(res.data.accessToken);
toast("Signed in.", "success");
toast(t("signedIn"), "success");
navigate(nextPath, { replace: true });
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Login failed.";
const msg = e?.response?.data || e?.message || t("loginFailed");
toast(String(msg), "error");
} finally {
setLoading(false);
@@ -72,67 +71,36 @@ export default function LoginPage() {
>
<Paper sx={{ width: "min(520px, 100%)", p: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
Sign in
{t("signInTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{cfg?.requireAuth ? "Authentication is required to use this app." : "Authentication is optional in this environment."}
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
</Typography>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Email & password" />
<Tab label="Google" />
<Tab label={t("emailAndPassword")} />
<Tab label={t("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 component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
<TextField label={t("profileCurrentPassword")} 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 type="button" variant="outlined" disabled={loading} onClick={() => void submit("register")}>
{t("createAccount")}
</Button>
)}
<Button type="submit" variant="contained" disabled={loading}>
Sign in
{t("signInTitle")}
</Button>
</Box>
</Box>
)}
{tab === 1 && (
<GoogleAuthCard
onSignedIn={() => {
navigate(nextPath, { replace: true });
}}
/>
)}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</Paper>
</Box>
);
+33
View File
@@ -0,0 +1,33 @@
import React from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useI18n } from "../i18n/I18nProvider";
export default function NotFoundPage() {
const navigate = useNavigate();
const { t } = useI18n();
return (
<Paper sx={{ p: { xs: 3, md: 5 }, borderRadius: 4 }}>
<Box sx={{ display: "grid", gap: 1.5, maxWidth: 560 }}>
<Typography variant="overline" sx={{ color: "text.secondary", letterSpacing: 1.6 }}>
404
</Typography>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{t("notFoundTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>
{t("notFoundBody")}
</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mt: 1 }}>
<Button variant="contained" onClick={() => navigate("/jobs")}>
{t("goHome")}
</Button>
<Button variant="outlined" onClick={() => navigate(-1)}>
{t("goBack")}
</Button>
</Box>
</Box>
</Paper>
);
}
+133 -43
View File
@@ -2,9 +2,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
import { api } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type MeResponse = {
provider?: "local" | "google" | "external";
@@ -15,6 +20,7 @@ type MeResponse = {
lastName?: string;
displayName?: string;
profileCvText?: string;
avatarImageDataUrl?: string;
roles?: string[];
googleLink?: {
linked: boolean;
@@ -23,6 +29,9 @@ type MeResponse = {
} | null;
};
const CV_UPLOAD_ACCEPT = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
function initialsFrom(values: Array<string | undefined>) {
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
if (joined.length === 0) return "?";
@@ -36,10 +45,15 @@ function initialsFrom(values: Array<string | undefined>) {
export default function ProfilePage() {
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { t } = useI18n();
const cvInputRef = useRef<HTMLInputElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [uploadingCv, setUploadingCv] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [email, setEmail] = useState("");
const [userName, setUserName] = useState("");
@@ -77,23 +91,102 @@ export default function ProfilePage() {
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession");
const googleLabel = me?.googleLink?.linked
? me.googleLink.email
? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email })
: t("profileGoogleLinked")
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
return (
<Paper sx={{ mt: 0, p: 2.5 }}>
<CropImageDialog
open={cropOpen}
file={avatarFile}
onClose={() => {
setCropOpen(false);
setAvatarFile(null);
}}
onSave={async (blob) => {
const file = new File([blob], "avatar.png", { type: "image/png" });
const formData = new FormData();
formData.append("file", file);
setUploadingAvatar(true);
try {
const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev));
setCropOpen(false);
setAvatarFile(null);
toast(t("profileImageUpdated"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error");
} finally {
setUploadingAvatar(false);
}
}}
/>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Avatar sx={{ width: 64, height: 64, fontWeight: 900, fontSize: 24 }}>{initials}</Avatar>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
<input
ref={avatarInputRef}
type="file"
accept={AVATAR_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
event.target.value = "";
if (!file) return;
setAvatarFile(file);
setCropOpen(true);
}}
/>
<Button variant="outlined" size="small" startIcon={<PhotoCameraOutlinedIcon />} disabled={!isLocal || uploadingAvatar} onClick={() => avatarInputRef.current?.click()}>
{uploadingAvatar ? t("profileUploading") : t("profileChangeImage")}
</Button>
{me?.avatarImageDataUrl ? (
<Button
variant="text"
size="small"
color="inherit"
startIcon={<DeleteOutlineIcon />}
disabled={!isLocal || uploadingAvatar}
onClick={async () => {
setUploadingAvatar(true);
try {
await api.delete("/auth/avatar");
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
toast(t("profileImageRemoved"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
} finally {
setUploadingAvatar(false);
}
}}
>
{t("profileRemoveImage")}
</Button>
) : null}
</Box>
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
Profile
{t("profileTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.displayName || fullName || me?.userName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || "Add a short headline to personalize your account view."}</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
<Chip label={me?.provider === "local" ? "Local account" : me?.provider === "google" ? "Google session" : "External session"} color={me?.provider === "local" ? "primary" : "default"} />
<Chip label={me?.googleLink?.linked ? `Google linked${me.googleLink.email ? `: ${me.googleLink.email}` : ""}` : "Google not linked"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={profileCvText.trim() ? `CV ready · ${cvWordCount} words` : "CV missing"} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
</Box>
</Box>
@@ -101,40 +194,40 @@ export default function ProfilePage() {
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="h6">Account</Typography>
<Typography variant="h6">{t("profileAccountSection")}</Typography>
{!isLocal ? (
<Alert severity="info" sx={{ mt: 1 }}>
This session is not using a local app token, so profile edits are read-only right now.
{t("profileReadOnlyInfo")}
</Alert>
) : null}
</Box>
<TextField label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Username" value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="First name" value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Last name" value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileDisplayName")} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileUsername")} value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileFirstName")} value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileLastName")} value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
<TextField
label="Profile headline"
label={t("profileHeadline")}
value={headline}
onChange={(e) => setHeadline(e.target.value)}
helperText="Stored only in this browser to personalize your workspace."
helperText={t("profileHeadlineHelp")}
fullWidth
/>
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="h6">Master CV</Typography>
<Typography variant="h6">{t("profileMasterCv")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Paste your resume text here or import a .txt/.md version. The app uses it to explain fit, gaps, interview talking points, and tailored messaging.
{t("profileMasterCvBody")}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<input
ref={fileInputRef}
ref={cvInputRef}
type="file"
accept=".txt,.md,text/plain,text/markdown"
accept={CV_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={async (event) => {
const file = event.target.files?.[0];
@@ -146,28 +239,28 @@ export default function ProfilePage() {
try {
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
toast("CV text imported.", "success");
toast(t("profileCvUploaded"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error");
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
} finally {
setUploadingCv(false);
}
}}
/>
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => fileInputRef.current?.click()}>
{uploadingCv ? "Importing..." : "Import .txt/.md"}
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
Copy CV text
{t("profileCopyCvText")}
</Button>
</Box>
</Box>
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
<TextField
label="Profile CV / master resume text"
label={t("profileCvTextLabel")}
value={profileCvText}
onChange={(e) => setProfileCvText(e.target.value)}
helperText="Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next."
helperText={t("profileCvTextHelp")}
multiline
minRows={12}
disabled={!isLocal}
@@ -178,15 +271,12 @@ export default function ProfilePage() {
{cvWordCount} words
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Tip: plain text works best right now.
{t("profileCvPreferredUploads")}
</Typography>
</Box>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
</Typography>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Button
variant="contained"
disabled={!isLocal || loading}
@@ -196,27 +286,27 @@ export default function ProfilePage() {
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText });
window.localStorage.setItem("profileHeadline", headline.trim());
await loadProfile();
toast("Profile updated.", "success");
toast(t("profileUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update profile.";
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
toast(String(msg), "error");
} finally {
setLoading(false);
}
}}
>
Save changes
{t("profileSaveChanges")}
</Button>
</Box>
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="h6">Change password</Typography>
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>Password changes are only available for local accounts.</Typography> : null}
<Typography variant="h6">{t("profileChangePassword")}</Typography>
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</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 />
<TextField label={t("profileCurrentPassword")} type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
@@ -228,16 +318,16 @@ export default function ProfilePage() {
await api.post("/auth/change-password", { currentPassword, newPassword });
setCurrentPassword("");
setNewPassword("");
toast("Password updated.", "success");
toast(t("profilePasswordUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to change password.";
const msg = e?.response?.data || e?.message || t("profilePasswordUpdateFailed");
toast(String(msg), "error");
} finally {
setLoading(false);
}
}}
>
Update password
{t("profileUpdatePassword")}
</Button>
</Box>
</Box>
+11 -10
View File
@@ -6,6 +6,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
function useQuery() {
const { search } = useLocation();
@@ -14,6 +15,7 @@ function useQuery() {
export default function ResetPasswordPage() {
const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate();
const q = useQuery();
@@ -37,10 +39,10 @@ export default function ResetPasswordPage() {
>
<Paper sx={{ width: "min(520px, 100%)", p: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
Reset password
{t("resetPasswordTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Set a new password for your account.
{t("resetPasswordBody")}
</Typography>
<Box
@@ -48,33 +50,33 @@ export default function ResetPasswordPage() {
onSubmit={(e) => {
e.preventDefault();
if (!email || !token) {
toast("Missing email/token in link.", "error");
toast(t("missingResetLinkInfo"), "error");
return;
}
setLoading(true);
api
.post("/auth/reset-password", { email, token, newPassword })
.then(() => {
toast("Password reset. Please sign in.", "success");
toast(t("passwordResetSuccess"), "success");
navigate("/login", { replace: true });
})
.catch((e2: any) => {
const msg = e2?.response?.data || e2?.message || "Reset failed.";
const msg = e2?.response?.data || e2?.message || t("resetFailed");
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 />
<TextField label={t("profileEmail")} value={email} disabled fullWidth />
<TextField label={t("profileNewPassword")} 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
{t("backToLogin")}
</Button>
<Button type="submit" variant="contained" disabled={loading}>
Update password
{t("updatePassword")}
</Button>
</Box>
</Box>
@@ -82,4 +84,3 @@ export default function ResetPasswordPage() {
</Box>
);
}
@@ -0,0 +1,43 @@
import React from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { useNavigate, useRouteError } from "react-router-dom";
import { useI18n } from "../i18n/I18nProvider";
export default function RouteErrorPage() {
const navigate = useNavigate();
const error = useRouteError() as any;
const { t } = useI18n();
const details = typeof error?.statusText === "string" && error.statusText.trim()
? error.statusText
: typeof error?.message === "string" && error.message.trim()
? error.message
: null;
return (
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", p: 3 }}>
<Paper sx={{ p: { xs: 3, md: 5 }, borderRadius: 4, width: "100%", maxWidth: 640 }}>
<Box sx={{ display: "grid", gap: 1.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary", letterSpacing: 1.6 }}>
{error?.status || 500}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{t("appErrorTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>
{t("appErrorBody")}
</Typography>
{details ? <Typography sx={{ color: "text.secondary" }}>{details}</Typography> : null}
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mt: 1 }}>
<Button variant="contained" onClick={() => navigate("/jobs")}>
{t("goHome")}
</Button>
<Button variant="outlined" onClick={() => navigate(-1)}>
{t("goBack")}
</Button>
</Box>
</Box>
</Paper>
</Box>
);
}