mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
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.
136 lines
5.0 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|