feat: complete release readiness work

- consolidate API ownership and remove dead vendor code

- add Stripe billing, learning paths, and public CV hardening

- add migration, recovery, security, audit, and browser gates
This commit is contained in:
cesnimda
2026-07-31 16:54:16 +02:00
parent a23c3dfc97
commit ce76046a29
1634 changed files with 6889 additions and 135429 deletions
+24 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Alert, Box, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
import { Alert, Box, Button, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
@@ -12,6 +12,8 @@ type Usage = {
storageLimitBytes: number;
};
type BillingStatus = { enabled: boolean; canCheckout: boolean; canManage: boolean };
function formatBytes(bytes: number) {
if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`;
if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
@@ -21,6 +23,9 @@ function formatBytes(bytes: number) {
export default function AiUsageCard() {
const { t } = useI18n();
const [usage, setUsage] = useState<Usage | null>(null);
const [billing, setBilling] = useState<BillingStatus | null>(null);
const [billingBusy, setBillingBusy] = useState(false);
const [billingFailed, setBillingFailed] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
@@ -28,9 +33,24 @@ export default function AiUsageCard() {
api.get<Usage>("/ai/usage")
.then((response) => { if (active) setUsage(response.data); })
.catch(() => { if (active) setFailed(true); });
api.get<BillingStatus>("/billing/status")
.then((response) => { if (active) setBilling(response.data); })
.catch(() => { /* Billing is optional; usage remains available. */ });
return () => { active = false; };
}, []);
const openBilling = async (path: "checkout" | "portal") => {
setBillingBusy(true);
setBillingFailed(false);
try {
const response = await api.post<{ url: string }>(`/billing/${path}`);
window.location.assign(response.data.url);
} catch {
setBillingFailed(true);
setBillingBusy(false);
}
};
if (failed) return <Alert severity="warning">{t("settingsUsageUnavailable")}</Alert>;
if (!usage) return <Skeleton variant="rounded" height={150} />;
@@ -57,6 +77,9 @@ export default function AiUsageCard() {
<LinearProgress variant="determinate" value={storagePercent} aria-label="Attachment storage used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsUsageReset")}</Typography>
{billing?.canCheckout ? <Button sx={{ mt: 2 }} variant="contained" disabled={billingBusy} onClick={() => void openBilling("checkout")}>{t("settingsBillingUpgrade")}</Button> : null}
{billing?.canManage ? <Button sx={{ mt: 2 }} variant="outlined" disabled={billingBusy} onClick={() => void openBilling("portal")}>{t("settingsBillingManage")}</Button> : null}
{billingFailed ? <Alert severity="error" sx={{ mt: 2 }}>{t("settingsBillingUnavailable")}</Alert> : null}
</Paper>
);
}
@@ -326,6 +326,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
}, [open, jobId, tab, matchScore, matchScoreCache]);
const updateLearningRecommendation = useCallback(async (id: number, status: "done" | "dismissed") => {
if (!jobId) return;
try {
await api.patch(`/jobapplications/${jobId}/checklist/${id}`, { status });
setMatchScore(current => {
if (!current) return current;
const updated = {
...current,
learningRecommendations: (current.learningRecommendations ?? []).map(item => item.id === id ? { ...item, status } : item),
};
matchScoreCache.setCached(`${jobId}:match-score`, updated);
return updated;
});
} catch (error: any) {
toast(getApiErrorMessage(error, t("matchScoreLearningUpdateFailed")), "error");
}
}, [jobId, matchScoreCache, t, toast]);
// Suggest a status move from the latest inbound email when the workspace opens.
useEffect(() => {
if (!open || !jobId) return;
@@ -1195,6 +1213,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
tab={tab}
matchScore={matchScore}
loadingMatchScore={loadingMatchScore}
updateLearningRecommendation={updateLearningRecommendation}
candidateFit={candidateFit}
loadingCandidateFit={loadingCandidateFit}
regenerateCandidateFit={regenerateCandidateFit}
@@ -8,7 +8,11 @@ function copyLines(items: string[]) {
void navigator.clipboard.writeText(items.join("\n"));
}
export function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
export function MatchScoreCard({ score, loading, onUpdateLearningRecommendation }: {
score: MatchScore | null;
loading: boolean;
onUpdateLearningRecommendation?: (id: number, status: "done" | "dismissed") => void;
}) {
const { t } = useI18n();
if (loading && !score) {
@@ -25,6 +29,9 @@ export function MatchScoreCard({ score, loading }: { score: MatchScore | null; l
const color: "success" | "warning" | "error" | "inherit" =
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
const learningRecommendations = score.learningRecommendations ?? [];
const visibleRecommendations = learningRecommendations.filter(item => item.status !== "dismissed");
const dismissedCount = learningRecommendations.length - visibleRecommendations.length;
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
@@ -80,6 +87,28 @@ export function MatchScoreCard({ score, loading }: { score: MatchScore | null; l
</Box>
</Box>
) : null}
{learningRecommendations.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreLearningPath")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>
{t("matchScoreLearningPathHint")}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
{visibleRecommendations.map(item => (
<Box key={item.id} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={item.keyword} color={item.status === "done" ? "success" : "warning"} variant={item.status === "done" ? "filled" : "outlined"} />
{item.status === "done" ? <Typography variant="caption" color="success.main">{t("matchScoreLearned")}</Typography> : (
<Box sx={{ display: "flex", gap: 0.75 }}>
<Button size="small" onClick={() => onUpdateLearningRecommendation?.(item.id, "done")}>{t("matchScoreMarkLearned")}</Button>
<Button size="small" color="inherit" onClick={() => onUpdateLearningRecommendation?.(item.id, "dismissed")}>{t("matchScoreDismiss")}</Button>
</Box>
)}
</Box>
))}
{dismissedCount ? <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreDismissedCount", { count: dismissedCount })}</Typography> : null}
</Box>
</Box>
) : null}
</Box>
);
}
@@ -9,6 +9,7 @@ type Props = {
tab: number;
matchScore: MatchScore | null;
loadingMatchScore: boolean;
updateLearningRecommendation: (id: number, status: "done" | "dismissed") => void;
candidateFit: CandidateFit | null;
loadingCandidateFit: boolean;
regenerateCandidateFit: () => void;
@@ -25,11 +26,11 @@ type Props = {
export default function JobInsightTabs(props: Props) {
const { t } = useI18n();
const { tab, matchScore, loadingMatchScore, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
const { tab, matchScore, loadingMatchScore, updateLearningRecommendation, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
return <>
{tab === 5 && (
<Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
<MatchScoreCard score={matchScore} loading={loadingMatchScore} onUpdateLearningRecommendation={updateLearningRecommendation} />
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
@@ -38,7 +38,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
const [working, setWorking] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
const clientId = (process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
const actionLabel = !signedIn
? t("continueWithMicrosoft")