diff --git a/src/hooks/use-session-storage-sync.ts b/src/hooks/use-session-storage-sync.ts index 67b25513d..41cfde612 100644 --- a/src/hooks/use-session-storage-sync.ts +++ b/src/hooks/use-session-storage-sync.ts @@ -1,10 +1,19 @@ import { useEffect } from 'react'; -import { useSessionStore } from '@/lib/stores/session-store'; +import { useSessionStore, type SessionState } from '@/lib/stores/session-store'; const SESSION_STORAGE_KEY = 'debate-trainer-storage'; interface PersistedSessionPayload { - state?: Partial>; + state?: Partial; +} + +/** Another tab's snapshot, or null when it belongs to a different account. */ +export function persistedSessionFromStorage(newValue: string, currentOwner: string | null): Partial | null { + const payload = JSON.parse(newValue) as PersistedSessionPayload; + if (!payload.state) return null; + const incomingOwner = payload.state.ownerUserId ?? null; + if (currentOwner && incomingOwner && incomingOwner !== currentOwner) return null; + return payload.state; } export function useSessionStorageSync() { @@ -13,9 +22,8 @@ export function useSessionStorageSync() { if (event.key !== SESSION_STORAGE_KEY || !event.newValue) return; try { - const payload = JSON.parse(event.newValue) as PersistedSessionPayload; - if (!payload.state) return; - useSessionStore.setState(payload.state); + const next = persistedSessionFromStorage(event.newValue, useSessionStore.getState().ownerUserId); + if (next) useSessionStore.setState(next); } catch (error) { console.warn('[SessionStore] Failed to sync persisted state from another tab:', error); } diff --git a/src/lib/stores/debate-store.ts b/src/lib/stores/debate-store.ts index 271d88dbe..ea536e501 100644 --- a/src/lib/stores/debate-store.ts +++ b/src/lib/stores/debate-store.ts @@ -57,6 +57,8 @@ interface DebateState { setOneOff: (oneOff: boolean) => void; openDialog: (dialog: DebateDialog, motion?: string) => void; resetLocalData: () => void; + /** Drops another account's debate list before the server copy loads. */ + prepareForUser: (userId: string) => void; } export function normalizeMotion(motion: string): string { @@ -244,11 +246,18 @@ export const useDebateStore = create()( hydrationEpoch += 1; set({ ownerUserId: null, debates: [], currentDebateId: null, oneOff: false, hydrated: false, dialog: null, dialogMotion: '' }); }, + + prepareForUser: (userId) => { + if (get().ownerUserId === userId) return; + hydrationEpoch += 1; + set({ ownerUserId: userId, debates: [], currentDebateId: null, oneOff: false, hydrated: false, dialog: null, dialogMotion: '' }); + }, }), { name: 'debatelab-current-debate', storage: createJSONStorage(() => localStorage), version: 1, + merge: mergePersistedDebateState, partialize: (state) => ({ ownerUserId: state.ownerUserId, debates: state.debates, @@ -258,6 +267,18 @@ export const useDebateStore = create()( ), ); +/** + * A rehydrate that finishes after sign-in must not restore another account's + * debate list. Lists saved before an owner was recorded stay only until this + * account is bound. + */ +export function mergePersistedDebateState(persistedState: unknown, currentState: DebateState): DebateState { + const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial : {}; + const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null; + if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState; + return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId }; +} + export function selectCurrentDebate(state: Pick): Debate | null { return state.debates.find((debate) => debate.id === state.currentDebateId) ?? null; } diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 561ef360c..51539f496 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -121,6 +121,8 @@ export interface FlowchartData { } export interface SessionState { + /** Account these persisted drafts belong to. Null until sign-in binds them. */ + ownerUserId: string | null; generateResult: GenerateOutput | null; generateLoading: boolean; /** The Generate section being regenerated; kept here so it survives leaving the page. */ @@ -235,6 +237,12 @@ export interface SessionState { serverHydration: 'pending' | 'partial' | 'complete'; clearAllData: () => Promise; resetLocalData: () => void; + /** + * Binds persisted drafts to the signed-in account. A different account, or + * drafts saved before accounts were recorded, is cleared first so one + * browser cannot show the previous person's work. + */ + prepareForUser: (userId: string) => void; clearSession: () => void; pendingFlowchartData: FlowchartData | null; @@ -285,6 +293,54 @@ function normalizeFolder(folder?: string | null) { return trimmed || 'General'; } +/** Device-only work that must not cross accounts. Server lists reload after this. */ +function sessionWorkForAccount(ownerUserId: string | null): Partial { + return { + ownerUserId, + serverHydration: 'pending', + savedCases: [], + savedAttacks: [], + savedBrainstorms: [], + savedFactoids: [], + savedChats: [], + savedFlowcharts: [], + savedCrossfires: [], + autosavedCases: [], + autosavedAttacks: [], + autosavedBrainstorms: [], + autosavedFactoids: [], + askChatId: null, + askChatDebateId: null, + askPersistedCount: 0, + sourceFolders: ['General'], + sourceLibrary: [], + generateResult: null, + attackResult: null, + brainstormResult: null, + factoidsResult: null, + askMessages: [], + askStreamingAssistant: null, + pendingFlowchartData: null, + generateForm: {}, + generateResultDebateId: undefined, + attackForm: {}, + brainstormForm: {}, + factoidsForm: {}, + }; +} + +/** + * Keeps a rehydrate that finishes after sign-in from putting another account's + * snapshot back. Drafts with no owner are kept only while nobody is bound yet; + * binding then clears them. + */ +export function mergePersistedSessionState(persistedState: unknown, currentState: SessionState): SessionState { + const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial : {}; + const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null; + if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState; + return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId }; +} + function mergeSourceFolders(currentFolders: string[], sources: Source[] = []) { return Array.from(new Set([ 'General', @@ -460,6 +516,7 @@ export function parseSourceProvenance(value: unknown): SourceProvenance | undefi export const useSessionStore = create()( persist( (set, get) => ({ + ownerUserId: null as string | null, generateResult: null as GenerateOutput | null, generateLoading: false, generateRegeneratingSection: null as string | null, @@ -1265,39 +1322,11 @@ export const useSessionStore = create()( askPersistedCount: 0, }); }, - resetLocalData: () => set({ - serverHydration: 'pending', - savedCases: [], - savedAttacks: [], - savedBrainstorms: [], - savedFactoids: [], - savedChats: [], - savedFlowcharts: [], - savedCrossfires: [], - autosavedCases: [], - autosavedAttacks: [], - autosavedBrainstorms: [], - autosavedFactoids: [], - askChatId: null, - askChatDebateId: null, - askPersistedCount: 0, - sourceFolders: ['General'], - sourceLibrary: [], - generateResult: null, - attackResult: null, - brainstormResult: null, - factoidsResult: null, - askMessages: [], - askStreamingAssistant: null, - // Drafts belong to the signed-in user too; the next person on this - // browser must not see them prefilled. - pendingFlowchartData: null, - generateForm: {}, - generateResultDebateId: undefined, - attackForm: {}, - brainstormForm: {}, - factoidsForm: {}, - }), + resetLocalData: () => set(sessionWorkForAccount(null)), + prepareForUser: (userId) => { + if (get().ownerUserId === userId) return; + set(sessionWorkForAccount(userId)); + }, clearSession: () => set({ generateResult: null, @@ -1324,6 +1353,7 @@ export const useSessionStore = create()( // older clients: the server replaces them on hydrate, and keeping them // would resurrect purged legacy rows and debate-less sources. version: 1, + merge: mergePersistedSessionState, migrate: (persistedState) => { const state = { ...(persistedState as Record) }; for (const key of [ diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 4d96bd91d..15f53330d 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -20,6 +20,8 @@ type CoreFeatureTab = Exclude; type FeatureConfigs = Record & Partial>; interface SettingsState { + /** Account these device settings belong to. Null until sign-in binds them. */ + ownerUserId: string | null; language: AppLanguage; outputLanguage: AppLanguage; theme: 'light' | 'dark' | 'system'; @@ -36,6 +38,8 @@ interface SettingsState { setWikiResearchEnabled: (enabled: boolean) => void; hydrateFromServer: () => Promise; resetLocalData: (preserveLanguage?: boolean) => void; + /** Drops another account's prompt and model choices before the server copy loads. */ + prepareForUser: (userId: string) => void; } const DEFAULT_PROVIDER = 'openrouter'; @@ -205,9 +209,22 @@ async function persistMigratedFeatureConfigs( } } +/** + * A rehydrate that finishes after sign-in must not restore another account's + * prompt or model choices. Settings saved before an owner was recorded stay + * only until this account is bound. + */ +export function mergePersistedSettingsState(persistedState: unknown, currentState: SettingsState): SettingsState { + const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial : {}; + const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null; + if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState; + return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId }; +} + export const useSettingsStore = create()( persist( (set, get) => ({ + ownerUserId: null as string | null, language: 'en', outputLanguage: 'en', theme: 'system', @@ -338,6 +355,7 @@ export const useSettingsStore = create()( resetLocalData: (preserveLanguage = false) => { const language = preserveLanguage ? get().language : 'en'; set({ + ownerUserId: null, language, outputLanguage: 'en', theme: 'system', @@ -347,10 +365,20 @@ export const useSettingsStore = create()( }); void applyAppLanguage(language); }, + prepareForUser: (userId) => { + if (get().ownerUserId === userId) return; + set({ + ownerUserId: userId, + customSystemPrompt: undefined, + featureConfigs: getDefaultFeatureConfigs(), + wikiResearchEnabled: false, + }); + }, }), { name: 'debate-trainer-settings', version: 2, + merge: mergePersistedSettingsState, migrate: (persistedState: unknown, version) => { const saved = asRecord(persistedState); if (!saved) return persistedState; diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index 531eef579..c7a3c5255 100644 --- a/src/lib/supabase/AuthProvider.tsx +++ b/src/lib/supabase/AuthProvider.tsx @@ -98,9 +98,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const hydrateSessionStore = useSessionStore((state) => state.hydrateFromServer); + const prepareSessionStore = useSessionStore((state) => state.prepareForUser); const hydrateSettingsStore = useSettingsStore((state) => state.hydrateFromServer); + const prepareSettingsStore = useSettingsStore((state) => state.prepareForUser); const hydrateSparringStore = useSparringStore((state) => state.hydrateSavedSessions); const prepareSparringStore = useSparringStore((state) => state.prepareForUser); + const prepareDebateStore = useDebateStore((state) => state.prepareForUser); const resetSessionData = useSessionStore((state) => state.resetLocalData); // A tool still writing for the previous account stops with its data. const resetSessionStore = useCallback(() => { @@ -120,7 +123,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const hydrateStores = (userId?: string) => { if (!userId || hydratedUserId === userId) return; hydratedUserId = userId; + // Bind before any paint of protected pages. A full-page sign-in (magic + // link, Google) never sees the previous account's in-tab sign-out, so + // the persisted snapshot has to be checked against this user here. + prepareSessionStore(userId); + prepareSettingsStore(userId); prepareSparringStore(userId); + prepareDebateStore(userId); Promise.resolve(hydrateSessionStore()).catch((err) => console.warn('[Auth] hydrateSessionStore failed:', err)); Promise.resolve(hydrateSettingsStore()).catch((err) => console.warn('[Auth] hydrateSettingsStore failed:', err)); Promise.resolve(hydrateSparringStore()).catch((err) => console.warn('[Auth] hydrateSparringStore failed:', err)); @@ -176,7 +185,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setSession(oauthSession); currentUserIdRef.current = oauthSession.user?.id; setJamUserId(oauthSession.user?.id); - setLoading(false); hydrateStores(oauthSession.user?.id); return; } @@ -224,7 +232,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setJamUserId(nextUserId); setActiveAccessToken(newSession?.access_token ?? null, newSession?.user.id ?? null); setSession(newSession); - setLoading(false); // Provider access tokens are short-lived. Keep them only for this browser // session; a later export must reauthorize if no fresh token is available. @@ -241,6 +248,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { resetDebateStore(); useDebateContextStore.getState().resetLocalData(); clearGoogleTokens(); + setLoading(false); return; } @@ -254,13 +262,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } hydrateStores(nextUserId); + setLoading(false); }); return () => { mounted = false; authListener.subscription.unsubscribe(); }; - }, [hydrateDebateStore, hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareSparringStore, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]); + }, [hydrateDebateStore, hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareDebateStore, prepareSessionStore, prepareSettingsStore, prepareSparringStore, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]); // sessionStorage holds this tab's Google token and sparring state. const discardTabState = useCallback(() => { diff --git a/src/test/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx index 9281e421a..e825a72e4 100644 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ b/src/test/auth/AuthProvider.behavior.test.tsx @@ -15,7 +15,9 @@ const { signOutMock, unsubscribeMock, hydrateSessionStoreMock, + prepareSessionStoreMock, hydrateSettingsStoreMock, + prepareSettingsStoreMock, resetSessionStoreMock, clearSessionStoreMock, resetSettingsStoreMock, @@ -36,7 +38,9 @@ const { const signOutMock = vi.fn(); const unsubscribeMock = vi.fn(); const hydrateSessionStoreMock = vi.fn(); + const prepareSessionStoreMock = vi.fn(); const hydrateSettingsStoreMock = vi.fn(); + const prepareSettingsStoreMock = vi.fn(); const resetSessionStoreMock = vi.fn(); const clearSessionStoreMock = vi.fn(); const resetSettingsStoreMock = vi.fn(); @@ -63,7 +67,9 @@ const { signOutMock, unsubscribeMock, hydrateSessionStoreMock, + prepareSessionStoreMock, hydrateSettingsStoreMock, + prepareSettingsStoreMock, resetSessionStoreMock, clearSessionStoreMock, resetSettingsStoreMock, @@ -104,6 +110,7 @@ vi.mock('@/lib/native', () => ({ vi.mock('@/lib/stores/session-store', () => ({ useSessionStore: (selector: (state: any) => any) => selector({ hydrateFromServer: hydrateSessionStoreMock, + prepareForUser: prepareSessionStoreMock, resetLocalData: resetSessionStoreMock, clearSession: clearSessionStoreMock, }), @@ -112,6 +119,7 @@ vi.mock('@/lib/stores/session-store', () => ({ vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: (selector: (state: any) => any) => selector({ hydrateFromServer: hydrateSettingsStoreMock, + prepareForUser: prepareSettingsStoreMock, resetLocalData: resetSettingsStoreMock, }), })); @@ -206,7 +214,9 @@ describe('AuthProvider behavior', () => { expect(screen.getByTestId('user')).toHaveTextContent('user_1'); expect(hydrateSessionStoreMock).toHaveBeenCalled(); + expect(prepareSessionStoreMock).toHaveBeenCalledWith('user_1'); expect(hydrateSettingsStoreMock).toHaveBeenCalled(); + expect(prepareSettingsStoreMock).toHaveBeenCalledWith('user_1'); expect(prepareSparringStoreMock).toHaveBeenCalledWith('user_1'); expect(hydrateSparringStoreMock).toHaveBeenCalled(); expect(onAuthStateChangeMock).toHaveBeenCalled(); @@ -232,7 +242,9 @@ describe('AuthProvider behavior', () => { }); expect(hydrateSessionStoreMock).toHaveBeenCalledTimes(1); + expect(prepareSessionStoreMock).toHaveBeenCalledTimes(1); expect(hydrateSettingsStoreMock).toHaveBeenCalledTimes(1); + expect(prepareSettingsStoreMock).toHaveBeenCalledTimes(1); expect(prepareSparringStoreMock).toHaveBeenCalledTimes(1); expect(hydrateSparringStoreMock).toHaveBeenCalledTimes(1); }); @@ -261,10 +273,45 @@ describe('AuthProvider behavior', () => { }); expect(hydrateSessionStoreMock).toHaveBeenCalled(); + expect(prepareSessionStoreMock).toHaveBeenCalledWith('user_2'); expect(hydrateSettingsStoreMock).toHaveBeenCalled(); + expect(prepareSettingsStoreMock).toHaveBeenCalledWith('user_2'); expect(prepareSparringStoreMock).toHaveBeenCalledWith('user_2'); }); + it('drops another account\'s current debate before the app unlocks', async () => { + useDebateStore.setState({ + ownerUserId: 'user_a', + debates: [{ + id: 'debate-a', + motion: 'Secret motion', + settings: {}, + instructions: '', + lastActivityAt: 1, + createdAt: 1, + }], + currentDebateId: 'debate-a', + }); + getSessionMock.mockResolvedValue({ + data: { session: { user: { id: 'user_b' }, access_token: 'token' } }, + error: null, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + }); + + expect(useDebateStore.getState().ownerUserId).toBe('user_b'); + expect(useDebateStore.getState().debates).toEqual([]); + expect(useDebateStore.getState().currentDebateId).toBeNull(); + }); + it('removes refresh tokens persisted by older clients during startup', async () => { localStorage.setItem('google_provider_refresh_token', 'legacy-refresh-token'); getSessionMock.mockResolvedValue({ diff --git a/src/test/stores/account-local-state.test.ts b/src/test/stores/account-local-state.test.ts new file mode 100644 index 000000000..cbf95090d --- /dev/null +++ b/src/test/stores/account-local-state.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mergePersistedDebateState, useDebateStore } from '@/lib/stores/debate-store'; +import { mergePersistedSessionState, useSessionStore } from '@/lib/stores/session-store'; +import { mergePersistedSettingsState, useSettingsStore } from '@/lib/stores/settings-store'; +import { persistedSessionFromStorage } from '@/hooks/use-session-storage-sync'; + +describe('local state belongs to one account', () => { + beforeEach(() => { + useSessionStore.getState().resetLocalData(); + useSettingsStore.getState().resetLocalData(); + useDebateStore.getState().resetLocalData(); + }); + + it('clears the previous account\'s drafts when a different person signs in', () => { + useSessionStore.setState({ + ownerUserId: 'user-a', + generateForm: { motion: 'User A private motion' }, + askMessages: [{ id: 'm1', role: 'user', content: 'User A secret', timestamp: 1 }], + }); + useSettingsStore.setState({ ownerUserId: 'user-a', customSystemPrompt: 'User A private prompt' }); + + useSessionStore.getState().prepareForUser('user-b'); + useSettingsStore.getState().prepareForUser('user-b'); + + expect(useSessionStore.getState().ownerUserId).toBe('user-b'); + expect(useSessionStore.getState().generateForm).toEqual({}); + expect(useSessionStore.getState().askMessages).toEqual([]); + expect(useSettingsStore.getState().customSystemPrompt).toBeUndefined(); + expect(useSettingsStore.getState().ownerUserId).toBe('user-b'); + }); + + it('keeps drafts when the same account signs in again', () => { + useSessionStore.getState().prepareForUser('user-a'); + useSessionStore.getState().setGenerateForm({ motion: 'My motion' }); + useSettingsStore.getState().prepareForUser('user-a'); + useSettingsStore.setState({ customSystemPrompt: 'My prompt' }); + + useSessionStore.getState().prepareForUser('user-a'); + useSettingsStore.getState().prepareForUser('user-a'); + + expect(useSessionStore.getState().generateForm.motion).toBe('My motion'); + expect(useSettingsStore.getState().customSystemPrompt).toBe('My prompt'); + }); + + it('does not let a late rehydrate restore another account after binding', () => { + useSessionStore.getState().prepareForUser('user-b'); + useSessionStore.getState().setGenerateForm({ motion: 'Mine' }); + const session = mergePersistedSessionState( + { ownerUserId: 'user-a', generateForm: { motion: 'User A private motion' }, askMessages: [{ id: 'm1', role: 'user', content: 'secret', timestamp: 1 }] }, + useSessionStore.getState(), + ); + expect(session.ownerUserId).toBe('user-b'); + expect(session.generateForm.motion).toBe('Mine'); + + useDebateStore.getState().prepareForUser('user-b'); + const debate = mergePersistedDebateState( + { ownerUserId: 'user-a', currentDebateId: 'debate-a', debates: [{ id: 'debate-a', motion: 'Secret', settings: {}, instructions: '', lastActivityAt: 1, createdAt: 1 }] }, + useDebateStore.getState(), + ); + expect(debate.ownerUserId).toBe('user-b'); + expect(debate.debates).toEqual([]); + + useSettingsStore.getState().prepareForUser('user-b'); + const settings = mergePersistedSettingsState( + { ownerUserId: 'user-a', customSystemPrompt: 'User A private prompt' }, + useSettingsStore.getState(), + ); + expect(settings.customSystemPrompt).toBeUndefined(); + }); + + it('ignores a cross-tab snapshot from a different account', () => { + const foreign = JSON.stringify({ + state: { ownerUserId: 'user-a', askMessages: [{ id: 'm1', role: 'user', content: 'secret', timestamp: 1 }] }, + }); + expect(persistedSessionFromStorage(foreign, 'user-b')).toBeNull(); + + const mine = JSON.stringify({ + state: { ownerUserId: 'user-b', generateForm: { motion: 'Mine' } }, + }); + expect(persistedSessionFromStorage(mine, 'user-b')?.generateForm).toEqual({ motion: 'Mine' }); + }); +});