21c9b1ea63
Complete the Phase 2.2 split. Each dedicated component now carries only its own state, effects, and JSX; the shared-copy duplication from the split checkpoint is removed. - ProfilePage (/profile): 1372 -> ~495 lines. Dropped the 700-line master-CV block, all CV/rewrite/PDF state + helpers + the extraction-run polling effects. loadProfile now fetches only /auth/me (no runs/jobs). Saves identity only. - CareerProfilePage (/career): dropped identity fields, password, 2FA/sessions and their state; loadProfile no longer sets identity fields. Saves the master profile only. Owns the master-CV editing surface. Both save through the partial-update PUT /auth/profile, so neither can overwrite the other's data. The master career profile stays the only editable source of truth on /career. Tests: the CV-editing tests in profile-page.test.tsx now render CareerProfilePage (where that surface lives) — all 5 pass, fixing 4 pre-existing failures that were caused by the display:none shared block. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
491 lines
20 KiB
TypeScript
491 lines
20 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
|
|
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
|
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
|
|
|
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 {
|
|
emptyStructuredCv,
|
|
getStructuredCvFieldMetadata,
|
|
joinLines,
|
|
normalizeStructuredCv,
|
|
parseStructuredCvJson,
|
|
splitLines,
|
|
StructuredCvFieldMetadata,
|
|
StructuredCvProfile,
|
|
} from "../profileCv";
|
|
import { JobApplication } from "../types";
|
|
|
|
|
|
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
|
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
|
|
|
type CvBuilderTone = "Concise and direct" | "Executive and polished" | "Technical and detailed" | "Warm and people-focused";
|
|
type CvBuilderLanguage = "English" | "Norwegian" | "Spanish" | "French" | "German";
|
|
|
|
type ExtractionRun = {
|
|
id: number;
|
|
trigger: string;
|
|
status: string;
|
|
artifactFileName?: string;
|
|
startedAtUtc: string;
|
|
completedAtUtc?: string;
|
|
appliedAtUtc?: string;
|
|
parserVersion: string;
|
|
normalizerVersion: string;
|
|
llmPromptVersion: string;
|
|
errorMessage?: string;
|
|
};
|
|
|
|
type QueuedCvRunResponse = {
|
|
queued: boolean;
|
|
extractionRunId: number;
|
|
status: string;
|
|
};
|
|
|
|
type JobListResponse = {
|
|
items: JobApplication[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
};
|
|
|
|
type RewriteTemplateOption = {
|
|
id: CvSectionStyle;
|
|
title: string;
|
|
eyebrow: string;
|
|
accent: string;
|
|
blurb: string;
|
|
sampleHeading: string;
|
|
sampleMeta: string;
|
|
sampleBullets: string[];
|
|
};
|
|
|
|
type CvBuilderPreview = {
|
|
templateId: CvSectionStyle;
|
|
html: string;
|
|
suggestedFileName: string;
|
|
fullText: string;
|
|
rewrittenText: string;
|
|
structuredCv: StructuredCvProfile;
|
|
sectionName?: string | null;
|
|
targetRole?: string | null;
|
|
jobApplicationId?: number | null;
|
|
};
|
|
|
|
type PdfCarouselItem = {
|
|
templateId: CvSectionStyle;
|
|
title: string;
|
|
fileName: string;
|
|
pdfUrl?: string;
|
|
status: "loading" | "ready" | "error";
|
|
error?: string;
|
|
};
|
|
|
|
type RewriteRequestPayload = {
|
|
sectionName: string | null;
|
|
style: CvSectionStyle;
|
|
templateId: CvSectionStyle;
|
|
targetRole: string | null;
|
|
jobApplicationId: number | null;
|
|
sourceText: string | null;
|
|
promptBackground: string | null;
|
|
tone: string | null;
|
|
language: string | null;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
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[] = [
|
|
{
|
|
id: "ats-minimal",
|
|
title: "ATS Minimal",
|
|
eyebrow: "Scanner-friendly",
|
|
accent: "#0f172a",
|
|
blurb: "Compact, direct, and easy for screening systems to parse.",
|
|
sampleHeading: "Senior Backend Engineer",
|
|
sampleMeta: "Acme Systems · Oslo · 2021 - Present",
|
|
sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."]
|
|
},
|
|
{
|
|
id: "harvard",
|
|
title: "Harvard",
|
|
eyebrow: "Traditional",
|
|
accent: "#7f1d1d",
|
|
blurb: "Formal hierarchy and restrained tone for conservative hiring flows.",
|
|
sampleHeading: "Professional Summary",
|
|
sampleMeta: "Clear structure · precise dates · credible language",
|
|
sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."]
|
|
},
|
|
{
|
|
id: "auckland",
|
|
title: "Auckland",
|
|
eyebrow: "Modern sidebar",
|
|
accent: "#0f766e",
|
|
blurb: "Sharper highlights with a more contemporary, design-forward rhythm.",
|
|
sampleHeading: "Selected Impact",
|
|
sampleMeta: "Focused strengths · compact highlights",
|
|
sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."]
|
|
},
|
|
{
|
|
id: "edinburgh",
|
|
title: "Edinburgh",
|
|
eyebrow: "Editorial",
|
|
accent: "#5b21b6",
|
|
blurb: "More personality and stronger section contrast without losing clarity.",
|
|
sampleHeading: "Experience Highlights",
|
|
sampleMeta: "Premium spacing · stronger visual voice",
|
|
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
|
|
},
|
|
{
|
|
id: "monarch",
|
|
title: "Monarch",
|
|
eyebrow: "Executive",
|
|
accent: "#7c2d12",
|
|
blurb: "High-contrast premium presentation for leadership-heavy applications.",
|
|
sampleHeading: "Executive Profile",
|
|
sampleMeta: "Leadership clarity · premium hierarchy",
|
|
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
|
|
},
|
|
{
|
|
id: "fjord",
|
|
title: "Fjord",
|
|
eyebrow: "Technical",
|
|
accent: "#0f4c5c",
|
|
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
|
|
sampleHeading: "Projects & Systems",
|
|
sampleMeta: "Technical depth · practical readability",
|
|
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
|
|
},
|
|
];
|
|
|
|
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();
|
|
}
|
|
|
|
function confidenceTone(confidence?: number) {
|
|
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
|
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
|
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
|
|
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
|
|
}
|
|
|
|
function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
|
|
if (!metadata) return null;
|
|
const tone = confidenceTone(metadata.confidence);
|
|
return (
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
|
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
|
|
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
|
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
|
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
|
|
{metadata.sourceSnippet ? (
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{metadata.sourceSnippet}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// 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 [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 ?? "");
|
|
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
|
setLoadError(null);
|
|
} catch (error: any) {
|
|
setMe(null);
|
|
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
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()}>Retry</Button>}>
|
|
Unable to load profile.
|
|
<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("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth />
|
|
<TextField
|
|
label={t("profileHeadline")}
|
|
value={headline}
|
|
onChange={(e) => setHeadline(e.target.value)}
|
|
helperText={t("profileHeadlineHelp")}
|
|
fullWidth
|
|
/>
|
|
</> : 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", { email, userName, firstName, lastName, displayName });
|
|
window.localStorage.setItem("profileHeadline", 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>
|
|
);
|
|
}
|