fix(spaces): persist active-space choice across reloads via localStorage

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
This commit is contained in:
Till JS 2026-04-21 16:04:30 +02:00
parent be45dcff82
commit 8e70bc9107
5 changed files with 73 additions and 20 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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<ActiveSpace | null> {
if (!opts.force && status === 'ready') return active;
@ -91,18 +118,28 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
if (member) {
active = member;
status = 'ready';
writeActiveSpaceHint(member.id);
return member;
}
// No active org on the session — activate the personal space.
// Server says no active org. Two reasons we might still know one:
// (a) the user switched to a non-personal space earlier and the
// hint survived in localStorage even though the cookie didn't.
// (b) it's truly a fresh session, in which case we activate
// Personal.
const orgs = await fetchOrganizations();
const personal = orgs.find((o) => 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);

View file

@ -16,6 +16,7 @@ export {
setActiveSpace,
loadActiveSpace,
getEffectiveTier,
writeActiveSpaceHint,
type ActiveSpace,
type ActiveSpaceStatus,
} from './active-space.svelte';

View file

@ -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) {