This commit is contained in:
@@ -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
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
|
|||||||
@@ -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(() => "");
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
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
@@ -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
@@ -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
@@ -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*",
|
||||||
],
|
],
|
||||||
|
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user