This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
// -----------------------------
|
||||
|
||||
@@ -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<BuildDto[]>([]);
|
||||
const [error, setError] = useState<string | null>(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 <token> 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(() => "");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+77
-138
@@ -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<void>;
|
||||
register: (params: RegisterParams) => Promise<void>;
|
||||
logout: () => void;
|
||||
getAuthHeaders: () => HeadersInit;
|
||||
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
|
||||
refreshMeAndSession: () => Promise<void>;
|
||||
|
||||
refreshUser: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
const TOKEN_KEY = "ballistic_auth_token";
|
||||
const USER_KEY = "ballistic_auth_user";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser>(null);
|
||||
const [loading, setLoading] = useState<boolean>(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<AuthUser>) => {
|
||||
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<AuthUser>;
|
||||
|
||||
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<AuthUser>);
|
||||
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<AuthUser>);
|
||||
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
+17
-23
@@ -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<string, string> = {};
|
||||
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]);
|
||||
}, []);
|
||||
}
|
||||
+25
-54
@@ -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<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) {
|
||||
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*",
|
||||
],
|
||||
|
||||
};
|
||||
Reference in New Issue
Block a user