mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +02:00
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:
parent
be45dcff82
commit
8e70bc9107
5 changed files with 73 additions and 20 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export {
|
|||
setActiveSpace,
|
||||
loadActiveSpace,
|
||||
getEffectiveTier,
|
||||
writeActiveSpaceHint,
|
||||
type ActiveSpace,
|
||||
type ActiveSpaceStatus,
|
||||
} from './active-space.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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue