account settings and account creation

This commit is contained in:
2025-12-27 20:01:17 -05:00
parent aa2dfb0407
commit 2202abe89f
21 changed files with 1848 additions and 718 deletions
+8
View File
@@ -0,0 +1,8 @@
export default function FavoritesPage() {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold">Favorite Parts</h2>
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
</div>
);
}
+350
View File
@@ -0,0 +1,350 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function OnboardingPage() {
const router = useRouter();
const { user, loading, token, logout, refreshMeAndSession } = useAuth();
// Step: 1=password, 2=username, 3=optional display name
const [step, setStep] = useState<1 | 2 | 3>(1);
// Password
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
// Username
const [username, setUsername] = useState("");
const [checking, setChecking] = useState(false);
const [available, setAvailable] = useState<boolean | null>(null);
const [userMsg, setUserMsg] = useState<string | null>(null);
const [savingUsername, setSavingUsername] = useState(false);
// Display name (optional)
const [displayName, setDisplayName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameMsg, setNameMsg] = 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 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;
// Hydrate local inputs when user becomes available
useEffect(() => {
if (!user) return;
setUsername(String((user as any)?.username ?? ""));
setDisplayName(String(user.displayName ?? ""));
}, [user]);
// Route guards + step sync (NO redirects during render)
useEffect(() => {
if (loading) return;
if (!user) return;
// If complete, bounce them out
if (hasUsername && hasPassword) {
router.replace("/builder");
return;
}
// Choose the right step based on what's missing
if (!hasPassword) setStep(1);
else if (!hasUsername) setStep(2);
else setStep(3);
}, [loading, user, hasUsername, hasPassword, 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>;
async function savePassword() {
setPwMsg(null);
if (pw1.length < 8) return setPwMsg("Password must be at least 8 characters.");
if (pw1 !== pw2) return setPwMsg("Passwords dont 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 saved ✅");
setPw1("");
setPw2("");
// step will auto-sync via useEffect once user updates, but this makes UI feel instant:
setStep(2);
} catch (e: any) {
setPwMsg(e?.message || "Couldnt set password.");
} finally {
setPwSaving(false);
}
}
async function checkUsername() {
setChecking(true);
setUserMsg(null);
setAvailable(null);
try {
const u = username.trim().toLowerCase();
const res = await fetch(
`/api/users/me/username-available?username=${encodeURIComponent(u)}`,
{
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
cache: "no-store",
}
);
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to check username");
}
const data = await res.json();
setAvailable(!!data?.available);
} catch (e: any) {
setUserMsg(e?.message || "Couldnt check username.");
} finally {
setChecking(false);
}
}
async function saveUsername() {
setSavingUsername(true);
setUserMsg(null);
try {
const u = username.trim().toLowerCase();
const res = await fetch(`/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ username: u }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to save username");
}
await refreshMeAndSession();
setAvailable(true);
setUserMsg("Username saved ✅");
setStep(3);
} catch (e: any) {
setUserMsg(e?.message || "Couldnt save username.");
} finally {
setSavingUsername(false);
}
}
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 || "Couldnt save display name.");
} finally {
setSavingName(false);
}
}
return (
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-6">
<h2 className="text-lg font-semibold">Welcome to the beta 👋</h2>
<p className="mt-1 text-sm text-zinc-300/80">
Step {step} of 3. Password + username required.
</p>
{step === 1 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">Set password *</div>
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<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"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort ? "Password must be at least 8 characters." : "Passwords dont match."}
</div>
)}
<button
type="button"
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
{pwMsg && <div className="text-xs text-zinc-200/80">{pwMsg}</div>}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<button
type="button"
onClick={logout}
className="text-xs underline opacity-70 hover:opacity-100"
>
Log out
</button>
</div>
</div>
)}
{step === 2 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">Choose username *</div>
<input
value={username}
onChange={(e) => {
setUsername(e.target.value);
setAvailable(null);
setUserMsg(null);
}}
placeholder="e.g. s2tactical"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={checkUsername}
disabled={checking || savingUsername || username.trim().length < 3}
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
>
{checking ? "Checking…" : "Check availability"}
</button>
<button
type="button"
onClick={saveUsername}
disabled={savingUsername || available !== true}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
>
{savingUsername ? "Saving…" : "Save username"}
</button>
{available !== null && (
<span className={`text-xs ${available ? "text-emerald-300" : "text-red-300"}`}>
{available ? "Available" : "Taken / invalid"}
</span>
)}
</div>
{userMsg && <div className="text-xs text-zinc-200/80">{userMsg}</div>}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<button
type="button"
onClick={logout}
className="text-xs underline opacity-70 hover:opacity-100"
>
Log out
</button>
</div>
</div>
)}
{step === 3 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">
Display name <span className="text-zinc-400">(optional)</span>
</div>
<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"
/>
<button
type="button"
onClick={saveDisplayName}
disabled={savingName || displayName.trim().length === 0}
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
>
{savingName ? "Saving…" : "Save display name"}
</button>
{nameMsg && <div className="text-xs text-zinc-200/80">{nameMsg}</div>}
<div className="mt-6 flex items-center justify-between border-t border-white/10 pt-4">
<button
type="button"
onClick={() => router.replace("/builder")}
disabled={!(hasUsername && hasPassword)}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
>
Continue to Builder
</button>
<button
type="button"
onClick={() => router.replace("/account/profile")}
className="text-xs underline opacity-70 hover:opacity-100"
>
Ill finish later
</button>
</div>
</div>
)}
</div>
);
}
+4 -438
View File
@@ -1,439 +1,5 @@
// app/(account)/account/page.tsx
"use client";
import { redirect } from "next/navigation";
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">
Youre 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 || "Couldnt 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 dont 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 || "Couldnt 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 youre 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 dont 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 users 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 dont 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>
);
}
export default function AccountIndex() {
redirect("/account/profile");
}
+270
View File
@@ -0,0 +1,270 @@
"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 || "Couldnt 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 dont 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 || "Couldnt 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 dont 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>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function SecurityPage() {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold">Security</h2>
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function VaultPage() {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold">My Vault</h2>
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
</div>
);
}
+24 -26
View File
@@ -1,59 +1,57 @@
// app/(account)/layout.tsx
import Link from "next/link";
import AccountChrome from "./AccountChrome";
const nav = [
{ href: "/account", label: "My Account" },
{ href: "/account/settings", label: "Settings" },
{ href: "/vault", label: "My Vault" },
{ name: "Profile", href: "/account/profile" },
{ name: "Security", href: "/account/security" },
{ name: "My Vault", href: "/account/vault" },
{ name: "Favorite Parts", href: "/account/favorites" },
];
export default function AccountLayout({ children }: { children: React.ReactNode }) {
export default function AccountLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen">
<AccountChrome />
<div className="mx-auto w-full max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-10">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
<div className="text-xs tracking-[0.25em] uppercase text-zinc-400">
Account
</div>
<h1 className="mt-1 text-2xl font-semibold text-white/90">
Settings & Profile
</h1>
<p className="mt-1 text-sm text-white/60">
<h1 className="mt-2 text-3xl font-semibold">Settings & Profile</h1>
<p className="mt-2 text-sm text-zinc-400">
Profile, password, and account settings.
</p>
</div>
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm hover:bg-white/10"
>
Back to Builder
</Link>
</div>
<div className="grid gap-6 lg:grid-cols-12">
<aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
<nav className="flex flex-col gap-1">
<div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-12">
{/* Sidebar */}
<aside className="lg:col-span-3">
<nav className="rounded-xl border border-white/10 bg-white/5 p-2">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
className="block rounded-lg px-3 py-2 text-sm text-zinc-200 hover:bg-white/5"
>
{item.label}
{item.name}
</Link>
))}
</nav>
</aside>
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
{children}
</main>
{/* Content */}
<main className="lg:col-span-9">{children}</main>
</div>
</div>
</div>
-102
View File
@@ -1,102 +0,0 @@
import React from "react";
import Link from "next/link";
export const metadata = {
title: "Terms of Service | BattlBuilder",
description: "Terms of Service and legal disclaimers for rifle assembly and part compatibility.",
};
export default function TermsOfService() {
const lastUpdated = "December 26, 2024";
return (
<div className="mx-auto max-w-4xl px-4 py-12">
<div className="mb-10">
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
</div>
<div className="space-y-10 text-sm leading-relaxed text-white/90">
{/* 1. Acceptance */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">1. Acceptance of Terms</h2>
<p>
By accessing or using Shadow GunBuilder AI (the "Site"), you agree to be bound by these Terms of Service.
This Site is intended for informational and entertainment purposes only, providing a platform to
visualize and plan AR-platform rifle builds using third-party components.
</p>
</section>
{/* 2. Legal Compliance */}
<section className="rounded-xl border border-orange-500/30 bg-orange-500/5 p-6">
<h2 className="mb-4 text-xl font-semibold text-orange-200">2. Legal Compliance & Firearms Safety</h2>
<div className="space-y-4">
<p className="font-medium text-orange-100">
IMPORTANT: It is your sole responsibility to ensure that any firearm configuration you assemble
complies with all applicable local, state, federal, and international laws.
</p>
<ul className="list-disc space-y-2 pl-5">
<li>Laws regarding "assault weapons," magazine capacity, barrel length (NFA regulations), and specific features vary significantly by jurisdiction.</li>
<li>The Site does not provide legal advice regarding the legality of any specific build in your area.</li>
<li>Always consult with a qualified gunsmith or legal professional before commencing a physical build.</li>
</ul>
</div>
</section>
{/* 3. No Guarantee of Compatibility */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">3. Compatibility Disclaimer</h2>
<p>
While we strive for accuracy, the Site is a digital simulation. Compatibility data between parts
(e.g., gas block diameters, handguard clearances, or buffer weights) is based on manufacturer
specifications which are subject to change.
</p>
<p className="mt-4">
Shadow GunBuilder AI does <strong>not</strong> guarantee that parts selected in the digital builder
will fit together physically or function safely. Mechanical tolerances and manufacturer variations
may exist.
</p>
</section>
{/* 4. User Generated Content */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">4. User Builds and the "Vault"</h2>
<p>
When you save a build to your Vault or publish it to the community, you grant us a non-exclusive
license to display and store that data. You are responsible for the content of your descriptions
and ensuring they do not violate any laws or third-party rights.
</p>
</section>
{/* 5. Affiliate and Pricing Disclosure */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">5. Affiliate & Pricing Data</h2>
<p>
Pricing and availability data are pulled from third-party retailers. We are not responsible
for pricing errors or out-of-stock items. We may receive commissions for purchases made
through links on the Site.
</p>
</section>
{/* 6. Limitation of Liability */}
<section className="border-t border-white/10 pt-10">
<h2 className="mb-4 text-xl font-semibold text-white">6. Limitation of Liability</h2>
<p className="uppercase text-xs opacity-70">
SHADOW GUNBUILDER AI AND ITS OPERATORS SHALL NOT BE LIABLE FOR ANY DAMAGES, INJURIES, OR LEGAL
CONSEQUENCES ARISING FROM THE USE OF THIS SITE OR THE PHYSICAL ASSEMBLY OF FIREARMS BASED ON
PLANS CREATED HEREIN. USE AT YOUR OWN RISK.
</p>
</section>
<div className="pt-10">
<Link
href="/public"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
Return to Home
</Link>
</div>
</div>
</div>
);
}
+3
View File
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import { BuilderNav } from "@/components/BuilderNav";
import { TopNav } from "@/components/TopNav";
import { Import } from "lucide-react";
import { Footer } from "@/components/Footer";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
@@ -10,6 +12,7 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
<TopNav />
<BuilderNav />
<main className="min-h-screen">{children}</main>
<Footer />
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import React from "react";
import Link from "next/link";
export const metadata = {
title: "Terms of Service | Battl Builder",
description:
"Terms of Service and legal disclaimers for Battl Builder, including firearm safety, compatibility, and community content.",
};
export default function TermsOfService() {
const lastUpdated = "December 27, 2025";
return (
<div className="mx-auto max-w-4xl px-4 py-12">
<div className="mb-10">
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
</div>
<div className="space-y-12 text-sm leading-relaxed text-white/90">
{/* 1. Acceptance */}
<section>
<h2 className="mb-4 text-xl font-semibold">1. Acceptance of Terms</h2>
<p>
These Terms of Service (Terms) govern your access to and use of
Battl Builder (the Service), including all content, tools,
features, and community functionality provided through the website.
</p>
<p className="mt-4">
By accessing or using any part of the Service, you agree to be bound
by these Terms and our Privacy Policy. If you do not agree, you may
not access or use the Service.
</p>
<p className="mt-4">
The Service is offered only to individuals who are at least 18 years
of age.
</p>
</section>
{/* 2. Nature of the Service */}
<section>
<h2 className="mb-4 text-xl font-semibold">
2. Nature of the Service
</h2>
<p>
Battl Builder is an informational and planning platform designed to
help users explore, visualize, and compare firearm-related
components and configurations. The Service may include pricing data,
compatibility indicators, build summaries, and community-generated
content.
</p>
<p className="mt-4 font-medium text-orange-200">
Battl Builder does NOT provide gunsmithing services, mechanical
instructions, professional advice, training, or certification of any
kind.
</p>
</section>
{/* 3. Legal Compliance & Firearms Safety */}
<section className="rounded-xl border border-orange-500/30 bg-orange-500/5 p-6">
<h2 className="mb-4 text-xl font-semibold text-orange-200">
3. Legal Compliance, Firearms Safety & Assumption of Risk
</h2>
<div className="space-y-4">
<p className="font-medium text-orange-100">
You are solely responsible for ensuring that any firearm,
component, or configuration complies with all applicable local,
state, federal, and international laws.
</p>
<ul className="list-disc space-y-2 pl-5">
<li>
Firearm laws vary significantly by jurisdiction and change
frequently.
</li>
<li>
Battl Builder does not provide legal advice or determine the
legality of any build.
</li>
<li>
Compatibility indicators are informational only and may be
incomplete or inaccurate.
</li>
</ul>
<p className="font-semibold">
YOU ACKNOWLEDGE AND AGREE THAT THE PHYSICAL ASSEMBLY,
MODIFICATION, POSSESSION, OR USE OF FIREARMS INVOLVES INHERENT
RISKS, INCLUDING SERIOUS INJURY OR DEATH. YOU ASSUME ALL SUCH
RISKS.
</p>
<p>
Nothing on the Service constitutes legal, mechanical, firearms,
safety, or professional advice of any kind.
</p>
<p className="mt-4 font-semibold">
You are solely responsible for verifying all component
compatibility, legality, and safe operation before acquiring,
assembling, or using any firearm or related component.
</p>
</div>
</section>
{/* 4. No Warranty / No Reliance */}
<section>
<h2 className="mb-4 text-xl font-semibold">
4. No Warranty; No Reliance
</h2>
<p>
The Service is provided for general informational purposes only. You
agree that you will not rely on the Service as a substitute for
professional advice, inspection, or training.
</p>
<p className="mt-4">
All content, data, and tools are provided AS IS and AS
AVAILABLE, without warranties of any kind, whether express or
implied.
</p>
</section>
{/* 5. User Content */}
<section>
<h2 className="mb-4 text-xl font-semibold">
5. User Content & Community Builds
</h2>
<p>
By submitting builds, images, descriptions, comments, or other
content (User Content), you represent and warrant that:
</p>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>You own or have rights to submit the content</li>
<li>The content does not infringe third-party rights</li>
<li>The content is not unlawful, misleading, or harmful</li>
<li>The content does not contain malware or malicious code</li>
</ul>
<p className="mt-4">
You grant Battl Builder a worldwide, royalty-free, non-exclusive
license to host, display, modify, and distribute your User Content
in connection with the Service.
</p>
</section>
{/* 6. Affiliate & Third-Party Data */}
<section>
<h2 className="mb-4 text-xl font-semibold">
6. Pricing, Affiliates & Third-Party Links
</h2>
<p>
Product pricing, availability, images, and specifications are
provided by third parties and may be inaccurate or outdated. Battl
Builder does not guarantee the accuracy of such data.
</p>
<p className="mt-4">
We may earn affiliate commissions from qualifying purchases made
through links on the Service.
</p>
</section>
{/* 7. Access & Restrictions */}
<section>
<h2 className="mb-4 text-xl font-semibold">
7. Access, License & Restrictions
</h2>
<p>
Battl Builder grants you a limited, non-exclusive, non-transferable
license to access the Service for personal, non-commercial use.
</p>
<p className="mt-4">You may NOT:</p>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>Scrape, crawl, or harvest data from the Service</li>
<li>Use automated tools, bots, or data-mining techniques</li>
<li>Republish or resell Service data</li>
<li>Use the Service for commercial purposes without permission</li>
</ul>
</section>
{/* 8. Termination */}
<section>
<h2 className="mb-4 text-xl font-semibold">8. Termination</h2>
<p>
Battl Builder may suspend or terminate your access to the Service at
any time, with or without notice, for any reason.
</p>
</section>
{/* 9. Limitation of Liability */}
<section className="border-t border-white/10 pt-10">
<h2 className="mb-4 text-xl font-semibold">
9. Limitation of Liability
</h2>
<p className="uppercase text-xs opacity-70">
TO THE MAXIMUM EXTENT PERMITTED BY LAW, BATTL BUILDER SHALL NOT BE
LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR
PUNITIVE DAMAGES, INCLUDING PERSONAL INJURY, LOSS OF DATA, OR LEGAL
CONSEQUENCES ARISING FROM USE OF THE SERVICE.
</p>
<p className="mt-4">
OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT PAID BY YOU TO BATTL
BUILDER IN THE TWELVE (12) MONTHS PRECEDING THE CLAIM, OR $100,
WHICHEVER IS GREATER.
</p>
</section>
{/* 10. Indemnification */}
<section>
<h2 className="mb-4 text-xl font-semibold">10. Indemnification</h2>
<p>
You agree to indemnify and hold harmless Battl Builder and its
operators from any claims, damages, losses, or expenses arising from
your use of the Service or violation of these Terms.
</p>
</section>
{/* 11. Arbitration */}
<section>
<h2 className="mb-4 text-xl font-semibold">
11. Arbitration & Class Action Waiver
</h2>
<p>
Any dispute arising out of or relating to these Terms shall be
resolved by binding arbitration on an individual basis. YOU WAIVE
THE RIGHT TO PARTICIPATE IN A CLASS ACTION.
</p>
<p className="mt-4">
Any arbitration shall take place in the State of Florida, conducted
in the English language, unless otherwise required by applicable
law.
</p>
<p className="mt-4">
This provision does not prevent either party from seeking injunctive
relief for intellectual property violations.
</p>
</section>
{/* 12. Misc */}
<section>
<h2 className="mb-4 text-xl font-semibold">12. Miscellaneous</h2>
<p>
These Terms constitute the entire agreement between you and Battl
Builder. If any provision is found unenforceable, the remaining
provisions will remain in effect.
</p>
<p className="mt-4">
These Terms and any dispute arising out of or relating to the
Service shall be governed by and construed in accordance with the
laws of the State of Florida, without regard to its conflict of law
principles.
</p>
</section>
<div className="pt-10">
<Link
href="/"
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
>
Return to Home
</Link>
</div>
</div>
</div>
);
}
+429 -51
View File
@@ -1,20 +1,123 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Field, Input } from "@/components/ui/form";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type AdminBetaRequestDto = {
id: number;
uuid: string;
email: string;
displayName?: string | null;
invited: boolean;
verified: boolean;
active: boolean;
createdAt?: string;
updatedAt?: string;
};
type PageResponse<T> = {
content: T[];
number: number;
size: number;
totalElements: number;
totalPages: number;
};
// Expected invite response shape (adjust fields if your DTO differs)
type AdminInviteResponse = {
ok?: boolean;
email?: string;
// backend returns this
inviteUrl?: string;
// keep these optional if you ever add them later
magicUrl?: string;
token?: string;
};
async function fetchJson(
path: string,
init: RequestInit = {},
authHeaders: HeadersInit = {}
) {
const res = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
...authHeaders,
},
cache: "no-store",
});
const text = await res.text().catch(() => "");
const data = text
? (() => {
try {
return JSON.parse(text);
} catch {
return text;
}
})()
: null;
if (!res.ok) {
const msg =
typeof data === "string"
? data
: data?.message || data?.error || `Request failed (${res.status})`;
throw new Error(msg);
}
return data;
}
function Badge({
ok,
yes = "Yes",
no = "No",
}: {
ok: boolean;
yes?: string;
no?: string;
}) {
return (
<span
className={[
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
ok
? "border border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border border-zinc-700 bg-zinc-950/40 text-zinc-400",
].join(" ")}
>
{ok ? yes : no}
</span>
);
}
export default function AdminBetaInvitesPage() {
const { getAuthHeaders, user } = useAuth();
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
// ------------------------------
// Batch invites
// ------------------------------
const [dryRun, setDryRun] = useState(true);
const [limit, setLimit] = useState("1");
const [limit, setLimit] = useState("25");
const [tokenMinutes, setTokenMinutes] = useState("30");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [loadingBatch, setLoadingBatch] = useState(false);
const [batchResult, setBatchResult] = useState<any>(null);
const [batchError, setBatchError] = useState<string | null>(null);
async function runInvites() {
setLoading(true);
setError(null);
setResult(null);
setLoadingBatch(true);
setBatchError(null);
setBatchResult(null);
try {
const qs = new URLSearchParams({
@@ -23,70 +126,345 @@ export default function AdminBetaInvitesPage() {
tokenMinutes: String(tokenMinutes || "30"),
});
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
method: "POST",
});
const data = await fetchJson(
`/api/v1/admin/beta/invites/send?${qs.toString()}`,
{ method: "POST" },
authHeaders
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
setResult(data);
setBatchResult(data);
await loadRequests();
} catch (e: any) {
setError(e?.message ?? "Failed");
setBatchError(e?.message ?? "Failed");
} finally {
setLoading(false);
setLoadingBatch(false);
}
}
// ------------------------------
// Requests table
// ------------------------------
const [page, setPage] = useState(0);
const size = 25;
const [loadingList, setLoadingList] = useState(false);
const [listError, setListError] = useState<string | null>(null);
const [requests, setRequests] =
useState<PageResponse<AdminBetaRequestDto> | null>(null);
// per-row loading state
const [rowBusy, setRowBusy] = useState<Record<number, boolean>>({});
// last invite response (for visibility / debug)
const [lastInvite, setLastInvite] = useState<AdminInviteResponse | null>(
null
);
async function loadRequests() {
setLoadingList(true);
setListError(null);
try {
const data = (await fetchJson(
`/api/v1/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" },
authHeaders
)) as PageResponse<AdminBetaRequestDto>;
setRequests(data);
} catch (e: any) {
setListError(e?.message ?? "Failed to load beta requests");
} finally {
setLoadingList(false);
}
}
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
return (await fetchJson(
`/api/v1/admin/beta/requests/${userId}/invite`,
{ method: "POST" },
authHeaders
)) as AdminInviteResponse;
}
async function onSendInviteEmail(userId: number) {
setListError(null);
setLastInvite(null);
setRowBusy((m) => ({ ...m, [userId]: true }));
try {
const resp = await inviteSingle(userId);
setLastInvite(resp);
await loadRequests();
} catch (e: any) {
setListError(e?.message ?? "Failed to send invite");
} finally {
setRowBusy((m) => ({ ...m, [userId]: false }));
}
}
async function onCopyInviteLink(userId: number) {
setListError(null);
setLastInvite(null);
setRowBusy((m) => ({ ...m, [userId]: true }));
try {
const resp = await inviteSingle(userId);
setLastInvite(resp);
await loadRequests();
// build URL
const url =
resp.inviteUrl || // what your backend returns
resp.magicUrl ||
(resp.token
? `${window.location.origin}/beta/magic?token=${resp.token}`
: null);
if (!url) {
throw new Error(
`Invite response missing inviteUrl. Backend should return {"inviteUrl": "..."}`
);
}
await navigator.clipboard.writeText(url);
} catch (e: any) {
setListError(e?.message ?? "Failed to copy invite link");
} finally {
setRowBusy((m) => ({ ...m, [userId]: false }));
}
}
useEffect(() => {
loadRequests();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
if (!isAdmin) {
return (
<div className="space-y-2">
<h1 className="text-xl font-semibold">Admin</h1>
<p className="text-sm text-zinc-400">
You dont have access to this page.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
<h1 className="text-xl font-semibold">Beta Access</h1>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender (uses your email templates + tracking).
Review pending beta requests and send invite links (batch or
per-user).
</p>
</div>
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
Dry run (do everything except actually send)
</label>
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
{/* Left: Batch Invites */}
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Batch Invites
</h2>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Limit" htmlFor="limit">
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
</Field>
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
{dryRun ? "Dry run" : "Live send"}
</span>
</div>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
</Field>
Dry run (do everything except actually send)
</label>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Limit" htmlFor="limit">
<Input
id="limit"
value={limit}
onChange={(e) => setLimit(e.target.value)}
/>
</Field>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
/>
</Field>
</div>
<Button onClick={runInvites} disabled={loadingBatch}>
{loadingBatch ? "Running…" : "Send Beta Invites"}
</Button>
{batchError && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{batchError}
</div>
)}
{batchResult && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(batchResult, null, 2)}
</pre>
)}
</div>
<Button onClick={runInvites} disabled={loading}>
{loading ? "Running…" : "Send Beta Invites"}
</Button>
{/* Right: Requests List */}
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Beta Requests
</h2>
<p className="mt-1 text-sm text-zinc-400">
Users with role <span className="text-zinc-200">BETA</span> and{" "}
<span className="text-zinc-200">is_active=false</span>.
</p>
</div>
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
<Button
onClick={loadRequests}
disabled={loadingList}
className="w-auto px-6"
>
{loadingList ? "Refreshing…" : "Refresh"}
</Button>
</div>
)}
{result && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(result, null, 2)}
</pre>
)}
{listError && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{listError}
</div>
)}
<div className="overflow-x-auto rounded-md border border-zinc-800">
{" "}
<table className="min-w-[680px] w-full text-left text-sm">
<thead className="bg-black/30 text-xs text-zinc-500">
<tr className="border-b border-zinc-800">
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Invited</th>
<th className="px-3 py-2">Verified</th>
<th className="px-3 py-2">Active</th>
<th className="px-3 py-2 text-right w-[190px] whitespace-nowrap">
Actions
</th>
</tr>
</thead>
<tbody className="text-zinc-200">
{requests?.content?.length ? (
requests.content.map((u) => {
const busy = !!rowBusy[u.id];
return (
<tr key={u.id} className="border-b border-zinc-900">
<td className="px-3 py-2">
<div className="font-medium">{u.email}</div>
<div className="text-xs text-zinc-500">
{u.displayName ?? "—"} #{u.id}
</div>
</td>
<td className="px-3 py-2">
<Badge ok={u.invited} yes="Invited" no="—" />
</td>
<td className="px-3 py-2">
<Badge ok={u.verified} yes="Verified" no="—" />
</td>
<td className="px-3 py-2">
<Badge ok={u.active} yes="Active" no="Inactive" />
</td>
<td className="px-3 py-2 w-[190px]">
<div className="flex justify-end gap-2">
<button
onClick={() => onCopyInviteLink(u.id)}
disabled={busy}
className="rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-900 disabled:opacity-60"
title="Dev helper: generate invite and copy URL (also triggers invite generation server-side)"
>
{busy ? "Working…" : "Copy link (dev)"}
</button>
<button
onClick={() => onSendInviteEmail(u.id)}
disabled={busy}
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 hover:bg-amber-500/15 disabled:opacity-60"
title="Send invite email to this user"
>
{busy ? "Working…" : "Send invite email"}
</button>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td className="px-3 py-6 text-sm text-zinc-400" colSpan={5}>
{loadingList ? "Loading…" : "No pending beta requests."}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* pager */}
<div className="flex items-center justify-between text-xs text-zinc-500">
<button
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
disabled={!requests || requests.number <= 0 || loadingList}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Prev
</button>
<span>
Page {requests ? requests.number + 1 : 1} of{" "}
{requests ? requests.totalPages : 1} {" "}
{requests ? requests.totalElements : 0} total
</span>
<button
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
disabled={
!requests ||
loadingList ||
requests.number >= requests.totalPages - 1
}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
{/* optional debug panel */}
{lastInvite && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(lastInvite, null, 2)}
</pre>
)}
</div>
</div>
</div>
);
}
}
+26 -5
View File
@@ -1,22 +1,43 @@
// app/api/beta-signup/route.ts
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const TOS_VERSION = "2025-12-27";
type BetaSignupBody = {
email?: string;
useCase?: string;
acceptedTos?: boolean;
tosVersion?: string;
};
export async function POST(req: Request) {
try {
const body = await req.json();
const body = (await req.json()) as BetaSignupBody;
const email = (body.email ?? "").trim().toLowerCase();
const useCase = (body.useCase ?? "").trim();
const acceptedTos = Boolean(body.acceptedTos);
const tosVersion = (body.tosVersion ?? TOS_VERSION).trim() || TOS_VERSION;
// Keep "no enumeration": always return ok:true, but silently refuse bad input
if (!email || !acceptedTos) {
return NextResponse.json({ ok: true });
}
// Forward only known fields
const payload = { email, useCase, acceptedTos, tosVersion };
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify(payload),
cache: "no-store",
});
// Always return ok=true (matches your server behavior)
if (!res.ok) {
// Optional: log server response for debugging
const text = await res.text().catch(() => "");
console.error("beta-signup proxy failed:", res.status, text);
}
@@ -24,6 +45,6 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true });
} catch (e) {
console.error("beta-signup proxy error:", e);
return NextResponse.json({ ok: true }); // keep “no enumeration”
return NextResponse.json({ ok: true });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export async function POST(req: Request) {
const body = await req.text().catch(() => "");
const auth = req.headers.get("authorization") ?? "";
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me/password`, {
method: "POST",
headers: {
"content-type": req.headers.get("content-type") ?? "application/json",
...(auth ? { authorization: auth } : {}),
},
body,
cache: "no-store",
});
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
function passthroughHeaders(req: Request) {
const h = new Headers();
const auth = req.headers.get("authorization");
if (auth) h.set("authorization", auth);
h.set("content-type", req.headers.get("content-type") ?? "application/json");
return h;
}
export async function GET(req: Request) {
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me`, {
method: "GET",
headers: passthroughHeaders(req),
cache: "no-store",
});
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
export async function PATCH(req: Request) {
const body = await req.text().catch(() => "");
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me`, {
method: "PATCH",
headers: passthroughHeaders(req),
body,
cache: "no-store",
});
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export async function GET(req: Request) {
const url = new URL(req.url);
const username = url.searchParams.get("username") ?? "";
const auth = req.headers.get("authorization") ?? "";
const upstream = await fetch(
`${API_BASE_URL}/api/v1/users/me/username-available?username=${encodeURIComponent(username)}`,
{
method: "GET",
headers: {
...(auth ? { authorization: auth } : {}),
},
cache: "no-store",
}
);
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
+11 -3
View File
@@ -44,13 +44,21 @@ export default function BetaConfirmPage() {
}
const data = await res.json();
const jwt = data.token ?? data.accessToken;
if (!jwt) throw new Error("No token returned from server");
const uuid = data.uuid ?? data.user?.uuid;
if (!uuid) throw new Error("No uuid returned from server");
const email = data.email ?? data.user?.email;
if (!email) throw new Error("No email returned from server");
setSession(jwt, {
email: data.email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
uuid,
email,
displayName: data.displayName ?? data.user?.displayName ?? null,
role: data.role ?? data.user?.role ?? "USER",
});
setStatus("success");
+51 -3
View File
@@ -2,6 +2,7 @@
import { FormEvent, useState } from "react";
import Image from "next/image";
import Link from "next/link";
export default function HomePage() {
const [email, setEmail] = useState("");
@@ -9,7 +10,10 @@ export default function HomePage() {
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [message, setMessage] = useState<string | null>(null);
const [accepted, setAccepted] = useState(false);
const TOS_VERSION = "2025-12-27";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
@@ -20,6 +24,14 @@ export default function HomePage() {
return;
}
if (!accepted) {
setMessage(
"You must accept the Terms and confirm youre responsible for safe/legal assembly."
);
setStatus("error");
return;
}
try {
setStatus("loading");
setMessage(null);
@@ -27,7 +39,7 @@ export default function HomePage() {
const res = await fetch("/api/beta-signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, useCase }),
body: JSON.stringify({ email, useCase, acceptedTos: accepted, tosVersion: TOS_VERSION }),
});
if (!res.ok) {
@@ -36,7 +48,9 @@ export default function HomePage() {
}
setStatus("success");
setMessage("✅ Youre on the list. Invites drop soon — well email your access link when its go-time.");
setMessage(
"✅ Youre on the list. Invites drop soon — well email your access link when its go-time."
);
setEmail("");
setUseCase("");
} catch (err) {
@@ -187,9 +201,43 @@ export default function HomePage() {
/>
</div>
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
<label className="flex items-start gap-2 text-[11px] text-zinc-400">
<input
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
disabled={status === "loading" || status === "success"}
className="mt-0.5 h-4 w-4 rounded border-zinc-700 bg-zinc-950 text-amber-400 focus:ring-2 focus:ring-amber-500/40"
required
/>
<span>
I agree to the{" "}
<Link
href="/tos"
className="text-amber-300 hover:text-amber-200 underline"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-amber-300 hover:text-amber-200 underline"
>
Privacy Policy
</Link>
. I understand Battl Builders is informational only and Im
solely responsible for legality, compatibility, and safe
assembly/use of any firearm or components.
</span>
</label>
</div>
<button
type="submit"
disabled={status === "loading" || status === "success"}
disabled={
status === "loading" || status === "success" || !accepted
}
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
>
{status === "loading"
+111 -59
View File
@@ -7,6 +7,8 @@ import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
import { Button, Field, Input } from "@/components/ui/form";
const TOS_VERSION = "2025-12-27";
export default function RegisterPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -15,6 +17,7 @@ export default function RegisterPage() {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [accepted, setAccepted] = useState(false);
const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/builder";
@@ -23,76 +26,125 @@ export default function RegisterPage() {
e.preventDefault();
setError(null);
if (!accepted) {
setError(
"You must accept the Terms and confirm youre responsible for safe/legal assembly."
);
return;
}
try {
await register({ email, password, displayName });
await register({
email,
password,
displayName,
acceptedTos: accepted,
tosVersion: TOS_VERSION,
});
router.push(next);
} catch (err: any) {
setError(err.message ?? "Failed to create account");
setError(
typeof err === "string"
? err
: err?.message ?? "Failed to create account"
);
}
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
<h1 className="text-2xl font-semibold tracking-tight">
Join the <span className="text-amber-300">Battl Builder</span> Beta
</h1>
<p className="mt-2 text-sm text-zinc-400">
Create an account so we can save your builds, watch price drops, and
roll out new features to you first.
</p>
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
<h1 className="text-2xl font-semibold tracking-tight">
Join the <span className="text-amber-300">Battl Builder</span> Beta
</h1>
<p className="mt-2 text-sm text-zinc-400">
Create an account so we can save your builds, watch price drops, and
roll out new features to you first.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
<Field label="Display Name (optional)" htmlFor="displayName">
<Input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
<Field label="Display Name (optional)" htmlFor="displayName">
<Input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</Field>
<Field label="Email" htmlFor="email">
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field label="Password" htmlFor="password">
<Input
id="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<div className="rounded-md border border-zinc-800 bg-zinc-950/40 px-3 py-3">
<label className="flex items-start gap-2 text-[11px] text-zinc-400">
<input
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-zinc-700 bg-zinc-950 text-amber-400 focus:ring-2 focus:ring-amber-500/40"
required
/>
</Field>
<span>
I agree to the{" "}
<Link
href="/tos"
className="text-amber-300 hover:text-amber-200 underline"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-amber-300 hover:text-amber-200 underline"
>
Privacy Policy
</Link>
. I understand Battl Builders is informational only and Im
solely responsible for legality, compatibility, and safe
assembly/use of any firearm or components.
</span>
</label>
</div>
<Field label="Email" htmlFor="email">
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Button type="submit" disabled={loading || !accepted}>
{loading ? "Creating account…" : "Join Beta"}
</Button>
</form>
<Field label="Password" htmlFor="password">
<Input
id="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<Button type="submit" disabled={loading}>
{loading ? "Creating account…" : "Join Beta"}
</Button>
</form>
<p className="mt-4 text-xs text-zinc-500">
Already have an account?{" "}
<Link href="/login" className="text-amber-300 hover:text-amber-200">
Log in
</Link>
.
</p>
</div>
</main>
<p className="mt-4 text-xs text-zinc-500">
Already have an account?{" "}
<Link href="/login" className="text-amber-300 hover:text-amber-200">
Log in
</Link>
.
</p>
</div>
</main>
);
}
+108
View File
@@ -0,0 +1,108 @@
import Link from "next/link";
export function Footer() {
const year = new Date().getFullYear();
const linkClass =
"text-zinc-400 hover:text-amber-300 transition-colors";
const sectionTitle =
"text-[11px] font-semibold uppercase tracking-[0.28em] text-zinc-500";
return (
<footer className="border-t border-zinc-900 bg-black/60">
<div className="mx-auto max-w-6xl px-4 py-12">
<div className="grid gap-10 md:grid-cols-3">
{/* Brand */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-zinc-500">
Battl Builders
</p>
<p className="mt-3 text-sm text-zinc-300">
Build rifles smarter, not harder.
</p>
<p className="mt-2 text-[11px] text-zinc-500">
Early access platform · Data & pricing may change
</p>
</div>
{/* Explore */}
<div>
<p className={sectionTitle}>Explore</p>
<ul className="mt-4 space-y-2 text-sm">
<li>
<Link href="/builder" className={linkClass}>
Builder
</Link>
</li>
<li>
<Link href="/builds" className={linkClass}>
Community Builds
</Link>
</li>
<li>
<Link href="/vault" className={linkClass}>
My Builds
</Link>
</li>
</ul>
</div>
{/* Platform */}
<div>
<p className={sectionTitle}>Platform</p>
<ul className="mt-4 space-y-2 text-sm">
<li>
<Link href="/about" className={linkClass}>
About
</Link>
</li>
<li>
<Link href="/privacy" className={linkClass}>
Privacy Policy
</Link>
</li>
<li>
<Link href="/tos" className={linkClass}>
Terms of Service
</Link>
</li>
{/* Optional, if you have it */}
{/* <li>
<Link href="/contact" className={linkClass}>
Contact
</Link>
</li> */}
</ul>
</div>
</div>
{/* Bottom bar */}
<div className="mt-10 flex flex-col gap-3 border-t border-zinc-900 pt-6 text-[11px] text-zinc-500 md:flex-row md:items-center md:justify-between">
<span>© {year} Battl Builders</span>
<div className="flex flex-col items-center gap-2 md:flex-row md:gap-4">
<span className="text-zinc-600">
Built by people who actually build rifles
</span>
<span className="hidden text-zinc-700 md:inline"></span>
<span className="text-zinc-600">
<Link href="/tos" className="hover:text-amber-300 transition-colors">
Terms
</Link>{" "}
·{" "}
<Link href="/privacy" className="hover:text-amber-300 transition-colors">
Privacy
</Link>
</span>
</div>
</div>
</div>
</footer>
);
}
+70 -28
View File
@@ -15,28 +15,32 @@ const API_BASE_URL =
type AuthUser = {
uuid: string;
email: string;
username?: string | null;
passwordSetAt?: string | null;
displayName?: string | null;
role: string;
} | null;
export type RegisterParams = {
email: string;
password: string;
displayName?: string;
acceptedTos: boolean;
tosVersion: string;
};
// 1) update the type
type AuthContextValue = {
user: AuthUser;
token: string | null;
loading: boolean;
login: (params: { email: string; password: string }) => Promise<void>;
register: (params: {
email: string;
password: string;
displayName?: string;
}) => Promise<void>;
register: (params: RegisterParams) => Promise<void>;
logout: () => void;
getAuthHeaders: () => HeadersInit;
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
refreshMeAndSession: () => Promise<void>;
/**
* Used for non-password auth flows (ex: magic link).
* Persists session exactly like login/register.
*/
setSession: (token: string, user: NonNullable<AuthUser>) => void;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
@@ -110,24 +114,55 @@ export function AuthProvider({ children }: { children: ReactNode }) {
* Used by login/register/magic-link
*/
const setSession = useCallback(
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
// ✅ Make middleware / server aware of the session
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: nextToken,
role: nextUser.role,
}),
}).catch(() => {});
// ✅ await so middleware sees cookie before navigation
try {
await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: nextToken,
role: nextUser.role,
}),
});
} catch {
// ignore
}
},
[persistAuth]
);
const refreshMeAndSession = useCallback(async () => {
if (!token) return;
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(text || "Failed to refresh session");
}
const me = await res.json();
const nextUser = {
uuid: me.uuid,
email: me.email,
username: me.username || null,
displayName: me.displayName || null,
role: me.role || "USER",
passwordSetAt: me.passwordSetAt || null,
} as NonNullable<AuthUser>;
await setSession(token, nextUser);
}, [token, setSession]);
const login = useCallback(
async ({ email, password }: { email: string; password: string }) => {
setLoading(true);
@@ -152,10 +187,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? null,
passwordSetAt: data.passwordSetAt ?? null,
role: data.role ?? "USER",
} as AuthUser);
setSession(nextToken, nextUser as NonNullable<AuthUser>);
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
} finally {
setLoading(false);
}
@@ -168,17 +204,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
email,
password,
displayName,
}: {
email: string;
password: string;
displayName?: string;
}) => {
acceptedTos,
tosVersion,
}: RegisterParams) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, displayName }),
body: JSON.stringify({
email,
password,
displayName,
acceptedTos,
tosVersion,
}),
});
if (!res.ok) {
@@ -195,10 +235,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null,
passwordSetAt: data.passwordSetAt ?? null,
role: data.role ?? "USER",
} as AuthUser);
setSession(nextToken, nextUser as NonNullable<AuthUser>);
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
} finally {
setLoading(false);
}
@@ -228,6 +269,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logout,
getAuthHeaders,
setSession,
refreshMeAndSession
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+12 -3
View File
@@ -1,8 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
appDir: true
}
appDir: true,
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8080/api/:path*",
},
];
},
};
export default nextConfig;
export default nextConfig;