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
+80 -94
View File
@@ -1,7 +1,10 @@
"use client";
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 { useAuth } from "@/context/AuthContext";
import {
LayoutDashboard,
Download,
@@ -15,113 +18,96 @@ import {
} from "lucide-react";
const navItems = [
{
label: "Dashboard",
href: "/admin",
icon: <LayoutDashboard className="h-4 w-4" />,
},
{
label: "Imports",
href: "/admin/import-status",
icon: <Download 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" />,
},
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
{ label: "Imports", href: "/admin/import-status", icon: <Download 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 an 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({
children,
}: {
children,
}: {
children: React.ReactNode;
}) {
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 (
<div className="flex min-h-screen bg-black text-zinc-50">
<AdminLeftNavigation
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)}
/>
<div className="flex min-h-screen bg-black text-zinc-50">
<AdminLeftNavigation
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)}
items={navItems}
/>
{/* Main column */}
<div className="flex min-h-screen flex-1 flex-col">
{/* Top bar */}
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Admin
</p>
<p className="text-sm text-zinc-200">
Battl Builders Control Panel
</p>
</div>
<div className="flex items-center gap-3">
{/* Main column */}
<div className="flex min-h-screen flex-1 flex-col">
{/* Top bar */}
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Admin
</p>
<p className="text-sm text-zinc-200">
Battl Builders Control Panel
</p>
</div>
<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">
Internal v0.1
</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 className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
ADMIN
</div>
</header>
</div>
</header>
{/* Content */}
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
{children}
</main>
</div>
{/* Content */}
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
{children}
</main>
</div>
</div>
);
}
+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
import "./globals.css";
import type { ReactNode } from "react";
import Script from "next/script";
import { AuthProvider } from "@/context/AuthContext";
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">
{" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
strategy="lazyOnload"
/>
<AuthProvider>
<Banner />
{children}
+29 -65
View File
@@ -1,6 +1,7 @@
"use client";
import type React from "react";
import Link from "next/link";
import {
LayoutDashboard,
Download,
@@ -12,74 +13,37 @@ import {
LucideMail,
Wand2,
} from "lucide-react";
import Link from "next/link";
export type AdminNavItem = {
label: string;
href: string;
icon: React.ReactNode;
};
type AdminLeftNavigationProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
items?: AdminNavItem[]; // ✅ NEW
};
const navItems = [
{
label: "Dashboard",
href: "/admin",
icon: <LayoutDashboard className="h-4 w-4" />,
},
{
label: "Imports",
href: "/admin/import-status",
icon: <Download 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" />,
},
const defaultNavItems: AdminNavItem[] = [
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
{ label: "Imports", href: "/admin/import-status", icon: <Download 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 an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
];
export default function AdminLeftNavigation({
collapsed,
onToggleCollapsed,
items = defaultNavItems, // ✅ NEW default
}: AdminLeftNavigationProps) {
return (
<aside
@@ -104,7 +68,9 @@ export default function AdminLeftNavigation({
{!collapsed && (
<div>
<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 className="text-[10px] text-zinc-600">Admin Command</p>
</div>
@@ -112,8 +78,8 @@ export default function AdminLeftNavigation({
</div>
<nav className="flex-1 space-y-1 text-sm">
{navItems.map((item) => (
<a
{items.map((item) => (
<Link
key={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"
@@ -123,15 +89,13 @@ export default function AdminLeftNavigation({
{!collapsed && <span>{item.label}</span>}
</div>
{!collapsed && <span className="text-[10px] text-zinc-600"></span>}
</a>
</Link>
))}
</nav>
{!collapsed && (
<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">
Status
</p>
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
<p className="mt-1">
Import engine: <span className="text-emerald-400">online</span>
</p>
@@ -140,4 +104,4 @@ export default function AdminLeftNavigation({
)}
</aside>
);
}
}
+43 -7
View File
@@ -49,6 +49,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true);
/**
* Persist auth to localStorage
*/
const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => {
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(() => {
if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken) setToken(storedToken);
if (storedUser) {
if (storedToken && storedUser) {
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 {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
}
}
@@ -88,11 +106,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false);
}, []);
/**
* Used by login/register/magic-link
*/
const setSession = useCallback(
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken);
setUser(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]
);
@@ -118,6 +149,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser =
data.user ??
({
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
@@ -160,6 +192,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser =
data.user ??
({
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null,
role: data.role ?? "USER",
@@ -177,6 +210,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(null);
setUser(null);
persistAuth(null, null);
// Clear server session cookies
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
}, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => {
@@ -191,7 +227,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register,
logout,
getAuthHeaders,
setSession, // ✅ important
setSession,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -203,4 +239,4 @@ export function useAuth(): AuthContextValue {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}
}
+31 -9
View File
@@ -12,15 +12,37 @@ const ALWAYS_ALLOW = new Set<string>([
"/sitemap.xml",
]);
// Match common public/static file extensions so middleware never blocks them
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
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) {
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
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 (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
@@ -31,17 +53,17 @@ export function middleware(req: NextRequest) {
return NextResponse.next();
}
// ✅ Allow the root + a few public files
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
// 🚫 Everything else: hide it
return NextResponse.rewrite(new URL("/404", req.url));
}
` 1`
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: [
// 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)).*)",
],
};