Files
webmail/app/(main)/admin/_layout-client.tsx
T
Linus Rath 416e59fa54 fix: give the admin panel the real navigation rail, extension icons and the plugin settings last
The admin drew its own copy of the rail, with a logo and only the app
links, next to a sidebar that signed out from its footer. It now renders
the webmail's NavigationRail (desktop and phone), which needs the
translation, theme and tour providers under /admin, so the admin layout
loads the message catalog itself; the rail marks Admin as the current
app for password admins too. The sidebar footer carries the admin
session links and the same about footer as the settings. Plugin and
theme rows and the detail header show the extension's icon from the
directory listing instead of a generic one, and a plugin's own settings
move to the bottom of its page, after availability, permissions and
versions.
2026-10-20 15:38:55 +02:00

136 lines
5.0 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { AdminShell } from '@/components/admin/admin-shell';
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
/**
* Gates the admin panel behind the admin session and draws its shell. The
* pages themselves are one route (./[[...segments]]) and switch through
* history.pushState, so this layout stays mounted across them.
*/
export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false);
const [configReadOnly, setConfigReadOnly] = useState(false);
useEffect(() => {
if (pathname === '/admin/login') return;
// One check per visit, not one per page: the pages switch without a
// route change, and the session does not change with them.
if (authenticated === true) 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;
// `stalwartAutoLogin` is only true in "auto" mode; in "password" mode
// a Stalwart admin still lands on the password form (#870).
const stalwartAutoLogin = data.stalwartAutoLogin === true;
setIsStalwartAutoLogin(stalwartAutoLogin);
// If neither password-based admin nor Stalwart auto-login, redirect away
if (!data.enabled && !stalwartAutoLogin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin (auto mode) but not yet authenticated, auto-login
if (stalwartAutoLogin) {
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;
}
// Carry the page that was asked for through the login, so a connector
// link to a panel (/connector/admin_extension?slug=x) does not dump
// the admin on the overview after signing in.
const next = encodeURIComponent(`${pathname}${window.location.search}`);
router.replace(`/admin/login?next=${next}`);
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router, authenticated]);
// With ADMIN_CONFIG_READONLY the panel still shows every setting, but
// nothing can be saved; say so up front instead of after a failed save (#1249).
useEffect(() => {
if (!authenticated) return;
let cancelled = false;
apiFetch('/api/setup/status')
.then(res => (res.ok ? res.json() : null))
.then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); })
.catch(() => {});
return () => { cancelled = true; };
}, [authenticated]);
function handleLogout() {
requestLeave(async () => {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
});
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
return (
<>
<UnsavedChangesGuard />
<AdminShell
authenticated={authenticated === true}
isStalwartAutoLogin={isStalwartAutoLogin}
configReadOnly={configReadOnly}
onLogout={handleLogout}
>
{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}
</AdminShell>
</>
);
}