Files
SRCmail/app/(main)/admin/layout.tsx
T

480 lines
19 KiB
TypeScript

'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 { usePolicyStore } from '@/stores/policy-store';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch, getPathPrefix } 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/<segment> 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<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
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}</>;
}
// /admin lives outside the [locale] tree, so links back to the webmail
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
// basePath to <Link>/router APIs - for these we prepend it manually so
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
const prefix = getPathPrefix();
const navContent = (
<>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{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 (
<button
key={tab}
type="button"
onClick={handleClick}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</button>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</>
);
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
<div className="w-7 h-7 mb-2" />
)}
<a
href={`${prefix}/`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Mail"
>
<Mail className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/calendar`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Calendar"
>
<Calendar className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/contacts`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Contacts"
>
<BookUser className="w-[18px] h-[18px]" />
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" />
</div>
<a
href={`${prefix}/settings`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Settings"
>
<Settings className="w-[18px] h-[18px]" />
</a>
</div>
</nav>
{/* Admin Sidebar (desktop only) */}
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
{navContent}
</aside>
{/* Mobile drawer overlay */}
{mobileNavOpen && (
<div
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileNavOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Admin navigation"
aria-hidden={!mobileNavOpen}
>
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
<button
type="button"
onClick={() => setMobileNavOpen(false)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close navigation"
>
<X className="w-5 h-5" />
</button>
</div>
{navContent}
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 overflow-x-hidden">
{/* Mobile header */}
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
aria-label="Open navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div>
</main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href={`${prefix}/`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href={`${prefix}/calendar`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href={`${prefix}/contacts`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
)}
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href={`${prefix}/settings`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div>
);
}