6 Commits

Author SHA1 Message Date
sean 77c31ae4f9 fixed all admin email pages. added admin user info and button on builder page to route to admin page.
CI / test (push) Successful in 5s
2026-01-26 14:58:56 -05:00
sean fb2effca47 productSlug fixes
CI / test (push) Successful in 7s
2026-01-26 07:10:37 -05:00
sean b09ccc542e fixed login session for user name, and updated the api calls on the product detials pages.
CI / test (push) Successful in 6s
2026-01-25 20:20:48 -05:00
sean 3aee0e6755 fixing admin pages
CI / test (push) Successful in 6s
2026-01-25 14:22:58 -05:00
sean e49dc96522 clean up
CI / test (push) Successful in 5s
2026-01-25 14:05:36 -05:00
sean 50ef395a38 woof. killed useClient and use nextjs api
CI / test (push) Successful in 5s
2026-01-25 13:40:10 -05:00
56 changed files with 1691 additions and 531 deletions
+72 -19
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";
@@ -296,7 +295,7 @@ function buildAssemblyRows(params: {
rows.push({ rows.push({
key: `${groupLabel}-${paths.complete}`, key: `${groupLabel}-${paths.complete}`,
kind: "category", kind: "category",
label: "Complete Assembly (Fastest)", label: "Completed Lower (Fastest)",
categoryId: paths.complete, categoryId: paths.complete,
indent: 0, indent: 0,
pill: mode === "complete" ? "Selected" : undefined, pill: mode === "complete" ? "Selected" : undefined,
@@ -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
]); ]);
// ----------------------------- // -----------------------------
@@ -1200,6 +1195,7 @@ function GunbuilderPageContent() {
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10"> <div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */} {/* Header */}
<header className="mb-6"> <header className="mb-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500"> <p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS BATTL BUILDERS
@@ -1213,6 +1209,17 @@ function GunbuilderPageContent() {
update as you go. This early-access builder keeps your setup saved update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime. locally so you can come back and refine it anytime.
</p> </p>
</div>
{user?.role === "ADMIN" ? (
<Link
href="/admin"
className="inline-flex items-center gap-2 rounded-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-200 hover:border-amber-300 hover:text-amber-100"
>
Admin Toolbar
</Link>
) : null}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3"> <div className="mt-4 flex flex-wrap items-center gap-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2"> <label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
@@ -1246,7 +1253,6 @@ function GunbuilderPageContent() {
Parts list updates automatically when you change platforms. Parts list updates automatically when you change platforms.
</p> </p>
</div> </div>
</div>
</header> </header>
{/* Build summary panel */} {/* Build summary panel */}
@@ -1475,6 +1481,11 @@ function GunbuilderPageContent() {
if (groupCategories.length === 0) return null; if (groupCategories.length === 0) return null;
// Check if there are locked/conflicting parts in this group
const hasConflicts = tableRows.some(
(row) => row.kind === "category" && row.locked
);
return ( return (
<div <div
id={`group-${group.id}`} id={`group-${group.id}`}
@@ -1494,6 +1505,23 @@ function GunbuilderPageContent() {
)} )}
</div> </div>
</div> </div>
{hasConflicts && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<span className="text-amber-400 text-sm"></span>
<div className="flex-1">
<p className="text-sm font-medium text-amber-300">
Part Conflict Detected
</p>
<p className="text-xs text-zinc-300 mt-1">
Some parts below are disabled because you've selected a complete assembly that already includes them. To customize individual parts, remove the complete assembly first.
</p>
</div>
</div>
</div>
)}
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80"> <div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm"> <table className="min-w-full text-xs md:text-sm">
<thead> <thead>
@@ -1608,9 +1636,15 @@ function GunbuilderPageContent() {
</div> </div>
{selectedPart ? ( {selectedPart ? (
<div className="text-[0.7rem] text-zinc-500 line-clamp-1"> <Link
href={`/parts/p/${canonicalPlatform}/${category.id}/${selectedPart.id}-${selectedPart.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")}`}
className="text-[0.7rem] text-zinc-500 hover:text-amber-300 hover:underline line-clamp-1 transition-colors"
>
{selectedPart.name} {selectedPart.name}
</div> </Link>
) : ( ) : (
<div className="text-[0.7rem] text-zinc-600"> <div className="text-[0.7rem] text-zinc-600">
No part selected No part selected
@@ -1640,8 +1674,27 @@ function GunbuilderPageContent() {
<td className="px-3 py-2 align-top"> <td className="px-3 py-2 align-top">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{isLocked ? ( {isLocked ? (
<div className="text-[0.7rem] text-zinc-500"> <div className="flex items-center gap-2">
<span className="text-[0.7rem] text-amber-400/70">
⚠ Disabled
</span>
<div className="group relative">
<button
type="button"
className="text-zinc-500 hover:text-zinc-300 text-xs"
title="Why is this disabled?"
>
</button>
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs text-zinc-300 shadow-xl">
<p className="font-semibold text-amber-300 mb-1">
Part Conflict
</p>
<p>
You've selected a complete assembly which already includes this part. Remove the complete assembly to select individual parts.
</p>
</div>
</div>
</div> </div>
) : selectedPart ? ( ) : selectedPart ? (
<> <>
@@ -18,6 +18,7 @@ type OfferFromApi = {
price?: number | null; price?: number | null;
originalPrice?: number | null; originalPrice?: number | null;
buyUrl?: string | null; buyUrl?: string | null;
buyShortUrl?: string | null;
inStock?: boolean | null; inStock?: boolean | null;
lastUpdated?: string | null; lastUpdated?: string | null;
}; };
@@ -29,6 +30,7 @@ type GunbuilderProductFromApi = {
platform: string; platform: string;
partRole: string; partRole: string;
price: number | null; price: number | null;
caliber?: string | null;
// Optional (legacy fallback label if product.offers is missing) // Optional (legacy fallback label if product.offers is missing)
merchantName?: string | null; merchantName?: string | null;
@@ -37,6 +39,7 @@ type GunbuilderProductFromApi = {
mainImageUrl?: string | null; mainImageUrl?: string | null;
buyUrl?: string | null; buyUrl?: string | null;
buyShortUrl?: string | null;
inStock?: boolean | null; inStock?: boolean | null;
shortDescription?: string | null; shortDescription?: string | null;
@@ -238,14 +241,23 @@ export default function ProductDetailsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/v1/products/${numericId}`; const url = `/api/catalog/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`); throw new Error(`Failed to load product (${res.status})`);
} }
const data: GunbuilderProductFromApi = await res.json(); const data: GunbuilderProductFromApi = await res.json();
console.log('[DEBUG] Product from API:', {
id: data.id,
name: data.name,
buyUrl: data.buyUrl,
buyShortUrl: data.buyShortUrl
});
setProduct(data); setProduct(data);
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") return; if (err?.name === "AbortError") return;
@@ -272,14 +284,18 @@ export default function ProductDetailsPage() {
try { try {
setOffersLoading(true); setOffersLoading(true);
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`; const url = `/api/catalog/products/${numericId}/offers`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load offers (${res.status})`); throw new Error(`Failed to load offers (${res.status})`);
} }
const data: OfferFromApi[] = await res.json(); const data: OfferFromApi[] = await res.json();
console.log('[DEBUG] Offers from API:', data);
setOffersFromApi(Array.isArray(data) ? data : []); setOffersFromApi(Array.isArray(data) ? data : []);
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") return; if (err?.name === "AbortError") return;
@@ -330,14 +346,23 @@ export default function ProductDetailsPage() {
* - Fallback to legacy single-offer derived from product if needed * - Fallback to legacy single-offer derived from product if needed
*/ */
const offers = useMemo(() => { const offers = useMemo(() => {
if (offersFromApi.length > 0) return sortOffers(offersFromApi); if (offersFromApi.length > 0) {
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
const fallbackShortUrl = product?.buyShortUrl ?? null;
const normalized = offersFromApi.map((offer) => ({
...offer,
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
}));
return sortOffers(normalized);
}
if (product?.buyUrl) { if (product?.buyUrl || product?.buyShortUrl) {
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
return sortOffers([ return sortOffers([
{ {
merchantName: product.merchantName ?? "Retailer", merchantName: product.merchantName ?? "Retailer",
price: product.price, price: product.price,
buyUrl: product.buyUrl, buyUrl: product.buyShortUrl || product.buyUrl,
inStock: product.inStock ?? true, inStock: product.inStock ?? true,
}, },
]); ]);
@@ -559,6 +584,14 @@ export default function ProductDetailsPage() {
{product.platform || platform} {product.platform || platform}
</span> </span>
</span> </span>
{product.caliber && (
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Caliber:{" "}
<span className="font-semibold text-zinc-100">
{product.caliber}
</span>
</span>
)}
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300"> <span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "} Role:{" "}
<span className="font-semibold text-zinc-100"> <span className="font-semibold text-zinc-100">
@@ -660,9 +693,9 @@ export default function ProductDetailsPage() {
{o.price != null ? `$${o.price.toFixed(2)}` : "—"} {o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
{o.buyUrl ? ( {(o.buyShortUrl || o.buyUrl) ? (
<a <a
href={o.buyUrl} href={o.buyShortUrl || o.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300" className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
@@ -686,10 +719,10 @@ export default function ProductDetailsPage() {
)} )}
{/* Best Offer CTA */} {/* Best Offer CTA */}
{bestOffer?.buyUrl ? ( {(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<a <a
href={bestOffer.buyUrl} href={bestOffer.buyShortUrl || bestOffer.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800" className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
+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(() => "");
+11 -41
View File
@@ -1,11 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Field, Input } from "@/components/ui/form"; import { Button, Field, Input } from "@/components/ui/form";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type AdminBetaRequestDto = { type AdminBetaRequestDto = {
id: number; id: number;
@@ -40,17 +36,12 @@ type AdminInviteResponse = {
token?: string; token?: string;
}; };
async function fetchJson( async function fetchJson(path: string, init: RequestInit = {}) {
path: string, const res = await fetch(path, {
init: RequestInit = {},
authHeaders: HeadersInit = {}
) {
const res = await fetch(`${API_BASE_URL}${path}`, {
...init, ...init,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(init.headers ?? {}), ...(init.headers ?? {}),
...authHeaders,
}, },
cache: "no-store", cache: "no-store",
}); });
@@ -101,9 +92,6 @@ function Badge({
} }
export default function AdminBetaInvitesPage() { export default function AdminBetaInvitesPage() {
const { getAuthHeaders, user } = useAuth();
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
// ------------------------------ // ------------------------------
// Batch invites // Batch invites
// ------------------------------ // ------------------------------
@@ -126,11 +114,9 @@ export default function AdminBetaInvitesPage() {
tokenMinutes: String(tokenMinutes || "30"), tokenMinutes: String(tokenMinutes || "30"),
}); });
const data = await fetchJson( const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
`/api/v1/admin/beta/invites/send?${qs.toString()}`, method: "POST",
{ method: "POST" }, });
authHeaders
);
setBatchResult(data); setBatchResult(data);
await loadRequests(); await loadRequests();
@@ -166,9 +152,8 @@ export default function AdminBetaInvitesPage() {
try { try {
const data = (await fetchJson( const data = (await fetchJson(
`/api/v1/admin/beta/requests?page=${page}&size=${size}`, `/api/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" }, { method: "GET" }
authHeaders
)) as PageResponse<AdminBetaRequestDto>; )) as PageResponse<AdminBetaRequestDto>;
setRequests(data); setRequests(data);
@@ -181,11 +166,9 @@ export default function AdminBetaInvitesPage() {
async function inviteSingle(userId: number): Promise<AdminInviteResponse> { async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite // calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
return (await fetchJson( return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
`/api/v1/admin/beta/requests/${userId}/invite`, method: "POST",
{ method: "POST" }, })) as AdminInviteResponse;
authHeaders
)) as AdminInviteResponse;
} }
async function onSendInviteEmail(userId: number) { async function onSendInviteEmail(userId: number) {
@@ -241,19 +224,6 @@ export default function AdminBetaInvitesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]); }, [page]);
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
if (!isAdmin) {
return (
<div className="space-y-2">
<h1 className="text-xl font-semibold">Admin</h1>
<p className="text-sm text-zinc-400">
You dont have access to this page.
</p>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
+5 -20
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { import {
fetchEmailRequests, fetchEmailRequests,
createEmailRequest, createEmailRequest,
@@ -17,7 +16,6 @@ import { formatDate } from "@/lib/dateFormattingUtility";
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER"; type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
export default function AdminEmailPage() { export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]); const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -33,11 +31,9 @@ export default function AdminEmailPage() {
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL"); const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
const loadEmails = useCallback(async () => { const loadEmails = useCallback(async () => {
if (!token) return;
try { try {
setLoading(true); setLoading(true);
const data = await fetchEmailRequests(token); const data = await fetchEmailRequests();
setEmails(data); setEmails(data);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
@@ -46,7 +42,7 @@ export default function AdminEmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, []);
useEffect(() => { useEffect(() => {
loadEmails(); loadEmails();
@@ -108,11 +104,10 @@ export default function AdminEmailPage() {
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return; if (!confirm("Are you sure you want to delete this email request?")) return;
try { try {
await deleteEmailRequest(token, id); await deleteEmailRequest(id);
await loadEmails(); await loadEmails();
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Failed to delete email request"); alert(e?.message ?? "Failed to delete email request");
@@ -121,17 +116,15 @@ export default function AdminEmailPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) return;
try { try {
setSubmitting(true); setSubmitting(true);
if (modalMode === "create") { if (modalMode === "create") {
await createEmailRequest(token, formData); await createEmailRequest(formData);
} else if (selectedEmail) { } else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {}; const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email; if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status; if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData); await updateEmailRequest(selectedEmail.id, updateData);
} }
setShowModal(false); setShowModal(false);
await loadEmails(); await loadEmails();
@@ -142,14 +135,6 @@ export default function AdminEmailPage() {
} }
}; };
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Header */} {/* Header */}
+5 -20
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { import {
fetchEmailRequests, fetchEmailRequests,
createEmailRequest, createEmailRequest,
@@ -14,7 +13,6 @@ import {
import { Pencil, Trash2, Plus, X } from "lucide-react"; import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/dateFormattingUtility"; import { formatDate } from "@/lib/dateFormattingUtility";
export default function AdminEmailPage() { export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]); const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -28,11 +26,9 @@ export default function AdminEmailPage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const loadEmails = useCallback(async () => { const loadEmails = useCallback(async () => {
if (!token) return;
try { try {
setLoading(true); setLoading(true);
const data = await fetchEmailRequests(token); const data = await fetchEmailRequests();
setEmails(data); setEmails(data);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
@@ -41,7 +37,7 @@ export default function AdminEmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, []);
useEffect(() => { useEffect(() => {
loadEmails(); loadEmails();
@@ -62,11 +58,10 @@ export default function AdminEmailPage() {
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return; if (!confirm("Are you sure you want to delete this email request?")) return;
try { try {
await deleteEmailRequest(token, id); await deleteEmailRequest(id);
await loadEmails(); await loadEmails();
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Failed to delete email request"); alert(e?.message ?? "Failed to delete email request");
@@ -75,17 +70,15 @@ export default function AdminEmailPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) return;
try { try {
setSubmitting(true); setSubmitting(true);
if (modalMode === "create") { if (modalMode === "create") {
await createEmailRequest(token, formData); await createEmailRequest(formData);
} else if (selectedEmail) { } else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {}; const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email; if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status; if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData); await updateEmailRequest(selectedEmail.id, updateData);
} }
setShowModal(false); setShowModal(false);
await loadEmails(); await loadEmails();
@@ -96,14 +89,6 @@ export default function AdminEmailPage() {
} }
}; };
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Header */} {/* Header */}
+4 -3
View File
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
setError(null); setError(null);
const [summaryRes, byMerchantRes] = await Promise.all([ const [summaryRes, byMerchantRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/admin/import-status/summary`), fetch('/api/admin/import-status/summary', { credentials: 'include' }),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`), fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
]); ]);
if (!summaryRes.ok || !byMerchantRes.ok) { if (!summaryRes.ok || !byMerchantRes.ok) {
@@ -108,9 +108,10 @@ export default function ImportStatusPage() {
try { try {
setImportingId(merchantId); setImportingId(merchantId);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`, `/api/admin/merchants/${merchantId}/import`,
{ {
method: "POST", method: "POST",
credentials: 'include',
} }
); );
+3 -6
View File
@@ -149,7 +149,7 @@ export default function AdminLayout({
return ( return (
// ✅ prevent browser-level horizontal scroll from any wide children // ✅ prevent browser-level horizontal scroll from any wide children
<div className="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]"> <div className="h-screen bg-black text-zinc-50 overflow-hidden">
<AdminLeftNavigation <AdminLeftNavigation
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)} onToggleCollapsed={() => setCollapsed((v) => !v)}
@@ -158,7 +158,7 @@ export default function AdminLayout({
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */} {/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
<div <div
className="flex min-h-screen flex-1 min-w-0 flex-col transition-all duration-300" className="flex h-full flex-1 min-w-0 flex-col transition-all duration-300"
style={{ marginLeft: collapsed ? '68px' : '280px' }} style={{ marginLeft: collapsed ? '68px' : '280px' }}
> >
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm"> <header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
@@ -173,14 +173,11 @@ export default function AdminLayout({
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300"> <span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
Internal v0.1 Internal v0.1
</span> </span>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
ADMIN
</div>
</div> </div>
</header> </header>
{/* ✅ min-w-0 here prevents table min-width from widening the page */} {/* ✅ min-w-0 here prevents table min-width from widening the page */}
<main className="flex w-full flex-1 min-w-0 flex-col px-4 py-6"> <main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
{children} {children}
</main> </main>
</div> </div>
+10 -6
View File
@@ -198,8 +198,9 @@ export default function MappingAdminPage() {
// EXPECTED backend endpoint (new): // EXPECTED backend endpoint (new):
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] } // GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, { const res = await fetch('/api/admin/mapping/options', {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store", cache: "no-store",
}); });
@@ -243,8 +244,9 @@ export default function MappingAdminPage() {
if (tab === "roles") { if (tab === "roles") {
// Existing endpoint you already have: // Existing endpoint you already have:
// GET /api/admin/mapping/pending-buckets // GET /api/admin/mapping/pending-buckets
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, { const res = await fetch('/api/admin/mapping/pending-buckets', {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store", cache: "no-store",
}); });
@@ -277,8 +279,8 @@ export default function MappingAdminPage() {
if (q?.trim()) params.set("q", q.trim()); if (q?.trim()) params.set("q", q.trim());
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`, `/api/admin/mapping/raw-categories?${params.toString()}`,
{ headers: { Accept: "application/json" }, cache: "no-store" } { headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
); );
if (!res.ok) { if (!res.ok) {
@@ -336,9 +338,10 @@ export default function MappingAdminPage() {
// Existing endpoint you already have: // Existing endpoint you already have:
// POST /api/admin/mapping/apply // POST /api/admin/mapping/apply
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, { const res = await fetch('/api/admin/mapping/apply', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
merchantId: row.merchantId, merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey, rawCategoryKey: row.rawCategoryKey,
@@ -390,9 +393,10 @@ export default function MappingAdminPage() {
// EXPECTED new endpoint: // EXPECTED new endpoint:
// POST /api/admin/mapping/upsert // POST /api/admin/mapping/upsert
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, { const res = await fetch('/api/admin/mapping/upsert', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
merchantId: row.merchantId, merchantId: row.merchantId,
platform: row.platform ?? platform ?? null, platform: row.platform ?? platform ?? null,
+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({
+3 -1
View File
@@ -24,7 +24,9 @@ export default function AdminLandingPage() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`); const res = await fetch('/api/admin/dashboard/overview', {
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load dashboard (${res.status})`); throw new Error(`Failed to load dashboard (${res.status})`);
} }
+1 -1
View File
@@ -6,7 +6,7 @@ const API_BASE_URL =
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30 // POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) { export async function POST(req: Request) {
const token = cookies().get("bb_access_token")?.value; const token = cookies().get("session_token")?.value;
if (!token) { if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 }); return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
@@ -0,0 +1,39 @@
import { 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(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/beta/requests/${params.id}/invite`,
{
method: "POST",
headers,
cache: "no-store",
}
);
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 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { 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(req: Request) {
try {
const headers = await getAuthHeader();
const url = new URL(req.url);
const search = url.searchParams.toString();
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
search ? `?${search}` : ""
}`;
const res = await fetch(upstream, {
headers,
cache: "no-store",
});
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 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { 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/admin/dashboard/overview`, {
headers,
cache: 'no-store',
});
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 || 'Unauthorized' },
{ status: 401 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
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 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/email/${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/email/${params.id}`, {
method: "DELETE",
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
+56
View File
@@ -0,0 +1,56 @@
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/email`, {
headers,
cache: "no-store",
});
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/email`, {
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,35 @@
import { 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/admin/import-status/by-merchant`, {
headers,
cache: 'no-store',
});
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 || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { 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/admin/import-status/summary`, {
headers,
cache: 'no-store',
});
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 || 'Unauthorized' },
{ status: 401 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
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) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
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: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { 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/admin/mapping/options`, {
headers,
cache: 'no-store',
});
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 || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { 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/admin/mapping/pending-buckets`, {
headers,
cache: 'no-store',
});
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 || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,39 @@
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/admin/mapping/raw-categories?${searchParams.toString()}`,
{
headers,
cache: 'no-store',
}
);
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 || 'Unauthorized' },
{ status: 401 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
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) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
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: err?.message || '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}/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 });
}
}
+35
View File
@@ -0,0 +1,35 @@
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,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// Public build detail - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
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 build' },
{ status: 500 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
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);
// Public builds endpoint - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds?${searchParams}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
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 builds' },
{ status: 500 }
);
}
}
+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 }
);
}
}
@@ -0,0 +1,35 @@
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,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}/offers`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
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 product offers' },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
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,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
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 product' },
{ 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 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
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/products?${searchParams}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
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 }
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import { springProxy } from "@/lib/springProxy";
import type {
MerchantAdminResponse,
MerchantAdminUpdateRequest,
} from "@/types/merchant-admin";
export async function PUT(
req: Request,
{ params }: { params: { id: string } }
) {
const body = (await req.json()) as MerchantAdminUpdateRequest;
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
req,
`/api/v1/admin/merchants/${params.id}`,
{ method: "PUT", body }
);
}
-19
View File
@@ -1,19 +0,0 @@
import { springProxy } from "@/lib/springProxy";
import type {
MerchantAdminCreateRequest,
MerchantAdminResponse,
} from "@/types/merchant-admin";
export async function GET(req: Request) {
return springProxy<MerchantAdminResponse[]>(req, "/api/v1/admin/merchants");
}
export async function POST(req: Request) {
const body = (await req.json()) as MerchantAdminCreateRequest;
return springProxy<MerchantAdminResponse, MerchantAdminCreateRequest>(
req,
"/api/v1/admin/merchants",
{ method: "POST", body }
);
}
+3 -2
View File
@@ -31,8 +31,9 @@ export default async function BuildBreakdownPage({
}: { }: {
params: { buildId: string }; params: { buildId: string };
}) { }) {
// Public detail endpoint // Public detail endpoint - use Next.js API route
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/builds/public/${params.buildId}`, {
cache: "no-store", cache: "no-store",
}); });
+1 -1
View File
@@ -132,7 +132,7 @@ export default function BuildsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, { const res = await fetch('/api/builds/public?limit=50', {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
+24 -6
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react"; import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
export type AdminNavItem = { export type AdminNavItem = {
label: string; label: string;
@@ -42,14 +43,21 @@ export default function AdminLeftNavigation({
groups: AdminNavGroup[]; groups: AdminNavGroup[];
}) { }) {
const pathname = usePathname() || "/admin"; const pathname = usePathname() || "/admin";
const { user } = useAuth();
const identity =
user?.displayName?.trim() ||
user?.username?.trim() ||
user?.email?.trim() ||
user?.uuid ||
"Admin";
const secondary = user?.email && user.email !== identity ? user.email : null;
return ( return (
<aside <aside
className={cx( className={cx(
"fixed left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40", "fixed inset-y-0 left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
collapsed ? "w-[68px]" : "w-[280px]" collapsed ? "w-[68px]" : "w-[280px]"
)} )}
style={{ top: '52px', height: 'calc(100vh - 52px)' }}
> >
{/* Header / Brand */} {/* Header / Brand */}
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}> <div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
@@ -177,20 +185,30 @@ export default function AdminLeftNavigation({
{/* Footer */} {/* Footer */}
<div className={cx("border-t border-zinc-900 p-3", collapsed && "px-2")}> <div className={cx("border-t border-zinc-900 p-3", collapsed && "px-2")}>
<Link
href="/builder"
className={cx(
"mb-3 inline-flex w-full items-center justify-center rounded-md border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900/70",
collapsed && "px-2"
)}
title="Back to Builder"
>
{collapsed ? "↩" : "Back to Builder"}
</Link>
<div <div
className={cx( className={cx(
"flex items-center gap-3 rounded-md bg-zinc-900/30 px-3 py-2 ring-1 ring-zinc-800", "flex items-center gap-3 rounded-md bg-zinc-900/30 px-3 py-2 ring-1 ring-zinc-800",
collapsed && "justify-center px-2" collapsed && "justify-center px-2"
)} )}
title="Admin user" title={secondary ?? identity}
> >
<div className="h-8 w-8 rounded-full bg-zinc-900 ring-1 ring-zinc-800" /> <div className="h-8 w-8 rounded-full bg-zinc-900 ring-1 ring-zinc-800" />
{!collapsed && ( {!collapsed && (
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500"> <div className="truncate text-sm text-zinc-200">{identity}</div>
Internal <div className="truncate text-xs text-zinc-500">
{secondary ?? "Authenticated user"}
</div> </div>
<div className="truncate text-sm text-zinc-200">ADMIN</div>
</div> </div>
)} )}
</div> </div>
+7 -1
View File
@@ -1,8 +1,14 @@
"use client";
import React from "react"; import React from "react";
import { usePathname } from "next/navigation";
export function Banner() { export function Banner() {
const pathname = usePathname();
if (pathname?.startsWith("/admin")) return null;
return ( return (
<div className="fixed top-0 left-0 right-0 z-50"> <div className="w-full">
{/* Early access bar */} {/* Early access bar */}
<div className="border-b border-amber-500/20 bg-amber-500/5 backdrop-blur-sm"> <div className="border-b border-amber-500/20 bg-amber-500/5 backdrop-blur-sm">
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between"> <div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
+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,
} }
+5 -2
View File
@@ -50,9 +50,12 @@ export default function PartsListPageClient(props: {
// Your current list endpoint: // Your current list endpoint:
// GET /api/v1/products?platform=AR-15&partRoles=upper-receiver // GET /api/v1/products?platform=AR-15&partRoles=upper-receiver
const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`; const url = `/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
const res = await fetch(url, { headers: { Accept: "application/json" } }); const res = await fetch(url, {
headers: { Accept: "application/json" },
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
const txt = await res.text().catch(() => ""); const txt = await res.text().catch(() => "");
throw new Error(`Failed to load products (${res.status}): ${txt}`); throw new Error(`Failed to load products (${res.status}): ${txt}`);
+4 -3
View File
@@ -41,8 +41,9 @@ async function fetchProductDetail(params: {
// ---- Preferred (if you add it): GET /api/v1/products/{id} // ---- Preferred (if you add it): GET /api/v1/products/{id}
// If it 404s, we fall back. // If it 404s, we fall back.
try { try {
const res = await fetch(`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}`, { const res = await fetch(`/api/catalog/products/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
}); });
if (res.ok) { if (res.ok) {
@@ -54,8 +55,8 @@ async function fetchProductDetail(params: {
// ---- Fallback: use list endpoint + find by id (works right now with your current API) // ---- Fallback: use list endpoint + find by id (works right now with your current API)
const listRes = await fetch( const listRes = await fetch(
`${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`, `/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
{ headers: { Accept: "application/json" } } { headers: { Accept: "application/json" }, credentials: 'include' }
); );
if (!listRes.ok) { if (!listRes.ok) {
+16 -17
View File
@@ -1,16 +1,10 @@
"use client"; "use client";
import { useMemo } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type React from "react"; import type React from "react";
import "react-quill/dist/quill.snow.css"; import "react-quill/dist/quill.snow.css";
// Import Quill modules after React Quill is loaded
if (typeof window !== 'undefined') {
const Quill = require('quill');
const QuillImageResize = require('quill-image-resize-module').default;
Quill.register("modules/imageResize", QuillImageResize);
}
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false }); const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
type RichTextEditorProps = { type RichTextEditorProps = {
@@ -26,15 +20,8 @@ export default function RichTextEditor({
placeholder, placeholder,
className, className,
}: RichTextEditorProps) { }: RichTextEditorProps) {
return ( const modules = useMemo(
<div className={className}> () => ({
<div className="bb-quill">
<ReactQuill
theme="snow"
value={value}
onChange={onChange}
placeholder={placeholder}
modules={{
toolbar: [ toolbar: [
[{ header: [1, 2, 3, false] }], [{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"], ["bold", "italic", "underline", "strike"],
@@ -44,7 +31,19 @@ export default function RichTextEditor({
["link"], ["link"],
["clean"], ["clean"],
], ],
}} }),
[]
);
return (
<div className={className}>
<div className="bb-quill">
<ReactQuill
theme="snow"
value={value}
onChange={onChange}
placeholder={placeholder}
modules={modules}
/> />
</div> </div>
</div> </div>
+79 -121
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,137 @@ 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;
refreshUser: () => Promise<void>;
getAuthHeaders: () => HeadersInit; getAuthHeaders: () => HeadersInit;
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) {
window.localStorage.setItem(TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (nextUser) { if (nextUser) {
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser)); window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else { } else {
window.localStorage.removeItem(USER_KEY); 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", { credentials: 'include' })
.then((res) => {
/** if (res.ok) {
* Used by login/register/magic-link return res.json();
*/
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
} }
}, throw new Error("Not authenticated");
[persistAuth] })
); .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 refreshMeAndSession = useCallback(async () => { const refreshUser = useCallback(async () => {
if (!token) return; const res = await fetch("/api/auth/me", {
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store", cache: "no-store",
credentials: 'include',
}); });
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" },
credentials: 'include',
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,9 +169,10 @@ 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" },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
email, email,
password, password,
@@ -222,54 +183,51 @@ 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", {
}, [persistAuth]); method: "POST",
credentials: 'include',
}).catch(() => {});
}, [persistUser]);
const getAuthHeaders = useCallback((): HeadersInit => { const getAuthHeaders = useCallback((): HeadersInit => {
if (typeof window === "undefined") return {};
const token =
localStorage.getItem("token") ||
localStorage.getItem("jwt") ||
localStorage.getItem("accessToken");
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
}, [token]); }, []);
const value: AuthContextValue = { const value: AuthContextValue = {
user, user,
token,
loading, loading,
login, login,
register, register,
logout, logout,
refreshUser,
getAuthHeaders, getAuthHeaders,
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 -25
View File
@@ -17,28 +17,29 @@ export interface UpdateEmailRequestDto {
status?: string; status?: string;
} }
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || ''; export async function fetchEmailRequests(): Promise<EmailRequest[]> {
const response = await fetch("/api/admin/email", { cache: "no-store" });
export async function fetchEmailRequests(token: string): Promise<EmailRequest[]> {
const response = await fetch(`${API_BASE}/api/email`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch email requests: ${response.statusText}`); throw new Error(`Failed to fetch email requests: ${response.statusText}`);
} }
return response.json(); const data = (await response.json()) as unknown;
if (Array.isArray(data)) return data as EmailRequest[];
const wrapped = data as { data?: unknown; content?: unknown };
if (Array.isArray(wrapped.data)) return wrapped.data as EmailRequest[];
if (Array.isArray(wrapped.content)) return wrapped.content as EmailRequest[];
return [];
} }
export async function createEmailRequest(token: string, data: CreateEmailRequestDto): Promise<EmailRequest> { export async function createEmailRequest(
const response = await fetch(`${API_BASE}/api/email`, { data: CreateEmailRequestDto
method: 'POST', ): Promise<EmailRequest> {
const response = await fetch("/api/admin/email", {
method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@@ -50,12 +51,14 @@ export async function createEmailRequest(token: string, data: CreateEmailRequest
return response.json(); return response.json();
} }
export async function updateEmailRequest(token: string, id: number, data: UpdateEmailRequestDto): Promise<EmailRequest> { export async function updateEmailRequest(
const response = await fetch(`${API_BASE}/api/email/${id}`, { id: number,
method: 'PUT', data: UpdateEmailRequestDto
): Promise<EmailRequest> {
const response = await fetch(`/api/admin/email/${id}`, {
method: "PUT",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@@ -67,12 +70,9 @@ export async function updateEmailRequest(token: string, id: number, data: Update
return response.json(); return response.json();
} }
export async function deleteEmailRequest(token: string, id: number): Promise<void> { export async function deleteEmailRequest(id: number): Promise<void> {
const response = await fetch(`${API_BASE}/api/email/${id}`, { const response = await fetch(`/api/admin/email/${id}`, {
method: 'DELETE', method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
}); });
if (!response.ok) { if (!response.ok) {
+10 -4
View File
@@ -58,9 +58,12 @@ export async function fetchProducts(params: {
if (sort) sp.set("sort", sort); if (sort) sp.set("sort", sort);
const url = `${API_BASE_URL}/api/v1/products?${sp.toString()}`; const url = `/api/catalog/products?${sp.toString()}`;
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, {
cache: "no-store",
credentials: 'include',
});
if (!res.ok) throw new Error(`Failed to load products (${res.status})`); if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
const data = (await res.json()) as ProductListItem[]; const data = (await res.json()) as ProductListItem[];
@@ -83,10 +86,13 @@ export async function fetchProductById(params: {
const { id, platform } = params; const { id, platform } = params;
const url = const url =
`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}` + `/api/catalog/products/${encodeURIComponent(id)}` +
(platform ? `?${new URLSearchParams({ platform })}` : ""); (platform ? `?${new URLSearchParams({ platform })}` : "");
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, {
cache: "no-store",
credentials: 'include',
});
if (!res.ok) throw new Error(`Failed to load product (${res.status})`); if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
return (await res.json()) as ProductDetail; return (await res.json()) as ProductDetail;
} }
+21 -50
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 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(); const url = req.nextUrl.clone();
url.pathname = "/login"; url.pathname = "/login";
url.searchParams.set("next", pathname); url.searchParams.set("next", pathname);
return NextResponse.redirect(url); 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.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*",
], ],
}; };