'use client'; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; import { usePathname } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { ArrowLeft, ArrowUpCircle, Activity, Building2, ChevronRight, KeyRound, LayoutDashboard, LayoutTemplate, LogOut, Palette, PenLine, Puzzle, Scale, ScrollText, Search, Server, Share2, Shield, ShieldCheck, Store, SwatchBook, X, type AppIcon, } from '@/components/icons'; import { Input } from '@/components/ui/input'; import { NavigationRail } from '@/components/layout/navigation-rail'; import { AboutFooter } from '@/components/settings/about-data-settings'; import { scrollToSettingsSection } from '@/components/settings/settings-section'; import { useIsDesktop } from '@/hooks/use-media-query'; import { useDocumentTitle } from '@/hooks/use-document-title'; import { useAdminLocation, navigateAdmin } from '@/hooks/use-admin-navigation'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; import { ADMIN_PAGES, adminPath, type AdminPageDef, type AdminPageId, type AdminSectionDef } from '@/lib/admin/admin-nav'; import { cn } from '@/lib/utils'; import { AdminLink } from './admin-link'; import { AdminShellContext } from './shell-context'; const PAGE_ICONS: Record = { dashboard: LayoutDashboard, settings: Server, auth: Shield, branding: Palette, policy: Scale, organisation: Building2, 'org-signatures': PenLine, 'org-templates': LayoutTemplate, 'org-security': ShieldCheck, 'org-sharing': Share2, plugins: Puzzle, themes: SwatchBook, marketplace: Store, version: ArrowUpCircle, telemetry: Activity, logs: ScrollText, }; const GROUP_LABELS: Record = { top: null, organisation: 'Organisation', extensions: 'Extensions', system: 'System', }; interface AdminShellProps { /** Whether the admin session is in place; the sidebar counts load then. */ authenticated: boolean; /** Stalwart admins signed in automatically have no password to change. */ isStalwartAutoLogin: boolean; configReadOnly: boolean; onLogout: () => void; children: ReactNode; } interface Match { page: AdminPageDef; sections: AdminSectionDef[]; } const subscribeNothing = () => () => {}; /** * The admin panel's frame: the webmail's own navigation rail, the page list * laid out like the settings sidebar (search included), the content column * and, on phones, the settings' list-then-page flow. */ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, onLogout, children }: AdminShellProps) { const pathname = usePathname(); const tShell = useTranslations('shell'); const tSettings = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); const location = useAdminLocation(); const isDesktop = useIsDesktop(); // The server can't know the window width, so its HTML would be the phone // layout, shown on desktop until hydration swaps it (the rail's Admin link // is a full page load). const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false); const requestLeave = useAdminUnsavedStore((s) => s.requestLeave); const [query, setQuery] = useState(''); const [saveBarHost, setSaveBarHost] = useState(null); const [pendingApprovals, setPendingApprovals] = useState(0); const [pendingSection, setPendingSection] = useState<{ page: AdminPageId; id: string } | null>(null); const scrollRef = useRef(null); const onChangePassword = pathname === '/admin/change-password'; const activePage: AdminPageId | null = location.page ?? (isDesktop && !onChangePassword ? 'dashboard' : null); const activeDef = location.page ? ADMIN_PAGES.find((p) => p.id === location.page) ?? null : null; // " - Admin - ", so the tab and the window list say which // admin page is open (WCAG 2.4.2). const pageName = onChangePassword ? 'Change password' : ADMIN_PAGES.find((p) => p.id === activePage)?.label; useDocumentTitle(pageName ? `${pageName} - ${tSidebar('admin')}` : tSidebar('admin'), { showAccount: false }); // 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'; const refreshBadges = useCallback(() => { if (!authenticated) return; apiFetch('/api/admin/plugin-approvals') .then((res) => (res.ok ? res.json() : null)) .then((data: { entries?: Array<{ status: string }> } | null) => { setPendingApprovals(data?.entries?.filter((e) => e.status === 'pending').length ?? 0); }) .catch(() => {}); }, [authenticated]); useEffect(() => { refreshBadges(); }, [refreshBadges]); // A new page starts at the top; the back button restores nothing finer. const scrollKey = `${location.page ?? ''}/${location.detail ?? ''}`; useEffect(() => { scrollRef.current?.scrollTo({ top: 0 }); }, [scrollKey]); // A section picked from the search scrolls into view once its page has // rendered it; pages fetch first, so retry for a moment. useEffect(() => { if (!pendingSection || pendingSection.page !== location.page) return; let cancelled = false; let timer: ReturnType | undefined; const deadline = Date.now() + 2000; const attempt = () => { if (cancelled) return; if (scrollToSettingsSection(pendingSection.id)) { setPendingSection(null); return; } if (Date.now() < deadline) timer = setTimeout(attempt, 80); else setPendingSection(null); }; const raf = window.requestAnimationFrame(attempt); return () => { cancelled = true; window.cancelAnimationFrame(raf); if (timer) clearTimeout(timer); }; }, [pendingSection, location.page]); const trimmed = query.trim().toLowerCase(); const matches = useMemo(() => { if (!trimmed) return ADMIN_PAGES.map((page) => ({ page, sections: [] })); const words = trimmed.split(/\s+/); const hits = (text: string) => words.every((w) => text.includes(w)); return ADMIN_PAGES.flatMap((page) => { const sections = page.sections.filter((s) => hits(`${s.title} ${s.keywords ?? ''}`.toLowerCase())); const pageHit = hits(`${page.label} ${page.description} ${page.keywords}`.toLowerCase()); if (!pageHit && sections.length === 0) return []; return [{ page, sections: sections.slice(0, 5) }]; }); }, [trimmed]); const groups = useMemo(() => ( (['top', 'organisation', 'extensions', 'system'] as const) .map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) })) .filter((g) => g.items.length > 0) ), [matches]); const goTo = (page: AdminPageId, section?: string) => { requestLeave(() => { setPendingSection(section ? { page, id: section } : null); navigateAdmin(adminPath(page)); setQuery(''); }); }; const prefix = getPathPrefix(); const contextValue = useMemo(() => ({ saveBarHost, refreshBadges }), [saveBarHost, refreshBadges]); const badgeFor = (id: AdminPageId): ReactNode => { if (id === 'plugins' && pendingApprovals > 0) { return ( {pendingApprovals} ); } if (id === 'version' && hasUpdate) { // A dot only: role="img" makes screen readers announce its label, which // they skip on a plain span; the title shows it on hover. // The same words as the dot on the rail's Settings entry. const label = tShell(updateImportant ? 'update_important' : 'update_available'); return ( ); } return null; }; const searchBox = (
setQuery(e.target.value)} placeholder={tShell('admin_search')} aria-label={tShell('admin_search')} className={cn('h-9 ps-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none', query && 'pe-8')} /> {query && ( )}
); const noResults = (
Nothing matches
); // The admin session is its own: a password admin signs out of the panel, // not of their mailbox, so these stay with the panel rather than the rail. // A Stalwart admin signed in automatically has neither a password to change // nor a session worth ending, since opening the panel signs them back in. // Which kind of admin this is is known once the session check is done. const showSessionLinks = authenticated && !isStalwartAutoLogin; const sessionLinks = (mobile: boolean) => ( <> Change password ); const readOnlyBanner = configReadOnly && authenticated && (

