refactor, security updates, cv extraction upgrades

This commit is contained in:
2026-04-11 01:34:32 +02:00
parent 806b200ac5
commit 27fd70a2d7
59 changed files with 6817 additions and 1561 deletions
+46 -6
View File
@@ -32,7 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import RouteErrorPage from "./pages/RouteErrorPage";
import { api } from "./api";
import { clearAuthToken, getAuthToken } from "./auth";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
@@ -112,6 +112,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const [quickOpen, setQuickOpen] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
const [authResolved, setAuthResolved] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [me, setMe] = useState<MeResponse | null>(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
@@ -124,7 +125,26 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
}, []);
useEffect(() => {
api.get<MeResponse>("/auth/me").then((r) => { setMe(r.data); setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); }).catch(() => { setMe(null); setIsAdmin(false); });
let active = true;
api.get<MeResponse>("/auth/me")
.then((r) => {
if (!active) return;
setMe(r.data);
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
})
.catch(() => {
if (!active) return;
setMe(null);
setIsAdmin(false);
clearAuthClientState(false);
})
.finally(() => {
if (active) setAuthResolved(true);
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const load = () => {
@@ -134,6 +154,27 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const id = window.setInterval(load, 60000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
const onAuthChanged = () => {
setAuthResolved(false);
api.get<MeResponse>("/auth/me")
.then((r) => {
setMe(r.data);
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
})
.catch(() => {
setMe(null);
setIsAdmin(false);
clearAuthClientState(false);
})
.finally(() => setAuthResolved(true));
};
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
@@ -145,9 +186,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const token = getAuthToken();
if (requireAuth === null) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
if (requireAuth && !token) return <Navigate to="/login" replace state={{ from: path }} />;
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
@@ -223,7 +263,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
onOpenNotifications={() => navigate("/reminders")}
onOpenSettings={() => navigate("/settings")}
onOpenProfile={() => navigate("/profile")}
onSignOut={() => { clearAuthToken(); navigate("/login"); }}
onSignOut={() => { void api.post("/auth/logout").catch(() => undefined).finally(() => { clearAuthClientState(); navigate("/login"); }); }}
rightActions={rightActions}
>
<Suspense fallback={<PageLoader />}>
+12 -10
View File
@@ -1,6 +1,5 @@
import axios from "axios";
import { getAuthToken } from "./auth";
import { clearAuthToken } from "./auth";
import { clearAuthClientState, getCsrfToken } from "./auth";
export function getApiErrorMessage(error: any, fallback = "Request failed.") {
const data = error?.response?.data;
@@ -33,13 +32,19 @@ const defaultBaseUrl =
export const api = axios.create({
baseURL: envBaseUrl && envBaseUrl.trim().length > 0 ? envBaseUrl : defaultBaseUrl,
withCredentials: true,
xsrfCookieName: "XSRF-TOKEN",
xsrfHeaderName: "X-CSRF-TOKEN",
});
api.interceptors.request.use((config) => {
const token = getAuthToken();
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
const method = (config.method ?? "get").toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const csrfToken = getCsrfToken();
if (csrfToken) {
config.headers = config.headers ?? {};
config.headers["X-CSRF-TOKEN"] = csrfToken;
}
}
return config;
});
@@ -47,12 +52,9 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use(
(r) => r,
(err) => {
// If tokens expire (Google ID tokens are short-lived), clear and let the UI prompt the user.
const status = err?.response?.status;
if (status === 401) {
clearAuthToken();
// Avoid hard navigation loops; let views handle the missing token state.
// We still reject so callers can show a toast if they want.
clearAuthClientState();
}
return Promise.reject(err);
},
+38 -56
View File
@@ -1,13 +1,9 @@
export const AUTH_TOKEN_KEY = "authToken";
export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
const LEGACY_AUTH_TOKEN_KEY = "googleIdToken";
const AUTH_TOKEN_PERSISTENCE_KEY = "authTokenPersistence";
const AUTH_PERSISTENCE_KEY = "authTokenPersistence";
const AUTH_USER_KEY = "authUserKey";
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
type AuthPersistence = "local" | "session";
function normalizePersistence(value: string | null | undefined): AuthPersistence {
return value === "session" ? "session" : "local";
}
export type AuthPersistence = "local" | "session";
function safeGet(storage: Storage, key: string): string | null {
try {
@@ -33,28 +29,27 @@ function safeRemove(storage: Storage, key: string) {
}
}
function emitAuthChanged() {
window.dispatchEvent(new Event("auth-changed"));
}
function normalizePersistence(value: string | null | undefined): AuthPersistence {
return value === "session" ? "session" : "local";
}
function persistPreference(persistence: AuthPersistence) {
safeSet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY, persistence);
safeSet(window.localStorage, AUTH_PERSISTENCE_KEY, persistence);
safeSet(window.localStorage, AUTH_REMEMBER_ME_KEY, persistence === "local" ? "1" : "0");
}
function getStoredPersistence(): AuthPersistence {
const explicit = safeGet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY);
const explicit = safeGet(window.localStorage, AUTH_PERSISTENCE_KEY);
if (explicit) return normalizePersistence(explicit);
const rememberMe = safeGet(window.localStorage, AUTH_REMEMBER_ME_KEY);
if (rememberMe === "0") return "session";
return "local";
}
function migrateLegacyToken(): string | null {
const legacy = safeGet(window.localStorage, LEGACY_AUTH_TOKEN_KEY) ?? safeGet(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY);
if (!legacy) return null;
safeRemove(window.localStorage, LEGACY_AUTH_TOKEN_KEY);
safeRemove(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY);
setAuthToken(legacy, getStoredPersistence());
return legacy;
}
export function getRememberMePref(): boolean {
return getAuthPersistencePreference() === "local";
}
@@ -63,53 +58,40 @@ export function setRememberMePref(value: boolean) {
persistPreference(value ? "local" : "session");
}
export function getAuthToken(): string | null {
const localToken = safeGet(window.localStorage, AUTH_TOKEN_KEY);
if (localToken) {
persistPreference("local");
return localToken;
}
const sessionToken = safeGet(window.sessionStorage, AUTH_TOKEN_KEY);
if (sessionToken) {
persistPreference("session");
return sessionToken;
}
return migrateLegacyToken();
}
export function getAuthPersistencePreference(): AuthPersistence {
if (safeGet(window.sessionStorage, AUTH_TOKEN_KEY)) return "session";
if (safeGet(window.localStorage, AUTH_TOKEN_KEY)) return "local";
return getStoredPersistence();
}
export function setAuthToken(token: string, persistence: AuthPersistence = "local") {
safeRemove(window.localStorage, AUTH_TOKEN_KEY);
safeRemove(window.sessionStorage, AUTH_TOKEN_KEY);
if (persistence === "session") {
safeSet(window.sessionStorage, AUTH_TOKEN_KEY, token);
} else {
safeSet(window.localStorage, AUTH_TOKEN_KEY, token);
}
export function setAuthPersistencePreference(persistence: AuthPersistence) {
persistPreference(persistence);
emitAuthChanged();
}
export function clearAuthToken() {
safeRemove(window.localStorage, AUTH_TOKEN_KEY);
safeRemove(window.sessionStorage, AUTH_TOKEN_KEY);
export function getAuthUserKey(): string {
return safeGet(window.localStorage, AUTH_USER_KEY) ?? "anon";
}
export function decodeJwtPayload(token: string): any {
export function setAuthUserKey(value: string | null | undefined, emit = true) {
const next = typeof value === "string" ? value.trim() : "";
if (!next) {
safeRemove(window.localStorage, AUTH_USER_KEY);
} else {
safeSet(window.localStorage, AUTH_USER_KEY, next);
}
if (emit) emitAuthChanged();
}
export function clearAuthClientState(emit = true) {
safeRemove(window.localStorage, AUTH_USER_KEY);
if (emit) emitAuthChanged();
}
export function getCsrfToken(): string | null {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const base64 = parts[1].replaceAll("-", "+").replaceAll("_", "/");
const json = atob(base64);
return JSON.parse(json);
const parts = document.cookie.split(";").map((part) => part.trim());
const match = parts.find((part) => part.startsWith(`${AUTH_CSRF_COOKIE}=`));
if (!match) return null;
return decodeURIComponent(match.slice(AUTH_CSRF_COOKIE.length + 1));
} catch {
return null;
}
@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { clearAuthToken, getAuthToken } from "../auth";
import { clearAuthClientState } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -25,19 +25,20 @@ type MeResponse = {
export default function AuthStatusCard() {
const { toast } = useToast();
const { t } = useI18n();
const token = getAuthToken();
const [me, setMe] = useState<MeResponse | null>(null);
useEffect(() => {
if (!token) {
setMe(null);
return;
}
api
.get<MeResponse>("/auth/me")
.then((r) => setMe(r.data))
.catch(() => setMe(null));
}, [token]);
const refresh = () => {
api
.get<MeResponse>("/auth/me")
.then((r) => setMe(r.data))
.catch(() => setMe(null));
};
refresh();
window.addEventListener("auth-changed", refresh);
return () => window.removeEventListener("auth-changed", refresh);
}, []);
const label = useMemo(() => me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
@@ -47,7 +48,7 @@ export default function AuthStatusCard() {
{t("authStatusTitle")}
</Typography>
{!token ? (
{!me ? (
<Typography sx={{ color: "text.secondary" }}>{t("authStatusNotSignedIn")}</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
@@ -69,9 +70,11 @@ export default function AuthStatusCard() {
<Button
variant="outlined"
onClick={() => {
clearAuthToken();
setMe(null);
toast(t("signedOut"), "info");
void api.post("/auth/logout").catch(() => undefined).finally(() => {
setMe(null);
clearAuthClientState();
toast(t("signedOut"), "info");
});
}}
>
{t("signOut")}
@@ -23,10 +23,12 @@ import {
import useMediaQuery from "@mui/material/useMediaQuery";
import { api, getApiErrorMessage } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { Company } from "../types";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
export default function CompaniesTable() {
const isMobile = useMediaQuery("(max-width:767.95px)");
@@ -34,7 +36,6 @@ export default function CompaniesTable() {
const { t } = useI18n();
const location = useLocation();
const navigate = useNavigate();
const [companies, setCompanies] = useState<Company[]>([]);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<Company | null>(null);
@@ -45,9 +46,19 @@ export default function CompaniesTable() {
const [lastContactedAt, setLastContactedAt] = useState("");
const [nextContactAt, setNextContactAt] = useState("");
useEffect(() => {
api.get<Company[]>("/companies").then((r) => setCompanies(r.data)).catch((error) => toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error"));
}, [t, toast]);
const companiesResource = useViewResource(
async () => {
const response = await api.get<Company[]>("/companies");
return response.data;
},
{
initialData: [],
errorMessage: t("companiesUpdateFailed"),
deps: [t],
},
);
const companies = companiesResource.data;
useEffect(() => {
const params = new URLSearchParams(location.search);
@@ -88,7 +99,7 @@ export default function CompaniesTable() {
nextContactAt: nextContactAt || null,
});
setCompanies((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x)));
companiesResource.setData((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x)));
toast(t("companiesUpdated"), "success");
setEditOpen(false);
setEditing(null);
@@ -106,82 +117,92 @@ export default function CompaniesTable() {
return (
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
{isMobile ? (
<Stack spacing={1.5}>
{companies.map((c) => (
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
<Stack spacing={1.25}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
<Box>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
</Box>
<IconButton size="small" onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Box>
<ViewStateNotice
loading={companiesResource.loading}
error={companiesResource.error}
title="Unable to load companies"
description="The companies list is unavailable right now. Try again when the API is reachable."
onRetry={companiesResource.reload}
/>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
{renderCompanyMeta(t("companiesSource"), c.source)}
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
</Box>
</Stack>
</Paper>
))}
{companies.length === 0 ? (
<Typography sx={{ py: 2, textAlign: "center" }}>
{t("companiesEmpty")}
</Typography>
) : null}
</Stack>
) : (
<TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("companiesName")}</TableCell>
<TableCell>{t("companiesLocation")}</TableCell>
<TableCell>{t("companiesSource")}</TableCell>
<TableCell>{t("companiesPipeline")}</TableCell>
<TableCell>{t("companiesRecruiter")}</TableCell>
<TableCell>{t("companiesNextContact")}</TableCell>
<TableCell width={1} align="right" />
</TableRow>
</TableHead>
<TableBody>
{companies.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.name}</TableCell>
<TableCell>{c.location ?? ""}</TableCell>
<TableCell>{c.source ?? ""}</TableCell>
<TableCell>{c.pipelineStage ?? ""}</TableCell>
<TableCell>
{c.recruiterName ?? ""}
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
</TableCell>
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
<TableCell align="right">
{!companiesResource.loading && !companiesResource.error ? (
isMobile ? (
<Stack spacing={1.5}>
{companies.map((c) => (
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
<Stack spacing={1.25}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
<Box>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
</Box>
<IconButton size="small" onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{companies.length === 0 && (
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
{renderCompanyMeta(t("companiesSource"), c.source)}
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
</Box>
</Stack>
</Paper>
))}
{companies.length === 0 ? (
<Typography sx={{ py: 2, textAlign: "center" }}>
{t("companiesEmpty")}
</Typography>
) : null}
</Stack>
) : (
<TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
<Table>
<TableHead>
<TableRow>
<TableCell colSpan={7}>
<Typography sx={{ py: 2, textAlign: "center" }}>
{t("companiesEmpty")}
</Typography>
</TableCell>
<TableCell>{t("companiesName")}</TableCell>
<TableCell>{t("companiesLocation")}</TableCell>
<TableCell>{t("companiesSource")}</TableCell>
<TableCell>{t("companiesPipeline")}</TableCell>
<TableCell>{t("companiesRecruiter")}</TableCell>
<TableCell>{t("companiesNextContact")}</TableCell>
<TableCell width={1} align="right" />
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
)}
</TableHead>
<TableBody>
{companies.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.name}</TableCell>
<TableCell>{c.location ?? ""}</TableCell>
<TableCell>{c.source ?? ""}</TableCell>
<TableCell>{c.pipelineStage ?? ""}</TableCell>
<TableCell>
{c.recruiterName ?? ""}
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
</TableCell>
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{companies.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
<Typography sx={{ py: 2, textAlign: "center" }}>
{t("companiesEmpty")}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
)
) : null}
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
<DialogTitle>{t("companiesEdit")}</DialogTitle>
+75 -23
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -22,10 +22,12 @@ import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
interface JobStats {
total: number;
@@ -130,28 +132,58 @@ export default function DashboardView() {
const isMobile = useMediaQuery("(max-width:767.95px)");
const navigate = useNavigate();
const { t } = useI18n();
const [stats, setStats] = useState<JobStats | null>(null);
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null);
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
const [tags, setTags] = useState<TagPoint[]>([]);
const [months, setMonths] = useState<6 | 12 | 24>(12);
const [reminderJobs, setReminderJobs] = useState<ReminderJob[]>([]);
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
const summaryResource = useViewResource(
async () => {
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
api.get<JobStats>("/jobapplications/stats"),
api.get<OverviewAnalytics>("/jobapplications/analytics-overview"),
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }),
]);
useEffect(() => {
api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data));
api.get<OverviewAnalytics>("/jobapplications/analytics-overview").then((r) => setOverview(r.data)).catch(() => setOverview(null));
api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderJobs(Array.isArray(r.data) ? r.data : [])).catch(() => setReminderJobs([]));
}, []);
return {
stats: statsResponse.data,
overview: overviewResponse.data,
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
};
},
{
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
errorMessage: "Unable to load dashboard summary data right now.",
deps: [],
},
);
useEffect(() => {
const params = { months };
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([]));
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([]));
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
}, [months]);
const trendsResource = useViewResource(
async () => {
const params = { months };
const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }),
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }),
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }),
]);
return {
analytics: analyticsResponse.data ?? [],
tags: tagsResponse.data ?? [],
tagTrends: trendsResponse.data,
};
},
{
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
errorMessage: "Unable to load dashboard trends right now.",
deps: [months],
},
);
const stats = summaryResource.data.stats;
const overview = summaryResource.data.overview;
const reminderJobs = summaryResource.data.reminderJobs;
const analytics = trendsResource.data.analytics;
const tags = trendsResource.data.tags;
const tagTrends = trendsResource.data.tagTrends;
const appliedValues = analytics.map((x) => x.applied);
const responseValues = analytics.map((x) => x.responses);
@@ -299,7 +331,23 @@ export default function DashboardView() {
</Box>
</SectionCard>
{prefs.cards ? (
<ViewStateNotice
loading={summaryResource.loading}
error={summaryResource.error}
title="Unable to load dashboard summary"
description="The dashboard summary is unavailable right now."
onRetry={summaryResource.reload}
/>
<ViewStateNotice
loading={trendsResource.loading}
error={trendsResource.error}
title="Unable to load dashboard trends"
description="Charts and trend panels could not reach the API."
onRetry={trendsResource.reload}
compact
/>
{!summaryResource.loading && !summaryResource.error && prefs.cards ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
{metricCards.map((card) => (
<SectionCard key={card.label}>
@@ -322,7 +370,7 @@ export default function DashboardView() {
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
{prefs.activity ? (
{!summaryResource.loading && !summaryResource.error && prefs.activity ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
@@ -364,7 +412,8 @@ export default function DashboardView() {
</SectionCard>
) : null}
<SectionCard>
{!summaryResource.loading && !summaryResource.error ? (
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardResponseSources")}</Typography>
<Stack spacing={1.2}>
@@ -402,9 +451,11 @@ export default function DashboardView() {
</Typography>
</Box>
</SectionCard>
) : null}
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error ? (
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("remindersTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
@@ -432,8 +483,9 @@ export default function DashboardView() {
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
) : null}
{prefs.companies ? (
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
<Stack spacing={1.25}>
@@ -452,7 +504,7 @@ export default function DashboardView() {
</SectionCard>
) : null}
{prefs.skills ? (
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
{tags.length === 0 ? (
@@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { clearAuthToken, decodeJwtPayload, getAuthPersistencePreference, getAuthToken, setAuthToken } from "../auth";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -49,26 +49,19 @@ function loadGoogleScript(): Promise<void> {
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
const { toast } = useToast();
const { t } = useI18n();
const [token, setToken] = useState<string | null>(() => getAuthToken());
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
const actionLabel = !token
const signedIn = Boolean(me?.provider);
const actionLabel = !signedIn
? t("continueWithGoogle")
: me?.provider === "local" && !me?.googleLink?.linked
? t("linkWithGoogle")
: t("signInWithGoogle");
async function refreshMe() {
if (!getAuthToken()) {
setMe(null);
return;
}
try {
const res = await api.get<MeResponse>("/auth/me");
setMe(res.data);
@@ -79,37 +72,19 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
useEffect(() => {
void refreshMe();
}, [token]);
}, []);
useEffect(() => {
if (!token || !isRawGoogleToken) return;
let cancelled = false;
const exchange = async () => {
try {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
if (cancelled) return;
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
setToken(res.data.accessToken);
toast(t("googleSignedIn"), "success");
onSignedIn?.();
} catch {
if (cancelled) return;
clearAuthToken();
setToken(null);
toast(t("googleNotLinkedYet"), "info");
}
};
void exchange();
return () => {
cancelled = true;
};
}, [token, isRawGoogleToken, onSignedIn, toast, t]);
const onAuthChanged = () => { void refreshMe(); };
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
}, []);
useEffect(() => {
const host = hostRef.current;
if (!clientId || !host) return;
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
const shouldRenderButton = !signedIn || (me?.provider === "local" && !me?.googleLink?.linked);
host.replaceChildren();
if (!shouldRenderButton) return;
@@ -126,13 +101,12 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
setWorking(true);
try {
if (me?.provider === "local") {
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential });
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
await refreshMe();
} else {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
setToken(res.data.accessToken);
await api.post("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}
@@ -157,7 +131,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
active = false;
host.replaceChildren();
};
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signedIn, toast, t]);
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
@@ -180,7 +154,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString() })} /> : null}
</Box>
{!token ? (
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{t("googleSignInHint")}
</Typography>
@@ -204,14 +178,15 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{token ? (
{signedIn ? (
<Button
variant="outlined"
onClick={() => {
clearAuthToken();
setToken(null);
setMe(null);
toast(t("signedOut"), "info");
void api.post("/auth/logout").catch(() => undefined).finally(() => {
clearAuthClientState();
setMe(null);
toast(t("signedOut"), "info");
});
}}
>
{t("signOut")}
@@ -239,7 +214,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
) : null}
</Box>
{token && me?.email ? (
{signedIn && me?.email ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("signedInAs", { name: signedInName })}
</Typography>
+42 -10
View File
@@ -41,6 +41,7 @@ import ViewColumnIcon from "@mui/icons-material/ViewColumn";
import SearchIcon from "@mui/icons-material/Search";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { useCompanies } from "../hooks/useCompanies";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import JobDetailsDialog from "./JobDetailsDialog";
@@ -50,6 +51,7 @@ import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
interface PagedResult<T> {
@@ -127,7 +129,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const debouncedLocation = useDebouncedValue(locationFilter, 250);
const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(false);
const [readinessFilter, setReadinessFilter] = useState<"all" | "needs-work" | "interview">("all");
const { companies } = useCompanies();
const { companies, error: companiesError, reload: reloadCompanies } = useCompanies();
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
const [detailsInitialTab, setDetailsInitialTab] = useState(0);
@@ -153,13 +155,25 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
needsFollowUp: needsFollowUpOnly ? true : undefined,
}), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly]);
const jobsResource = useViewResource(
async () => {
const r = await api.get<PagedResult<JobApplication>>("/jobapplications", { params });
return r.data;
},
{
initialData: { items: [], total: 0, page: 1, pageSize },
errorMessage: "Unable to load jobs right now.",
deps: [params, refreshToken, reloadToken, pageSize],
},
);
useEffect(() => {
api.get<PagedResult<JobApplication>>("/jobapplications", { params }).then((r) => {
setJobs(r.data.items);
setTotal(r.data.total);
setJobs(jobsResource.data.items);
setTotal(jobsResource.data.total);
if (!jobsResource.error) {
setSelectedIds([]);
});
}, [params, refreshToken, reloadToken]);
}
}, [jobsResource.data, jobsResource.error]);
useEffect(() => {
const paramsSearch = new URLSearchParams(location.search);
@@ -460,6 +474,22 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
))}
</Menu>
<ViewStateNotice
error={jobsResource.error}
title={mode === "trash" ? "Unable to load trash" : "Unable to load jobs"}
description={mode === "trash" ? "The deleted-jobs view cannot reach the API right now." : "The jobs list cannot reach the API right now."}
onRetry={jobsResource.reload}
/>
{companiesError ? (
<ViewStateNotice
error={companiesError}
title="Unable to load company filters"
description="Company filter data is unavailable right now."
onRetry={reloadCompanies}
compact
/>
) : null}
<Paper sx={{ mt: 2, overflow: "hidden" }}>
{isMobile ? (
<Stack spacing={1.25} sx={{ p: 1.25 }}>
@@ -467,7 +497,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
</Box>
{filteredJobs.map((job) => {
{jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : null}
{!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => {
const toneName = statusTone(job.status);
const primaryAction = getPrimaryAction(job);
const actionSignals = getActionSignals(job);
@@ -596,7 +627,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Paper>
);
})}
{filteredJobs.length === 0 ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
</Stack>
) : (
<Box sx={{ overflowX: "auto" }}>
@@ -615,7 +646,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</TableRow>
</TableHead>
<TableBody>
{filteredJobs.map((job) => {
{jobsResource.loading ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography></TableCell></TableRow> : null}
{!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => {
const open = expanded.includes(job.id);
const toneName = statusTone(job.status);
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
@@ -690,7 +722,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</React.Fragment>
);
})}
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
</TableBody>
</Table>
</Box>
+112 -91
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import {
Box,
@@ -15,8 +15,10 @@ import { alpha, useTheme } from "@mui/material/styles";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
type Status = (typeof STATUSES)[number];
@@ -57,14 +59,23 @@ function statusLabel(t: (key: any, params?: any) => string, status: Status): str
export default function KanbanBoard() {
const theme = useTheme();
const { t } = useI18n();
const [jobs, setJobs] = useState<JobApplication[]>([]);
const [dragJobId, setDragJobId] = useState<number | null>(null);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuJobId, setMenuJobId] = useState<number | null>(null);
useEffect(() => {
api.get<JobApplication[]>("/jobapplications/board").then((r) => setJobs(r.data));
}, []);
const jobsResource = useViewResource(
async () => {
const response = await api.get<JobApplication[]>("/jobapplications/board");
return response.data;
},
{
initialData: [],
errorMessage: "Unable to load the board right now.",
deps: [],
},
);
const jobs = jobsResource.data;
const groups = useMemo(() => {
const map = new Map<string, JobApplication[]>();
@@ -85,12 +96,12 @@ export default function KanbanBoard() {
if (!dragJobId) return;
setDragJobId(null);
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
};
const setStatus = async (id: number, status: Status) => {
await api.patch(`/jobapplications/${id}/status`, { status });
setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
};
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
@@ -101,92 +112,102 @@ export default function KanbanBoard() {
{t("kanbanHint")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
{STATUSES.map((status) => {
const c = toneColor(theme, status);
const list = groups.get(status) ?? [];
return (
<Paper
key={status}
onDragOver={(e) => e.preventDefault()}
onDrop={() => void onDropTo(status)}
sx={{
p: 1.5,
borderRadius: 3,
minHeight: 220,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`,
background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06),
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
{statusLabel(t, status)}
</Typography>
<Chip
size="small"
label={list.length}
sx={{
fontWeight: 800,
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9),
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12),
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
}}
/>
</Box>
<ViewStateNotice
loading={jobsResource.loading}
error={jobsResource.error}
title="Unable to load the kanban board"
description="The board could not reach the API."
onRetry={jobsResource.reload}
/>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{list.map((j) => (
<Card
key={j.id}
draggable
onDragStart={() => setDragJobId(j.id)}
onDragEnd={() => setDragJobId(null)}
sx={{
cursor: "grab",
borderRadius: 3,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)",
backdropFilter: "blur(8px)",
color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a",
}}
>
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}>
{j.company?.name ?? ""}
</Typography>
<IconButton
size="small"
sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }}
onClick={(e) => {
e.stopPropagation();
setMenuJobId(j.id);
setMenuAnchor(e.currentTarget);
}}
>
<MoreHorizIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}>
{j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} />
{j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null}
</Box>
</CardContent>
</Card>
))}
{list.length === 0 && (
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
{t("kanbanDropHere")}
{!jobsResource.loading && !jobsResource.error ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
{STATUSES.map((status) => {
const c = toneColor(theme, status);
const list = groups.get(status) ?? [];
return (
<Paper
key={status}
onDragOver={(e) => e.preventDefault()}
onDrop={() => void onDropTo(status)}
sx={{
p: 1.5,
borderRadius: 3,
minHeight: 220,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`,
background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06),
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
{statusLabel(t, status)}
</Typography>
)}
</Box>
</Paper>
);
})}
</Box>
<Chip
size="small"
label={list.length}
sx={{
fontWeight: 800,
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9),
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12),
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
}}
/>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{list.map((j) => (
<Card
key={j.id}
draggable
onDragStart={() => setDragJobId(j.id)}
onDragEnd={() => setDragJobId(null)}
sx={{
cursor: "grab",
borderRadius: 3,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)",
backdropFilter: "blur(8px)",
color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a",
}}
>
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}>
{j.company?.name ?? ""}
</Typography>
<IconButton
size="small"
sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }}
onClick={(e) => {
e.stopPropagation();
setMenuJobId(j.id);
setMenuAnchor(e.currentTarget);
}}
>
<MoreHorizIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}>
{j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} />
{j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null}
</Box>
</CardContent>
</Card>
))}
{list.length === 0 && (
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
{t("kanbanDropHere")}
</Typography>
)}
</Box>
</Paper>
);
})}
</Box>
) : null}
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
+53 -37
View File
@@ -1,13 +1,15 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
type ReminderGroups = {
missingCv: JobApplication[];
@@ -41,26 +43,27 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
});
return (
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}></span> {j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
<Chip size="small" label={j.status} variant="outlined" />
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}></span> {j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
<Chip size="small" label={j.status} variant="outlined" />
</Box>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
</Box>
</Paper>
)})}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
</Box>
</Paper>
);
})}
</Box>
);
}
@@ -69,17 +72,20 @@ export default function RemindersView() {
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useI18n();
const [items, setItems] = useState<JobApplication[]>([]);
const load = async () => {
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
setItems(res.data);
};
useEffect(() => {
void load();
}, []);
const remindersResource = useViewResource(
async () => {
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
return Array.isArray(res.data) ? res.data : [];
},
{
initialData: [],
errorMessage: "Unable to load reminders right now.",
deps: [],
},
);
const items = remindersResource.data;
const grouped = useMemo(() => groupItems(items), [items]);
const openJob = (job: JobApplication) => {
@@ -91,7 +97,7 @@ export default function RemindersView() {
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success");
await load();
await remindersResource.reload();
} catch {
toast(t("remindersFollowUpFailed"), "error");
}
@@ -104,14 +110,24 @@ export default function RemindersView() {
{t("remindersSubtitle")}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ViewStateNotice
loading={remindersResource.loading}
error={remindersResource.error}
title="Unable to load reminders"
description="The reminders view cannot reach the API right now."
onRetry={remindersResource.reload}
/>
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
</Box>
{!remindersResource.loading && !remindersResource.error ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
</Box>
) : null}
<Divider sx={{ my: 2 }} />
<Typography variant="caption" sx={{ color: "text.secondary" }}>
@@ -1,9 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import { Box, Button, Paper, TextField, Typography } from "@mui/material";
import { api } from "../api";
import { getAuthToken } from "../auth";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
@@ -20,7 +19,6 @@ export default function UserManagementCard() {
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const { t } = useI18n();
const token = getAuthToken();
const [supported, setSupported] = useState<boolean | null>(null);
const [users, setUsers] = useState<UserDto[]>([]);
@@ -30,8 +28,6 @@ export default function UserManagementCard() {
const [newPassword, setNewPassword] = useState("");
const [newIsAdmin, setNewIsAdmin] = useState(false);
const canRender = useMemo(() => Boolean(token), [token]);
async function load() {
setLoading(true);
try {
@@ -52,17 +48,15 @@ export default function UserManagementCard() {
}
useEffect(() => {
if (!canRender) {
setSupported(null);
setUsers([]);
return;
}
void load();
const onAuthChanged = () => { void load(); };
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canRender]);
}, []);
if (!canRender) return null;
if (supported === false) return null;
if (supported === null) return null;
return (
<Paper sx={{ mt: 2, p: 2 }}>
@@ -0,0 +1,45 @@
import React from "react";
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
import type { ViewResourceError } from "../hooks/useViewResource";
type Props = {
loading?: boolean;
error?: ViewResourceError | null;
title: string;
description?: string;
retryLabel?: string;
onRetry?: () => void | Promise<void>;
compact?: boolean;
};
export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel = "Retry", onRetry, compact = false }: Props) {
if (loading) {
return (
<Box sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
<CircularProgress size={compact ? 24 : 28} />
</Box>
);
}
if (!error) return null;
const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error";
return (
<Alert
severity={severity}
sx={{
mb: compact ? 1.5 : 2,
alignItems: "flex-start",
borderRadius: 3,
}}
action={error.retryable && onRetry ? <Button color="inherit" size="small" onClick={() => void onRetry()}>{retryLabel}</Button> : undefined}
>
<Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography>
{description ? <Typography variant="body2">{description}</Typography> : null}
{error.message ? <Typography variant="body2" sx={{ mt: 0.5 }}>{error.message}</Typography> : null}
</Alert>
);
}
@@ -209,7 +209,7 @@ test('reminders open action routes tailored-cv gaps into the tailored cv workspa
await waitFor(() => {
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
});
expect(await screen.findByText(/build the package here, then save the working copy back onto this job/i)).toBeInTheDocument();
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
});
test('job table urgency signals and next actions route into the shared workspace flow', async () => {
@@ -231,6 +231,6 @@ test('job table urgency signals and next actions route into the shared workspace
await waitFor(() => {
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
});
expect(await screen.findByText(/build the package here, then save the working copy back onto this job/i)).toBeInTheDocument();
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
expect(await screen.findByText(/platform work/i)).toBeInTheDocument();
});
+30 -19
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { Company } from "../types";
import { useViewResource, ViewResourceError } from "./useViewResource";
let cachedCompanies: Company[] | null = null;
let inflight: Promise<Company[]> | null = null;
@@ -10,7 +12,7 @@ async function fetchCompanies(): Promise<Company[]> {
if (inflight) return inflight;
inflight = api
.get<Company[]>("/companies")
.get<Company[]>('/companies')
.then((r) => {
cachedCompanies = r.data;
return r.data;
@@ -26,25 +28,34 @@ export function invalidateCompaniesCache() {
cachedCompanies = null;
}
export function useCompanies() {
const [companies, setCompanies] = useState<Company[]>(cachedCompanies ?? []);
const [loading, setLoading] = useState(!cachedCompanies);
export function useCompanies(): {
companies: Company[];
loading: boolean;
refreshing: boolean;
error: ViewResourceError | null;
reload: () => Promise<void>;
} {
const [cacheBust, setCacheBust] = useState(0);
const resource = useViewResource(fetchCompanies, {
initialData: cachedCompanies ?? [],
errorMessage: 'Unable to load companies right now.',
deps: [cacheBust],
});
useEffect(() => {
let mounted = true;
setLoading(!cachedCompanies);
fetchCompanies()
.then((c) => {
if (mounted) setCompanies(c);
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, []);
if (!resource.error) {
cachedCompanies = resource.data;
}
}, [resource.data, resource.error]);
return { companies, loading };
return {
companies: resource.data,
loading: resource.loading,
refreshing: resource.refreshing,
error: resource.error,
reload: async () => {
invalidateCompaniesCache();
setCacheBust((value) => value + 1);
},
};
}
@@ -0,0 +1,99 @@
import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { getApiErrorMessage } from "../api";
export type ViewResourceErrorKind = "unauthorized" | "unavailable" | "error";
export type ViewResourceError = {
kind: ViewResourceErrorKind;
message: string;
retryable: boolean;
status?: number;
};
export type ViewResourceState<T> = {
data: T;
loading: boolean;
refreshing: boolean;
error: ViewResourceError | null;
hasLoaded: boolean;
reload: () => Promise<void>;
setData: Dispatch<SetStateAction<T>>;
};
function normalizeError(error: any, fallback: string): ViewResourceError {
const status = error?.response?.status as number | undefined;
if (status === 401 || status === 403) {
return {
kind: "unauthorized",
message: getApiErrorMessage(error, fallback),
retryable: false,
status,
};
}
if (!status || status >= 500) {
return {
kind: "unavailable",
message: getApiErrorMessage(error, fallback),
retryable: true,
status,
};
}
return {
kind: "error",
message: getApiErrorMessage(error, fallback),
retryable: true,
status,
};
}
export function useViewResource<T>(
load: () => Promise<T>,
options: {
initialData: T;
errorMessage: string;
deps?: DependencyList;
enabled?: boolean;
},
): ViewResourceState<T> {
const { initialData, errorMessage, deps = [], enabled = true } = options;
const [data, setData] = useState<T>(initialData);
const [loading, setLoading] = useState(enabled);
const [refreshing, setRefreshing] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [error, setError] = useState<ViewResourceError | null>(null);
const reload = useCallback(async () => {
if (!enabled) return;
setLoading((current) => !hasLoaded && current);
setRefreshing(hasLoaded);
try {
const next = await load();
setData(next);
setError(null);
setHasLoaded(true);
} catch (err: any) {
setError(normalizeError(err, errorMessage));
setHasLoaded(true);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [enabled, errorMessage, hasLoaded, load]);
useEffect(() => {
if (!enabled) {
setLoading(false);
return;
}
setLoading(!hasLoaded);
void reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, reload, ...deps]);
return useMemo(() => ({ data, loading, refreshing, error, hasLoaded, reload, setData }), [data, error, hasLoaded, loading, refreshing, reload]);
}
+6 -4
View File
@@ -52,8 +52,9 @@ describe('LoginPage', () => {
consoleErrorSpy.mockRestore();
});
it('stores auth token in session storage when remember me is unchecked', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { accessToken: 'header.payload.sig', tokenType: 'Bearer' } } as any);
it('posts remember-me preference without storing an auth token in browser storage', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
renderLoginPage();
await screen.findByLabelText('Email');
@@ -63,9 +64,10 @@ describe('LoginPage', () => {
await userEvent.click(screen.getByLabelText('Remember me'));
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2', rememberMe: false }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
expect(window.sessionStorage.getItem('authToken')).toBe('header.payload.sig');
expect(window.sessionStorage.getItem('authToken')).toBeNull();
expect(window.localStorage.getItem('authToken')).toBeNull();
expect(window.localStorage.getItem('authTokenPersistence')).toBe('session');
});
+4 -4
View File
@@ -5,7 +5,7 @@ import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, T
import { useLocation, useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { getRememberMePref, setAuthToken, setRememberMePref } from "../auth";
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -44,9 +44,9 @@ 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 });
setRememberMePref(rememberMe);
setAuthToken(res.data.accessToken, rememberMe ? "local" : "session");
await api.post(url, { email, password, rememberMe });
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
toast(t("signedIn"), "success");
navigate(nextPath, { replace: true });
} catch (e: any) {
+53 -10
View File
@@ -42,6 +42,12 @@ type ExtractionRun = {
errorMessage?: string;
};
type QueuedCvRunResponse = {
queued: boolean;
extractionRunId: number;
status: string;
};
type JobListResponse = {
items: JobApplication[];
total: number;
@@ -199,6 +205,7 @@ export default function ProfilePage() {
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
@@ -225,10 +232,12 @@ export default function ProfilePage() {
const [reprocessingCv, setReprocessingCv] = useState(false);
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const runStatusRef = useRef<Record<number, string>>({});
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const loadProfile = useCallback(async () => {
setLoading(true);
try {
const [profileResponse, runsResponse, jobsResponse] = await Promise.all([
api.get<MeResponse>("/auth/me"),
@@ -247,10 +256,14 @@ export default function ProfilePage() {
setExtractionRuns(runsResponse.data ?? []);
setSavedJobs(jobsResponse.data?.items ?? []);
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
} catch {
setLoadError(null);
} catch (error: any) {
setMe(null);
setExtractionRuns([]);
setSavedJobs([]);
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
} finally {
setLoading(false);
}
}, []);
@@ -258,6 +271,31 @@ export default function ProfilePage() {
void loadProfile();
}, [loadProfile]);
useEffect(() => {
const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running");
if (activeRuns.length === 0) return;
const timer = window.setInterval(() => {
void loadProfile();
}, 4000);
return () => window.clearInterval(timer);
}, [extractionRuns, loadProfile]);
useEffect(() => {
const previous = runStatusRef.current;
for (const run of extractionRuns) {
const prior = previous[run.id];
if ((prior === "queued" || prior === "running") && run.status === "applied") {
toast(`CV ${run.trigger} completed.`, "success");
}
if ((prior === "queued" || prior === "running") && run.status === "failed") {
toast(run.errorMessage || `CV ${run.trigger} failed.`, "error");
}
previous[run.id] = run.status;
}
}, [extractionRuns, toast]);
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
const isLocal = me?.provider === "local";
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
@@ -305,6 +343,13 @@ export default function ProfilePage() {
}}
/>
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
Unable to load profile.
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
</Alert>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
@@ -413,7 +458,7 @@ export default function ProfilePage() {
formData.append("file", file);
setUploadingCv(true);
try {
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
toast(t("profileCvUploaded"), "success");
} catch (e: any) {
@@ -432,10 +477,9 @@ export default function ProfilePage() {
onClick={async () => {
setRebuildingCv(true);
try {
const res = await api.post<{ text?: string }>("/profile-cv/rebuild");
if (res.data?.text) setProfileCvText(res.data.text);
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
await loadProfile();
toast(t("profileCvRebuilt"), "success");
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
} finally {
@@ -451,10 +495,9 @@ export default function ProfilePage() {
onClick={async () => {
setImprovingCv(true);
try {
const res = await api.post<{ text?: string }>("/profile-cv/improve");
if (res.data?.text) setProfileCvText(res.data.text);
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
await loadProfile();
toast(t("profileCvImproved"), "success");
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
} finally {
@@ -470,9 +513,9 @@ export default function ProfilePage() {
onClick={async () => {
setReprocessingCv(true);
try {
await api.post("/profile-cv/reprocess");
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
await loadProfile();
toast(t("profileCvReprocessed"), "success");
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
} finally {
+2 -19
View File
@@ -1,26 +1,9 @@
import { decodeJwtPayload, getAuthToken } from "./auth";
import { getAuthUserKey } from "./auth";
export type ThemeModePref = "system" | "light" | "dark";
export function getUserKeyFromToken(): string {
const token = getAuthToken();
if (!token) return "anon";
const payload = decodeJwtPayload(token) ?? {};
const candidates = [
payload.sub,
payload.nameid,
payload["nameid"],
payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"],
payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid"],
];
for (const c of candidates) {
if (typeof c === "string" && c.trim().length > 0) return c.trim();
}
return "anon";
return getAuthUserKey();
}
function k(base: string) {