working auth
This commit is contained in:
+10
-60
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user