The configuration is read-only

ADMIN_CONFIG_READONLY is set, so you can look at the settings here but not save changes. Change them in the mounted config files or environment instead.

); if (!hydrated) return
; if (!isDesktop) { const showIndex = location.page === null && !onChangePassword; const title = onChangePassword ? 'Change password' : activeDef?.label ?? 'Admin'; const backHref = location.detail ? adminPath(location.page) : '/admin'; return (
{showIndex ? ( ) : ( )}
{showIndex && }

{title}

{showIndex ? ( <>
{searchBox}
{groups.length === 0 && noResults} {groups.map((group, groupIndex) => (
{groupIndex > 0 &&
} {group.label && (
{group.label}
)} {group.items.map(({ page, sections }) => { const Icon = PAGE_ICONS[page.id]; return (
{ e.preventDefault(); goTo(page.id); }} data-testid={`admin-page-${page.id}`} className="flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-foreground transition-colors duration-150 hover:bg-muted" > {page.label} {badgeFor(page.id)} {sections.map((section) => ( ))}
); })}
))}
{showSessionLinks &&
{sessionLinks(true)}
}
) : (
{readOnlyBanner} {children}
)}
); } return (
{/* The same rail as mail and settings, with Admin as the current app. */}
{/* The page list, laid out like the settings sidebar. */}
{readOnlyBanner} {children}
); }