From 15f456561f9e7a29cd355f562e8c2ee481358f96 Mon Sep 17 00:00:00 2001 From: Dev Sahad Date: Tue, 21 Jul 2026 01:09:06 +0400 Subject: [PATCH 1/5] fix: protect admin routes with server-side auth --- middleware.ts | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 middleware.ts diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..8a61cb1 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,90 @@ +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +const ADMIN_LOGIN_PATH = '/admin/login' + +function getAllowedAdminEmails(): string[] { + return (process.env.ADMIN_EMAILS ?? process.env.NEXT_PUBLIC_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*'], +} From 3265408fbc9c5d9b122bd44a736eccb49fa743d0 Mon Sep 17 00:00:00 2001 From: Dev Sahad Date: Tue, 21 Jul 2026 01:09:19 +0400 Subject: [PATCH 2/5] feat: add protected admin health endpoint --- src/app/api/admin/health/route.ts | 67 +++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/app/api/admin/health/route.ts diff --git a/src/app/api/admin/health/route.ts b/src/app/api/admin/health/route.ts new file mode 100644 index 0000000..823a632 --- /dev/null +++ b/src/app/api/admin/health/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/utils/supabase/server' + +export const dynamic = 'force-dynamic' + +type ServiceStatus = 'healthy' | 'degraded' | 'unavailable' + +export async function GET() { + const startedAt = Date.now() + const hasSupabaseConfig = Boolean( + process.env.NEXT_PUBLIC_SUPABASE_URL && + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + ) + + const checks: Record = { + application: { + status: 'healthy', + message: 'Next.js server is responding.', + }, + database: { + status: hasSupabaseConfig ? 'degraded' : 'unavailable', + message: hasSupabaseConfig + ? 'Database configuration detected; connectivity not yet verified.' + : 'Supabase environment variables are missing.', + }, + } + + if (hasSupabaseConfig) { + try { + const supabase = await createClient() + const { error } = await supabase + .from('projects') + .select('id', { count: 'exact', head: true }) + + checks.database = error + ? { status: 'degraded', message: error.message } + : { status: 'healthy', message: 'Supabase connection is working.' } + } catch (error) { + checks.database = { + status: 'unavailable', + message: error instanceof Error ? error.message : 'Database check failed.', + } + } + } + + const overallStatus = Object.values(checks).every( + (check) => check.status === 'healthy', + ) + ? 'healthy' + : Object.values(checks).some((check) => check.status === 'unavailable') + ? 'unavailable' + : 'degraded' + + return NextResponse.json( + { + status: overallStatus, + checks, + environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? 'unknown', + responseTimeMs: Date.now() - startedAt, + timestamp: new Date().toISOString(), + }, + { + status: overallStatus === 'unavailable' ? 503 : 200, + headers: { 'Cache-Control': 'no-store' }, + }, + ) +} From da0525644d64c04520b20082f604a3fab02a7744 Mon Sep 17 00:00:00 2001 From: Dev Sahad Date: Tue, 21 Jul 2026 01:09:38 +0400 Subject: [PATCH 3/5] feat: add admin system status page --- src/app/admin/status/page.tsx | 135 ++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/app/admin/status/page.tsx diff --git a/src/app/admin/status/page.tsx b/src/app/admin/status/page.tsx new file mode 100644 index 0000000..22080bb --- /dev/null +++ b/src/app/admin/status/page.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Sidebar from '@/app/admin/Sidebar' +import { Activity, CheckCircle2, RefreshCcw, Server, TriangleAlert, XCircle } from 'lucide-react' + +type ServiceStatus = 'healthy' | 'degraded' | 'unavailable' + +type HealthData = { + status: ServiceStatus + checks: Record + environment: string + responseTimeMs: number + timestamp: string +} + +const statusStyles: Record = { + healthy: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300', + degraded: 'border-amber-500/20 bg-amber-500/10 text-amber-300', + unavailable: 'border-red-500/20 bg-red-500/10 text-red-300', +} + +function StatusIcon({ status }: { status: ServiceStatus }) { + if (status === 'healthy') return + if (status === 'degraded') return + return +} + +export default function AdminStatusPage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const loadHealth = useCallback(async () => { + setLoading(true) + setError('') + + try { + const response = await fetch('/api/admin/health', { cache: 'no-store' }) + const payload = await response.json() + + if (!response.ok && !payload?.checks) { + throw new Error(payload?.error || 'Unable to load system status.') + } + + setData(payload) + } catch (caughtError) { + setError( + caughtError instanceof Error + ? caughtError.message + : 'Unable to load system status.', + ) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadHealth() + }, [loadHealth]) + + return ( +
+ +
+
+
+
+
+ Diagnostics +
+

System Status

+

+ Verify the application and database connection from the admin panel. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {data && ( + <> +
+
+ +
+

{data.status}

+

+ {data.environment} · {data.responseTimeMs} ms · {new Date(data.timestamp).toLocaleString()} +

+
+
+
+ +
+ {Object.entries(data.checks).map(([name, check]) => ( +
+
+
+
+ +
+
+

{name}

+

{check.message}

+
+
+ + + {check.status} + +
+
+ ))} +
+ + )} +
+
+
+ ) +} From ccbd2d6cc12db97b9df4a82024ece5281ef47871 Mon Sep 17 00:00:00 2001 From: Dev Sahad Date: Tue, 21 Jul 2026 01:10:03 +0400 Subject: [PATCH 4/5] fix: preserve existing admin email fallback --- middleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/middleware.ts b/middleware.ts index 8a61cb1..0f3c614 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,9 +2,10 @@ 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 ?? '') + return (process.env.ADMIN_EMAILS ?? process.env.NEXT_PUBLIC_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL) .split(',') .map((email) => email.trim().toLowerCase()) .filter(Boolean) From b779b22eaee7d8e2e156f5516c0da843cbba4065 Mon Sep 17 00:00:00 2001 From: Dev Sahad Date: Tue, 21 Jul 2026 01:10:31 +0400 Subject: [PATCH 5/5] feat: wire system status into admin navigation --- src/app/admin/Sidebar.tsx | 175 ++++++++++---------------------------- 1 file changed, 46 insertions(+), 129 deletions(-) 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 && ( -