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:
@@ -42,6 +42,15 @@ const updateProfileSchema = z.object({
|
|||||||
dark_mode: z.boolean().optional(),
|
dark_mode: z.boolean().optional(),
|
||||||
locale: z.string().optional(),
|
locale: z.string().optional(),
|
||||||
timezone: 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
|
// Per-table column visibility. Keyed by entity type — entries
|
||||||
// with an empty `hiddenColumns` mean "all visible". The validator
|
// with an empty `hiddenColumns` mean "all visible". The validator
|
||||||
// caps total entries / IDs so a malicious client can't bloat the
|
// 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
|
// .passthrough(); the merge prunes them so legacy bloat doesn't
|
||||||
// accumulate forever, and a future schema regression that tries
|
// accumulate forever, and a future schema regression that tries
|
||||||
// to ship arbitrary keys still gets dropped here at write time.
|
// 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 existing = (profile.preferences as Record<string, unknown>) ?? {};
|
||||||
const merged = Object.fromEntries(
|
const merged = Object.fromEntries(
|
||||||
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
|
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export function MobileLayout({ children }: { children: ReactNode }) {
|
|||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-shell="mobile" className="min-h-screen bg-background">
|
<div data-shell="mobile" className="min-h-[100dvh] bg-background">
|
||||||
<MobileLayoutProvider>
|
<MobileLayoutProvider>
|
||||||
<MobileTopbar />
|
<MobileTopbar />
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 min-h-screen',
|
'px-4 min-h-[100dvh]',
|
||||||
// 56px topbar + safe-area + 16px breathing room
|
// 56px topbar + safe-area + 16px breathing room
|
||||||
'pt-[calc(56px+env(safe-area-inset-top)+1rem)]',
|
'pt-[calc(56px+env(safe-area-inset-top)+1rem)]',
|
||||||
// 56px tab bar + safe-area + 32px breathing room
|
// 56px tab bar + safe-area + 32px breathing room
|
||||||
|
|||||||
@@ -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
|
* Client-side fetch wrapper that attaches the `X-Port-Id` header to
|
||||||
* UI store to every request. Used by all queryFn/mutationFn callbacks.
|
* every request.
|
||||||
*
|
*
|
||||||
* Falls back to extracting the port slug from `window.location.pathname` and
|
* multi-port-auditor C1: the URL slug is authoritative — Zustand
|
||||||
* resolving it via `/api/v1/admin/ports` when the Zustand store hasn't been
|
* is a cache that lags by one render after `PortProvider`'s reconcile
|
||||||
* populated yet (fresh page load before `PortProvider`'s effect has fired).
|
* 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> {
|
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];
|
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);
|
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);
|
const headers = new Headers(opts.headers);
|
||||||
if (portId) {
|
if (portId) {
|
||||||
headers.set('X-Port-Id', portId);
|
headers.set('X-Port-Id', portId);
|
||||||
|
|||||||
Reference in New Issue
Block a user