"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, KeyRound, Loader2, Save, Shield, Trash2, User, } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/settings/password-input"; import { useAuth } from "@/hooks/use-auth"; import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { describePasskeyError } from "@/lib/passkey-errors"; interface PasskeyInfo { id: string; name: string; credentialDeviceType: string; credentialBackedUp: boolean; createdAt: string; } export function AccountSection() { const { t, locale, setLocale } = useTranslations(); const { user, isLoading, isAuthenticated, refetch } = useAuth(); const router = useRouter(); const [email, setEmail] = useState(""); const [heightCm, setHeightCm] = useState(""); const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( null, ); // Password change dialog state. const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordSaving, setPasswordSaving] = useState(false); const [passwordMsg, setPasswordMsg] = useState(null); const [passwordMsgType, setPasswordMsgType] = useState< "success" | "error" | null >(null); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); // Passkey registration state. const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyMsg, setPasskeyMsg] = useState(null); const [passkeyMsgType, setPasskeyMsgType] = useState< "success" | "error" | null >(null); // Auth gate — push back to login if the user is unauthenticated. Effect // intentionally only navigates (never sets state in a way that re-runs the // effect), so the lint rule is satisfied without a disable. useEffect(() => { if (!isLoading && !isAuthenticated) { router.push("/auth/login"); } }, [isLoading, isAuthenticated, router]); // Hydrate the form draft once the auth payload arrives. Using the // React-recommended "store-the-prop-id alongside the state" pattern so the // sync-from-server happens during render (not in a setState-in-effect), // satisfying the strict `react-hooks/set-state-in-effect` rule. const [seededUserId, setSeededUserId] = useState(null); if (user && user.id !== seededUserId) { setSeededUserId(user.id); setEmail(user.email ?? ""); setHeightCm(user.heightCm?.toString() ?? ""); setDateOfBirth( user.dateOfBirth ? new Date(user.dateOfBirth).toISOString().slice(0, 10) : "", ); setGender(user.gender ?? ""); } async function handleSaveProfile(e: React.FormEvent) { e.preventDefault(); setSaving(true); setSaveMsg(null); setSaveMsgType(null); const res = await fetch("/api/auth/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: email.trim() || null, heightCm: heightCm ? parseFloat(heightCm) : null, dateOfBirth: dateOfBirth || null, gender: gender || null, }), }); if (res.ok) { setSaveMsg(t("settings.profileSaved")); setSaveMsgType("success"); await refetch(); } else { const json = await res.json(); setSaveMsg(json.error || t("settings.savingError")); setSaveMsgType("error"); } setSaving(false); } async function handleAddPasskey() { setPasskeyLoading(true); setPasskeyMsg(null); setPasskeyMsgType(null); try { const optRes = await fetch("/api/auth/passkey/register-options", { method: "POST", }); if (!optRes.ok) { setPasskeyMsg(t("settings.passkeyOptionsError")); setPasskeyMsgType("error"); setPasskeyLoading(false); return; } const optJson = await optRes.json(); const { options, challengeId } = optJson.data; const { startRegistration } = await import("@simplewebauthn/browser"); const credential = await startRegistration({ optionsJSON: options }); const verifyRes = await fetch("/api/auth/passkey/register-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challengeId, credential }), }); if (verifyRes.ok) { setPasskeyMsg(t("settings.passkeyAdded")); setPasskeyMsgType("success"); } else { const verifyJson = await verifyRes.json(); setPasskeyMsg( verifyJson.error || t("settings.passkeyRegistrationFailed"), ); setPasskeyMsgType("error"); } } catch (err) { const { key, params } = describePasskeyError(err); setPasskeyMsg(t(key, params)); setPasskeyMsgType("error"); } finally { setPasskeyLoading(false); } } async function handleChangePassword(e: React.FormEvent) { e.preventDefault(); setPasswordSaving(true); setPasswordMsg(null); setPasswordMsgType(null); if (newPassword !== confirmPassword) { setPasswordMsg(t("settings.passwordMismatch")); setPasswordMsgType("error"); setPasswordSaving(false); return; } try { const res = await fetch("/api/auth/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword, newPassword, confirmPassword, }), }); const json = await res.json(); if (!res.ok) { setPasswordMsg(json.error || t("settings.savingError")); setPasswordMsgType("error"); return; } setPasswordMsg(t("settings.passwordUpdated")); setPasswordMsgType("success"); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch { setPasswordMsg(t("common.networkError")); setPasswordMsgType("error"); } finally { setPasswordSaving(false); } } if (isLoading) { return (
); } if (!user) return null; return (

{t("settings.sections.account.title")}

{t("settings.sections.account.description")}

{/* Profile card */}

{t("settings.profile")}

setEmail(e.target.value)} placeholder={t("auth.emailPlaceholder")} maxLength={320} />

{t("settings.genderHint")}

setHeightCm(e.target.value)} placeholder="175" min={50} max={300} step={0.1} />
setDateOfBirth(e.target.value)} max={new Date().toISOString().slice(0, 10)} />

{t("settings.dateOfBirthHint")}

{t("settings.languageDescription")}

{saveMsg && (

{saveMsg}

)}
{/* Passkeys card */}

{t("settings.passkeys")}

{passkeyMsg && (

{passkeyMsg}

)}
{/* Password card */}

{t("settings.passwordReset")}

{t("settings.changePasswordDescription")}

{ setPasswordDialogOpen(open); if (!open) { setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); setPasswordMsg(null); setPasswordMsgType(null); } }} > {t("settings.passwordReset")} {t("settings.changePasswordDescription")}
setCurrentPassword(e.target.value)} />
setNewPassword(e.target.value)} />
setConfirmPassword(e.target.value)} />
{passwordMsg && (

{passwordMsg}

)}
); } function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [deleteMsg, setDeleteMsg] = useState(null); const { data: passkeys } = useQuery({ queryKey: ["passkeys"], queryFn: async () => { const res = await fetch("/api/auth/passkeys"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as PasskeyInfo[]; }, enabled: isAuthenticated, }); const deletePasskey = useMutation({ mutationFn: async (id: string) => { const res = await fetch(`/api/auth/passkeys/${id}`, { method: "DELETE" }); const json = await res.json(); if (!res.ok) throw new Error(json.error || t("common.error")); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["passkeys"] }); setDeleteMsg(null); }, onError: (err: Error) => { setDeleteMsg(err.message); }, }); const DEVICE_TYPE_LABELS: Record = { singleDevice: t("settings.singleDevice"), multiDevice: t("settings.multiDevice"), }; if (!passkeys || passkeys.length === 0) { return (

{t("settings.passkeys")}

{t("settings.noPasskeys")}

); } return (

{t("settings.registeredPasskeys")}

{t("settings.passkeysDescription")}

{passkeys.map((pk, idx) => ( ))}
{t("settings.passkeyName")} {t("settings.passkeyDevice")} {t("settings.passkeyBackup")} {t("settings.passkeyCreated")} {t("settings.passkeyActions")}
{pk.name} {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? pk.credentialDeviceType} {pk.credentialBackedUp ? t("settings.backedUp") : t("common.no")} {formatDate(pk.createdAt)} {t("settings.deletePasskey")} {t("settings.deletePasskeyDescription")} {t("common.cancel")} deletePasskey.mutate(pk.id)} > {t("common.delete")}
{deleteMsg && (
{deleteMsg}
)}
); }