'use client'; import { useEffect, useState } from 'react'; import { useRouter, usePathname } from 'next/navigation'; import Link from 'next/link'; import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store'; import { LayoutDashboard, Settings, Palette, Shield, Scale, ScrollText, LogOut, KeyRound, Puzzle, SwatchBook, Activity, Package, Mail, Calendar, BookUser, HardDrive, Store, Menu, X, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useConfig } from '@/hooks/use-config'; import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; import { apiFetch } from '@/lib/browser-navigation'; // Single-page tab navigation: clicks update a Zustand store. The URL stays // at /admin so React doesn't fire a route transition on every tab switch - // matches the regular settings page pattern, fixes the dev-mode "Rendering…" // hang we saw with both /admin/ routes and ?tab= search params. const NAV_GROUPS: ReadonlyArray<{ label: string; items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>; }> = [ { label: 'Overview', items: [ { tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, ], }, { label: 'Configuration', items: [ { tab: 'settings', label: 'Settings', icon: Settings }, { tab: 'branding', label: 'Branding', icon: Palette }, { tab: 'auth', label: 'Authentication', icon: Shield }, { tab: 'policy', label: 'Policy', icon: Scale }, ], }, { label: 'Extensions', items: [ { tab: 'plugins', label: 'Plugins', icon: Puzzle }, { tab: 'themes', label: 'Themes', icon: SwatchBook }, { tab: 'marketplace', label: 'Marketplace', icon: Store }, ], }, { label: 'System', items: [ { tab: 'version', label: 'Version', icon: Package }, { tab: 'telemetry', label: 'Telemetry', icon: Activity }, { tab: 'logs', label: 'Audit Log', icon: ScrollText }, ], }, ]; export default function AdminLayout({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const storeActiveTab = useAdminTabStore((s) => s.activeTab); const setActiveTab = useAdminTabStore((s) => s.setActiveTab); // Highlight the active tab only on /admin itself - on dynamic routes // (e.g. /admin/plugins/[id]) no tab is "current". const activeTab = pathname === '/admin' ? storeActiveTab : null; const [authenticated, setAuthenticated] = useState(null); const [authError, setAuthError] = useState(null); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); // Match the navigation rail: red for security/deprecated, amber for normal. const hasUpdate = useUpdateStore(selectHasUpdate); const updateSeverity = useUpdateStore((s) => s.status?.severity); const startUpdatePolling = useUpdateStore((s) => s.startPolling); useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]); const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated'; useEffect(() => { setMobileNavOpen(false); }, [pathname]); useEffect(() => { if (!mobileNavOpen) return; const previous = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = previous; }; }, [mobileNavOpen]); useEffect(() => { if (pathname === '/admin/login') return; let cancelled = false; async function checkAuth() { try { const jmapHeaders = getActiveAccountSlotHeaders(); const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders }); const data = await res.json(); if (cancelled) return; const stalwartAdmin = data.stalwartAdmin === true; setIsStalwartAdmin(stalwartAdmin); // If neither password-based admin nor Stalwart admin, redirect away if (!data.enabled && !stalwartAdmin) { router.replace('/'); return; } if (data.authenticated) { setAuthenticated(true); return; } // If Stalwart admin but not yet authenticated, auto-login if (stalwartAdmin) { const loginRes = await apiFetch('/api/admin/auth', { method: 'POST', headers: { 'Content-Type': 'application/json', ...jmapHeaders }, body: JSON.stringify({ stalwartAuth: true }), }); if (cancelled) return; if (loginRes.ok) { setAuthenticated(true); return; } const body = await loginRes.json().catch(() => ({})); setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`); setAuthenticated(false); return; } router.replace('/admin/login'); } catch (err) { if (cancelled) return; setAuthError(err instanceof Error ? err.message : 'Network error during admin check'); setAuthenticated(false); } } checkAuth(); return () => { cancelled = true; }; }, [pathname, router]); async function handleLogout() { await apiFetch('/api/admin/auth', { method: 'DELETE' }); router.replace('/admin/login'); } // Don't gate the login page if (pathname === '/admin/login') { return <>{children}; } const navContent = ( <>
{NAV_GROUPS.map((group, groupIndex) => (
{groupIndex > 0 &&
}
{group.label}
{group.items.map(({ tab, label, icon: Icon }) => { const active = activeTab === tab; const showDot = tab === 'version' && hasUpdate; const handleClick = () => { setActiveTab(tab); // From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug]) // we still need a real navigation back to /admin so the page renders. if (pathname !== '/admin') router.push('/admin'); }; return ( ); })}
))}
{!isStalwartAdmin && ( Change Password )}
); return (
{/* Slim webmail nav rail (desktop only) */} {/* Admin Sidebar (desktop only) */} {/* Mobile drawer overlay */} {mobileNavOpen && (
setMobileNavOpen(false)} aria-hidden="true" /> )} {/* Mobile drawer */} {/* Main content */}
{/* Mobile header */}
{logoUrl ? ( ) : ( )} Admin Panel
{authError ? (

Admin authentication failed

{authError}

) : authenticated === null ? (
Loading admin panel…
) : authenticated ? ( children ) : null}
{/* Mobile bottom nav (main webmail nav) */}
); }