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
+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");