diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 305b47d4..41fe9e05 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -65,6 +65,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const router = useRouter(); const pathname = usePathname(); const [authenticated, setAuthenticated] = useState(null); + const [authError, setAuthError] = useState(null); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); @@ -73,54 +74,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); useEffect(() => { - if (pathname !== '/admin/login') { - checkAuth(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pathname]); + if (pathname === '/admin/login') return; + let cancelled = false; - function getJmapHeaders(): Record { - return getActiveAccountSlotHeaders(); - } + async function checkAuth() { + try { + const jmapHeaders = getActiveAccountSlotHeaders(); + const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders }); + const data = await res.json(); + if (cancelled) return; - async function checkAuth() { - try { - const jmapHeaders = getJmapHeaders(); - const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders }); - const data = await res.json(); + const stalwartAdmin = data.stalwartAdmin === true; + setIsStalwartAdmin(stalwartAdmin); - 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 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 (loginRes.ok) { + if (data.authenticated) { setAuthenticated(true); return; } - } - router.replace('/admin/login'); - } catch { - router.replace('/admin/login'); + // 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' }); @@ -132,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return <>{children}; } - if (authenticated === null) { - return ( -
-
Loading...
-
- ); - } - return (
{/* Slim webmail nav rail */} @@ -269,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {/* Main content */}
- {children} + {authError ? ( +
+

Admin authentication failed

+

{authError}

+
+ ) : authenticated === null ? ( +
+ Loading admin panel… +
+ ) : authenticated ? ( + children + ) : null}
diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 3ba03af6..4d94f59c 100644 Binary files a/app/api/admin/auth/route.ts and b/app/api/admin/auth/route.ts differ diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 595dc55a..4bdccb05 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react"; import { useConfig } from "@/hooks/use-config"; import { useThemeStore } from "@/stores/theme-store"; import { usePathname, Link, useRouter } from "@/i18n/navigation"; -import NextLink from "next/link"; import { useTranslations } from "next-intl"; import { useCalendarStore } from "@/stores/calendar-store"; import { useEmailStore } from "@/stores/email-store"; @@ -336,9 +335,9 @@ export function NavigationRail({ ); })} - {/* Admin (Stalwart admins) */} + {/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */} {isStalwartAdmin && ( - {t("admin") || "Admin"} - + )} {/* Settings */} @@ -514,13 +513,13 @@ export function NavigationRail({ {/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
{isStalwartAdmin && ( - - + )}