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

This commit is contained in:
2026-01-26 14:58:56 -05:00
parent fb2effca47
commit 77c31ae4f9
15 changed files with 393 additions and 214 deletions
+52 -41
View File
@@ -1195,53 +1195,64 @@ 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> <div className="flex flex-wrap items-start justify-between gap-3">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500"> <div>
BATTL BUILDERS <p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
</p> BATTL BUILDERS
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight"> </p>
Builder <span className="text-amber-300">Early Access</span> <h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
</h1> Builder <span className="text-amber-300">Early Access</span>
<p className="mt-2 text-sm text-zinc-400 max-w-xl"> </h1>
Explore components from trusted brands, choose one part per <p className="mt-2 text-sm text-zinc-400 max-w-xl">
category, track live prices, and watch your total build cost Explore components from trusted brands, choose one part per
update as you go. This early-access builder keeps your setup saved category, track live prices, and watch your total build cost
locally so you can come back and refine it anytime. update as you go. This early-access builder keeps your setup saved
</p> locally so you can come back and refine it anytime.
</p>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3"> {user?.role === "ADMIN" ? (
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2"> <Link
Platform href="/admin"
<select 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"
value={platform} >
onChange={(e) => { Admin Toolbar
const next = e.target.value as (typeof PLATFORMS)[number]; </Link>
setPlatform(next); ) : null}
</div>
// Keep URL in sync, and clear transient action params <div className="mt-4 flex flex-wrap items-center gap-3">
const qp = new URLSearchParams(searchParams.toString()); <label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
qp.set("platform", normalizePlatformKey(next)); Platform
qp.delete("select"); <select
qp.delete("remove"); value={platform}
onChange={(e) => {
const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next);
router.replace(`/builder?${qp.toString()}`, { // Keep URL in sync, and clear transient action params
scroll: false, const qp = new URLSearchParams(searchParams.toString());
}); qp.set("platform", normalizePlatformKey(next));
}} qp.delete("select");
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400" qp.delete("remove");
>
{PLATFORMS.map((p) => ( router.replace(`/builder?${qp.toString()}`, {
<option key={p} value={p}> scroll: false,
{PLATFORM_LABEL[p]} });
</option> }}
))} className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
</select> >
</label> {PLATFORMS.map((p) => (
<p className="text-[0.7rem] text-zinc-500"> <option key={p} value={p}>
{PLATFORM_LABEL[p]}
</option>
))}
</select>
</label>
<p className="text-[0.7rem] text-zinc-500">
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 */}
+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 -7
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,17 +173,14 @@ 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>
</div> </div>
); );
} }
+2 -2
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 });
@@ -32,4 +32,4 @@ export async function POST(req: Request) {
status: res.status, status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" }, headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
}); });
} }
@@ -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 });
}
}
+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 });
}
}
+25 -7
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,24 +185,34 @@ 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>
</div> </div>
</aside> </aside>
); );
} }
+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">
+43 -44
View File
@@ -1,17 +1,11 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import { useMemo } from "react";
import type React from "react"; import dynamic from "next/dynamic";
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 ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
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 });
type RichTextEditorProps = { type RichTextEditorProps = {
value: string; value: string;
@@ -20,33 +14,38 @@ type RichTextEditorProps = {
className?: string; className?: string;
}; };
export default function RichTextEditor({ export default function RichTextEditor({
value, value,
onChange, onChange,
placeholder, placeholder,
className, className,
}: RichTextEditorProps) { }: RichTextEditorProps) {
return ( const modules = useMemo(
<div className={className}> () => ({
<div className="bb-quill"> toolbar: [
<ReactQuill [{ header: [1, 2, 3, false] }],
theme="snow" ["bold", "italic", "underline", "strike"],
value={value} [{ color: [] }, { background: [] }],
onChange={onChange} [{ list: "ordered" }, { list: "bullet" }],
placeholder={placeholder} ["blockquote", "code-block"],
modules={{ ["link"],
toolbar: [ ["clean"],
[{ header: [1, 2, 3, false] }], ],
["bold", "italic", "underline", "strike"], }),
[{ color: [] }, { background: [] }], []
[{ list: "ordered" }, { list: "bullet" }], );
["blockquote", "code-block"],
["link"], return (
["clean"], <div className={className}>
], <div className="bb-quill">
}} <ReactQuill
/> theme="snow"
</div> value={value}
</div> onChange={onChange}
); placeholder={placeholder}
} modules={modules}
/>
</div>
</div>
);
}
+11
View File
@@ -33,6 +33,7 @@ type AuthContextValue = {
register: (params: RegisterParams) => Promise<void>; register: (params: RegisterParams) => Promise<void>;
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
getAuthHeaders: () => HeadersInit;
}; };
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
@@ -210,6 +211,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}).catch(() => {}); }).catch(() => {});
}, [persistUser]); }, [persistUser]);
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}` } : {};
}, []);
const value: AuthContextValue = { const value: AuthContextValue = {
user, user,
loading, loading,
@@ -217,6 +227,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register, register,
logout, logout,
refreshUser, refreshUser,
getAuthHeaders,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+31 -31
View File
@@ -17,64 +17,64 @@ 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),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to create email request: ${response.statusText}`); throw new Error(`Failed to create email request: ${response.statusText}`);
} }
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),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to update email request: ${response.statusText}`); throw new Error(`Failed to update email request: ${response.statusText}`);
} }
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) {
throw new Error(`Failed to delete email request: ${response.statusText}`); throw new Error(`Failed to delete email request: ${response.statusText}`);
} }