Files
jobtrackingapp/job-tracker-ui/src/views/ProfilePage.tsx
T
2026-08-29 23:40:29 +02:00

373 lines
17 KiB
TypeScript

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<string | undefined>) {
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<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
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("");
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<MeResponse>("/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<PendingEmailChange>("/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 (
<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)" }}>
<CropImageDialog
open={cropOpen}
file={avatarFile}
onClose={() => {
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 ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} 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 }}>
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
<input
ref={avatarInputRef}
type="file"
accept={AVATAR_UPLOAD_ACCEPT}
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
event.target.value = "";
if (!file) return;
setAvatarFile(file);
setCropOpen(true);
}}
/>
<Button variant="outlined" size="small" startIcon={<PhotoCameraOutlinedIcon />} disabled={!isLocal || uploadingAvatar} onClick={() => avatarInputRef.current?.click()}>
{uploadingAvatar ? t("profileUploading") : t("profileChangeImage")}
</Button>
{me?.avatarImageDataUrl ? (
<Button
variant="text"
size="small"
color="inherit"
startIcon={<DeleteOutlineIcon />}
disabled={!isLocal || uploadingAvatar}
onClick={async () => {
setUploadingAvatar(true);
try {
await api.delete("/auth/avatar");
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
toast(t("profileImageRemoved"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
} finally {
setUploadingAvatar(false);
}
}}
>
{t("profileRemoveImage")}
</Button>
) : null}
</Box>
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
{careerOnly ? "Master career profile" : t("profileTitle")}
</Typography>
<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" }}>
<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>
{!careerOnly ? <>
<AuthStatusCard />
<GoogleAuthCard />
<MicrosoftAuthCard />
</> : null}
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="h6">{t("profileAccountSection")}</Typography>
{!isLocal ? (
<Alert severity="info" sx={{ mt: 1 }}>
{t("profileReadOnlyInfo")}
</Alert>
) : null}
</Box> : null}
{!careerOnly ? <>
<TextField label={t("profileDisplayName")} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
<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("profileNewEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} helperText={t("profileCurrentEmail", { email: me?.email || "-" })} fullWidth />
<TextField
label={t("profileHeadline")}
value={headline}
onChange={(e) => setHeadline(e.target.value)}
helperText={t("profileHeadlineHelp")}
fullWidth
/>
</> : 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
variant="contained"
disabled={!isLocal || loading}
onClick={async () => {
setLoading(true);
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", { 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");
} finally {
setLoading(false);
}
}}
>
{t("profileSaveChanges")}
</Button>
</Box>
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="h6">{t("profileChangePassword")}</Typography>
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
</Box> : null}
{!careerOnly ? <>
<TextField label={t("profileCurrentPassword")} type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
variant="outlined"
disabled={!isLocal || loading}
onClick={async () => {
setLoading(true);
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");
} finally {
setLoading(false);
}
}}
>
{t("profileUpdatePassword")}
</Button>
</Box>
</> : null}
</Box>
{!careerOnly && isLocal ? <TwoFactorSettingsCard /> : null}
{!careerOnly && isLocal ? <SessionsSettingsCard /> : null}
</Paper>
);
}