diff --git a/src/client/hooks/useAccount.ts b/src/client/hooks/useAccount.ts new file mode 100644 index 0000000..f744d80 --- /dev/null +++ b/src/client/hooks/useAccount.ts @@ -0,0 +1,37 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiGet, apiPost } from "../lib/api"; + +export function useHasPassword() { + return useQuery({ + queryKey: ["account", "hasPassword"], + queryFn: () => + apiGet<{ hasPassword: boolean }>("/api/account/has-password"), + }); +} + +export function useChangePassword() { + return useMutation({ + mutationFn: (data: { currentPassword: string; newPassword: string }) => + apiPost<{ ok: boolean }>("/api/account/password", data), + }); +} + +export function useChangeEmail() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { newEmail: string }) => + apiPost<{ ok: boolean }>("/api/account/email", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + }); +} + +export function useDeleteAccount() { + return useMutation({ + mutationFn: () => + apiPost<{ ok: boolean; redirectTo: string }>("/api/account/delete", { + confirmation: "DELETE", + }), + }); +} diff --git a/src/client/routes/profile.tsx b/src/client/routes/profile.tsx new file mode 100644 index 0000000..146b55e --- /dev/null +++ b/src/client/routes/profile.tsx @@ -0,0 +1,385 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { ProfileSection } from "../components/ProfileSection"; +import { + useChangeEmail, + useChangePassword, + useDeleteAccount, + useHasPassword, +} from "../hooks/useAccount"; +import { useAuth } from "../hooks/useAuth"; + +export const Route = createFileRoute("/profile")({ + component: ProfilePage, +}); + +function ProfilePage() { + const { data: auth, isLoading } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (!isLoading && !auth?.authenticated) { + navigate({ to: "/login" }); + } + }, [auth, isLoading, navigate]); + + if (isLoading || !auth?.authenticated) { + return null; + } + + return ( +
+
+ + ← Back + +

Profile

+
+ + {/* Section 1: Profile Info (D-02) */} +
+ +
+ + {/* Section 2: Account Info (D-02) */} +
+ +
+ + {/* Section 3: Security (D-05) */} +
+ +
+ + {/* Section 4: Danger Zone (D-05, D-06) */} +
+ +
+
+ ); +} + +// ── Account Info Section ──────────────────────────────────────────── + +function AccountInfoSection({ + email, + createdAt, +}: { + email?: string; + createdAt?: string; +}) { + const changeEmail = useChangeEmail(); + const [editing, setEditing] = useState(false); + const [newEmail, setNewEmail] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const memberSince = createdAt + ? new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(new Date(createdAt)) + : null; + + async function handleEmailChange(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + try { + await changeEmail.mutateAsync({ newEmail: newEmail.trim() }); + setMessage({ type: "success", text: "Email updated" }); + setEditing(false); + setNewEmail(""); + } catch (err) { + setMessage({ type: "error", text: (err as Error).message }); + } + } + + return ( +
+
+

Account

+

Your account information

+
+ + {/* Email row */} +
+
+ Email + + {email || "No email on file"} + +
+ {!editing && ( + + )} +
+ + {/* Inline email change form */} + {editing && ( +
+ setNewEmail(e.target.value)} + required + className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> + + +
+ )} + + {message && ( +

+ {message.text} +

+ )} + + {/* Member since row */} + {memberSince && ( +
+ + Member since + + {memberSince} +
+ )} +
+ ); +} + +// ── Security Section ──────────────────────────────────────────────── + +function SecuritySection() { + const { data: hasPasswordData } = useHasPassword(); + const changePassword = useChangePassword(); + const hasPassword = hasPasswordData?.hasPassword ?? true; + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + // Client-side validation per D-11 + const passwordValid = + newPassword.length >= 8 && + /[A-Z]/.test(newPassword) && + /[a-z]/.test(newPassword) && + /[0-9]/.test(newPassword); + const passwordsMatch = newPassword === confirmPassword; + const canSubmit = hasPassword + ? currentPassword.length > 0 && passwordValid && passwordsMatch + : passwordValid && passwordsMatch; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + try { + await changePassword.mutateAsync({ + currentPassword: hasPassword ? currentPassword : "", + newPassword, + }); + setMessage({ type: "success", text: "Password updated" }); + // Per T-28-08: clear password fields on success + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (err) { + setMessage({ type: "error", text: (err as Error).message }); + } + } + + return ( +
+
+

Security

+

Manage your password

+
+ +
+ {hasPassword && ( +
+ + setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ )} + +
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ +

+ Password must be at least 8 characters with uppercase, lowercase, and + a number. +

+ + {message && ( +

+ {message.text} +

+ )} + + +
+
+ ); +} + +// ── Danger Zone Section ───────────────────────────────────────────── + +function DangerZoneSection() { + const deleteAccount = useDeleteAccount(); + const [showConfirm, setShowConfirm] = useState(false); + const [confirmation, setConfirmation] = useState(""); + + async function handleDelete() { + try { + await deleteAccount.mutateAsync(); + window.location.href = "/logout"; + } catch (err) { + console.error("Account deletion failed:", err); + } + } + + return ( +
+
+

Danger Zone

+

+ Delete your account and all personal data. Public setups will be + attributed to "Deleted User". +

+
+ + {!showConfirm ? ( + + ) : ( +
+

+ This action is permanent. Type DELETE to confirm. +

+ setConfirmation(e.target.value)} + className="w-full px-3 py-2 border border-red-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-200" + /> +
+ + +
+
+ )} +
+ ); +} diff --git a/src/client/routes/settings.tsx b/src/client/routes/settings.tsx index 76e2e11..f39c699 100644 --- a/src/client/routes/settings.tsx +++ b/src/client/routes/settings.tsx @@ -1,6 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useRef, useState } from "react"; -import { ProfileSection } from "../components/ProfileSection"; import { useApiKeys, useAuth, @@ -220,13 +219,7 @@ function SettingsPage() {

Settings

- {auth?.user && ( -
- -
- )} - -
+

Weight Unit