import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Avatar, Box, Button, Chip, Divider, Paper, TextField, Typography } from "@mui/material"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined"; import { api, getApiErrorMessage } from "../api"; import GoogleAuthCard from "../components/GoogleAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import AuthStatusCard from "../components/AuthStatusCard"; import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; import SessionsSettingsCard from "../components/SessionsSettingsCard"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { parseStructuredCvJson } from "../profileCv"; import { getUserScopedStorageKey } from "../auth"; type MeResponse = { provider?: "local" | "google" | "external"; id?: string; email?: string; userName?: string; firstName?: string; lastName?: string; displayName?: string; profileCvText?: string; profileCvStructureJson?: string; avatarImageDataUrl?: string; roles?: string[]; googleLink?: { linked: boolean; email?: string | null; linkedAt?: string | null; } | null; }; type PendingEmailChange = { pendingEmail?: string | null; requestedAtUtc?: string | null; }; const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp"; function initialsFrom(values: Array) { const joined = values.map((x) => (x ?? "").trim()).filter(Boolean); if (joined.length === 0) return "?"; if (joined.length === 1) { const parts = joined[0].split(/[\s@._-]+/).filter(Boolean); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[1][0]).toUpperCase(); } return (joined[0][0] + joined[1][0]).toUpperCase(); } // ProfilePage backs /profile: account identity + security + preferences. The master career // profile lives in CareerProfilePage (/career). Split in Phase 2.2. Saves only identity fields // (partial update), never the master profile. export default function ProfilePage() { // Retained so the shared JSX reads identically; hardcoded for /profile. const careerOnly = false; const { toast } = useToast(); const { t } = useI18n(); const avatarInputRef = useRef(null); const [me, setMe] = useState(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [avatarFile, setAvatarFile] = useState(null); const [cropOpen, setCropOpen] = useState(false); const [email, setEmail] = useState(""); const [pendingEmail, setPendingEmail] = useState(null); const [emailChangePassword, setEmailChangePassword] = useState(""); const [userName, setUserName] = useState(""); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [displayName, setDisplayName] = useState(""); const [headline, setHeadline] = useState(""); const [profileCvText, setProfileCvText] = useState(""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const loadProfile = useCallback(async () => { setLoading(true); try { // /profile only needs the account row. The master-profile data (runs, structured CV, // saved jobs) is loaded by CareerProfilePage on /career. const r = await api.get("/auth/me"); setMe(r.data); setEmail(r.data?.email ?? ""); setUserName(r.data?.userName ?? ""); setFirstName(r.data?.firstName ?? ""); setLastName(r.data?.lastName ?? ""); setDisplayName(r.data?.displayName ?? ""); setProfileCvText(r.data?.profileCvText ?? ""); const persistedHeadline = parseStructuredCvJson(r.data?.profileCvStructureJson).contact.headline; const userKey = r.data?.id || r.data?.email || r.data?.userName || "anon"; setHeadline(persistedHeadline ?? window.localStorage.getItem(getUserScopedStorageKey("profileHeadline", userKey)) ?? ""); if (r.data?.provider === "local") { const pending = await api.get("/auth/email-change"); setPendingEmail(pending.data?.pendingEmail ?? null); } else { setPendingEmail(null); } setLoadError(null); } catch (error: any) { setMe(null); setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed"))); } finally { setLoading(false); } }, [t]); useEffect(() => { void loadProfile(); }, [loadProfile]); const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]); const isLocal = me?.provider === "local"; const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" "); const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0; const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession"); const googleLabel = me?.googleLink?.linked ? me.googleLink.email ? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email }) : t("profileGoogleLinked") : t("profileGoogleNotLinked"); const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing"); return ( { setCropOpen(false); setAvatarFile(null); }} onSave={async (blob) => { const file = new File([blob], "avatar.png", { type: "image/png" }); const formData = new FormData(); formData.append("file", file); setUploadingAvatar(true); try { const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, { headers: { "Content-Type": "multipart/form-data" }, }); setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev)); setCropOpen(false); setAvatarFile(null); toast(t("profileImageUpdated"), "success"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error"); } finally { setUploadingAvatar(false); } }} /> {loadError ? ( void loadProfile()}>{t("retry")}}> {t("profileLoadFailed")} {loadError} ) : null} {initials} { const file = event.target.files?.[0] ?? null; event.target.value = ""; if (!file) return; setAvatarFile(file); setCropOpen(true); }} /> {me?.avatarImageDataUrl ? ( ) : null} {careerOnly ? "Master career profile" : t("profileTitle")} {me?.userName || me?.displayName || fullName || me?.email || "-"} {headline || t("profileHeadlinePlaceholder")} {!careerOnly ? <> : null} {!careerOnly ? {t("profileAccountSection")} {!isLocal ? ( {t("profileReadOnlyInfo")} ) : null} : null} {!careerOnly ? <> setDisplayName(e.target.value)} disabled={!isLocal} fullWidth /> setUserName(e.target.value)} disabled={!isLocal} fullWidth /> setFirstName(e.target.value)} disabled={!isLocal} fullWidth /> setLastName(e.target.value)} disabled={!isLocal} fullWidth /> setEmail(e.target.value)} disabled={!isLocal} helperText={t("profileCurrentEmail", { email: me?.email || "-" })} fullWidth /> setHeadline(e.target.value)} helperText={t("profileHeadlineHelp")} fullWidth /> : null} {!careerOnly && isLocal ? {pendingEmail ? {t("profilePendingEmail", { email: pendingEmail })} : null} setEmailChangePassword(e.target.value)} autoComplete="current-password" fullWidth /> : null} {!careerOnly ? {t("profileChangePassword")} {!isLocal ? {t("profilePasswordLocalOnly")} : null} : null} {!careerOnly ? <> setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth /> setNewPassword(e.target.value)} disabled={!isLocal} fullWidth /> : null} {!careerOnly && isLocal ? : null} {!careerOnly && isLocal ? : null} ); }