feat(28-02): create profile page with account management, separate from settings

Adds /profile route with four sections: profile info (reuses ProfileSection),
account info (email + member since), security (password change/set), and
danger zone (account deletion with typed confirmation). Removes ProfileSection
from settings page per D-01.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 17:49:10 +02:00
parent e8207a33f9
commit 23692514cb
3 changed files with 423 additions and 8 deletions

View File

@@ -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",
}),
});
}

View File

@@ -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 (
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="mb-6">
<Link
to="/"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
>
&larr; Back
</Link>
<h1 className="text-xl font-semibold text-gray-900">Profile</h1>
</div>
{/* Section 1: Profile Info (D-02) */}
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
<ProfileSection />
</div>
{/* Section 2: Account Info (D-02) */}
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<AccountInfoSection
email={auth.user?.email}
createdAt={auth.user?.createdAt}
/>
</div>
{/* Section 3: Security (D-05) */}
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<SecuritySection />
</div>
{/* Section 4: Danger Zone (D-05, D-06) */}
<div className="bg-white rounded-xl border border-red-200 p-5 space-y-6 mt-4">
<DangerZoneSection />
</div>
</div>
);
}
// ── 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 (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-900">Account</h3>
<p className="text-xs text-gray-500 mt-0.5">Your account information</p>
</div>
{/* Email row */}
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-700">Email</span>
<span className="text-sm text-gray-900 ml-3">
{email || "No email on file"}
</span>
</div>
{!editing && (
<button
type="button"
onClick={() => setEditing(true)}
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
Change
</button>
)}
</div>
{/* Inline email change form */}
{editing && (
<form onSubmit={handleEmailChange} className="flex gap-2">
<input
type="email"
placeholder="New email address"
value={newEmail}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={changeEmail.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{changeEmail.isPending ? "Updating..." : "Update Email"}
</button>
<button
type="button"
onClick={() => {
setEditing(false);
setNewEmail("");
setMessage(null);
}}
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Cancel
</button>
</form>
)}
{message && (
<p
className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}
>
{message.text}
</p>
)}
{/* Member since row */}
{memberSince && (
<div>
<span className="text-sm font-medium text-gray-700">
Member since
</span>
<span className="text-sm text-gray-500 ml-3">{memberSince}</span>
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-900">Security</h3>
<p className="text-xs text-gray-500 mt-0.5">Manage your password</p>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
{hasPassword && (
<div>
<label
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
Current Password
</label>
<input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => 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"
/>
</div>
)}
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
{hasPassword ? "New Password" : "Password"}
</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-gray-400">
Password must be at least 8 characters with uppercase, lowercase, and
a number.
</p>
{message && (
<p
className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}
>
{message.text}
</p>
)}
<button
type="submit"
disabled={!canSubmit || changePassword.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{changePassword.isPending
? "Changing..."
: hasPassword
? "Change Password"
: "Set Password"}
</button>
</form>
</div>
);
}
// ── 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 (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-900">Danger Zone</h3>
<p className="text-xs text-gray-500 mt-0.5">
Delete your account and all personal data. Public setups will be
attributed to &quot;Deleted User&quot;.
</p>
</div>
{!showConfirm ? (
<button
type="button"
onClick={() => setShowConfirm(true)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Delete Account
</button>
) : (
<div className="space-y-3">
<p className="text-sm text-red-600">
This action is permanent. Type DELETE to confirm.
</p>
<input
type="text"
placeholder="Type DELETE to confirm"
value={confirmation}
onChange={(e) => 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"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setShowConfirm(false);
setConfirmation("");
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={confirmation !== "DELETE" || deleteAccount.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteAccount.isPending ? "Deleting..." : "Delete Account"}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -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() {
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
</div>
{auth?.user && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
<ProfileSection />
</div>
)}
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>