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
@@ -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 {
+6 -3
View File
@@ -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 && (
+6 -4
View File
@@ -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>
);
}
+158
View File
@@ -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>
);
}
+61 -5
View File
@@ -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 -8
View File
@@ -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 }}>