1
0
Fork 0

feat(dev): confirm gate before navigating to the dashboard in dev

- DevDashboardGate intercepts left-clicks on links resolving to / and asks
  before navigating; the dev-mode dashboard render takes ~100s while the
  browser aborts at ~60s, so links otherwise look like a hang
- mounted only when NODE_ENV is development and a user is logged in;
  modifier-clicks, new-tab and download anchors bypass the gate
- layout now imports canAccessAdminPanel from divisionAccess
This commit is contained in:
Jason Fraley 2026-09-21 20:48:17 -04:00
parent 4dde790aa8
commit 5883990577
3 changed files with 110 additions and 1 deletions

View file

@ -11,7 +11,7 @@ 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 { canAccessAdminPanel } from "@/utils/access-control/hasPermission";
import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
@ -29,6 +29,7 @@ import { cookies } from "next/headers";
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
// Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user,
@ -184,6 +185,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
)}
{user && <GameTickRealtime />}
{user && <SessionWatchdog sessionExp={sessionExp} />}
{process.env.NODE_ENV === "development" && user && <DevDashboardGate />}
{user && <MissionReminderToasts />}
{isLogistics && <ShipmentToasts />}
<Toaster />

View file

@ -3576,3 +3576,4 @@
[data-radix-popper-content-wrapper] {
z-index: 1300 !important;
}

View file

@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
/**
* Dev-only guard for routes whose server render is pathologically slow under
* the Next.js 16 dev runtime (see payloadcms/payload#14487): the dashboard
* takes roughly 100s per render in dev while the browser gives up around 60s,
* so clicking any link to it looks like a hang. This component intercepts
* plain left-clicks on anchors pointing at those routes, shows a confirm
* dialog, and only navigates when the user insists.
*
* Gaps by design: direct URL entry, page refresh, modifier-clicks (Ctrl/Cmd +
* click), and programmatic router.push calls (e.g. the command palette) are
* not gated. Anchors inside a [data-dev-gate-bypass] ancestor are also
* exempt, so a specific link can opt out.
*/
const GATED_PATHS = new Set<string>(["/"]);
function resolveTarget(href: string): string | null {
try {
const url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return null;
if (!GATED_PATHS.has(url.pathname)) return null;
return url.pathname + url.search + url.hash;
} catch {
return null;
}
}
export function DevDashboardGate() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [target, setTarget] = useState("/");
const proceedRef = useRef<() => void>(() => undefined);
useEffect(() => {
if (process.env.NODE_ENV !== "development") return undefined;
function onClick(event: MouseEvent) {
if (event.defaultPrevented) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const anchor = (event.target as Element | null)?.closest?.("a");
if (!anchor) return;
if (anchor.hasAttribute("download")) return;
const targetAttr = anchor.getAttribute("target");
if (targetAttr && targetAttr !== "_self") return;
if (anchor.closest("[data-dev-gate-bypass]")) return;
const next = resolveTarget(anchor.getAttribute("href") ?? "");
if (!next) return;
event.preventDefault();
event.stopPropagation();
proceedRef.current = () => {
setOpen(false);
router.push(next);
};
setTarget(next);
setOpen(true);
}
document.addEventListener("click", onClick, { capture: true });
return () => document.removeEventListener("click", onClick, { capture: true });
}, [router]);
if (process.env.NODE_ENV !== "development") return null;
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) setOpen(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Open the dashboard in dev mode?</AlertDialogTitle>
<AlertDialogDescription>
In dev, <span className="font-mono text-xs">{target}</span> renders the dashboard
server-side in roughly 100 seconds (a known Next.js 16 dev-runtime regression) and the
browser usually gives up around 60s with an empty response. Production serves the same
page in a few seconds. Stay here, or open anyway and wait.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Stay here</AlertDialogCancel>
<AlertDialogAction onClick={() => proceedRef.current()}>Open anyway</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}