440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
// app/(account)/account/page.tsx
|
||
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { useSearchParams, useRouter } from "next/navigation";
|
||
import { useAuth } from "@/context/AuthContext";
|
||
|
||
const API_BASE_URL =
|
||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||
|
||
export default function AccountPage() {
|
||
const { user, loading, token, setSession, logout } = useAuth();
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
|
||
const welcome = searchParams.get("welcome") === "1";
|
||
|
||
const needsDisplayName = useMemo(() => {
|
||
return !user?.displayName || user.displayName.trim().length === 0;
|
||
}, [user?.displayName]);
|
||
|
||
// Only show welcome panel if they actually need setup
|
||
const showWelcomeCard = welcome && needsDisplayName;
|
||
|
||
// ----------------------------
|
||
// Display Name state (shared)
|
||
// ----------------------------
|
||
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
|
||
const [saving, setSaving] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
|
||
// Inline profile editor
|
||
const [editingName, setEditingName] = useState(false);
|
||
|
||
// ----------------------------
|
||
// Password state
|
||
// ----------------------------
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
const [pw1, setPw1] = useState("");
|
||
const [pw2, setPw2] = useState("");
|
||
const [pwSaving, setPwSaving] = useState(false);
|
||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||
|
||
const canSaveName = useMemo(() => {
|
||
return displayName.trim().length > 0 && !saving;
|
||
}, [displayName, saving]);
|
||
|
||
const pwMismatch = useMemo(
|
||
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
|
||
[pw1, pw2]
|
||
);
|
||
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
|
||
const canSavePassword = useMemo(() => {
|
||
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
|
||
}, [pwSaving, pw1, pw2]);
|
||
|
||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
||
|
||
if (!user) {
|
||
return (
|
||
<div className="text-sm opacity-70">
|
||
You’re not logged in. Please log in to view your account.
|
||
</div>
|
||
);
|
||
}
|
||
const authedUser = user; // user is non-null from here down
|
||
|
||
async function saveDisplayName() {
|
||
setSaving(true);
|
||
setMsg(null);
|
||
|
||
try {
|
||
const nextName = displayName.trim();
|
||
|
||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify({ displayName: nextName }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
throw new Error(text || "Failed to update profile");
|
||
}
|
||
|
||
const updated = await res.json().catch(() => null);
|
||
|
||
const nextUser = {
|
||
email: authedUser.email,
|
||
displayName: updated?.displayName ?? nextName,
|
||
role: authedUser.role,
|
||
};
|
||
|
||
if (token) setSession(token, nextUser);
|
||
|
||
setMsg("Saved ✅");
|
||
setEditingName(false);
|
||
|
||
// If we came from the welcome flow, clear the param
|
||
if (welcome) router.replace("/account");
|
||
} catch (e: any) {
|
||
setMsg(e?.message || "Couldn’t save. Try again.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function savePassword() {
|
||
setPwMsg(null);
|
||
|
||
if (pw1.length < 8) {
|
||
setPwMsg("Password must be at least 8 characters.");
|
||
return;
|
||
}
|
||
if (pw1 !== pw2) {
|
||
setPwMsg("Passwords don’t match.");
|
||
return;
|
||
}
|
||
|
||
setPwSaving(true);
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE_URL}/api/users/me/password`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify({ password: pw1 }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
throw new Error(text || "Failed to set password");
|
||
}
|
||
|
||
setPwMsg("Password set ✅ You can now use email + password login.");
|
||
setPw1("");
|
||
setPw2("");
|
||
setShowPassword(false);
|
||
|
||
if (welcome) router.replace("/account");
|
||
} catch (e: any) {
|
||
setPwMsg(e?.message || "Couldn’t set password. Try again.");
|
||
} finally {
|
||
setPwSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Welcome / Setup card (only if needed) */}
|
||
{showWelcomeCard && (
|
||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
|
||
<div>
|
||
<h2 className="text-base font-semibold">Welcome to the beta 👋</h2>
|
||
<p className="text-sm text-zinc-300/80">
|
||
Quick setup and you’re ready to cook.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Display name */}
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-medium text-zinc-200">
|
||
Display name
|
||
</label>
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
|
||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||
/>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={saveDisplayName}
|
||
disabled={!canSaveName}
|
||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||
>
|
||
{saving ? "Saving…" : "Save"}
|
||
</button>
|
||
{msg && (
|
||
<span
|
||
className={`text-xs ${
|
||
msg.toLowerCase().includes("couldn")
|
||
? "text-red-300"
|
||
: "text-zinc-200/80"
|
||
}`}
|
||
>
|
||
{msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Password */}
|
||
<div className="pt-2 border-t border-white/10">
|
||
<p className="text-xs text-zinc-300/80">
|
||
Want password login later? Optional (but recommended).
|
||
</p>
|
||
|
||
{!showPassword ? (
|
||
<button
|
||
onClick={() => setShowPassword(true)}
|
||
className="mt-2 text-xs underline opacity-80 hover:opacity-100"
|
||
>
|
||
Set a password
|
||
</button>
|
||
) : (
|
||
<div className="mt-3 space-y-2">
|
||
<input
|
||
type="password"
|
||
value={pw1}
|
||
onChange={(e) => setPw1(e.target.value)}
|
||
placeholder="New password (min 8 chars)"
|
||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||
/>
|
||
<input
|
||
type="password"
|
||
value={pw2}
|
||
onChange={(e) => setPw2(e.target.value)}
|
||
placeholder="Confirm password"
|
||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||
/>
|
||
|
||
{(pwTooShort || pwMismatch) && (
|
||
<div className="text-xs text-red-300">
|
||
{pwTooShort
|
||
? "Password must be at least 8 characters."
|
||
: "Passwords don’t match."}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={savePassword}
|
||
disabled={!canSavePassword}
|
||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||
>
|
||
{pwSaving ? "Saving…" : "Save password"}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowPassword(false);
|
||
setPw1("");
|
||
setPw2("");
|
||
setPwMsg(null);
|
||
}}
|
||
className="text-xs opacity-80 hover:opacity-100"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
|
||
{pwMsg && (
|
||
<div
|
||
className={`text-xs ${
|
||
pwMsg.toLowerCase().includes("couldn")
|
||
? "text-red-300"
|
||
: "text-zinc-200/80"
|
||
}`}
|
||
>
|
||
{pwMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Profile header */}
|
||
<div>
|
||
<h2 className="text-lg font-semibold">Profile</h2>
|
||
<p className="text-sm opacity-70">Basic account info.</p>
|
||
</div>
|
||
|
||
{/* Profile card */}
|
||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||
<div className="text-sm">
|
||
<span className="opacity-70">Email:</span>{" "}
|
||
<span className="font-medium">{user.email}</span>
|
||
</div>
|
||
|
||
{/* Display name (inline editable) */}
|
||
<div className="text-sm">
|
||
<span className="opacity-70">Display name:</span>{" "}
|
||
{!editingName ? (
|
||
<>
|
||
<span className="font-medium">{user.displayName || "—"}</span>
|
||
<button
|
||
onClick={() => {
|
||
setEditingName(true);
|
||
setMsg(null);
|
||
setDisplayName(user.displayName ?? "");
|
||
}}
|
||
className="ml-3 text-xs underline opacity-70 hover:opacity-100"
|
||
>
|
||
Edit
|
||
</button>
|
||
</>
|
||
) : (
|
||
<div className="mt-2 space-y-2">
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
placeholder="Your display name"
|
||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||
/>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={saveDisplayName}
|
||
disabled={!canSaveName}
|
||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
|
||
>
|
||
{saving ? "Saving…" : "Save"}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setEditingName(false);
|
||
setDisplayName(user.displayName ?? "");
|
||
setMsg(null);
|
||
}}
|
||
className="text-xs opacity-80 hover:opacity-100"
|
||
>
|
||
Cancel
|
||
</button>
|
||
{msg && (
|
||
<span
|
||
className={`text-xs ${
|
||
msg.toLowerCase().includes("couldn")
|
||
? "text-red-300"
|
||
: "text-zinc-200/80"
|
||
}`}
|
||
>
|
||
{msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-sm">
|
||
<span className="opacity-70">Role:</span>{" "}
|
||
<span className="font-medium">{user.role}</span>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
|
||
<button
|
||
onClick={logout}
|
||
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
|
||
>
|
||
Log out
|
||
</button>
|
||
|
||
{/* Optional: quick link to set password later */}
|
||
<button
|
||
onClick={() => {
|
||
setShowPassword(true);
|
||
// Scroll user’s attention to welcome panel area if it exists,
|
||
// otherwise just open the password UI in their mind.
|
||
// (Kept simple: you can add a dedicated card later if you want)
|
||
setPwMsg(null);
|
||
}}
|
||
className="text-xs underline opacity-70 hover:opacity-100"
|
||
>
|
||
Set / change password
|
||
</button>
|
||
</div>
|
||
|
||
{/* If user clicks "Set/change password" outside welcome flow, show it here */}
|
||
{!showWelcomeCard && showPassword && (
|
||
<div className="pt-4 border-t border-white/10 space-y-2">
|
||
<div className="text-sm font-medium">Set password</div>
|
||
<input
|
||
type="password"
|
||
value={pw1}
|
||
onChange={(e) => setPw1(e.target.value)}
|
||
placeholder="New password (min 8 chars)"
|
||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||
/>
|
||
<input
|
||
type="password"
|
||
value={pw2}
|
||
onChange={(e) => setPw2(e.target.value)}
|
||
placeholder="Confirm password"
|
||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||
/>
|
||
|
||
{(pwTooShort || pwMismatch) && (
|
||
<div className="text-xs text-red-300">
|
||
{pwTooShort
|
||
? "Password must be at least 8 characters."
|
||
: "Passwords don’t match."}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={savePassword}
|
||
disabled={!canSavePassword}
|
||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||
>
|
||
{pwSaving ? "Saving…" : "Save password"}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowPassword(false);
|
||
setPw1("");
|
||
setPw2("");
|
||
setPwMsg(null);
|
||
}}
|
||
className="text-xs opacity-80 hover:opacity-100"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
|
||
{pwMsg && (
|
||
<div
|
||
className={`text-xs ${
|
||
pwMsg.toLowerCase().includes("couldn")
|
||
? "text-red-300"
|
||
: "text-zinc-200/80"
|
||
}`}
|
||
>
|
||
{pwMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|