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:
parent
4dde790aa8
commit
5883990577
3 changed files with 110 additions and 1 deletions
|
|
@ -11,7 +11,7 @@ import { Metadata } from "next";
|
||||||
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
|
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
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 { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||||
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
||||||
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
||||||
|
|
@ -29,6 +29,7 @@ import { cookies } from "next/headers";
|
||||||
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
||||||
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
||||||
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
|
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)/).
|
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
||||||
// The layout calls `getPayload()` at render time to resolve the current user,
|
// 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 && <GameTickRealtime />}
|
||||||
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
||||||
|
{process.env.NODE_ENV === "development" && user && <DevDashboardGate />}
|
||||||
{user && <MissionReminderToasts />}
|
{user && <MissionReminderToasts />}
|
||||||
{isLogistics && <ShipmentToasts />}
|
{isLogistics && <ShipmentToasts />}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
|
||||||
|
|
@ -3576,3 +3576,4 @@
|
||||||
[data-radix-popper-content-wrapper] {
|
[data-radix-popper-content-wrapper] {
|
||||||
z-index: 1300 !important;
|
z-index: 1300 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
106
src/components/frontend/dev/DevDashboardGate.tsx
Normal file
106
src/components/frontend/dev/DevDashboardGate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue