fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch

**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) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:08:09 +02:00
parent 7370b2cd7d
commit 93399ea27e
3 changed files with 38 additions and 11 deletions

View File

@@ -40,23 +40,35 @@ async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
}
/**
* 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<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
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);