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:
37
src/client/hooks/useAccount.ts
Normal file
37
src/client/hooks/useAccount.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
385
src/client/routes/profile.tsx
Normal file
385
src/client/routes/profile.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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 "Deleted User".
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ProfileSection } from "../components/ProfileSection";
|
|
||||||
import {
|
import {
|
||||||
useApiKeys,
|
useApiKeys,
|
||||||
useAuth,
|
useAuth,
|
||||||
@@ -220,13 +219,7 @@ function SettingsPage() {
|
|||||||
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{auth?.user && (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
<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="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
|
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user