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);