From 8e70bc9107da3295c2f25330550471a94cfc2d24 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 21 Apr 2026 16:04:30 +0200 Subject: [PATCH] fix(spaces): persist active-space choice across reloads via localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a non-personal space then refreshing reverted to Personal: GET http://localhost:3001/api/auth/organization/get-active-member → 400 Bad Request (NO_ACTIVE_ORGANIZATION) Dev-mode cross-origin + SameSite=Lax means the Set-Cookie from Better Auth's /organization/set-active response is quietly dropped by the browser. The server's session record never gets activeOrganizationId updated, so on the next page load get-active-member throws BAD_REQUEST with NO_ACTIVE_ORGANIZATION — and my fallback promoted Personal, reverting the user's explicit choice. Fix: client-side localStorage hint that mirrors the server preference. loadActiveSpace(): 1. Ask the server (get-active-member) — trust it if it knows the org. 2. Otherwise fetch the org list + read the hint from localStorage. If the hint matches an org the user is actually a member of, call set-active again to re-sync the server and use that org. 3. Only fall back to Personal when there's no hint (truly fresh session or first-login). writeActiveSpaceHint() is called from: - SpaceSwitcher.switchTo() — on explicit switch. - SpaceCreateDialog — on successful create (the user just chose it). - accept-invitation — on accept (user opted in to the new space). - loadActiveSpace() itself — after any successful resolve, so the hint stays current. Exported from $lib/data/scope so additional flows (admin tools, future delete-space) can keep it in sync. Production impact: once COOKIE_DOMAIN=.mana.how is set with SameSite=None+Secure, the server cookie takes precedence and the hint is redundant but harmless. 0 errors across 7234 files. Plan: docs/plans/spaces-foundation.md --- .../layout/SpaceCreateDialog.svelte | 5 +- .../components/layout/SpaceSwitcher.svelte | 12 +++- .../src/lib/data/scope/active-space.svelte.ts | 71 ++++++++++++++----- .../mana/apps/web/src/lib/data/scope/index.ts | 1 + .../src/routes/accept-invitation/+page.svelte | 4 +- 5 files changed, 73 insertions(+), 20 deletions(-) diff --git a/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte b/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte index 363c10a37..8fe15ab33 100644 --- a/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte +++ b/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte @@ -11,7 +11,7 @@ import { SPACE_TYPES, SPACE_TYPE_LABELS, SPACE_TYPE_DESCRIPTIONS } from '@mana/shared-branding'; import type { SpaceType } from '@mana/shared-types'; - import { loadActiveSpace, authFetch } from '$lib/data/scope'; + import { loadActiveSpace, authFetch, writeActiveSpaceHint } from '$lib/data/scope'; interface Props { open: boolean; @@ -92,6 +92,9 @@ method: 'POST', body: JSON.stringify({ organizationId: created.id }), }); + // Persist the choice in localStorage as a fallback against + // cross-origin cookie drops in dev. + writeActiveSpaceHint(created.id); await loadActiveSpace({ force: true }); if (typeof window !== 'undefined') window.location.reload(); } catch (err) { diff --git a/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte b/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte index 3ffb24491..e0e3c3e61 100644 --- a/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte +++ b/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte @@ -11,7 +11,13 @@ */ import { onDestroy } from 'svelte'; - import { getActiveSpace, loadActiveSpace, authFetch, type ActiveSpace } from '$lib/data/scope'; + import { + getActiveSpace, + loadActiveSpace, + authFetch, + writeActiveSpaceHint, + type ActiveSpace, + } from '$lib/data/scope'; import { SPACE_TYPE_LABELS } from '@mana/shared-branding'; import { isSpaceType, isSpaceTier } from '@mana/shared-types'; import SpaceCreateDialog from './SpaceCreateDialog.svelte'; @@ -102,6 +108,10 @@ body: JSON.stringify({ organizationId: id }), }); if (!res.ok) throw new Error(`set-active failed: ${res.status}`); + // Persist the user's explicit choice so the reload-time + // bootstrap can recover it even if Better Auth's cross-origin + // Set-Cookie was dropped by the browser (dev SameSite=Lax). + writeActiveSpaceHint(id); await loadActiveSpace({ force: true }); if (typeof window !== 'undefined') window.location.reload(); } catch (err) { diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts index a2d9c7160..5ae4fe578 100644 --- a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -67,18 +67,45 @@ export function getEffectiveTier(userFallback: SpaceTier | string | undefined): } /** - * Resolve the user's active space from Better Auth. Idempotent — safe to - * call multiple times; successive calls short-circuit when `status === 'ready'`. + * LocalStorage hint: the last organization id the user explicitly + * switched to. Persists the choice across reloads even when Better + * Auth's cross-origin Set-Cookie (dev: localhost:5173 → localhost:3001 + * with SameSite=Lax) doesn't reliably update the session's + * activeOrganizationId. Without this, a brand/family switch gets + * reverted to Personal on every refresh. + */ +const ACTIVE_SPACE_HINT_KEY = 'mana.scope.activeSpaceId'; + +function readActiveSpaceHint(): string | null { + if (typeof localStorage === 'undefined') return null; + try { + return localStorage.getItem(ACTIVE_SPACE_HINT_KEY); + } catch { + return null; + } +} + +export function writeActiveSpaceHint(id: string | null): void { + if (typeof localStorage === 'undefined') return; + try { + if (id) localStorage.setItem(ACTIVE_SPACE_HINT_KEY, id); + else localStorage.removeItem(ACTIVE_SPACE_HINT_KEY); + } catch { + // ignore quota / sandbox errors + } +} + +/** + * Resolve the user's active space. Order of truth: + * 1. Server-side active member (get-active-member) — trustworthy when + * Better Auth's activeOrganizationId landed in the session. + * 2. Client-side hint from localStorage — survives cookie drops across + * page reloads in dev. If present, we call set-active to re-sync + * the server to match. + * 3. Personal space fallback — brand-new session, no previous choice. * - * Flow: - * 1. GET /api/auth/organization/get-active-member - * If it returns a member object → use it. - * 2. Otherwise GET /api/auth/organization/list, find the personal space - * by metadata.type === 'personal', POST /set-active to activate it. - * 3. Write the result into the reactive `active` state. - * - * Errors are captured in `lastError` and status flips to 'error'. Callers - * can retry by calling `loadActiveSpace({ force: true })`. + * Errors captured in `lastError`; status flips to 'error'. Callers can + * retry via `loadActiveSpace({ force: true })`. */ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise { if (!opts.force && status === 'ready') return active; @@ -91,18 +118,28 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise o.type === 'personal'); - if (!personal) { - throw new Error('No personal space found — signup hook may not have run'); + const hintId = readActiveSpaceHint(); + const hinted = hintId ? orgs.find((o) => o.id === hintId) : null; + + const chosen = hinted ?? orgs.find((o) => o.type === 'personal') ?? null; + if (!chosen) { + throw new Error('No accessible space found — signup hook may not have run'); } - await setActiveOnServer(personal.id); - active = { ...personal, role: 'owner' }; + + await setActiveOnServer(chosen.id); + active = { ...chosen, role: hinted ? hinted.role : 'owner' }; status = 'ready'; + writeActiveSpaceHint(chosen.id); return active; } catch (err) { lastError = err instanceof Error ? err.message : String(err); diff --git a/apps/mana/apps/web/src/lib/data/scope/index.ts b/apps/mana/apps/web/src/lib/data/scope/index.ts index 4b4622c27..54ed56afb 100644 --- a/apps/mana/apps/web/src/lib/data/scope/index.ts +++ b/apps/mana/apps/web/src/lib/data/scope/index.ts @@ -16,6 +16,7 @@ export { setActiveSpace, loadActiveSpace, getEffectiveTier, + writeActiveSpaceHint, type ActiveSpace, type ActiveSpaceStatus, } from './active-space.svelte'; diff --git a/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte b/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte index 4f02fc5e8..e12e67ff8 100644 --- a/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte +++ b/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte @@ -16,7 +16,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { SPACE_TYPE_LABELS } from '@mana/shared-branding'; import { isSpaceType, type SpaceType } from '@mana/shared-types'; - import { loadActiveSpace, authFetch } from '$lib/data/scope'; + import { loadActiveSpace, authFetch, writeActiveSpaceHint } from '$lib/data/scope'; interface InvitationPayload { id: string; @@ -93,6 +93,8 @@ method: 'POST', body: JSON.stringify({ organizationId: invitation.organizationId }), }); + // localStorage fallback for cross-origin cookie drops. + writeActiveSpaceHint(invitation.organizationId); await loadActiveSpace({ force: true }); goto('/'); } catch (err) {