set admin behind a cookie and check role on profile

This commit is contained in:
2025-12-26 17:19:11 -05:00
parent 5401f6464d
commit c8bf032f7a
6 changed files with 226 additions and 175 deletions
+51 -65
View File
@@ -1,7 +1,10 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import AdminLeftNavigation from "@/components/AdminLeftNavigation"; import AdminLeftNavigation from "@/components/AdminLeftNavigation";
import { useAuth } from "@/context/AuthContext";
import { import {
LayoutDashboard, LayoutDashboard,
Download, Download,
@@ -15,84 +18,66 @@ import {
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
{ { label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
label: "Dashboard", { label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
href: "/admin", { label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
icon: <LayoutDashboard className="h-4 w-4" />, { label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
}, { label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
{ { label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
label: "Imports", { label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
href: "/admin/import-status", { label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
icon: <Download className="h-4 w-4" />, { label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
}, { label: "List Emails", href: "/admin/email", icon: <LucideMail className="h-4 w-4" /> },
{ { label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
label: "Mappings",
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Enrichment",
href: "/admin/enrichment",
icon: <Wand2 className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
{
label: "List Emails",
href: "/admin/email",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
},
]; ];
// ... existing code ...
// ADMIN CHECK FOR LOGIN
// const { user, loading } = useAuth();
// if (!loading && user?.role !== "ADMIN") {
// redirect("/"); // or /login
// }
export default function AdminLayout({ export default function AdminLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
// Where to send people back after login
const next = useMemo(
() => encodeURIComponent(pathname || "/admin"),
[pathname]
);
/**
* ✅ AUTH GUARD
* Redirects happen in useEffect (NOT during render)
*/
useEffect(() => {
if (loading) return;
// Not logged in
if (!user) {
router.replace(`/login?next=${next}`);
return;
}
// Logged in but not admin
if (user.role !== "ADMIN") {
router.replace("/");
}
}, [loading, user, router, next]);
// While loading OR redirecting, render nothing
if (loading) return null;
if (!user) return null;
if (user.role !== "ADMIN") return null;
return ( return (
<div className="flex min-h-screen bg-black text-zinc-50"> <div className="flex min-h-screen bg-black text-zinc-50">
<AdminLeftNavigation <AdminLeftNavigation
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)} onToggleCollapsed={() => setCollapsed((v) => !v)}
items={navItems}
/> />
{/* Main column */} {/* Main column */}
@@ -107,6 +92,7 @@ export default function AdminLayout({
Battl Builders Control Panel Battl Builders Control Panel
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<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
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { token, role } = await req.json();
if (!token) {
return NextResponse.json({ ok: false }, { status: 400 });
}
const res = NextResponse.json({ ok: true });
// Server-readable, JS-unreadable
res.cookies.set("bb_access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
// Optional: for quick middleware role checks (not “secure” by itself)
if (role) {
res.cookies.set("bb_role", role, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}
return res;
}
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.set("bb_access_token", "", { path: "/", maxAge: 0 });
res.cookies.set("bb_role", "", { path: "/", maxAge: 0 });
return res;
}
+5
View File
@@ -1,6 +1,8 @@
// app/layout.tsx // app/layout.tsx
import "./globals.css"; import "./globals.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Script from "next/script";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
import { Banner } from "@/components/Banner"; import { Banner } from "@/components/Banner";
@@ -38,6 +40,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50"> <body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
{" "} {" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
strategy="lazyOnload"
/>
<AuthProvider> <AuthProvider>
<Banner /> <Banner />
{children} {children}
+28 -64
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import Link from "next/link";
import { import {
LayoutDashboard, LayoutDashboard,
Download, Download,
@@ -12,74 +13,37 @@ import {
LucideMail, LucideMail,
Wand2, Wand2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
export type AdminNavItem = {
label: string;
href: string;
icon: React.ReactNode;
};
type AdminLeftNavigationProps = { type AdminLeftNavigationProps = {
collapsed: boolean; collapsed: boolean;
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
items?: AdminNavItem[]; // ✅ NEW
}; };
const navItems = [ const defaultNavItems: AdminNavItem[] = [
{ { label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
label: "Dashboard", { label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
href: "/admin", { label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
icon: <LayoutDashboard className="h-4 w-4" />, { label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
}, { label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
{ { label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
label: "Imports", { label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
href: "/admin/import-status", { label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
icon: <Download className="h-4 w-4" />, { label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
}, { label: "Manage Emails", href: "/admin/email/manage", icon: <LucideMail className="h-4 w-4" /> },
{ { label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
label: "Mappings",
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Enrichment",
href: "/admin/enrichment",
icon: <Wand2 className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
{
label: "Manage Emails",
href: "/admin/email/manage",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
},
]; ];
export default function AdminLeftNavigation({ export default function AdminLeftNavigation({
collapsed, collapsed,
onToggleCollapsed, onToggleCollapsed,
items = defaultNavItems, // ✅ NEW default
}: AdminLeftNavigationProps) { }: AdminLeftNavigationProps) {
return ( return (
<aside <aside
@@ -104,7 +68,9 @@ export default function AdminLeftNavigation({
{!collapsed && ( {!collapsed && (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500"> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
<Link href={"/"} title={"Back to Battl Dashboard"} >Battl Builders</Link> <Link href="/" title="Back to Battl Dashboard">
Battl Builders
</Link>
</p> </p>
<p className="text-[10px] text-zinc-600">Admin Command</p> <p className="text-[10px] text-zinc-600">Admin Command</p>
</div> </div>
@@ -112,8 +78,8 @@ export default function AdminLeftNavigation({
</div> </div>
<nav className="flex-1 space-y-1 text-sm"> <nav className="flex-1 space-y-1 text-sm">
{navItems.map((item) => ( {items.map((item) => (
<a <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300" className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
@@ -123,15 +89,13 @@ export default function AdminLeftNavigation({
{!collapsed && <span>{item.label}</span>} {!collapsed && <span>{item.label}</span>}
</div> </div>
{!collapsed && <span className="text-[10px] text-zinc-600"></span>} {!collapsed && <span className="text-[10px] text-zinc-600"></span>}
</a> </Link>
))} ))}
</nav> </nav>
{!collapsed && ( {!collapsed && (
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600"> <div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"> <p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
Status
</p>
<p className="mt-1"> <p className="mt-1">
Import engine: <span className="text-emerald-400">online</span> Import engine: <span className="text-emerald-400">online</span>
</p> </p>
+42 -6
View File
@@ -49,6 +49,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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
*/
const persistAuth = useCallback( const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => { (nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -68,19 +71,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[] []
); );
// Hydrate from localStorage on first load /**
* ✅ Hydrate from localStorage ONCE
* 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 storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY); const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken) setToken(storedToken); if (storedToken && storedUser) {
if (storedUser) {
try { try {
setUser(JSON.parse(storedUser)); const parsedUser = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser);
// ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: storedToken,
role: parsedUser?.role,
}),
}).catch(() => {});
} catch { } catch {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY); window.localStorage.removeItem(USER_KEY);
} }
} }
@@ -88,11 +106,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false); setLoading(false);
}, []); }, []);
/**
* Used by login/register/magic-link
*/
const setSession = useCallback( const setSession = useCallback(
(nextToken: string, nextUser: NonNullable<AuthUser>) => { (nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken); setToken(nextToken);
setUser(nextUser); setUser(nextUser);
persistAuth(nextToken, nextUser); persistAuth(nextToken, nextUser);
// ✅ Make middleware / server aware of the session
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: nextToken,
role: nextUser.role,
}),
}).catch(() => {});
}, },
[persistAuth] [persistAuth]
); );
@@ -118,6 +149,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser = const nextUser: AuthUser =
data.user ?? data.user ??
({ ({
uuid: data.uuid,
email: data.email ?? email, email: data.email ?? email,
displayName: data.displayName ?? null, displayName: data.displayName ?? null,
role: data.role ?? "USER", role: data.role ?? "USER",
@@ -160,6 +192,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser = const nextUser: AuthUser =
data.user ?? data.user ??
({ ({
uuid: data.uuid,
email: data.email ?? email, email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null, displayName: data.displayName ?? displayName ?? null,
role: data.role ?? "USER", role: data.role ?? "USER",
@@ -177,6 +210,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(null); setToken(null);
setUser(null); setUser(null);
persistAuth(null, null); persistAuth(null, null);
// Clear server session cookies
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
}, [persistAuth]); }, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => { const getAuthHeaders = useCallback((): HeadersInit => {
@@ -191,7 +227,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register, register,
logout, logout,
getAuthHeaders, getAuthHeaders,
setSession, // ✅ important setSession,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+31 -9
View File
@@ -12,15 +12,37 @@ const ALWAYS_ALLOW = new Set<string>([
"/sitemap.xml", "/sitemap.xml",
]); ]);
// Match common public/static file extensions so middleware never blocks them const PUBLIC_FILE =
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i; /\.(?: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) {
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
// ✅ Always allow Next internals + static assets (public/ and _next/) // =========================
// 1) Admin gate (always on)
// =========================
if (pathname.startsWith("/admin")) {
const token = req.cookies.get("bb_access_token")?.value;
const role = req.cookies.get("bb_role")?.value; // optional
const ok = !!token && (!role || role === "ADMIN");
if (!ok) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
// =====================================
// 2) Launch-only gate (your existing one)
// =====================================
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
// ✅ Always allow Next internals + static assets
if ( if (
pathname.startsWith("/_next") || pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") || pathname.startsWith("/favicon") ||
@@ -31,17 +53,17 @@ export function middleware(req: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// ✅ Allow the root + a few public files
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next(); if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
// 🚫 Everything else: hide it
return NextResponse.rewrite(new URL("/404", req.url)); return NextResponse.rewrite(new URL("/404", req.url));
} }
` 1`
export const config = { export const config = {
// Run middleware on everything *except* Next internals and obvious static files.
// This keeps launch gating from ever breaking your logo/images/fonts/etc.
matcher: [ matcher: [
// run on everything except Next internals + obvious static files
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)", "/((?!_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)).*)",
], ],
}; };