diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index bebb4f1..fb2f84f 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -74,8 +74,7 @@ const isUuid = (v: string) => v ); -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; +// API routes now handled by Next.js /api routes (server-side) const STORAGE_KEY = "gunbuilder-build-state"; @@ -337,8 +336,8 @@ function GunbuilderPageContent() { const api = useApi(); - // ✅ Auth: we need the token ready before calling /builds/me/* - const { token, loading: authLoading, getAuthHeaders } = useAuth(); + // ✅ Auth: check if user is logged in + const { user, loading: authLoading } = useAuth(); const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15"); @@ -616,7 +615,7 @@ function GunbuilderPageContent() { try { const res = await fetch( - `${API_BASE_URL}/api/v1/catalog/products/by-ids`, + `/api/catalog/products/by-ids`, { method: "POST", signal: controller.signal, @@ -728,7 +727,7 @@ function GunbuilderPageContent() { if (authLoading) return; // If not logged in, don't spam the backend; show a helpful message instead. - if (!token) { + if (!user) { setShareStatus("Please log in to load builds from your Vault."); window.setTimeout(() => setShareStatus(null), 4500); return; @@ -738,14 +737,12 @@ function GunbuilderPageContent() { try { setShareStatus("Loading build…"); - // NOTE: using fetch here avoids any “stale api instance” issues and lets us - // explicitly attach JWT headers. + // NOTE: using fetch here goes through our Next.js API routes with HTTP-only cookies const res = await fetch( - `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`, + `/api/builds/${encodeURIComponent(uuidToLoad)}`, { headers: { "Content-Type": "application/json", - ...getAuthHeaders(), }, } ); @@ -787,9 +784,7 @@ function GunbuilderPageContent() { searchParams, router, authLoading, - token, - getAuthHeaders, - // API_BASE_URL is a module constant; no need to include + user, ]); // ----------------------------- diff --git a/app/(app)/vault/page.tsx b/app/(app)/vault/page.tsx index cc3796e..072c779 100644 --- a/app/(app)/vault/page.tsx +++ b/app/(app)/vault/page.tsx @@ -18,8 +18,7 @@ type BuildDto = { updatedAt?: string | null; }; -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; +// API routes now handled by Next.js /api routes (server-side) // UUID validator (defensive) const isUuid = (v: string) => @@ -40,14 +39,14 @@ export default function VaultPage() { // NOTE: // - `loading` here is AuthContext hydration, not network. - // - `token` is the real gate for /me endpoints. - const { user, token, loading: authLoading } = useAuth(); + // - Auth is handled by HTTP-only cookies automatically. + const { user, loading: authLoading } = useAuth(); const [builds, setBuilds] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const authed = !!user && !!token; + const authed = !!user; // Redirect if not logged in (after auth hydrates) useEffect(() => { @@ -56,12 +55,12 @@ export default function VaultPage() { } }, [authLoading, authed, router]); - // When auth identity changes, clear stale list to avoid “ghost builds” + // When auth identity changes, clear stale list to avoid "ghost builds" useEffect(() => { if (authLoading) return; setBuilds([]); setError(null); - }, [authLoading, user?.uuid, token]); + }, [authLoading, user?.uuid]); // Fetch user builds useEffect(() => { @@ -75,8 +74,8 @@ export default function VaultPage() { try { // IMPORTANT: - // Use api wrapper so Authorization: Bearer is consistently applied. - const res = await api.get("/api/v1/builds/me"); + // Use api wrapper which includes credentials (cookies) automatically. + const res = await api.get("/api/builds"); if (!res.ok) { const text = await res.text().catch(() => ""); diff --git a/app/admin/merchants/page.tsx b/app/admin/merchants/page.tsx index 63f63ff..037bf7e 100644 --- a/app/admin/merchants/page.tsx +++ b/app/admin/merchants/page.tsx @@ -2,8 +2,7 @@ import { useEffect, useState } from "react"; -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; +// API routes now handled by Next.js /api routes (server-side) type MerchantAdminDto = { id: number; @@ -91,7 +90,7 @@ export default function MerchantsAdminPage() { setLoading(true); setError(null); - const url = `${API_BASE_URL}/api/admin/merchants`; + const url = `/api/admin/merchants`; console.log("Loading merchants from:", url); const res = await fetch(url, { @@ -150,7 +149,7 @@ export default function MerchantsAdminPage() { setError(null); setBanner(null); - const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, { + const res = await fetch(`/api/admin/merchants/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -189,7 +188,7 @@ export default function MerchantsAdminPage() { setError(null); setBanner(null); - const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, { + const res = await fetch(`/api/admin/merchants/${id}/import`, { method: "POST", }); @@ -218,7 +217,7 @@ export default function MerchantsAdminPage() { setBanner(null); const res = await fetch( - `${API_BASE_URL}/api/admin/imports/${id}/offers-only`, + `/api/admin/merchants/${id}/offer-sync`, { method: "POST" } ); @@ -247,7 +246,7 @@ export default function MerchantsAdminPage() { setError(null); setBanner(null); - const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, { + const res = await fetch(`/api/admin/merchants`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/app/api/admin/merchants/[id]/import/route.ts b/app/api/admin/merchants/[id]/import/route.ts new file mode 100644 index 0000000..1724997 --- /dev/null +++ b/app/api/admin/merchants/[id]/import/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +async function getAuthHeader() { + const token = cookies().get('session_token')?.value; + if (!token) throw new Error('Unauthorized'); + return { Authorization: `Bearer ${token}` }; +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/admin/merchants/${params.id}/import`, + { method: 'POST', headers } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} diff --git a/app/api/admin/merchants/[id]/offer-sync/route.ts b/app/api/admin/merchants/[id]/offer-sync/route.ts new file mode 100644 index 0000000..5830936 --- /dev/null +++ b/app/api/admin/merchants/[id]/offer-sync/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +async function getAuthHeader() { + const token = cookies().get('session_token')?.value; + if (!token) throw new Error('Unauthorized'); + return { Authorization: `Bearer ${token}` }; +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/admin/merchants/${params.id}/offer-sync`, + { method: 'POST', headers } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} diff --git a/app/api/admin/merchants/[id]/route.ts b/app/api/admin/merchants/[id]/route.ts new file mode 100644 index 0000000..ab02998 --- /dev/null +++ b/app/api/admin/merchants/[id]/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +async function getAuthHeader() { + const token = cookies().get('session_token')?.value; + if (!token) throw new Error('Unauthorized'); + return { Authorization: `Bearer ${token}` }; +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/admin/merchants/${params.id}`, + { headers } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + const body = await request.json(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/admin/merchants/${params.id}`, + { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/admin/merchants/${params.id}`, + { method: 'DELETE', headers } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} diff --git a/app/api/admin/merchants/route.ts b/app/api/admin/merchants/route.ts new file mode 100644 index 0000000..e3f8e8f --- /dev/null +++ b/app/api/admin/merchants/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +async function getAuthHeader() { + const token = cookies().get('session_token')?.value; + if (!token) throw new Error('Unauthorized'); + return { Authorization: `Bearer ${token}` }; +} + +export async function GET() { + try { + const headers = await getAuthHeader(); + const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, { + headers, + }); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + +export async function POST(request: NextRequest) { + try { + const headers = await getAuthHeader(); + const body = await request.json(); + + const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..4dc7978 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Forward to backend API + const res = await fetch(`${API_BASE_URL}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + return NextResponse.json( + { error: text || 'Login failed' }, + { status: res.status } + ); + } + + const data = await res.json(); + + // Set HTTP-only cookie instead of returning token + cookies().set('session_token', data.token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + + // Return user data without token + return NextResponse.json({ + user: data.user, + }); + } catch (err: any) { + return NextResponse.json( + { error: err?.message || 'Login failed' }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a34af3c --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +export async function POST() { + cookies().delete('session_token'); + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..277c0cc --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +export async function GET(request: NextRequest) { + const token = cookies().get('session_token')?.value; + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const res = await fetch(`${API_BASE_URL}/api/users/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + cookies().delete('session_token'); + return NextResponse.json({ error: 'Session expired' }, { status: 401 }); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json( + { error: err?.message || 'Failed to fetch user' }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..a2b75d8 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Forward to backend API + const res = await fetch(`${API_BASE_URL}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + return NextResponse.json( + { error: text || 'Registration failed' }, + { status: res.status } + ); + } + + const data = await res.json(); + + // Set HTTP-only cookie on successful registration + if (data.token) { + cookies().set('session_token', data.token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + } + + // Return user data without token + return NextResponse.json({ + user: data.user, + }); + } catch (err: any) { + return NextResponse.json( + { error: err?.message || 'Registration failed' }, + { status: 500 } + ); + } +} diff --git a/app/api/builds/[id]/route.ts b/app/api/builds/[id]/route.ts new file mode 100644 index 0000000..66d70ef --- /dev/null +++ b/app/api/builds/[id]/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +async function getAuthHeader() { + const token = cookies().get('session_token')?.value; + if (!token) throw new Error('Unauthorized'); + return { Authorization: `Bearer ${token}` }; +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`, + { headers } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + const body = await request.json(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`, + { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const headers = await getAuthHeader(); + + const res = await fetch( + `${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`, + { method: 'DELETE', headers } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} diff --git a/app/api/builds/route.ts b/app/api/builds/route.ts new file mode 100644 index 0000000..1a86f8c --- /dev/null +++ b/app/api/builds/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +async function getAuthHeader() { + const token = cookies().get('session_token')?.value; + if (!token) throw new Error('Unauthorized'); + return { Authorization: `Bearer ${token}` }; +} + +export async function GET(request: NextRequest) { + try { + const headers = await getAuthHeader(); + const { searchParams } = new URL(request.url); + + const res = await fetch( + `${API_BASE_URL}/api/v1/gunbuilder/builds?${searchParams}`, + { headers } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} + +export async function POST(request: NextRequest) { + try { + const headers = await getAuthHeader(); + const body = await request.json(); + + const res = await fetch(`${API_BASE_URL}/api/v1/gunbuilder/builds`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json(), { status: res.status }); + } catch (err: any) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } +} diff --git a/app/api/catalog/options/route.ts b/app/api/catalog/options/route.ts new file mode 100644 index 0000000..a2acb95 --- /dev/null +++ b/app/api/catalog/options/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + + // No auth required for public catalog + const res = await fetch( + `${API_BASE_URL}/api/v1/catalog/options?${searchParams}` + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json( + { error: err?.message || 'Failed to fetch catalog' }, + { status: 500 } + ); + } +} diff --git a/app/api/catalog/products/by-ids/route.ts b/app/api/catalog/products/by-ids/route.ts new file mode 100644 index 0000000..c5e0327 --- /dev/null +++ b/app/api/catalog/products/by-ids/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // No auth required for public catalog + const res = await fetch( + `${API_BASE_URL}/api/v1/catalog/products/by-ids`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ); + + if (!res.ok) { + return NextResponse.json( + { error: await res.text() }, + { status: res.status } + ); + } + + return NextResponse.json(await res.json()); + } catch (err: any) { + return NextResponse.json( + { error: err?.message || 'Failed to fetch products' }, + { status: 500 } + ); + } +} diff --git a/components/parts/PartsBrowseClient.tsx b/components/parts/PartsBrowseClient.tsx index c6f8f18..f84ac8f 100644 --- a/components/parts/PartsBrowseClient.tsx +++ b/components/parts/PartsBrowseClient.tsx @@ -58,8 +58,7 @@ type GunbuilderProductFromApi = { inStock?: boolean | null; }; -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; +// API routes now handled by Next.js /api routes (server-side) const PAGE_SIZE = 24; @@ -175,7 +174,7 @@ export default function PartsBrowseClient(props: { } const res = await fetch( - `${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`, + `/api/catalog/options?${search.toString()}`, { signal: controller.signal, } diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index 2c6a1ae..0a9f41b 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -9,9 +9,6 @@ import { ReactNode, } from "react"; -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; - type AuthUser = { uuid: string; email: string; @@ -29,174 +26,132 @@ export type RegisterParams = { tosVersion: string; }; -// 1) update the type type AuthContextValue = { user: AuthUser; - token: string | null; loading: boolean; login: (params: { email: string; password: string }) => Promise; register: (params: RegisterParams) => Promise; logout: () => void; - getAuthHeaders: () => HeadersInit; - setSession: (token: string, user: NonNullable) => Promise; - refreshMeAndSession: () => Promise; - + refreshUser: () => Promise; }; const AuthContext = createContext(undefined); -const TOKEN_KEY = "ballistic_auth_token"; const USER_KEY = "ballistic_auth_user"; export function AuthProvider({ children }: { children: ReactNode }) { - const [token, setToken] = useState(null); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); /** - * Persist auth to localStorage + * Persist user to localStorage (for quick initial render) */ - const persistAuth = useCallback( - (nextToken: string | null, nextUser: AuthUser) => { - if (typeof window === "undefined") return; + const persistUser = useCallback((nextUser: AuthUser) => { + if (typeof window === "undefined") return; - if (nextToken) { - window.localStorage.setItem(TOKEN_KEY, nextToken); - } else { - window.localStorage.removeItem(TOKEN_KEY); - } - - if (nextUser) { - window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser)); - } else { - window.localStorage.removeItem(USER_KEY); - } - }, - [] - ); + if (nextUser) { + window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser)); + } else { + window.localStorage.removeItem(USER_KEY); + } + }, []); /** - * ✅ Hydrate from localStorage ONCE - * IMPORTANT: loading only flips false AFTER user/token are set (if present) + * Hydrate from localStorage and refresh from server */ useEffect(() => { if (typeof window === "undefined") return; - const storedToken = window.localStorage.getItem(TOKEN_KEY); const storedUser = window.localStorage.getItem(USER_KEY); - if (storedToken && storedUser) { + // Quick hydration from cached user + if (storedUser) { try { const parsedUser = JSON.parse(storedUser); - - setToken(storedToken); setUser(parsedUser); - - // ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav - fetch("/api/auth/session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - token: storedToken, - role: parsedUser?.role, - }), - }).catch(() => {}); } catch { - window.localStorage.removeItem(TOKEN_KEY); window.localStorage.removeItem(USER_KEY); } } - setLoading(false); - }, []); + // Verify session with server + fetch("/api/auth/me") + .then((res) => { + if (res.ok) { + return res.json(); + } + throw new Error("Not authenticated"); + }) + .then((data) => { + const nextUser: AuthUser = { + uuid: data.uuid, + email: data.email, + username: data.username || null, + displayName: data.displayName || null, + role: data.role || "USER", + passwordSetAt: data.passwordSetAt || null, + }; + setUser(nextUser); + persistUser(nextUser); + }) + .catch(() => { + setUser(null); + persistUser(null); + }) + .finally(() => { + setLoading(false); + }); + }, [persistUser]); - /** - * Used by login/register/magic-link - */ - const setSession = useCallback( - async (nextToken: string, nextUser: NonNullable) => { - setToken(nextToken); - setUser(nextUser); - persistAuth(nextToken, nextUser); + const refreshUser = useCallback(async () => { + const res = await fetch("/api/auth/me", { cache: "no-store" }); - // ✅ 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"); + throw new Error(text || "Failed to refresh user"); } - - 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; - - await setSession(token, nextUser); - }, [token, setSession]); + + const data = await res.json(); + + const nextUser: AuthUser = { + uuid: data.uuid, + email: data.email, + username: data.username || null, + displayName: data.displayName || null, + role: data.role || "USER", + passwordSetAt: data.passwordSetAt || null, + }; + + setUser(nextUser); + persistUser(nextUser); + }, [persistUser]); const login = useCallback( async ({ email, password }: { email: string; password: string }) => { setLoading(true); try { - const res = await fetch(`${API_BASE_URL}/api/auth/login`, { + const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(text || "Login failed"); + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.error || "Login failed"); } const data = await res.json(); - const nextToken: string = data.token ?? data.accessToken; - const nextUser: AuthUser = - data.user ?? - ({ - uuid: data.uuid, - email: data.email ?? email, - displayName: data.displayName ?? null, - passwordSetAt: data.passwordSetAt ?? null, - role: data.role ?? "USER", - } as AuthUser); + const nextUser: AuthUser = data.user; - await setSession(nextToken, nextUser as NonNullable); + setUser(nextUser); + persistUser(nextUser); } finally { setLoading(false); } }, - [setSession] + [persistUser] ); const register = useCallback( @@ -209,7 +164,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }: RegisterParams) => { setLoading(true); try { - const res = await fetch(`${API_BASE_URL}/api/auth/register`, { + const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -222,54 +177,38 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(text || "Registration failed"); + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.error || "Registration failed"); } const data = await res.json(); - const nextToken: string = data.token ?? data.accessToken; - const nextUser: AuthUser = - data.user ?? - ({ - uuid: data.uuid, - email: data.email ?? email, - displayName: data.displayName ?? displayName ?? null, - passwordSetAt: data.passwordSetAt ?? null, - role: data.role ?? "USER", - } as AuthUser); + const nextUser: AuthUser = data.user; - await setSession(nextToken, nextUser as NonNullable); + setUser(nextUser); + persistUser(nextUser); } finally { setLoading(false); } }, - [setSession] + [persistUser] ); const logout = useCallback(() => { - setToken(null); setUser(null); - persistAuth(null, null); + persistUser(null); // Clear server session cookies - fetch("/api/auth/session", { method: "DELETE" }).catch(() => {}); - }, [persistAuth]); - - const getAuthHeaders = useCallback((): HeadersInit => { - return token ? { Authorization: `Bearer ${token}` } : {}; - }, [token]); + fetch("/api/auth/logout", { method: "POST" }).catch(() => {}); + }, [persistUser]); const value: AuthContextValue = { user, - token, loading, login, register, logout, - getAuthHeaders, - setSession, - refreshMeAndSession + refreshUser, }; return {children}; diff --git a/lib/api.ts b/lib/api.ts index 5550bd2..065c6c0 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,63 +1,57 @@ "use client"; import { useMemo } from "react"; -import { useAuth } from "@/context/AuthContext"; -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; +// API routes now handled by Next.js /api routes (server-side) +// Authentication happens via HTTP-only cookies automatically type JsonValue = any; export function useApi() { - const { token } = useAuth(); - return useMemo(() => { - function authHeaders(extra?: HeadersInit): HeadersInit { - const base: Record = {}; - if (token) base.Authorization = `Bearer ${token}`; - return { ...base, ...(extra as any) }; - } - async function get(path: string, init?: RequestInit) { - return fetch(`${API_BASE_URL}${path}`, { + // Path should now point to Next.js API routes (e.g., /api/builds) + return fetch(path, { ...init, - headers: authHeaders(init?.headers), + credentials: 'include', // Include cookies in requests }); } async function post(path: string, body: JsonValue, init?: RequestInit) { - return fetch(`${API_BASE_URL}${path}`, { + return fetch(path, { method: "POST", ...init, - headers: authHeaders({ + credentials: 'include', + headers: { "Content-Type": "application/json", ...(init?.headers as any), - }), + }, body: JSON.stringify(body), }); } async function put(path: string, body: JsonValue, init?: RequestInit) { - return fetch(`${API_BASE_URL}${path}`, { + return fetch(path, { method: "PUT", ...init, - headers: authHeaders({ + credentials: 'include', + headers: { "Content-Type": "application/json", ...(init?.headers as any), - }), + }, body: JSON.stringify(body), }); } async function del(path: string, init?: RequestInit) { - return fetch(`${API_BASE_URL}${path}`, { + return fetch(path, { method: "DELETE", ...init, - headers: authHeaders(init?.headers), + credentials: 'include', + headers: init?.headers, }); } - // ✅ stable reference unless token changes return { get, post, put, del }; - }, [token]); + }, []); } \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index 9034512..4b371e2 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,71 +2,42 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -const LAUNCH_ONLY_ROOT = process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT === "true"; - -// ✅ Forever allow list (always reachable even during launch-only mode) -const ALWAYS_ALLOW = new Set([ - "/", - "/favicon.ico", - "/robots.txt", - "/sitemap.xml", -]); - -const PUBLIC_FILE = - /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i; - export function middleware(req: NextRequest) { const { pathname } = req.nextUrl; + const token = req.cookies.get("session_token")?.value; - console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT); + // Protect admin API routes + if (pathname.startsWith("/api/admin")) { + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + // Protect builds API routes + if (pathname.startsWith("/api/builds")) { + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } -// ========================= -// 1) Admin gate (always on) -// ========================= -if (pathname.startsWith("/admin")) { - const token = req.cookies.get("bb_access_token")?.value; - const role = req.cookies.get("bb_role")?.value; // optional - - const ok = !!token && (!role || role === "ADMIN"); - - if (!ok) { - const url = req.nextUrl.clone(); - url.pathname = "/login"; - url.searchParams.set("next", pathname); - return NextResponse.redirect(url); + // Protect admin pages + if (pathname.startsWith("/admin")) { + if (!token) { + const url = req.nextUrl.clone(); + url.pathname = "/login"; + url.searchParams.set("next", pathname); + return NextResponse.redirect(url); + } } return NextResponse.next(); } - // ===================================== - // 2) Launch-only gate (your existing one) - // ===================================== - if (!LAUNCH_ONLY_ROOT) return NextResponse.next(); - - // ✅ Always allow Next internals + static assets - if ( - pathname.startsWith("/_next") || - pathname.startsWith("/favicon") || - pathname === "/robots.txt" || - pathname === "/sitemap.xml" || - PUBLIC_FILE.test(pathname) - ) { - return NextResponse.next(); - } - - if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next(); - - - return NextResponse.rewrite(new URL("/404", req.url)); -} - -` 1` export const config = { matcher: [ - // run on everything except Next internals + obvious static files - "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)", + "/api/admin/:path*", + "/api/builds", + "/api/builds/:path+", + "/admin/:path*", ], - }; \ No newline at end of file