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

@@ -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<string, unknown>) ?? {};
const merged = Object.fromEntries(
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>