feat/Update_Controllers_to_Allow_for_Premium_Membership

This commit is contained in:
cesnimda
2026-08-03 09:17:28 +02:00
parent de937d25dc
commit c3f4a57195
187 changed files with 26062 additions and 991 deletions
@@ -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.">