From 93399ea27ecc34f1a36b0c5d06971f491fa7f869 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 13 May 2026 13:08:09 +0200 Subject: [PATCH] fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/v1/me/route.ts | 17 ++++++++++- .../layout/mobile/mobile-layout.tsx | 4 +-- src/lib/api/client.ts | 28 +++++++++++++------ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts index a5bbeaa8..ea753ec3 100644 --- a/src/app/api/v1/me/route.ts +++ b/src/app/api/v1/me/route.ts @@ -42,6 +42,15 @@ const updateProfileSchema = z.object({ dark_mode: z.boolean().optional(), locale: z.string().optional(), timezone: z.string().optional(), + // multi-port-auditor C3: super-admins land on whichever port + // sorts alphabetically-first when there's no header + no + // preference. Persisting their last-picked port here makes + // the dashboard "remember" their context across tabs. + // `withAuth` (helpers.ts) already reads this as the + // X-Port-Id fallback; previously the value could only ever + // be set via hand-rolled SQL because the allow-list at line + // 154 silently stripped unknown keys. + defaultPortId: z.string().uuid().optional(), // Per-table column visibility. Keyed by entity type — entries // with an empty `hiddenColumns` mean "all visible". The validator // caps total entries / IDs so a malicious client can't bloat the @@ -146,7 +155,13 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { // .passthrough(); the merge prunes them so legacy bloat doesn't // accumulate forever, and a future schema regression that tries // to ship arbitrary keys still gets dropped here at write time. - const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone', 'tablePreferences']); + const ALLOWED_PREF_KEYS = new Set([ + 'dark_mode', + 'locale', + 'timezone', + 'tablePreferences', + 'defaultPortId', + ]); const existing = (profile.preferences as Record) ?? {}; const merged = Object.fromEntries( Object.entries({ ...existing, ...body.preferences }).filter(([k]) => diff --git a/src/components/layout/mobile/mobile-layout.tsx b/src/components/layout/mobile/mobile-layout.tsx index fcafa353..ee0c4dfc 100644 --- a/src/components/layout/mobile/mobile-layout.tsx +++ b/src/components/layout/mobile/mobile-layout.tsx @@ -21,12 +21,12 @@ export function MobileLayout({ children }: { children: ReactNode }) { const [searchOpen, setSearchOpen] = useState(false); return ( -
+
{ } /** - * Client-side fetch wrapper that attaches the `X-Port-Id` header from the - * UI store to every request. Used by all queryFn/mutationFn callbacks. + * Client-side fetch wrapper that attaches the `X-Port-Id` header to + * every request. * - * Falls back to extracting the port slug from `window.location.pathname` and - * resolving it via `/api/v1/admin/ports` when the Zustand store hasn't been - * populated yet (fresh page load before `PortProvider`'s effect has fired). + * multi-port-auditor C1: the URL slug is authoritative — Zustand + * is a cache that lags by one render after `PortProvider`'s reconcile + * effect commits. The previous Zustand-first lookup caused first-load + * queries on a freshly-navigated port to fire with the PRIOR port's + * id and render cross-port data inside the new shell. Now we resolve + * the slug from `window.location.pathname` first and fall back to + * Zustand only when the URL doesn't carry a port slug (e.g. /dashboard + * / non-portSlug routes). */ export async function apiFetch(url: string, opts: ApiFetchOptions = {}): Promise { - let portId = useUIStore.getState().currentPortId; + let portId: string | null = null; - if (!portId && typeof window !== 'undefined') { + if (typeof window !== 'undefined') { const slug = window.location.pathname.split('/').filter(Boolean)[0]; - if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') { + if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api' && slug !== 'dashboard') { portId = await resolvePortIdFromSlug(slug); } } + // Fall back to the Zustand cache when the URL didn't yield a port — + // e.g. global routes (/dashboard) where the rep hasn't picked a port + // yet but a previous session set one. + if (!portId) { + portId = useUIStore.getState().currentPortId; + } + const headers = new Headers(opts.headers); if (portId) { headers.set('X-Port-Id', portId);