Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
138 lines
5.0 KiB
TypeScript
138 lines
5.0 KiB
TypeScript
import { eq } from "drizzle-orm";
|
|
import { LogOut } from "lucide-react";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import type { ReactNode } from "react";
|
|
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
|
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
|
import { AdminSidebarNav } from "@/components/admin/admin-sidebar-nav";
|
|
import { AdminTopbar } from "@/components/admin/admin-topbar";
|
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
import Link from "@/components/link";
|
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
import { requireStaff } from "@/lib/admin/guard";
|
|
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
|
import {
|
|
ADMIN_NAV_CONFIG_KEY,
|
|
parseAdminNavConfig,
|
|
} from "@/lib/admin-nav-config";
|
|
import { db, User } from "@/lib/db";
|
|
import { readCsrfCookieToken } from "@/lib/foundation/security";
|
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
|
|
// statically rendered. The root layout's Cache Components opt-out
|
|
// (instant = false) covers this route tree — leaf opt-outs are redundant.
|
|
|
|
export default async function AdminLayout({
|
|
children,
|
|
}: {
|
|
children: ReactNode;
|
|
}) {
|
|
const staff = await requireStaff();
|
|
const csrfToken = await readCsrfCookieToken();
|
|
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
|
const u = await db
|
|
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
|
|
.from(User)
|
|
.where(eq(User.id, staff.id))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
.catch(() => null);
|
|
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
|
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
|
<div
|
|
data-admin
|
|
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
|
|
style={{ backgroundColor: "var(--admin-canvas)" }}
|
|
>
|
|
<AdminTopbar staff={staff} />
|
|
<section className="admin-page flex-1">
|
|
<AdminHubChrome>{children}</AdminHubChrome>
|
|
</section>
|
|
</div>
|
|
</AdminMobileWrapper>
|
|
</>
|
|
);
|
|
}
|
|
|
|
async function Sidebar({
|
|
staff,
|
|
}: {
|
|
staff: { id: number; username: string; rank: number };
|
|
}) {
|
|
const t = await getTranslations("pages.admin.nav");
|
|
const initial = staff.username.charAt(0).toUpperCase();
|
|
const { permissions } = await getAdminContext();
|
|
const isSuperAdmin = permissions.isSuperAdmin;
|
|
const hasDashboard = canAccess(
|
|
permissions,
|
|
PERMS.ADMIN_DASHBOARD,
|
|
staff.rank,
|
|
);
|
|
// Super-admins see everything. Everyone else sees items their ACL grants.
|
|
// If they can open the admin panel but ACL is incomplete (common after HK→ACL
|
|
// migration gaps), still show *.view nav entries so categories do not vanish.
|
|
const allowedPermissions = isSuperAdmin
|
|
? []
|
|
: collectNavPermissionSlugs().filter((slug) => {
|
|
if (canAccess(permissions, slug, staff.rank)) return true;
|
|
if (!hasDashboard) return false;
|
|
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
|
});
|
|
|
|
const navConfig = parseAdminNavConfig(
|
|
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
|
);
|
|
|
|
return (
|
|
<aside
|
|
data-admin
|
|
className="max-lg:static sticky top-0 flex h-full max-h-screen min-h-0 w-full flex-col self-start overflow-hidden bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl lg:h-screen"
|
|
>
|
|
<div className="flex shrink-0 items-center gap-3 border-b border-[var(--admin-border)] px-4 py-5">
|
|
<span
|
|
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
|
|
aria-hidden
|
|
>
|
|
{initial}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
|
|
{staff.username}
|
|
</div>
|
|
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
|
|
{t("rankLabel", { rank: staff.rank })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<LanguageSwitcher variant="admin" />
|
|
<ThemeSwitcher variant="admin" />
|
|
</div>
|
|
</div>
|
|
|
|
<AdminSidebarNav
|
|
allowedPermissions={allowedPermissions}
|
|
isSuperAdmin={isSuperAdmin}
|
|
navConfig={navConfig}
|
|
/>
|
|
|
|
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
|
<Link
|
|
href="/"
|
|
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[var(--admin-sidebar-muted-readable)] text-xs font-medium hover:text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 transition-all duration-150 no-underline"
|
|
>
|
|
<LogOut size={14} />
|
|
<span>{t("backToSite")}</span>
|
|
</Link>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|