feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -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