feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -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.">
|
||||
|
||||
Reference in New Issue
Block a user