fix(02-12): gate protected routes on record scope

- resolve account capabilities before protected page queries mount\n- leave public routes outside the record loading gate
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:04:26 +02:00
1 parent b358cfa3b8
commit acd96a80e9
2 files changed
+26 -21

No files matched your search

+7 -21
View File
@@ -19,6 +19,7 @@ import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -225,17 +226,12 @@ export function AuthShell({
);
}
// Admin pages stay behind the auth gate while `/api/auth/me` is in
// flight: the role is unknown until the payload lands, and mounting
// `/admin/*` children early would fire admin queries that 403 for a
// non-admin before the redirect effect can move them away.
if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) {
return (
<div className="flex h-dvh items-center justify-center" role="status">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<span className="sr-only">{t("nav.loadingScreen")}</span>
</div>
);
// Every protected route waits for `/api/auth/me`, not only Settings and
// Admin. The payload resolves the active record as well as identity, so a
// child mounted before it could issue an actor-scoped read before the shell
// knows that a switched record must be refused or scoped differently.
if (isLoading) {
return <RecordScopeHydrationGate label={t("nav.loadingScreen")} />;
}
// Auth RESOLVED as unauthenticated — hold a spinner while the
@@ -249,16 +245,6 @@ export function AuthShell({
);
}
// While `/api/auth/me` is still in flight the shell renders the full
// app chrome + children immediately (the chrome components own their
// null-user skeletons, pages own their data skeletons). This takes the
// auth round-trip off the first-paint critical path: page-level
// queries fire in parallel with `/api/auth/me` instead of behind it.
// `src/proxy.ts` has already refused cookie-less requests to
// protected routes, so the unauthenticated-flash window is limited to
// expired/invalid sessions — those resolve into the redirect branch
// above as soon as the 401 lands.
// Onboarding page — minimal shell, no sidebar/nav
if (isOnboardingPage) {
return (
@@ -0,0 +1,19 @@
import { Loader2 } from "lucide-react";
/**
* Holds protected route trees until `/api/auth/me` has resolved the active
* record and its capabilities. Mounting a child before this point can issue a
* request for the actor's record before the shared-record scope is known.
*/
export function RecordScopeHydrationGate({ label }: { label: string }) {
return (
<div
data-slot="record-scope-hydration-gate"
className="flex h-dvh items-center justify-center"
role="status"
>
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<span className="sr-only">{label}</span>
</div>
);
}