1
0
Fork 0
polaris-task-force/src/app/(frontend)/layout.tsx
Z8MB1E c0d00fc113 fix(auth): reject MCP API-key sessions in user auth paths
With the MCP plugin enabled, payload.auth() can return a
payload-mcp-api-keys doc instead of a User. Add an isPayloadUser
type guard and use it in every server action, page, and route that
treats the auth result as a real user, so API-key sessions can no
longer pass user auth checks. hasRoles now explicitly requires a
users-collection doc.
2026-08-25 12:27:35 -04:00

158 lines
6 KiB
TypeScript

import React from "react";
import "./styles.css";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/frontend/blocks/AppSidebar";
import { getPayload } from "payload";
import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { headers as nextHeaders } from "next/headers";
import { SiteHeader } from "@/components/frontend/SiteHeader";
import { Metadata } from "next";
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import VersionOverlay from "@/components/static/VersionOverlay";
import { LandingPage } from "@/components/frontend/LandingPage";
import { Toaster } from "@/components/ui/sonner";
import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { Rank } from "@/payload-types";
import { cookies } from "next/headers";
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
// Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user,
// which connects to PostgreSQL. With force-dynamic, Next.js skips the
// build-time prerender pass (which would try to init Payload and hit the DB)
// and instead server-renders at request time. Without this, `next build`
// inside a container with no reachable DATABASE_URI fails.
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
description: "A gamified platform for Polaris Task Force, an Arma 3 unit.",
title: "Polaris Task Force",
icons: [
{
url: "/favicon.webp",
type: "image/webp",
rel: "icon",
},
],
};
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
let xpLevel: ResolvedLevel = {
levelName: "Recruit",
progress: 0,
currentLevelXP: 0,
nextLevelXP: null,
xp: 0,
};
let isIntelligence = false;
let isLogistics = false;
let currentRank = "";
if (user) {
[isIntelligence, isLogistics] = await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false),
]);
const [profileRes, experienceRes] = await Promise.all([
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
select: {
rank: true,
progression: {
awards: true,
scoreboard: true,
xp: true,
minigames: true,
},
},
}),
payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: {
name: true,
requiredPoints: true,
},
}),
]);
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs);
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
}
return (
<html lang="en" className="bg-black leading-8 antialiased h-full dark">
<body className="h-full font-sans m-0 text-white">
{user ? (
<SidebarProvider>
<KeyboardShortcutsProvider
userRoles={user.roles ?? []}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
>
<CommandPaletteProvider>
<AppSidebar
user={{
displayName: user.payloadDisplayName ?? "Guest",
username: user.username ?? "Not logged in",
rank: currentRank,
}}
xpLevel={xpLevel}
adminUrl={payloadConfig.routes.admin}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
showCallsign={user.preferences?.display?.showCallsign ?? false}
canImpersonate={await isSuperuser(payload, user)}
preferences={user.preferences}
className="border-r"
/>
<SidebarInset>
<SiteHeader />
{impersonating && <ImpersonationBanner />}
{children}
</SidebarInset>
<CommandPalette
userRoles={user.roles ?? []}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
username={user.username}
/>
</CommandPaletteProvider>
</KeyboardShortcutsProvider>
</SidebarProvider>
) : (
<LandingPage />
)}
{(!user || user.preferences?.display?.showVersionOverlay !== false) && (
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
)}
{user && <GameTickRealtime />}
{isLogistics && <ShipmentToasts />}
<Toaster />
</body>
</html>
);
}