feat/Update_Controllers_to_Allow_for_Premium_Membership

This commit is contained in:
cesnimda
2026-08-03 09:17:28 +02:00
parent de937d25dc
commit c3f4a57195
187 changed files with 26062 additions and 991 deletions
+34 -7
View File
@@ -33,10 +33,12 @@ import LandingPage from "./views/LandingPage";
import ForgotPasswordPage from "./views/ForgotPasswordPage";
import ResetPasswordPage from "./views/ResetPasswordPage";
import VerifyEmailPage from "./views/VerifyEmailPage";
import MicrosoftLegacyRelinkPage from "./views/MicrosoftLegacyRelinkPage";
import RouteErrorPage from "./views/RouteErrorPage";
import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import { AccountPlanProvider } from "./accountPlan";
import AppShell, { NavItem } from "./layout/AppShell";
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
@@ -60,6 +62,7 @@ const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./views/GmailReviewPage"));
const OperationsPage = lazy(() => import("./views/OperationsPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
@@ -73,6 +76,8 @@ type MeResponse = {
displayName?: string;
avatarImageDataUrl?: string;
roles?: string[];
plan?: "free" | "pro";
entitlements?: { ai?: boolean; proThemes?: boolean };
};
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
@@ -80,6 +85,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/discover")) return [t("home"), "Discover jobs"];
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
if (path.startsWith("/operations")) return [t("home"), "Operations"];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")];
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
@@ -99,6 +105,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
function titleFor(path: string, t: (k: any) => string): string {
if (path === "/dashboard") return t("dashboard");
if (path.startsWith("/reminders")) return t("reminders");
if (path.startsWith("/operations")) return "Operations";
if (path.startsWith("/discover")) return "Discover jobs";
if (path.startsWith("/jobs")) return t("jobApplications");
if (path.startsWith("/kanban")) return t("kanbanBoard");
@@ -147,7 +154,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const [isAdmin, setIsAdmin] = useState(false);
const [me, setMe] = useState<MeResponse | null>(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notifCount, setNotifCount] = useState(0);
const [reminderCount, setReminderCount] = useState(0);
const [notificationCount, setNotificationCount] = useState(0);
const path = location.pathname;
const isJobs = path.startsWith("/jobs");
@@ -195,12 +203,24 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
}, []);
useEffect(() => {
const load = () => {
api.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setNotifCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setNotifCount(0));
api.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setReminderCount(0));
};
load();
const id = window.setInterval(load, 60000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
const load = () => {
api.get<{ count: number }>("/notifications/unread-count").then((r) => setNotificationCount(Math.max(0, Number(r.data?.count) || 0))).catch(() => setNotificationCount(0));
};
load();
const id = window.setInterval(load, 60000);
window.addEventListener("notifications-changed", load);
return () => {
window.clearInterval(id);
window.removeEventListener("notifications-changed", load);
};
}, []);
useEffect(() => {
const onAuthChanged = () => {
setAuthResolved(false);
@@ -247,7 +267,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("manage") },
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/discover", label: "Discover jobs", icon: <SearchIcon fontSize="small" />, section: t("manage") },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
@@ -304,7 +324,11 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
);
return (
<>
<AccountPlanProvider value={{
plan: me?.plan === "pro" ? "pro" : "free",
canUseAi: Boolean(me?.entitlements?.ai),
canUseProThemes: Boolean(me?.entitlements?.proThemes),
}}>
<AppShell
pageTitle={pageTitle}
pageSubtitle={pageSubtitle}
@@ -316,8 +340,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
onToggleDrawer={setMobileDrawerOpen}
onNavigate={(to) => { setMobileDrawerOpen(false); navigate(to); }}
user={{ email: me?.email, userName: me?.userName, displayName: me?.displayName || fullName || undefined, avatarImageDataUrl: me?.avatarImageDataUrl, roleLabel: isAdmin ? t("superAdmin") : t("user") }}
notificationsCount={notifCount}
onOpenNotifications={() => navigate("/reminders")}
notificationsCount={notificationCount}
onOpenNotifications={() => navigate("/operations")}
onOpenSettings={() => navigate("/settings")}
onOpenProfile={() => navigate("/profile")}
onSignOut={() => { void api.post("/auth/logout").catch(() => undefined).finally(() => { clearAuthClientState(); navigate("/login"); }); }}
@@ -330,6 +354,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/discover" element={<JobDiscoveryPage />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
@@ -354,7 +379,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
</Suspense>
</>
</AccountPlanProvider>
);
}
@@ -395,6 +420,8 @@ export default function App() {
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/verify-email", element: <VerifyEmailPage />, errorElement: <RouteErrorPage /> },
{ path: "/confirm-email-change", element: <VerifyEmailPage emailChange />, errorElement: <RouteErrorPage /> },
{ path: "/microsoft-legacy-relink", element: <MicrosoftLegacyRelinkPage />, errorElement: <RouteErrorPage /> },
{ path: "/cv/:slug", element: <PublicCvPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
+19
View File
@@ -0,0 +1,19 @@
import React, { createContext, useContext } from "react";
export type AccountPlan = {
plan: "free" | "pro";
canUseAi: boolean;
canUseProThemes: boolean;
};
// Routed application screens always receive the server-backed value from App.
// The permissive default keeps isolated component previews usable; the API remains authoritative.
const AccountPlanContext = createContext<AccountPlan>({ plan: "pro", canUseAi: true, canUseProThemes: true });
export function AccountPlanProvider({ value, children }: { value: AccountPlan; children: React.ReactNode }) {
return <AccountPlanContext.Provider value={value}>{children}</AccountPlanContext.Provider>;
}
export function useAccountPlan() {
return useContext(AccountPlanContext);
}
+7 -7
View File
@@ -14,7 +14,8 @@ jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ t: (key: string, par
settingsUsageTokens: "{used} of {limit} estimated tokens",
settingsUsageStorage: "{used} of {limit} attachment storage",
settingsUsageReset: "AI limits reset at the start of each calendar month.",
settingsBillingUpgrade: "Upgrade to Premium",
settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.",
settingsBillingUpgrade: "Upgrade to Pro",
};
return Object.entries(params ?? {}).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), messages[key] ?? key);
} }) }));
@@ -24,8 +25,8 @@ test("shows monthly AI call and token limits", async () => {
data: url === "/billing/status" ? { enabled: true, canCheckout: true, canManage: false } : {
currentMonth: { calls: 4, estimatedTokens: 12000 },
plan: "free",
monthlyCallLimit: 25,
monthlyTokenLimit: 100000,
monthlyCallLimit: 0,
monthlyTokenLimit: 0,
storageUsedBytes: 50000000,
storageLimitBytes: 250000000,
},
@@ -33,9 +34,8 @@ test("shows monthly AI call and token limits", async () => {
render(<AiUsageCard />);
expect(await screen.findByText("4 of 25 generations this month")).toBeInTheDocument();
expect(screen.getByText("12,000 of 100,000 estimated tokens")).toBeInTheDocument();
expect(await screen.findByText(/Free plan includes core job tracking/)).toBeInTheDocument();
expect(screen.getByText("50.0 MB of 250.0 MB attachment storage")).toBeInTheDocument();
expect(screen.getByLabelText("Monthly AI generations used")).toHaveAttribute("aria-valuenow", "16");
expect(screen.getByRole("button", { name: "Upgrade to Premium" })).toBeInTheDocument();
expect(screen.queryByLabelText("Monthly AI generations used")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Upgrade to Pro" })).toBeInTheDocument();
});
@@ -6,6 +6,7 @@ import AiWorkspacePanel from "./components/AiWorkspacePanel";
import Markdown from "./components/Markdown";
import { ToastProvider } from "./toast";
import { api } from "./api";
import { AccountPlanProvider } from "./accountPlan";
jest.mock("./api", () => ({
api: {
@@ -67,6 +68,20 @@ test("cover letter sends the selected mode", async () => {
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", mode: "professional" })));
});
test("free users see a locked state and cannot start generation", async () => {
render(
<ToastProvider>
<AccountPlanProvider value={{ plan: "free", canUseAi: false, canUseProThemes: false }}>
<AiWorkspacePanel jobId={7} />
</AccountPlanProvider>
</ToastProvider>,
);
expect(await screen.findByText(/AI generation is a Pro feature/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Pro required" })).toBeDisabled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("Markdown renders headings, bold, and bullet lists", () => {
render(<Markdown text={"# Title\n**Strengths**\n- one\n- two\nplain *word*"} />);
expect(screen.getByText("Title")).toBeInTheDocument();
@@ -0,0 +1,37 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { CssVarsProvider } from "@mui/material/styles";
import { I18nProvider } from "./i18n/I18nProvider";
import AppShell from "./layout/AppShell";
import { getTheme } from "./theme";
test("notification bell exposes its unread count and keyboard-accessible action", () => {
const open = jest.fn();
render(
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
<I18nProvider>
<AppShell
pageTitle="Dashboard"
breadcrumbs={["Home"]}
pathname="/dashboard"
nav={[]}
navBottom={[]}
onNavigate={() => undefined}
onToggleDrawer={() => undefined}
drawerOpen={false}
notificationsCount={3}
onOpenNotifications={open}
>
<div>Content</div>
</AppShell>
</I18nProvider>
</CssVarsProvider>,
);
const bell = screen.getByRole("button", { name: "Notifications" });
expect(screen.getByText("3")).toBeInTheDocument();
fireEvent.click(bell);
expect(open).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,11 @@
import fs from "node:fs";
import path from "node:path";
test("application panels use distinct timeline, interview board, and generated brief routes", () => {
const workspace = fs.readFileSync(path.join(process.cwd(), "src/applicationWorkspace.ts"), "utf8");
const details = fs.readFileSync(path.join(process.cwd(), "src/components/JobDetailsDialog.tsx"), "utf8");
expect(workspace).toContain("/timeline");
expect(workspace).toContain("/interview-prep`");
expect(details).toContain("/interview-prep/brief`");
});
@@ -28,6 +28,7 @@ import {
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useAccountPlan } from "../accountPlan";
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
import { api, getApiErrorMessage } from "../api";
@@ -104,6 +105,7 @@ function normalizeLanguage(value?: string | null) {
}
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
const { canUseAi } = useAccountPlan();
const { toast } = useToast();
const { t, language } = useI18n();
@@ -351,7 +353,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
dateApplied,
});
if (response.data?.id && generateTailoredCv) {
if (response.data?.id && generateTailoredCv && canUseAi) {
try {
await api.post(`/jobapplications/${response.data.id}/generate-tailored-cv-draft`);
} catch (error: any) {
@@ -608,8 +610,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
</Typography>
</Box>
{activeStep === 2 ? <>
<FormControlLabel control={<Checkbox checked={generateTailoredCv} onChange={(event) => setGenerateTailoredCv(event.target.checked)} />} label="Generate a tailored CV draft after creating this job" />
<Typography variant="caption" sx={{ color: "text.secondary", mt: -1 }}>Uses your reviewed Career Profile and keeps the result as an editable suggestion.</Typography>
<FormControlLabel control={<Checkbox disabled={!canUseAi} checked={generateTailoredCv && canUseAi} onChange={(event) => setGenerateTailoredCv(event.target.checked)} />} label={canUseAi ? "Generate a tailored CV draft after creating this job" : "Tailored CV generation requires Pro"} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: -1 }}>{canUseAi ? "Uses your reviewed Career Profile and keeps the result as an editable suggestion." : "Create and track the job normally; no AI operation will be started."}</Typography>
{uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))}
</> : null}
{activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : null}
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from "react";
import { Alert, Box, Button, FormControlLabel, Paper, Skeleton, Switch, Typography } from "@mui/material";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { useToast } from "../toast";
type AiSettings = {
enabled: boolean;
externalProcessingAllowed: boolean;
externalProcessingAvailable: boolean;
effectiveExternalProcessing: boolean;
provider: string;
};
export default function AiPrivacySettingsCard() {
const { t } = useI18n();
const { toast } = useToast();
const [settings, setSettings] = useState<AiSettings | null>(null);
const [failed, setFailed] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let active = true;
api.get<AiSettings>("/ai/settings")
.then((response) => { if (active) setSettings(response.data); })
.catch(() => { if (active) setFailed(true); });
return () => { active = false; };
}, []);
const save = async () => {
if (!settings) return;
setSaving(true);
setFailed(false);
try {
const response = await api.put<AiSettings>("/ai/settings", {
enabled: settings.enabled,
externalProcessingAllowed: settings.externalProcessingAllowed,
});
setSettings(response.data);
toast(t("settingsAiSaved"), "success");
} catch {
setFailed(true);
} finally {
setSaving(false);
}
};
return (
<Paper sx={{ p: 2.5, borderRadius: 4, border: "none" }}>
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{t("settingsAiPrivacyTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{t("settingsAiPrivacyBody")}</Typography>
{failed ? <Alert severity="error" sx={{ mb: 1.5 }}>{t("settingsAiPrivacyUnavailable")}</Alert> : null}
{!settings ? <Skeleton variant="rounded" height={96} /> : <Box sx={{ display: "grid", gap: 1 }}>
<FormControlLabel
control={<Switch checked={settings.enabled} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} />}
label={t("settingsAiEnabled")}
/>
<FormControlLabel
control={<Switch
checked={settings.externalProcessingAllowed}
disabled={!settings.externalProcessingAvailable && !settings.externalProcessingAllowed}
onChange={(event) => setSettings({ ...settings, externalProcessingAllowed: event.target.checked })}
/>}
label={t("settingsAiExternalAllowed")}
/>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{settings.externalProcessingAvailable
? t("settingsAiExternalAvailable")
: t("settingsAiExternalLocalOnly")}
</Typography>
<Box><Button variant="contained" disabled={saving} onClick={() => void save()}>{t("settingsAiSave")}</Button></Box>
</Box>}
</Paper>
);
}
@@ -54,8 +54,8 @@ export default function AiUsageCard() {
if (failed) return <Alert severity="warning">{t("settingsUsageUnavailable")}</Alert>;
if (!usage) return <Skeleton variant="rounded" height={150} />;
const callsPercent = Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100);
const tokensPercent = Math.min(100, usage.currentMonth.estimatedTokens / usage.monthlyTokenLimit * 100);
const callsPercent = usage.monthlyCallLimit > 0 ? Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100) : 0;
const tokensPercent = usage.monthlyTokenLimit > 0 ? Math.min(100, usage.currentMonth.estimatedTokens / usage.monthlyTokenLimit * 100) : 0;
const storagePercent = Math.min(100, usage.storageUsedBytes / usage.storageLimitBytes * 100);
return (
@@ -64,14 +64,14 @@ export default function AiUsageCard() {
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{t("settingsUsageTitle")}</Typography>
<Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{t("settingsUsagePlan", { plan: usage.plan })}</Typography>
</Stack>
<Box sx={{ mb: 2 }}>
{usage.monthlyCallLimit === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("settingsUsageNoAi")}</Alert> : <><Box sx={{ mb: 2 }}>
<Typography variant="body2">{t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(), limit: usage.monthlyCallLimit.toLocaleString() })}</Typography>
<LinearProgress variant="determinate" value={callsPercent} aria-label="Monthly AI generations used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box>
<Box>
<Typography variant="body2">{t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(), limit: usage.monthlyTokenLimit.toLocaleString() })}</Typography>
<LinearProgress variant="determinate" value={tokensPercent} aria-label="Monthly AI tokens used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box>
</Box></>}
<Box sx={{ mt: 2 }}>
<Typography variant="body2">{t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes), limit: formatBytes(usage.storageLimitBytes) })}</Typography>
<LinearProgress variant="determinate" value={storagePercent} aria-label="Attachment storage used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
@@ -15,11 +15,13 @@ import { getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import Markdown from "./Markdown";
import { AI_MODULES, AiInteraction, AiUsage, COVER_LETTER_MODES, aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
// Phase 5 — the central AI Workspace for one job application. Every result is a suggestion the user
// reviews and copies; nothing is applied automatically.
export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
const [module, setModule] = useState("job-analysis");
const [mode, setMode] = useState("professional");
const [extra, setExtra] = useState("");
@@ -50,6 +52,7 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
}, [jobId, loadHistory]);
const generate = async () => {
if (!canUseAi) return;
setBusy(true);
setCompareWith(null);
try {
@@ -85,6 +88,9 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 300px" }, gap: 2 }}>
<Stack spacing={2}>
{!canUseAi && (
<Alert severity="info" action={<Button href="/settings" size="small">View Pro</Button>}>AI generation is a Pro feature. Your existing AI history remains available.</Alert>
)}
<Alert severity="info" sx={{ py: 0.5 }}>
AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep.
{provider && <> Provider: <strong>{provider}</strong>.</>}
@@ -114,8 +120,8 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
<TextField label="Extra context (optional)" size="small" fullWidth multiline minRows={2} sx={{ mb: 1.5 }}
placeholder="Anything specific to emphasise…" value={extra} onChange={(e) => setExtra(e.target.value)} />
<Button variant="contained" startIcon={<AutoFixHighIcon />} disabled={busy} onClick={generate}>
{busy ? "Generating…" : "Generate"}
<Button variant="contained" startIcon={<AutoFixHighIcon />} disabled={busy || !canUseAi} onClick={generate}>
{busy ? "Generating…" : canUseAi ? "Generate" : "Pro required"}
</Button>
</Paper>
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
@@ -36,6 +37,7 @@ import GradientButton from "./GradientButton";
import { useI18n } from "../i18n/I18nProvider";
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
import { useAccountPlan } from "../accountPlan";
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
@@ -134,6 +136,7 @@ function serializeTailoredDraft(draft: TailoredCvDraft) {
}
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode, onOpenWorkspace }: Props) {
const { canUseAi } = useAccountPlan();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
@@ -261,7 +264,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}, [open, jobId, tab, tailoredDraftCache]);
useEffect(() => {
if (!open || !jobId || tab !== 4) return;
if (!canUseAi || !open || !jobId || tab !== 4) return;
const cacheKey = `${jobId}:followup:${followUpMode}:${selectedAttachmentCsv || "none"}:${draftReloadToken}`;
const cached = followUpCache.getCached(cacheKey);
if (cached) {
@@ -278,10 +281,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setDraftSubject(r.data.subject);
setDraftBody(r.data.body);
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
}, [canUseAi, open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
useEffect(() => {
if (!open || !jobId || tab !== 5 || candidateFit) return;
if (!canUseAi || !open || !jobId || tab !== 5 || candidateFit) return;
const cacheKey = `${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`;
const cached = candidateFitCache.getCached(cacheKey);
if (cached) {
@@ -294,19 +297,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
candidateFitCache.setCached(cacheKey, r.data);
setCandidateFit(r.data);
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
}, [canUseAi, open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
// Persisted server-side like interview prep (career-workspace-implementation-roadmap.md Phase
// F5); Regenerate is the explicit escape hatch when the job has changed since it was written.
const regenerateCandidateFit = useCallback(() => {
if (!jobId) return;
if (!canUseAi || !jobId) return;
setLoadingCandidateFit(true);
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, r.data);
setCandidateFit(r.data);
toast("Candidate fit regenerated.", "success");
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate candidate fit."), "error")).finally(() => setLoadingCandidateFit(false));
}, [jobId, selectedAttachmentCsv, candidateFitCache, toast]);
}, [canUseAi, jobId, selectedAttachmentCsv, candidateFitCache, toast]);
// Match score is deterministic and cheap: load it on the Candidate Fit tab
// independently of the slow AI narrative so users see the number instantly.
@@ -370,7 +373,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
};
useEffect(() => {
if (!open || !jobId || tab !== 6 || focusPlan) return;
if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return;
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
const cached = focusPlanCache.getCached(cacheKey);
if (cached) {
@@ -383,20 +386,20 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
focusPlanCache.setCached(cacheKey, r.data);
setFocusPlan(r.data);
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
}, [canUseAi, open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
const regenerateFocusPlan = useCallback(() => {
if (!jobId) return;
if (!canUseAi || !jobId) return;
setLoadingFocusPlan(true);
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data);
setFocusPlan(r.data);
toast("Focus plan regenerated.", "success");
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false));
}, [jobId, selectedAttachmentCsv, focusPlanCache, toast]);
}, [canUseAi, jobId, selectedAttachmentCsv, focusPlanCache, toast]);
useEffect(() => {
if (!open || !jobId || tab !== 7 || interviewPrep) return;
if (!canUseAi || !open || !jobId || tab !== 7 || interviewPrep) return;
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
const cached = interviewPrepCache.getCached(cacheKey);
if (cached) {
@@ -405,24 +408,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}
setLoadingInterviewPrep(true);
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep/brief`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
interviewPrepCache.setCached(cacheKey, r.data);
setInterviewPrep(r.data);
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
}, [canUseAi, open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
// Interview prep is now persisted server-side (career-workspace-implementation-roadmap.md
// Phase F5) so it survives tab switches without re-running the AI call. Regenerate is the
// explicit escape hatch for when the underlying job/notes have changed since it was written.
const regenerateInterviewPrep = useCallback(() => {
if (!jobId) return;
if (!canUseAi || !jobId) return;
setLoadingInterviewPrep(true);
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep/brief`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
interviewPrepCache.setCached(`${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`, r.data);
setInterviewPrep(r.data);
toast("Interview prep regenerated.", "success");
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate interview prep."), "error")).finally(() => setLoadingInterviewPrep(false));
}, [jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
}, [canUseAi, jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
useEffect(() => {
setFollowUpDraft(null);
@@ -755,7 +758,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline" sx={{ fontWeight: 700 }}>{t("jobDetailsStrategySnapshot")}</Typography>
<GradientButton size="small" disabled={loadingStrategySnapshot} onClick={async () => {
<GradientButton size="small" disabled={loadingStrategySnapshot || !canUseAi} onClick={async () => {
if (!jobId) return;
setLoadingStrategySnapshot(true);
try {
@@ -772,7 +775,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} finally {
setLoadingStrategySnapshot(false);
}
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : t("jobDetailsGenerateStrategySnapshot")}</GradientButton>
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsGenerateStrategySnapshot") : "Pro required"}</GradientButton>
</Box>
{candidateFit || focusPlan ? (
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 4, backgroundColor: "background.paper", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
@@ -802,7 +805,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}>
<Typography variant="overline">{t("jobDetailsSummaryAndSkills")}</Typography>
<Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => {
<Button size="small" variant="outlined" disabled={refreshingAi || !canUseAi} onClick={async () => {
if (!jobId) return;
if (!(await confirmAction(t("jobDetailsRefreshAiConfirm"), { title: t("jobDetailsRefreshAiTitle"), confirmLabel: t("jobDetailsRefreshAi") }))) return;
setRefreshingAi(true);
@@ -815,7 +818,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} finally {
setRefreshingAi(false);
}
}}>{refreshingAi ? t("jobDetailsRefreshing") : t("jobDetailsRefreshAi")}</Button>
}}>{refreshingAi ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsRefreshAi") : "Pro required"}</Button>
</Box>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
</Box>
@@ -896,7 +899,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}} />
</Button>
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
<Button size="small" variant="outlined" disabled={!canUseAi || loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : canUseAi ? "Generate tailored draft" : "Pro required"}</Button>
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
@@ -1074,7 +1077,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
</Select>
</FormControl>
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
<Button size="small" variant="outlined" disabled={generatingPackage || !canUseAi} onClick={async () => {
if (!jobId) return;
setGeneratingPackage(true);
try {
@@ -1092,7 +1095,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} finally {
setGeneratingPackage(false);
}
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : canUseAi ? t("jobDetailsGeneratePackage") : "Pro required"}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
<Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
</Box>
@@ -1209,6 +1212,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box>
)}
{!canUseAi && [4, 5, 6, 7].includes(tab) && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI assistance on this tab requires Pro. Non-AI job data and manual editing remain available.</Alert>}
<JobInsightTabs
tab={tab}
matchScore={matchScore}
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { Box, Button, Chip, Paper, TextField, Typography } from "@mui/material";
import { PublicClientApplication } from "@azure/msal-browser";
import { api, getApiErrorMessage } from "../api";
@@ -24,9 +24,10 @@ type MeResponse = {
};
let msalInstance: PublicClientApplication | null = null;
function getMsalInstance(clientId: string): PublicClientApplication {
export function getMicrosoftMsalInstance(clientId: string): PublicClientApplication {
const tenant = (process.env.NEXT_PUBLIC_MICROSOFT_TENANT || "common").trim() || "common";
msalInstance ??= new PublicClientApplication({
auth: { clientId, authority: "https://login.microsoftonline.com/common", redirectUri: window.location.origin },
auth: { clientId, authority: `https://login.microsoftonline.com/${tenant}`, redirectUri: window.location.origin },
});
return msalInstance;
}
@@ -37,6 +38,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const [currentPassword, setCurrentPassword] = useState("");
const clientId = (process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
@@ -69,18 +71,25 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
if (!clientId) return;
setWorking(true);
try {
const msal = getMsalInstance(clientId);
const msal = getMicrosoftMsalInstance(clientId);
await msal.initialize();
const result = await msal.loginPopup({ scopes: ["openid", "profile", "email"] });
const idToken = result.idToken;
if (!idToken) throw new Error(t("microsoftAuthFailed"));
if (me?.provider === "local") {
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, currentPassword });
toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success");
await refreshMe();
clearAuthClientState();
setMe(null);
setCurrentPassword("");
toast(t("microsoftSecurityChangeSignInAgain"), "info");
} else {
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string; legacyRelinkRequired?: boolean }>("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
if (res.data?.legacyRelinkRequired) {
toast(t("microsoftLegacyRelinkEmailSent"), "info");
return;
}
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
} else {
@@ -150,12 +159,22 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
{actionLabel}
</Typography>
<Button variant="outlined" disabled={working} onClick={() => void handleSignIn()}>
<Button variant="outlined" disabled={working || (me?.provider === "local" && !currentPassword)} onClick={() => void handleSignIn()}>
{actionLabel}
</Button>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{me?.provider === "local" ? (
<TextField
size="small"
label={t("profileCurrentPassword")}
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
) : null}
{signedIn ? (
<Button
variant="outlined"
@@ -175,12 +194,15 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
<Button
variant="outlined"
color="warning"
disabled={working}
disabled={working || !currentPassword}
onClick={async () => {
try {
await api.delete("/auth/microsoft/link");
await api.delete("/auth/microsoft/link", { data: { currentPassword } });
clearAuthClientState();
setMe(null);
setCurrentPassword("");
toast(t("microsoftUnlinked"), "info");
await refreshMe();
toast(t("microsoftSecurityChangeSignInAgain"), "info");
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("microsoftUnlinkFailed");
toast(String(msg), "error");
@@ -23,6 +23,7 @@ import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard";
import AiUsageCard from "./AiUsageCard";
import AiPrivacySettingsCard from "./AiPrivacySettingsCard";
import { ThemeModePref } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
@@ -198,6 +199,7 @@ export default function SettingsView({
</Box>
</SectionCard>
<AiPrivacySettingsCard />
<AiUsageCard />
<QuickCaptureCard />
<SectionCard title="Connected accounts" subtitle="Manage inbox connections separately from your account and security settings.">
+1 -1
View File
@@ -33,7 +33,7 @@ export type CvTheme = {
photoShape: string;
supportsIcons: boolean;
atsFriendly: boolean;
premium: boolean;
requiresPro: boolean;
available: boolean;
swatches: string[];
};
+60 -2
View File
@@ -137,6 +137,15 @@ export const translations = {
pipelineGroupActive: "Active",
pipelineGroupClosed: "Closed",
settingsTitle: "Settings",
settingsAiPrivacyTitle: "AI privacy",
settingsAiPrivacyBody: "Control AI processing for your account. External processing is off unless both you and an administrator explicitly allow it.",
settingsAiPrivacyUnavailable: "AI privacy settings are temporarily unavailable.",
settingsAiEnabled: "Enable AI features for my account",
settingsAiExternalAllowed: "Allow approved external AI processing",
settingsAiExternalAvailable: "Approved external processing may be used only for Pro tasks when server policy permits it.",
settingsAiExternalLocalOnly: "This deployment is local-only. External processing cannot be enabled.",
settingsAiSave: "Save AI privacy settings",
settingsAiSaved: "AI privacy settings saved.",
settingsUsageTitle: "Account usage",
settingsUsageUnavailable: "Account usage is temporarily unavailable.",
settingsUsagePlan: "{plan} plan",
@@ -144,7 +153,8 @@ export const translations = {
settingsUsageTokens: "{used} of {limit} estimated tokens",
settingsUsageStorage: "{used} of {limit} attachment storage",
settingsUsageReset: "AI limits reset at the start of each calendar month.",
settingsBillingUpgrade: "Upgrade to Premium",
settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.",
settingsBillingUpgrade: "Upgrade to Pro",
settingsBillingManage: "Manage billing",
settingsBillingUnavailable: "Billing is temporarily unavailable.",
settingsSubtitle: "Preferences and admin tools.",
@@ -209,6 +219,14 @@ export const translations = {
profileFirstName: "First name",
profileLastName: "Last name",
profileEmail: "Email",
profileNewEmail: "New email",
profileCurrentEmail: "Current email: {email}",
profileEmailChangePassword: "Password to confirm email change",
profilePendingEmail: "Waiting for confirmation from {email}.",
profileRequestEmailChange: "Send confirmation",
profileEmailChangeSent: "Confirmation sent to the new email address.",
profileEmailChangeCancelled: "Pending email change cancelled.",
profileEmailChangeFailed: "Could not update the email change request.",
profileHeadline: "Profile headline",
profileHeadlineHelp: "Stored only in this browser to personalize your workspace.",
profileMasterCv: "Master CV",
@@ -754,6 +772,13 @@ export const translations = {
microsoftAuthFailed: "Microsoft authentication failed.",
microsoftUnlinked: "Microsoft account unlinked.",
microsoftUnlinkFailed: "Failed to unlink Microsoft account.",
microsoftSecurityChangeSignInAgain: "For security, sign in again after changing a linked account.",
microsoftLegacyRelinkEmailSent: "Check your verified Jobbjakt email to recover this legacy Microsoft link.",
microsoftLegacyRelinkTitle: "Recover Microsoft sign-in",
microsoftLegacyRelinkBody: "Authenticate with the same Microsoft account that started this recovery. Email similarity alone never links accounts.",
microsoftLegacyRelinkMissing: "This recovery link is incomplete or Microsoft sign-in is not configured.",
microsoftLegacyRelinkFailed: "Microsoft account recovery failed.",
microsoftLegacyRelinkSuccess: "Microsoft sign-in was relinked. You can now sign in normally.",
unlinkMicrosoft: "Unlink Microsoft",
signedOut: "Signed out.",
signedInAs: "Signed in as {name}.",
@@ -827,6 +852,10 @@ export const translations = {
verifyEmailSuccess: "Your email has been verified. You can now sign in.",
verifyEmailFailed: "This verification link is invalid or has expired.",
missingVerifyLinkInfo: "Missing user/token in link.",
confirmEmailChangeTitle: "Confirm your new email",
confirmEmailChangeVerifying: "Confirming your new email...",
confirmEmailChangeSuccess: "Your email has changed. Sign in again with the new address.",
missingEmailChangeLinkInfo: "Missing user, email, or token in link.",
jobTableSearch: "Search",
jobTableSearchPlaceholder: "Title, company, notes, messages",
jobTableStatus: "Status",
@@ -1235,6 +1264,15 @@ export const translations = {
pipelineGroupActive: "Aktive",
pipelineGroupClosed: "Avsluttet",
settingsTitle: "Innstillinger",
settingsAiPrivacyTitle: "KI-personvern",
settingsAiPrivacyBody: "Styr KI-behandling for kontoen din. Ekstern behandling er av med mindre både du og en administrator uttrykkelig tillater den.",
settingsAiPrivacyUnavailable: "Innstillinger for KI-personvern er midlertidig utilgjengelige.",
settingsAiEnabled: "Aktiver KI-funksjoner for kontoen min",
settingsAiExternalAllowed: "Tillat godkjent ekstern KI-behandling",
settingsAiExternalAvailable: "Godkjent ekstern behandling kan bare brukes for Pro-oppgaver når serverpolicyen tillater det.",
settingsAiExternalLocalOnly: "Denne installasjonen bruker bare lokal KI. Ekstern behandling kan ikke aktiveres.",
settingsAiSave: "Lagre KI-personvern",
settingsAiSaved: "Innstillinger for KI-personvern er lagret.",
settingsUsageTitle: "Kontobruk",
settingsUsageUnavailable: "Kontobruk er midlertidig utilgjengelig.",
settingsUsagePlan: "{plan}-abonnement",
@@ -1242,7 +1280,8 @@ export const translations = {
settingsUsageTokens: "{used} av {limit} estimerte tokener",
settingsUsageStorage: "{used} av {limit} vedleggslagring",
settingsUsageReset: "KI-grensene nullstilles ved starten av hver kalendermåned.",
settingsBillingUpgrade: "Oppgrader til Premium",
settingsUsageNoAi: "Free-abonnementet inkluderer grunnleggende jobbsporing uten KI. Oppgrader til Pro for KI-funksjoner.",
settingsBillingUpgrade: "Oppgrader til Pro",
settingsBillingManage: "Administrer betaling",
settingsBillingUnavailable: "Betaling er midlertidig utilgjengelig.",
settingsSubtitle: "Preferanser og adminverktøy.",
@@ -1307,6 +1346,14 @@ export const translations = {
profileFirstName: "Fornavn",
profileLastName: "Etternavn",
profileEmail: "E-post",
profileNewEmail: "Ny e-post",
profileCurrentEmail: "Nåværende e-post: {email}",
profileEmailChangePassword: "Passord for å bekrefte e-postendring",
profilePendingEmail: "Venter på bekreftelse fra {email}.",
profileRequestEmailChange: "Send bekreftelse",
profileEmailChangeSent: "Bekreftelse sendt til den nye e-postadressen.",
profileEmailChangeCancelled: "Ventende e-postendring avbrutt.",
profileEmailChangeFailed: "Kunne ikke oppdatere e-postendringen.",
profileHeadline: "Profiloverskrift",
profileHeadlineHelp: "Lagres bare i denne nettleseren for å gjøre arbeidsområdet mer personlig.",
profileMasterCv: "Hoved-CV",
@@ -1852,6 +1899,13 @@ export const translations = {
microsoftAuthFailed: "Microsoft-autentisering mislyktes.",
microsoftUnlinked: "Microsoft-konto koblet fra.",
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
microsoftSecurityChangeSignInAgain: "Av sikkerhetsgrunner må du logge inn igjen etter å ha endret en koblet konto.",
microsoftLegacyRelinkEmailSent: "Sjekk den bekreftede Jobbjakt-e-posten din for å gjenopprette den eldre Microsoft-koblingen.",
microsoftLegacyRelinkTitle: "Gjenopprett Microsoft-innlogging",
microsoftLegacyRelinkBody: "Autentiser med den samme Microsoft-kontoen som startet gjenopprettingen. Lik e-post alene kobler aldri kontoer.",
microsoftLegacyRelinkMissing: "Gjenopprettingslenken er ufullstendig, eller Microsoft-innlogging er ikke konfigurert.",
microsoftLegacyRelinkFailed: "Gjenoppretting av Microsoft-konto mislyktes.",
microsoftLegacyRelinkSuccess: "Microsoft-innloggingen er koblet på nytt. Du kan nå logge inn som vanlig.",
unlinkMicrosoft: "Koble fra Microsoft",
signedOut: "Logget ut.",
signedInAs: "Logget inn som {name}.",
@@ -1925,6 +1979,10 @@ export const translations = {
verifyEmailSuccess: "E-posten din er bekreftet. Du kan nå logge inn.",
verifyEmailFailed: "Denne bekreftelseslenken er ugyldig eller har utløpt.",
missingVerifyLinkInfo: "Mangler bruker/token i lenken.",
confirmEmailChangeTitle: "Bekreft den nye e-posten din",
confirmEmailChangeVerifying: "Bekrefter den nye e-posten din...",
confirmEmailChangeSuccess: "E-posten din er endret. Logg inn igjen med den nye adressen.",
missingEmailChangeLinkInfo: "Mangler bruker, e-post eller token i lenken.",
jobTableSearch: "Søk",
jobTableSearchPlaceholder: "Tittel, selskap, notater, meldinger",
jobTableStatus: "Status",
+2
View File
@@ -300,6 +300,7 @@ export default function AppShell({
color="secondary"
size="small"
title={t("notifications")}
aria-label={t("notifications")}
onClick={onOpenNotifications}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
@@ -361,6 +362,7 @@ export default function AppShell({
color="secondary"
size="small"
title={t("notifications")}
aria-label={t("notifications")}
onClick={onOpenNotifications}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
+18
View File
@@ -90,6 +90,24 @@ describe('LoginPage', () => {
expect(window.localStorage.getItem('authTokenPersistence')).toBe('session');
});
it('does not complete login when registration requires email verification', async () => {
mockedApi.get.mockClear();
mockedApi.get.mockResolvedValueOnce({
data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true },
} as any);
mockedApi.post.mockResolvedValueOnce({ data: { verificationRequired: true } } as any);
renderLoginPage('register');
await userEvent.type(await screen.findByLabelText('Email'), 'pending@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'password1');
await userEvent.type(screen.getByLabelText('Confirm password'), 'password1');
await click(screen.getByRole('button', { name: 'Create account' }));
expect(await screen.findByText('Please verify your email address before signing in.')).toBeInTheDocument();
expect(mockedApi.get).not.toHaveBeenCalledWith('/auth/me');
expect(mockNavigate).not.toHaveBeenCalled();
});
it('rejects an unsafe post-login redirect path', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
@@ -0,0 +1,44 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import MicrosoftLegacyRelinkPage from "./views/MicrosoftLegacyRelinkPage";
import { getMicrosoftMsalInstance } from "./components/MicrosoftAuthCard";
jest.mock("./api", () => ({
api: {
get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
}));
jest.mock("./components/MicrosoftAuthCard", () => ({ getMicrosoftMsalInstance: jest.fn() }));
test("legacy Microsoft recovery requires the emailed proof and a fresh matching Microsoft token", async () => {
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "test-client";
(getMicrosoftMsalInstance as jest.Mock).mockReturnValue({
initialize: jest.fn().mockResolvedValue(undefined),
loginPopup: jest.fn().mockResolvedValue({ idToken: "fresh-microsoft-token" }),
});
(api.post as jest.Mock).mockResolvedValue({ data: {} });
window.history.pushState({}, "", "/microsoft-legacy-relink?userId=user-1&tenantId=tenant-1&objectId=object-1&token=recovery-token");
render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<I18nProvider><MicrosoftLegacyRelinkPage /></I18nProvider>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "Continue with Microsoft" }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith("/auth/microsoft/legacy-relink/confirm", {
userId: "user-1",
tenantId: "tenant-1",
objectId: "object-1",
recoveryToken: "recovery-token",
microsoftToken: "fresh-microsoft-token",
}));
expect(await screen.findByText("Microsoft sign-in was relinked. You can now sign in normally.")).toBeInTheDocument();
});
+3 -3
View File
@@ -1,8 +1,8 @@
import fs from "node:fs";
import path from "node:path";
test("public health reaches the backend instead of the SPA fallback", () => {
const config = fs.readFileSync(path.join(process.cwd(), "nginx.conf"), "utf8");
test("canonical-host health reaches the backend instead of the SPA fallback", () => {
const config = fs.readFileSync(path.join(process.cwd(), "nginx.conf.template"), "utf8");
expect(config).toMatch(/location = \/health\s*{[^}]*proxy_pass http:\/\/backend:8080\/health;/s);
expect(config).toMatch(/location = \/health\s*{[^}]*proxy_pass http:\/\/backend-web:8080\/health;/s);
});
@@ -0,0 +1,82 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { api } from "./api";
import OperationsPage from "./views/OperationsPage";
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_error: unknown, fallback?: string) => fallback || "Request failed.",
}));
const mockedApi = api as jest.Mocked<typeof api>;
const operation = {
id: "11111111-1111-1111-1111-111111111111",
taskType: "strategy_snapshot",
status: "running",
createdAtUtc: "2026-08-02T12:00:00Z",
progressStage: "Analysing role",
progressPercent: 40,
canCancel: true,
canRetry: false,
};
const notification = {
id: "22222222-2222-2222-2222-222222222222",
operationId: operation.id,
kind: "operation_failed",
title: "Operation failed",
message: "A background operation failed. Review it for details.",
createdAtUtc: "2026-08-02T12:05:00Z",
readAtUtc: null,
};
beforeEach(() => {
jest.clearAllMocks();
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
data: url.startsWith("/operations") ? [operation] : [notification],
} as any));
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
});
test("shows persistent operation and notification states with accessible actions", async () => {
render(<OperationsPage />);
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
expect(screen.getByText("Analysing role")).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "strategy_snapshot progress" })).toHaveAttribute("aria-valuenow", "40");
expect(screen.getByText("Operation failed")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(`/operations/${operation.id}/cancel`));
const markRead = screen.getByRole("button", { name: "Mark read" });
await waitFor(() => expect(markRead).toBeEnabled());
fireEvent.click(markRead);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${notification.id}/read`));
const dismiss = screen.getByRole("button", { name: "Dismiss" });
await waitFor(() => expect(dismiss).toBeEnabled());
fireEvent.click(dismiss);
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith(`/notifications/${notification.id}`));
});
test("shows honest empty and failure states", async () => {
mockedApi.get.mockResolvedValueOnce({ data: [] } as any).mockResolvedValueOnce({ data: [] } as any);
const { unmount } = render(<OperationsPage />);
expect(await screen.findByText("No notifications.")).toBeInTheDocument();
expect(screen.getByText("No background operations yet.")).toBeInTheDocument();
unmount();
mockedApi.get.mockRejectedValue(new Error("offline"));
render(<OperationsPage />);
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
});
+19
View File
@@ -358,3 +358,22 @@ test('pending CV extraction can be reviewed and applied', async () => {
acceptedLowConfidenceIds: ['Languages|french'],
}));
});
test('account email changes require a password and use the confirmation flow', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/auth/email-change/request') return Promise.resolve({ data: { pendingEmail: 'new@example.com' } } as any);
return Promise.resolve({ data: {} } as any);
});
renderWith(ProfilePage);
const email = await screen.findByLabelText(/new email/i);
fireEvent.change(email, { target: { value: 'new@example.com' } });
fireEvent.change(screen.getByLabelText(/password to confirm email change/i), { target: { value: 'password1' } });
fireEvent.click(screen.getByRole('button', { name: /send confirmation/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/email-change/request', {
email: 'new@example.com',
currentPassword: 'password1',
}));
expect(await screen.findByText(/waiting for confirmation from new@example.com/i)).toBeInTheDocument();
});
+47
View File
@@ -46,6 +46,30 @@ function renderView() {
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/ai/settings') {
return Promise.resolve({
data: {
enabled: true,
externalProcessingAllowed: false,
externalProcessingAvailable: false,
effectiveExternalProcessing: false,
provider: 'local',
},
} as any);
}
if (url === '/ai/usage') {
return Promise.resolve({ data: {
currentMonth: { calls: 0, estimatedTokens: 0 },
plan: 'free',
monthlyCallLimit: 0,
monthlyTokenLimit: 0,
storageUsedBytes: 0,
storageLimitBytes: 250_000_000,
} } as any);
}
if (url === '/billing/status') {
return Promise.resolve({ data: { enabled: false, canCheckout: false, canManage: false } } as any);
}
if (url === '/rules') {
return Promise.resolve({
data: {
@@ -62,6 +86,14 @@ beforeEach(() => {
return Promise.resolve({ data: {} } as any);
});
window.localStorage.clear();
mockedApi.put.mockImplementation((url: string, body: any) => Promise.resolve({
data: {
...body,
externalProcessingAvailable: false,
effectiveExternalProcessing: false,
provider: 'local',
},
} as any));
});
afterEach(() => {
@@ -84,3 +116,18 @@ test('settings view has no accent picker and uses one follow-up section, one not
expect(screen.getByLabelText(/email reminders for follow-ups/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email alerts for ghosted jobs/i)).toBeInTheDocument();
});
test('AI privacy settings are server-backed and external processing is local-only by default', async () => {
renderView();
expect(await screen.findByText(/AI privacy/i)).toBeInTheDocument();
const external = screen.getByLabelText(/allow approved external AI processing/i);
expect(external).toBeDisabled();
fireEvent.click(screen.getByLabelText(/enable AI features for my account/i));
fireEvent.click(screen.getByRole('button', { name: /save AI privacy settings/i }));
expect(mockedApi.put).toHaveBeenCalledWith('/ai/settings', {
enabled: false,
externalProcessingAllowed: false,
});
});
+14 -4
View File
@@ -12,12 +12,13 @@ const mockedApi = api as jest.Mocked<typeof api>;
// jest.fn() in setupTests.ts before every test -- re-arm it here so error-derived text is testable.
const mockedGetApiErrorMessage = getApiErrorMessage as jest.Mock;
function renderVerifyEmailPage(search: string) {
window.history.pushState({}, '', `/verify-email${search}`);
function renderVerifyEmailPage(search: string, emailChange = false) {
const path = emailChange ? '/confirm-email-change' : '/verify-email';
window.history.pushState({}, '', `${path}${search}`);
return render(
<MemoryRouter initialEntries={[`/verify-email${search}`]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<MemoryRouter initialEntries={[`${path}${search}`]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<I18nProvider>
<VerifyEmailPage />
<VerifyEmailPage emailChange={emailChange} />
</I18nProvider>
</MemoryRouter>,
);
@@ -55,4 +56,13 @@ describe('VerifyEmailPage', () => {
expect(await screen.findByText('Missing user/token in link.')).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('confirms a pending email change and requires a fresh sign-in', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
renderVerifyEmailPage('?userId=user-1&email=new%40example.com&token=change-token', true);
expect(await screen.findByText('Your email has changed. Sign in again with the new address.')).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith('/auth/email-change/confirm', { userId: 'user-1', email: 'new@example.com', token: 'change-token' });
});
});
@@ -27,6 +27,7 @@ import {
} from "./career/CareerProfileSections";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useAccountPlan } from "../accountPlan";
import {
emptyStructuredCv,
getStructuredCvFieldMetadata,
@@ -119,6 +120,7 @@ type CareerVersion = { version: number; source: string; createdAtUtc: string; is
// truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the
// relational /career/profile API in Phase 3.
export default function CareerProfilePage() {
const { canUseAi } = useAccountPlan();
// Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career.
const careerOnly = true;
const { toast } = useToast();
@@ -368,6 +370,7 @@ export default function CareerProfilePage() {
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none" }}>
{!canUseAi && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI CV import, rebuilding, improvement, and reprocessing require Pro. Manual profile editing remains available.</Alert>}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="h6">{t("profileMasterCv")}</Typography>
@@ -399,12 +402,12 @@ export default function CareerProfilePage() {
}
}}
/>
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
<Button variant="outlined" disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRebuildingCv(true);
try {
@@ -422,7 +425,7 @@ export default function CareerProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setImprovingCv(true);
try {
@@ -440,7 +443,7 @@ export default function CareerProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
disabled={!canUseAi || !isLocal || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
onClick={async () => {
setReprocessingCv(true);
try {
+6 -3
View File
@@ -30,6 +30,7 @@ import {
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, moveItem,
} from "../cvBuilder";
import { useAccountPlan } from "../accountPlan";
const FONTS = [
"'Segoe UI', Roboto, Arial, sans-serif",
@@ -583,7 +584,7 @@ function CustomizeTab({ settings, update, themes }: {
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
{t.atsFriendly && <Chip size="small" label="ATS-friendly" color="success" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
{t.premium && <Chip size="small" label={locked ? "Premium" : "Premium unlocked"} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
{t.requiresPro && <Chip size="small" label={locked ? "Pro" : "Pro unlocked"} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
</Stack>
</Paper>
);
@@ -638,12 +639,14 @@ function CustomizeTab({ settings, update, themes }: {
function AiToolsTab() {
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
const [text, setText] = useState("");
const [role, setRole] = useState("");
const [result, setResult] = useState("");
const [busy, setBusy] = useState(false);
const run = async (action: string) => {
if (!canUseAi) return;
if (!text.trim()) {
toast("Paste some text to work on first.", "info");
return;
@@ -661,13 +664,13 @@ function AiToolsTab() {
return (
<Stack spacing={1.5}>
<Alert severity="info" sx={{ py: 0.5 }}>AI suggestions never change your profile automatically. Copy what you like back into your CV.</Alert>
<Alert severity="info" sx={{ py: 0.5 }} action={!canUseAi ? <Button href="/settings" size="small">View Pro</Button> : undefined}>{canUseAi ? "AI suggestions never change your profile automatically. Copy what you like back into your CV." : "AI writing assistance requires Pro. Your CV content remains editable."}</Alert>
<TextField label="Text to improve" multiline minRows={4} fullWidth size="small" value={text} onChange={(e) => setText(e.target.value)}
placeholder="Paste a summary, a bullet, or a whole section…" />
<TextField label="Target role (optional)" size="small" fullWidth value={role} onChange={(e) => setRole(e.target.value)} />
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{AI_ACTIONS.map((a) => (
<Button key={a.key} size="small" variant="outlined" disabled={busy} startIcon={<AutoFixHighIcon />} onClick={() => run(a.key)}>{a.label}</Button>
<Button key={a.key} size="small" variant="outlined" disabled={busy || !canUseAi} startIcon={<AutoFixHighIcon />} onClick={() => run(a.key)}>{canUseAi ? a.label : "Pro required"}</Button>
))}
</Box>
{result && (
+6 -4
View File
@@ -94,15 +94,17 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
try {
const url = mode === "register" ? "/auth/register" : "/auth/login";
const payload = { email, password, rememberMe, ...(cfg?.turnstileEnabled ? { turnstileToken } : {}) };
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, payload);
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string; verificationRequired?: boolean }>(url, payload);
if (res.data?.verificationRequired) {
setEmailNotVerified(true);
toast(t("registerCheckEmailForVerification"), "info");
return;
}
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
return;
}
await completeLogin();
if (mode === "register" && cfg?.requireEmailVerification) {
toast(t("registerCheckEmailForVerification"), "info");
}
} catch (e: any) {
if (mode === "login" && e?.response?.data?.error === "email_not_verified") {
setEmailNotVerified(true);
@@ -0,0 +1,66 @@
import React, { useState } from "react";
import { Alert, Box, Button, CircularProgress, Paper, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { getMicrosoftMsalInstance } from "../components/MicrosoftAuthCard";
import { useI18n } from "../i18n/I18nProvider";
export default function MicrosoftLegacyRelinkPage() {
const { t } = useI18n();
const navigate = useNavigate();
const [working, setWorking] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const params = new URLSearchParams(window.location.search);
const userId = params.get("userId") || "";
const tenantId = params.get("tenantId") || "";
const objectId = params.get("objectId") || "";
const recoveryToken = params.get("token") || "";
const clientId = (process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID || "").trim();
const missing = !userId || !tenantId || !objectId || !recoveryToken || !clientId;
async function confirm() {
setWorking(true);
setError(null);
try {
const msal = getMicrosoftMsalInstance(clientId);
await msal.initialize();
const result = await msal.loginPopup({ scopes: ["openid", "profile", "email"] });
if (!result.idToken) throw new Error(t("microsoftAuthFailed"));
await api.post("/auth/microsoft/legacy-relink/confirm", {
userId,
tenantId,
objectId,
recoveryToken,
microsoftToken: result.idToken,
});
setSuccess(true);
} catch (e: any) {
setError(getApiErrorMessage(e, t("microsoftLegacyRelinkFailed")));
} finally {
setWorking(false);
}
}
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", p: 2 }}>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{t("microsoftLegacyRelinkTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mt: 1 }}>{t("microsoftLegacyRelinkBody")}</Typography>
{missing ? <Alert severity="error" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkMissing")}</Alert> : null}
{error ? <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert> : null}
{success ? <Alert severity="success" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkSuccess")}</Alert> : null}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 3 }}>
<Button onClick={() => navigate("/login")}>{t("backToLogin")}</Button>
{!success ? (
<Button variant="contained" disabled={missing || working} onClick={() => void confirm()}>
{working ? <CircularProgress size={20} /> : t("continueWithMicrosoft")}
</Button>
) : null}
</Box>
</Paper>
</Box>
);
}
+158
View File
@@ -0,0 +1,158 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
LinearProgress,
Paper,
Stack,
Typography,
} from "@mui/material";
import { api, getApiErrorMessage } from "../api";
type Operation = {
id: string;
taskType: string;
status: string;
subjectType?: string | null;
createdAtUtc: string;
completedAtUtc?: string | null;
cancellationRequestedAtUtc?: string | null;
progressStage?: string | null;
progressPercent?: number | null;
failureCategory?: string | null;
canCancel: boolean;
canRetry: boolean;
};
type Notification = {
id: string;
operationId?: string | null;
kind: string;
title: string;
message: string;
createdAtUtc: string;
readAtUtc?: string | null;
};
const statusLabel = (value: string) => value.replaceAll("_", " ");
const dateLabel = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
};
export default function OperationsPage() {
const [operations, setOperations] = useState<Operation[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const load = useCallback(async (showLoading = false) => {
if (showLoading) setLoading(true);
try {
const [operationResponse, notificationResponse] = await Promise.all([
api.get<Operation[]>("/operations?limit=50"),
api.get<Notification[]>("/notifications?limit=50"),
]);
setOperations(operationResponse.data ?? []);
setNotifications(notificationResponse.data ?? []);
setError(null);
} catch (requestError) {
setError(getApiErrorMessage(requestError, "Operations could not be loaded."));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
const interval = window.setInterval(() => void load(), 15000);
return () => window.clearInterval(interval);
}, [load]);
const runAction = async (key: string, action: () => Promise<unknown>, notificationsChanged = false) => {
if (busyKey) return;
setBusyKey(key);
try {
await action();
await load();
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
} catch (requestError) {
setError(getApiErrorMessage(requestError, "The action could not be completed."));
} finally {
setBusyKey(null);
}
};
return (
<Stack spacing={2}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Typography color="text.secondary">Background work survives navigation and refresh.</Typography>
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>Refresh</Button>
</Box>
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
{loading ? <LinearProgress aria-label="Loading operations" /> : null}
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>Notifications</Typography>
{notifications.length === 0 && !loading ? <Typography color="text.secondary">No notifications.</Typography> : null}
<Stack spacing={1.5}>
{notifications.map((notification) => (
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
<Typography color="text.secondary">{notification.message}</Typography>
<Typography variant="caption" color="text.secondary">{dateLabel(notification.createdAtUtc)}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{!notification.readAtUtc ? (
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
Mark read
</Button>
) : null}
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
Dismiss
</Button>
</Stack>
</Box>
))}
</Stack>
</Paper>
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>Operations</Typography>
{operations.length === 0 && !loading ? <Typography color="text.secondary">No background operations yet.</Typography> : null}
<Stack spacing={1.5}>
{operations.map((operation) => (
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
<Chip size="small" label={statusLabel(operation.status)} />
</Box>
<Typography variant="caption" color="text.secondary">Started {dateLabel(operation.createdAtUtc)}</Typography>
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={`${operation.taskType} progress`} sx={{ mt: 1 }} /> : null}
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>Cancellation requested.</Typography> : null}
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>Failed: {statusLabel(operation.failureCategory)}</Alert> : null}
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
{operation.canCancel ? (
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
Cancel
</Button>
) : null}
{operation.canRetry ? (
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
Retry
</Button>
) : null}
</Stack>
</Box>
))}
</Stack>
</Paper>
</Stack>
);
}
+61 -5
View File
@@ -125,6 +125,11 @@ type MeResponse = {
} | null;
};
type PendingEmailChange = {
pendingEmail?: string | null;
requestedAtUtc?: string | null;
};
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
@@ -165,7 +170,7 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
accent: "#5b21b6",
blurb: "More personality and stronger section contrast without losing clarity.",
sampleHeading: "Experience Highlights",
sampleMeta: "Premium spacing · stronger visual voice",
sampleMeta: "Refined spacing · stronger visual voice",
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
},
{
@@ -173,9 +178,9 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
title: "Monarch",
eyebrow: "Executive",
accent: "#7c2d12",
blurb: "High-contrast premium presentation for leadership-heavy applications.",
blurb: "High-contrast presentation for leadership-heavy applications.",
sampleHeading: "Executive Profile",
sampleMeta: "Leadership clarity · premium hierarchy",
sampleMeta: "Leadership clarity · refined hierarchy",
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
},
{
@@ -243,6 +248,8 @@ export default function ProfilePage() {
const [cropOpen, setCropOpen] = useState(false);
const [email, setEmail] = useState("");
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
const [emailChangePassword, setEmailChangePassword] = useState("");
const [userName, setUserName] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
@@ -267,6 +274,12 @@ export default function ProfilePage() {
setDisplayName(r.data?.displayName ?? "");
setProfileCvText(r.data?.profileCvText ?? "");
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
if (r.data?.provider === "local") {
const pending = await api.get<PendingEmailChange>("/auth/email-change");
setPendingEmail(pending.data?.pendingEmail ?? null);
} else {
setPendingEmail(null);
}
setLoadError(null);
} catch (error: any) {
setMe(null);
@@ -412,7 +425,7 @@ export default function ProfilePage() {
<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={t("profileNewEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} helperText={t("profileCurrentEmail", { email: me?.email || "-" })} fullWidth />
<TextField
label={t("profileHeadline")}
value={headline}
@@ -422,6 +435,49 @@ export default function ProfilePage() {
/>
</> : null}
{!careerOnly && isLocal ? <Box sx={{ gridColumn: "1 / -1", display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto auto" }, gap: 2, alignItems: "center" }}>
{pendingEmail ? <Alert severity="info" sx={{ gridColumn: "1 / -1" }}>{t("profilePendingEmail", { email: pendingEmail })}</Alert> : null}
<TextField label={t("profileEmailChangePassword")} type="password" value={emailChangePassword} onChange={(e) => setEmailChangePassword(e.target.value)} autoComplete="current-password" fullWidth />
<Button
variant="outlined"
disabled={loading || !emailChangePassword || !email.trim() || email.trim().toLowerCase() === (me?.email || "").toLowerCase()}
onClick={async () => {
setLoading(true);
try {
const result = await api.post<PendingEmailChange>("/auth/email-change/request", { email, currentPassword: emailChangePassword });
setPendingEmail(result.data?.pendingEmail ?? email.trim());
setEmailChangePassword("");
toast(t("profileEmailChangeSent"), "success");
} catch (e: any) {
toast(getApiErrorMessage(e, t("profileEmailChangeFailed")), "error");
} finally {
setLoading(false);
}
}}
>
{t("profileRequestEmailChange")}
</Button>
<Button
disabled={loading || !pendingEmail || !emailChangePassword}
onClick={async () => {
setLoading(true);
try {
await api.post("/auth/email-change/cancel", { currentPassword: emailChangePassword });
setPendingEmail(null);
setEmail(me?.email ?? "");
setEmailChangePassword("");
toast(t("profileEmailChangeCancelled"), "success");
} catch (e: any) {
toast(getApiErrorMessage(e, t("profileEmailChangeFailed")), "error");
} finally {
setLoading(false);
}
}}
>
{t("cancel")}
</Button>
</Box> : null}
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Button
@@ -432,7 +488,7 @@ export default function ProfilePage() {
try {
// /profile saves identity only. The backend does partial updates, so omitting the
// master-profile fields leaves them untouched (they are owned by /career).
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
await api.put("/auth/profile", { userName, firstName, lastName, displayName });
window.localStorage.setItem("profileHeadline", headline.trim());
await loadProfile();
toast(t("profileUpdated"), "success");
+9 -8
View File
@@ -9,7 +9,7 @@ import { useI18n } from "../i18n/I18nProvider";
type Status = "verifying" | "success" | "error";
export default function VerifyEmailPage() {
export default function VerifyEmailPage({ emailChange = false }: { emailChange?: boolean }) {
const { t } = useI18n();
const navigate = useNavigate();
@@ -19,22 +19,23 @@ export default function VerifyEmailPage() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const userId = params.get("userId") || "";
const email = params.get("email") || "";
const token = params.get("token") || "";
if (!userId || !token) {
if (!userId || !token || (emailChange && !email)) {
setStatus("error");
setErrorMessage(t("missingVerifyLinkInfo"));
setErrorMessage(t(emailChange ? "missingEmailChangeLinkInfo" : "missingVerifyLinkInfo"));
return;
}
api
.post("/auth/verify-email", { userId, token })
.post(emailChange ? "/auth/email-change/confirm" : "/auth/verify-email", emailChange ? { userId, email, token } : { userId, token })
.then(() => setStatus("success"))
.catch((e: any) => {
setStatus("error");
setErrorMessage(getApiErrorMessage(e, t("verifyEmailFailed")));
});
}, [t]);
}, [emailChange, t]);
return (
<Box
@@ -50,17 +51,17 @@ export default function VerifyEmailPage() {
>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{t("verifyEmailTitle")}
{t(emailChange ? "confirmEmailChangeTitle" : "verifyEmailTitle")}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mt: 2 }}>
{status === "verifying" && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={20} />
<Typography sx={{ color: "text.secondary" }}>{t("verifyEmailVerifying")}</Typography>
<Typography sx={{ color: "text.secondary" }}>{t(emailChange ? "confirmEmailChangeVerifying" : "verifyEmailVerifying")}</Typography>
</Box>
)}
{status === "success" && <Alert severity="success">{t("verifyEmailSuccess")}</Alert>}
{status === "success" && <Alert severity="success">{t(emailChange ? "confirmEmailChangeSuccess" : "verifyEmailSuccess")}</Alert>}
{status === "error" && <Alert severity="error">{errorMessage}</Alert>}
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>