diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..0f3c614 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,91 @@ +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +const ADMIN_LOGIN_PATH = '/admin/login' +const DEFAULT_ADMIN_EMAIL = 'dev.sxhd@gmail.com' + +function getAllowedAdminEmails(): string[] { + return (process.env.ADMIN_EMAILS ?? process.env.NEXT_PUBLIC_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL) + .split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean) +} + +export async function middleware(request: NextRequest) { + const { pathname, search } = request.nextUrl + const isLoginPage = pathname === ADMIN_LOGIN_PATH + const isAdminApi = pathname.startsWith('/api/admin/') + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + + if (!supabaseUrl || !supabaseAnonKey) { + if (isAdminApi) { + return NextResponse.json( + { error: 'Admin services are not configured.' }, + { status: 503 }, + ) + } + + if (!isLoginPage) { + const loginUrl = request.nextUrl.clone() + loginUrl.pathname = ADMIN_LOGIN_PATH + loginUrl.searchParams.set('error', 'configuration') + return NextResponse.redirect(loginUrl) + } + + return NextResponse.next() + } + + let response = NextResponse.next({ request }) + + const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) + response = NextResponse.next({ request }) + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options), + ) + }, + }, + }) + + const { + data: { user }, + } = await supabase.auth.getUser() + + const allowedEmails = getAllowedAdminEmails() + const isAdmin = Boolean( + user && + (user.app_metadata?.role === 'admin' || + (user.email && allowedEmails.includes(user.email.toLowerCase()))), + ) + + if (isLoginPage && isAdmin) { + const dashboardUrl = request.nextUrl.clone() + dashboardUrl.pathname = '/admin/dashboard' + dashboardUrl.search = '' + return NextResponse.redirect(dashboardUrl) + } + + if (!isLoginPage && !isAdmin) { + if (isAdminApi) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const loginUrl = request.nextUrl.clone() + loginUrl.pathname = ADMIN_LOGIN_PATH + loginUrl.searchParams.set('next', `${pathname}${search}`) + return NextResponse.redirect(loginUrl) + } + + return response +} + +export const config = { + matcher: ['/admin/:path*', '/api/admin/:path*'], +} diff --git a/src/app/admin/Sidebar.tsx b/src/app/admin/Sidebar.tsx index d2692f8..5bd41fe 100644 --- a/src/app/admin/Sidebar.tsx +++ b/src/app/admin/Sidebar.tsx @@ -1,67 +1,37 @@ "use client"; import Link from "next/link"; -import { Bell, - LayoutDashboard, - Folder, +import { + Activity, Award, - MessageSquare, + Bell, + Folder, + LayoutDashboard, Layers, - Settings, - Menu, - X, LogOut, + Menu, + MessageSquare, + Settings, Sparkles, + X, } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; -import { motion, AnimatePresence } from "framer-motion"; -import { useState, useEffect } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; import { supabase } from "@/lib/supabase"; const menus = [ - { - name: "Dashboard", - icon: LayoutDashboard, - path: "/admin", - }, - { - name: "Projects", - icon: Folder, - path: "/admin/projects", - }, - { - name: "Certificates", - icon: Award, - path: "/admin/certificates", - }, - { - name: "Comments", - icon: MessageSquare, - path: "/admin/comments", - }, - { - name: "Technologies", - icon: Layers, - path: "/admin/technologies", - }, - { - name: "3D Scene", - icon: Sparkles, - path: "/admin/scene3d", - }, - { - name: "Settings", - icon: Settings, - path: "/admin/settings", - }, - { - name: "Webhook", - icon: Bell, - path: "/admin/webhook", - }, + { name: "Dashboard", icon: LayoutDashboard, path: "/admin" }, + { name: "Projects", icon: Folder, path: "/admin/projects" }, + { name: "Certificates", icon: Award, path: "/admin/certificates" }, + { name: "Comments", icon: MessageSquare, path: "/admin/comments" }, + { name: "Technologies", icon: Layers, path: "/admin/technologies" }, + { name: "3D Scene", icon: Sparkles, path: "/admin/scene3d" }, + { name: "System Status", icon: Activity, path: "/admin/status" }, + { name: "Settings", icon: Settings, path: "/admin/settings" }, + { name: "Webhook", icon: Bell, path: "/admin/webhook" }, ]; -// Fix: use startsWith so /admin/dashboard and /admin also match Dashboard function isActive(menuPath: string, currentPath: string): boolean { if (menuPath === "/admin") { return currentPath === "/admin" || currentPath === "/admin/dashboard"; @@ -69,24 +39,24 @@ function isActive(menuPath: string, currentPath: string): boolean { return currentPath.startsWith(menuPath); } -const SidebarContent = ({ +function SidebarContent({ hideTitle = false, onLinkClick, }: { hideTitle?: boolean; onLinkClick?: () => void; -}) => { +}) { const pathname = usePathname(); const router = useRouter(); const handleLogout = async () => { await supabase.auth.signOut(); - router.push("/admin/login"); + router.replace("/admin/login"); + router.refresh(); }; return ( <> - {/* TOP */}
{!hideTitle && (

@@ -94,66 +64,39 @@ const SidebarContent = ({

)} -
- {/* BOTTOM */}
-
- © 2026 Admin -
+
© 2026 Admin
); -}; +} export default function Sidebar() { const [open, setOpen] = useState(false); const [isMobile, setIsMobile] = useState(false); useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 1024); - }; - + const checkMobile = () => setIsMobile(window.innerWidth < 1024); checkMobile(); window.addEventListener("resize", checkMobile); - - return () => - window.removeEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); }, []); return ( <> - {/* DESKTOP */} {!isMobile && ( -