270 lines
8.6 KiB
TypeScript
270 lines
8.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useAuth } from "@/context/AuthContext";
|
||
|
||
export default function ProfilePage() {
|
||
const router = useRouter();
|
||
const { user, loading, token, logout, refreshMeAndSession } = useAuth();
|
||
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [savingName, setSavingName] = useState(false);
|
||
const [nameMsg, setNameMsg] = useState<string | null>(null);
|
||
|
||
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 hasUsername = useMemo(() => {
|
||
const current = String((user as any)?.username ?? "").trim();
|
||
return current.length > 0;
|
||
}, [user]);
|
||
|
||
const hasPassword = useMemo(() => {
|
||
return !!(user as any)?.passwordSetAt;
|
||
}, [user]);
|
||
|
||
const needsOnboarding = !!user && (!hasUsername || !hasPassword);
|
||
|
||
const pwMismatch = pw1.length > 0 && pw2.length > 0 && pw1 !== pw2;
|
||
const pwTooShort = pw1.length > 0 && pw1.length < 8;
|
||
const canSavePassword = !pwSaving && pw1.length >= 8 && pw1 === pw2;
|
||
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
setDisplayName(String(user.displayName ?? ""));
|
||
}, [user]);
|
||
|
||
// If missing required setup, bounce to onboarding
|
||
useEffect(() => {
|
||
if (loading) return;
|
||
if (!user) return;
|
||
|
||
if (needsOnboarding) {
|
||
router.replace("/account/onboarding");
|
||
}
|
||
}, [loading, user, needsOnboarding, router]);
|
||
|
||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
||
if (!user) return <div className="text-sm opacity-70">Not logged in.</div>;
|
||
|
||
// While redirecting, don't flash profile UI
|
||
if (needsOnboarding) return null;
|
||
|
||
async function saveDisplayName() {
|
||
setSavingName(true);
|
||
setNameMsg(null);
|
||
|
||
try {
|
||
const next = displayName.trim();
|
||
|
||
const res = await fetch(`/api/users/me`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify({ displayName: next }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error((await res.text().catch(() => "")) || "Failed to save display name");
|
||
}
|
||
|
||
await refreshMeAndSession();
|
||
setNameMsg("Saved ✅");
|
||
} catch (e: any) {
|
||
setNameMsg(e?.message || "Couldn’t save display name.");
|
||
} finally {
|
||
setSavingName(false);
|
||
}
|
||
}
|
||
|
||
async function savePassword() {
|
||
setPwMsg(null);
|
||
|
||
if (pw1.length < 8) return setPwMsg("Password must be at least 8 characters.");
|
||
if (pw1 !== pw2) return setPwMsg("Passwords don’t match.");
|
||
|
||
setPwSaving(true);
|
||
try {
|
||
const res = await fetch(`/api/users/me/password`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify({ password: pw1 }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error((await res.text().catch(() => "")) || "Failed to set password");
|
||
}
|
||
|
||
await refreshMeAndSession();
|
||
|
||
setPwMsg("Password updated ✅");
|
||
setPw1("");
|
||
setPw2("");
|
||
setShowPassword(false);
|
||
} catch (e: any) {
|
||
setPwMsg(e?.message || "Couldn’t set password. Try again.");
|
||
} finally {
|
||
setPwSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-lg font-semibold">Profile</h2>
|
||
<p className="text-sm opacity-70">Basic account info.</p>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-4">
|
||
<div className="text-sm">
|
||
<span className="opacity-70">Email:</span>{" "}
|
||
<span className="font-medium">{user.email}</span>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-sm">
|
||
<span className="opacity-70">Username:</span>{" "}
|
||
<span className="font-medium">{String((user as any)?.username ?? "—")}</span>
|
||
</div>
|
||
|
||
<div className="text-sm font-medium">Display name</div>
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => {
|
||
setDisplayName(e.target.value);
|
||
setNameMsg(null);
|
||
}}
|
||
placeholder="e.g. Sean / S2Tactical"
|
||
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
|
||
type="button"
|
||
onClick={saveDisplayName}
|
||
disabled={savingName || displayName.trim().length === 0}
|
||
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100 disabled:opacity-50"
|
||
>
|
||
{savingName ? "Saving…" : "Save display name"}
|
||
</button>
|
||
|
||
{nameMsg && (
|
||
<span
|
||
className={`text-xs ${
|
||
nameMsg.toLowerCase().includes("couldn") || nameMsg.toLowerCase().includes("failed")
|
||
? "text-red-300"
|
||
: "text-zinc-200/80"
|
||
}`}
|
||
>
|
||
{nameMsg}
|
||
</span>
|
||
)}
|
||
</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>
|
||
|
||
<button
|
||
onClick={() => {
|
||
setShowPassword((v) => !v);
|
||
setPwMsg(null);
|
||
}}
|
||
className="text-xs underline opacity-70 hover:opacity-100"
|
||
>
|
||
{showPassword ? "Hide password" : "Set / change password"}
|
||
</button>
|
||
</div>
|
||
|
||
{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") || pwMsg.toLowerCase().includes("failed")
|
||
? "text-red-300"
|
||
: "text-zinc-200/80"
|
||
}`}
|
||
>
|
||
{pwMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="pt-3 border-t border-white/10 flex items-center justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => router.replace("/builder")}
|
||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black"
|
||
>
|
||
Back to Builder
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |