Polish UI, harden company creation, and add error pages
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user