feat(auth): migrate account security presentation

This commit is contained in:
cesnimda
2026-10-11 21:16:05 +02:00
parent 8ad720da5d
commit ad7b4196af
22 changed files with 35532 additions and 143 deletions
+53 -42
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Avatar, Box, Button, Chip, Divider, Paper, TextField, Typography } from "@mui/material";
import { Alert, Avatar, Box, Button, Chip, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
@@ -16,6 +16,7 @@ import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { parseStructuredCvJson } from "../profileCv";
import { getUserScopedStorageKey } from "../auth";
import { PageSection } from "../layout/PageLayout";
type MeResponse = {
provider?: "local" | "google" | "external";
@@ -66,6 +67,8 @@ export default function ProfilePage() {
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [cropOpen, setCropOpen] = useState(false);
@@ -108,7 +111,7 @@ export default function ProfilePage() {
setLoadError(null);
} catch (error: any) {
setMe(null);
setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed")));
setLoadError(getApiErrorMessage(error, t("profileLoadFailed")));
} finally {
setLoading(false);
}
@@ -129,10 +132,10 @@ export default function ProfilePage() {
? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email })
: t("profileGoogleLinked")
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
const cvLabel = profileCvText.trim() ? t("profileCvTextAvailable", { count: cvWordCount }) : t("profileCvTextMissing");
return (
<Paper sx={{ mt: 0, p: 2.5, 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)" }}>
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
<CropImageDialog
open={cropOpen}
file={avatarFile}
@@ -154,7 +157,7 @@ export default function ProfilePage() {
setAvatarFile(null);
toast(t("profileImageUpdated"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error");
toast(getApiErrorMessage(e, t("profileImageUploadFailed")), "error");
} finally {
setUploadingAvatar(false);
}
@@ -162,17 +165,20 @@ export default function ProfilePage() {
/>
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
<Alert severity="error" sx={{ overflowWrap: "anywhere" }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
{t("profileLoadFailed")}
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
</Alert>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
{loading ? <Typography role="status" variant="body2" color="text.secondary">{t("loading")}</Typography> : null}
<PageSection surface title={careerOnly ? "Master career profile" : t("profileTitle")}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", minWidth: 0 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap", minWidth: 0, maxWidth: "100%" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, minWidth: 0, maxWidth: "100%" }}>
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center", maxWidth: "100%", "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal" } }}>
<input
ref={avatarInputRef}
type="file"
@@ -203,7 +209,7 @@ export default function ProfilePage() {
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
toast(t("profileImageRemoved"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
toast(getApiErrorMessage(e, t("profileImageRemoveFailed")), "error");
} finally {
setUploadingAvatar(false);
}
@@ -214,20 +220,18 @@ export default function ProfilePage() {
) : null}
</Box>
</Box>
<Box>
<Typography component="h2" variant="h5" sx={{ fontWeight: 900 }}>
{careerOnly ? "Master career profile" : t("profileTitle")}
</Typography>
<Box sx={{ minWidth: 0, overflowWrap: "anywhere" }}>
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start", minWidth: 0, maxWidth: "100%", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 32 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
</Box>
</Box>
</PageSection>
{!careerOnly ? <>
<AuthStatusCard />
@@ -235,15 +239,11 @@ export default function ProfilePage() {
<MicrosoftAuthCard />
</> : null}
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1" }}>
<Typography component="h3" variant="h6">{t("profileAccountSection")}</Typography>
{!isLocal ? (
<Alert severity="info" sx={{ mt: 1 }}>
{t("profileReadOnlyInfo")}
</Alert>
) : null}
</Box> : null}
<PageSection surface title={t("profileAccountSection")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiFormHelperText-root": { overflowWrap: "anywhere" }, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
{!careerOnly && !isLocal ? <Alert severity="info" sx={{ gridColumn: "1 / -1" }}>{t("profileReadOnlyInfo")}</Alert> : null}
{saveError ? <Alert severity="error" sx={{ gridColumn: "1 / -1", overflowWrap: "anywhere" }}>{saveError}</Alert> : null}
{!careerOnly ? <>
<TextField label={t("profileDisplayName")} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
@@ -260,10 +260,11 @@ 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}
{!careerOnly && isLocal ? <Box sx={{ gridColumn: "1 / -1", display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto auto" }, gap: 2, alignItems: "center", minWidth: 0 }}>
{pendingEmail ? <Alert severity="info" sx={{ gridColumn: "1 / -1", overflowWrap: "anywhere" }}>{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
type="button"
variant="outlined"
disabled={loading || !emailChangePassword || !email.trim() || email.trim().toLowerCase() === (me?.email || "").toLowerCase()}
onClick={async () => {
@@ -283,6 +284,7 @@ export default function ProfilePage() {
{t("profileRequestEmailChange")}
</Button>
<Button
type="button"
disabled={loading || !pendingEmail || !emailChangePassword}
onClick={async () => {
setLoading(true);
@@ -306,21 +308,24 @@ export default function ProfilePage() {
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Button
type="button"
variant="contained"
disabled={!isLocal || loading}
onClick={async () => {
setLoading(true);
setSaveError(null);
try {
// /profile saves identity only. The backend does partial updates, so omitting the
// master-profile fields leaves them untouched (they are owned by /career).
// /profile saves identity only. Omitting the master-profile fields leaves
// them untouched; they are owned by /career.
await api.put("/auth/profile", { userName, firstName, lastName, displayName });
const userKey = me?.id || me?.email || me?.userName;
if (userKey) window.localStorage.setItem(getUserScopedStorageKey("profileHeadline", userKey), headline.trim());
await loadProfile();
toast(t("profileUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
toast(String(msg), "error");
} catch (error: any) {
const message = getApiErrorMessage(error, t("profileUpdateFailed"));
setSaveError(message);
toast(message, "error");
} finally {
setLoading(false);
}
@@ -329,12 +334,14 @@ export default function ProfilePage() {
{t("profileSaveChanges")}
</Button>
</Box>
</Box>
</PageSection>
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Divider sx={{ mb: 2 }} />
<Typography component="h3" variant="h6">{t("profileChangePassword")}</Typography>
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
</Box> : null}
{!careerOnly ? <PageSection surface title={t("profileChangePassword")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal" } }}>
{!careerOnly && !isLocal ? <Typography sx={{ gridColumn: "1 / -1", color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
{passwordError ? <Alert severity="error" sx={{ gridColumn: "1 / -1", overflowWrap: "anywhere" }}>{passwordError}</Alert> : null}
{!careerOnly ? <>
<TextField label={t("profileCurrentPassword")} type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
@@ -342,18 +349,21 @@ export default function ProfilePage() {
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
type="button"
variant="outlined"
disabled={!isLocal || loading}
onClick={async () => {
setLoading(true);
setPasswordError(null);
try {
await api.post("/auth/change-password", { currentPassword, newPassword });
setCurrentPassword("");
setNewPassword("");
toast(t("profilePasswordUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("profilePasswordUpdateFailed");
toast(String(msg), "error");
} catch (error: any) {
const message = getApiErrorMessage(error, t("profilePasswordUpdateFailed"));
setPasswordError(message);
toast(message, "error");
} finally {
setLoading(false);
}
@@ -363,10 +373,11 @@ export default function ProfilePage() {
</Button>
</Box>
</> : null}
</Box>
</Box>
</PageSection> : null}
{!careerOnly && isLocal ? <TwoFactorSettingsCard /> : null}
{!careerOnly && isLocal ? <SessionsSettingsCard /> : null}
</Paper>
</Box>
);
}