working auth

This commit is contained in:
2025-12-03 10:50:25 -05:00
parent 3e3e0b0466
commit 606b926886
389 changed files with 2793 additions and 12188 deletions
+10 -60
View File
@@ -1,5 +1,8 @@
import "./globals.css";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { TopNav } from "@/components/TopNav";
const inter = Inter({ subsets: ["latin"] });
@@ -8,69 +11,16 @@ export const metadata = {
description: "The Armory — Early Access",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>
<div className="min-h-screen flex flex-col bg-black text-zinc-50">
{/* Top Menu Bar */}
<header className="border-b border-zinc-800 bg-black/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
{/* Brand / Home link */}
<a
href="/"
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
>
The Build Bench
</a>
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Search placeholder */}
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/60 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
aria-label="Search (coming soon)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<circle cx="11" cy="11" r="6" />
<line x1="16.5" y1="16.5" x2="21" y2="21" />
</svg>
</button>
<a
href="/login"
className="text-xs font-medium text-zinc-400 hover:text-zinc-100 transition-colors"
>
Log In
</a>
<a
href="/register"
className="rounded-md border border-amber-400/70 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-200 hover:bg-amber-400/20 transition-colors"
>
Join Beta
</a>
</div>
</div>
</header>
{/* Main app content */}
<main className="flex-1">{children}</main>
</div>
<AuthProvider>
<div className="min-h-screen flex flex-col bg-black text-zinc-50">
<TopNav />
<main className="flex-1">{children}</main>
</div>
</AuthProvider>
</body>
</html>
);
+26
View File
@@ -0,0 +1,26 @@
import { useAuth } from "@/context/AuthContext";
export function useApi() {
const { token } = useAuth();
async function get(path: string) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
}
async function post(path: string, body: any) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(body),
});
}
return { get, post };
}
+116
View File
@@ -0,0 +1,116 @@
// lib/auth-client.ts
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export type AuthUser = {
id: number;
email: string;
displayName?: string | null;
role: string;
};
export type AuthPayload = {
accessToken: string;
refreshToken?: string;
userId: number;
email: string;
displayName?: string | null;
role: string;
};
export type RegisterInput = {
email: string;
password: string;
displayName?: string;
};
export type LoginInput = {
email: string;
password: string;
};
const AUTH_STORAGE_KEY = "armory-auth";
export function storeAuth(payload: AuthPayload) {
if (typeof window === "undefined") return;
const user: AuthUser = {
id: payload.userId,
email: payload.email,
displayName: payload.displayName,
role: payload.role,
};
const toStore = {
user,
accessToken: payload.accessToken,
refreshToken: payload.refreshToken ?? null,
};
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(toStore));
}
export function loadStoredAuth():
| { user: AuthUser; accessToken: string; refreshToken: string | null }
| null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed.user || !parsed.accessToken) return null;
return {
user: parsed.user as AuthUser,
accessToken: parsed.accessToken as string,
refreshToken: parsed.refreshToken ?? null,
};
} catch {
return null;
}
}
export function clearStoredAuth() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(AUTH_STORAGE_KEY);
}
async function handleAuthResponse(res: Response): Promise<AuthPayload> {
if (!res.ok) {
let message = `Request failed (${res.status})`;
try {
const body = await res.json();
if (body?.message) message = body.message;
} catch {
// ignore
}
throw new Error(message);
}
return (await res.json()) as AuthPayload;
}
export async function apiRegister(input: RegisterInput): Promise<AuthPayload> {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(input),
});
return handleAuthResponse(res);
}
export async function apiLogin(input: LoginInput): Promise<AuthPayload> {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(input),
});
return handleAuthResponse(res);
}
+104
View File
@@ -0,0 +1,104 @@
// app/login/page.tsx
"use client";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { login, loading } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/gunbuilder";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
try {
await login({ email, password });
router.push(next);
} catch (err: any) {
setError(err.message ?? "Failed to log in");
}
}
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">
Log In to <span className="text-amber-300">The Armory</span>
</h1>
<p className="mt-2 text-sm text-zinc-400">
Use your beta credentials to get back to your saved builds.
</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>
)}
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-400" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1">
<label
className="text-xs font-medium text-zinc-400"
htmlFor="password"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button
type="submit"
disabled={loading}
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? "Signing in…" : "Log In"}
</button>
</form>
<p className="mt-4 text-xs text-zinc-500">
New here?{" "}
<Link
href="/register"
className="text-amber-300 hover:text-amber-200"
>
Join the beta
</Link>
.
</p>
</div>
</main>
);
}
+119
View File
@@ -0,0 +1,119 @@
// app/register/page.tsx
"use client";
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export default function RegisterPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { register, loading } = useAuth();
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/gunbuilder";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
try {
await register({ email, password, displayName });
router.push(next);
} catch (err: any) {
setError(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">Shadow Standard</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>
)}
<div className="space-y-1">
<label
className="text-xs font-medium text-zinc-400"
htmlFor="displayName"
>
Display Name (optional)
</label>
<input
id="displayName"
type="text"
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-400" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1">
<label
className="text-xs font-medium text-zinc-400"
htmlFor="password"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button
type="submit"
disabled={loading}
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{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>
);
}