feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.">
|
||||
|
||||
@@ -33,7 +33,7 @@ export type CvTheme = {
|
||||
photoShape: string;
|
||||
supportsIcons: boolean;
|
||||
atsFriendly: boolean;
|
||||
premium: boolean;
|
||||
requiresPro: boolean;
|
||||
available: boolean;
|
||||
swatches: string[];
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 }}>
|
||||
|
||||
Reference in New Issue
Block a user