woof. killed useClient and use nextjs api
CI / test (push) Successful in 5s

This commit is contained in:
2026-01-25 13:40:10 -05:00
parent 5b73d59c2c
commit 50ef395a38
19 changed files with 696 additions and 247 deletions
+8 -13
View File
@@ -74,8 +74,7 @@ const isUuid = (v: string) =>
v v
); );
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
@@ -337,8 +336,8 @@ function GunbuilderPageContent() {
const api = useApi(); const api = useApi();
// ✅ Auth: we need the token ready before calling /builds/me/* // ✅ Auth: check if user is logged in
const { token, loading: authLoading, getAuthHeaders } = useAuth(); const { user, loading: authLoading } = useAuth();
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15"); const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15");
@@ -616,7 +615,7 @@ function GunbuilderPageContent() {
try { try {
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/products/by-ids`, `/api/catalog/products/by-ids`,
{ {
method: "POST", method: "POST",
signal: controller.signal, signal: controller.signal,
@@ -728,7 +727,7 @@ function GunbuilderPageContent() {
if (authLoading) return; if (authLoading) return;
// If not logged in, don't spam the backend; show a helpful message instead. // 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."); setShareStatus("Please log in to load builds from your Vault.");
window.setTimeout(() => setShareStatus(null), 4500); window.setTimeout(() => setShareStatus(null), 4500);
return; return;
@@ -738,14 +737,12 @@ function GunbuilderPageContent() {
try { try {
setShareStatus("Loading build…"); setShareStatus("Loading build…");
// NOTE: using fetch here avoids any “stale api instance” issues and lets us // NOTE: using fetch here goes through our Next.js API routes with HTTP-only cookies
// explicitly attach JWT headers.
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`, `/api/builds/${encodeURIComponent(uuidToLoad)}`,
{ {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...getAuthHeaders(),
}, },
} }
); );
@@ -787,9 +784,7 @@ function GunbuilderPageContent() {
searchParams, searchParams,
router, router,
authLoading, authLoading,
token, user,
getAuthHeaders,
// API_BASE_URL is a module constant; no need to include
]); ]);
// ----------------------------- // -----------------------------
+8 -9
View File
@@ -18,8 +18,7 @@ type BuildDto = {
updatedAt?: string | null; updatedAt?: string | null;
}; };
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// UUID validator (defensive) // UUID validator (defensive)
const isUuid = (v: string) => const isUuid = (v: string) =>
@@ -40,14 +39,14 @@ export default function VaultPage() {
// NOTE: // NOTE:
// - `loading` here is AuthContext hydration, not network. // - `loading` here is AuthContext hydration, not network.
// - `token` is the real gate for /me endpoints. // - Auth is handled by HTTP-only cookies automatically.
const { user, token, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]); const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const authed = !!user && !!token; const authed = !!user;
// Redirect if not logged in (after auth hydrates) // Redirect if not logged in (after auth hydrates)
useEffect(() => { useEffect(() => {
@@ -56,12 +55,12 @@ export default function VaultPage() {
} }
}, [authLoading, authed, router]); }, [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(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
setBuilds([]); setBuilds([]);
setError(null); setError(null);
}, [authLoading, user?.uuid, token]); }, [authLoading, user?.uuid]);
// Fetch user builds // Fetch user builds
useEffect(() => { useEffect(() => {
@@ -75,8 +74,8 @@ export default function VaultPage() {
try { try {
// IMPORTANT: // IMPORTANT:
// Use api wrapper so Authorization: Bearer <token> is consistently applied. // Use api wrapper which includes credentials (cookies) automatically.
const res = await api.get("/api/v1/builds/me"); const res = await api.get("/api/builds");
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
+6 -7
View File
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type MerchantAdminDto = { type MerchantAdminDto = {
id: number; id: number;
@@ -91,7 +90,7 @@ export default function MerchantsAdminPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/admin/merchants`; const url = `/api/admin/merchants`;
console.log("Loading merchants from:", url); console.log("Loading merchants from:", url);
const res = await fetch(url, { const res = await fetch(url, {
@@ -150,7 +149,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, { const res = await fetch(`/api/admin/merchants/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -189,7 +188,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(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", method: "POST",
}); });
@@ -218,7 +217,7 @@ export default function MerchantsAdminPage() {
setBanner(null); setBanner(null);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`, `/api/admin/merchants/${id}/offer-sync`,
{ method: "POST" } { method: "POST" }
); );
@@ -247,7 +246,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, { const res = await fetch(`/api/admin/merchants`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -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 });
}
}
@@ -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 });
}
}
+90
View File
@@ -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 });
}
}
+54
View File
@@ -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 });
}
}
+46
View File
@@ -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 }
);
}
}
+7
View File
@@ -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 });
}
+30
View File
@@ -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 }
);
}
}
+48
View File
@@ -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 }
);
}
}
+90
View File
@@ -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 });
}
}
+57
View File
@@ -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 });
}
}
+28
View File
@@ -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 }
);
}
}
+33
View File
@@ -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 }
);
}
}
+2 -3
View File
@@ -58,8 +58,7 @@ type GunbuilderProductFromApi = {
inStock?: boolean | null; inStock?: boolean | null;
}; };
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
@@ -175,7 +174,7 @@ export default function PartsBrowseClient(props: {
} }
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`, `/api/catalog/options?${search.toString()}`,
{ {
signal: controller.signal, signal: controller.signal,
} }
+77 -138
View File
@@ -9,9 +9,6 @@ import {
ReactNode, ReactNode,
} from "react"; } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type AuthUser = { type AuthUser = {
uuid: string; uuid: string;
email: string; email: string;
@@ -29,174 +26,132 @@ export type RegisterParams = {
tosVersion: string; tosVersion: string;
}; };
// 1) update the type
type AuthContextValue = { type AuthContextValue = {
user: AuthUser; user: AuthUser;
token: string | null;
loading: boolean; loading: boolean;
login: (params: { email: string; password: string }) => Promise<void>; login: (params: { email: string; password: string }) => Promise<void>;
register: (params: RegisterParams) => Promise<void>; register: (params: RegisterParams) => Promise<void>;
logout: () => void; logout: () => void;
getAuthHeaders: () => HeadersInit; refreshUser: () => Promise<void>;
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
refreshMeAndSession: () => Promise<void>;
}; };
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const TOKEN_KEY = "ballistic_auth_token";
const USER_KEY = "ballistic_auth_user"; const USER_KEY = "ballistic_auth_user";
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser>(null); const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
/** /**
* Persist auth to localStorage * Persist user to localStorage (for quick initial render)
*/ */
const persistAuth = useCallback( const persistUser = useCallback((nextUser: AuthUser) => {
(nextToken: string | null, nextUser: AuthUser) => { if (typeof window === "undefined") return;
if (typeof window === "undefined") return;
if (nextToken) { if (nextUser) {
window.localStorage.setItem(TOKEN_KEY, nextToken); window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else { } else {
window.localStorage.removeItem(TOKEN_KEY); 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 * Hydrate from localStorage and refresh from server
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
*/ */
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY); const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken && storedUser) { // Quick hydration from cached user
if (storedUser) {
try { try {
const parsedUser = JSON.parse(storedUser); const parsedUser = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser); 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 { } catch {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_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]);
/** const refreshUser = useCallback(async () => {
* Used by login/register/magic-link const res = await fetch("/api/auth/me", { cache: "no-store" });
*/
const setSession = useCallback(
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
// ✅ 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) { if (!res.ok) {
const text = await res.text().catch(() => res.statusText); 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 data = await res.json();
const nextUser = { const nextUser: AuthUser = {
uuid: me.uuid, uuid: data.uuid,
email: me.email, email: data.email,
username: me.username || null, username: data.username || null,
displayName: me.displayName || null, displayName: data.displayName || null,
role: me.role || "USER", role: data.role || "USER",
passwordSetAt: me.passwordSetAt || null, passwordSetAt: data.passwordSetAt || null,
} as NonNullable<AuthUser>; };
await setSession(token, nextUser); setUser(nextUser);
}, [token, setSession]); persistUser(nextUser);
}, [persistUser]);
const login = useCallback( const login = useCallback(
async ({ email, password }: { email: string; password: string }) => { async ({ email, password }: { email: string; password: string }) => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => res.statusText); const errorData = await res.json().catch(() => ({}));
throw new Error(text || "Login failed"); throw new Error(errorData.error || "Login failed");
} }
const data = await res.json(); const data = await res.json();
const nextToken: string = data.token ?? data.accessToken; const nextUser: AuthUser = data.user;
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);
await setSession(nextToken, nextUser as NonNullable<AuthUser>); setUser(nextUser);
persistUser(nextUser);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[setSession] [persistUser]
); );
const register = useCallback( const register = useCallback(
@@ -209,7 +164,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}: RegisterParams) => { }: RegisterParams) => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, { const res = await fetch("/api/auth/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -222,54 +177,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => res.statusText); const errorData = await res.json().catch(() => ({}));
throw new Error(text || "Registration failed"); throw new Error(errorData.error || "Registration failed");
} }
const data = await res.json(); const data = await res.json();
const nextToken: string = data.token ?? data.accessToken; const nextUser: AuthUser = data.user;
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);
await setSession(nextToken, nextUser as NonNullable<AuthUser>); setUser(nextUser);
persistUser(nextUser);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[setSession] [persistUser]
); );
const logout = useCallback(() => { const logout = useCallback(() => {
setToken(null);
setUser(null); setUser(null);
persistAuth(null, null); persistUser(null);
// Clear server session cookies // Clear server session cookies
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {}); fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
}, [persistAuth]); }, [persistUser]);
const getAuthHeaders = useCallback((): HeadersInit => {
return token ? { Authorization: `Bearer ${token}` } : {};
}, [token]);
const value: AuthContextValue = { const value: AuthContextValue = {
user, user,
token,
loading, loading,
login, login,
register, register,
logout, logout,
getAuthHeaders, refreshUser,
setSession,
refreshMeAndSession
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+17 -23
View File
@@ -1,63 +1,57 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; // Authentication happens via HTTP-only cookies automatically
type JsonValue = any; type JsonValue = any;
export function useApi() { export function useApi() {
const { token } = useAuth();
return useMemo(() => { return useMemo(() => {
function authHeaders(extra?: HeadersInit): HeadersInit {
const base: Record<string, string> = {};
if (token) base.Authorization = `Bearer ${token}`;
return { ...base, ...(extra as any) };
}
async function get(path: string, init?: RequestInit) { 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, ...init,
headers: authHeaders(init?.headers), credentials: 'include', // Include cookies in requests
}); });
} }
async function post(path: string, body: JsonValue, init?: RequestInit) { async function post(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, { return fetch(path, {
method: "POST", method: "POST",
...init, ...init,
headers: authHeaders({ credentials: 'include',
headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(init?.headers as any), ...(init?.headers as any),
}), },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} }
async function put(path: string, body: JsonValue, init?: RequestInit) { async function put(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, { return fetch(path, {
method: "PUT", method: "PUT",
...init, ...init,
headers: authHeaders({ credentials: 'include',
headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(init?.headers as any), ...(init?.headers as any),
}), },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} }
async function del(path: string, init?: RequestInit) { async function del(path: string, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, { return fetch(path, {
method: "DELETE", method: "DELETE",
...init, ...init,
headers: authHeaders(init?.headers), credentials: 'include',
headers: init?.headers,
}); });
} }
// ✅ stable reference unless token changes
return { get, post, put, del }; return { get, post, put, del };
}, [token]); }, []);
} }
+25 -54
View File
@@ -2,71 +2,42 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } 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<string>([
"/",
"/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) { export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; 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 });
}
}
// ========================= // Protect admin pages
// 1) Admin gate (always on) if (pathname.startsWith("/admin")) {
// ========================= if (!token) {
if (pathname.startsWith("/admin")) { const url = req.nextUrl.clone();
const token = req.cookies.get("bb_access_token")?.value; url.pathname = "/login";
const role = req.cookies.get("bb_role")?.value; // optional url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
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);
} }
return NextResponse.next(); 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 = { export const config = {
matcher: [ matcher: [
// run on everything except Next internals + obvious static files "/api/admin/:path*",
"/((?!_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/builds",
"/api/builds/:path+",
"/admin/:path*",
], ],
}; };