Skip to content

Commit d141363

Browse files
Bind device drafts to the signed-in account
A full-page sign-in never ran the in-tab account switch, so the next person on the same browser could see the previous account's Ask thread, case drafts, custom prompt, and current debate. Sign-in now clears that local snapshot when it belongs to someone else, and a late reload cannot put it back. Co-authored-by: Gáborjáni-Szabó Ádám <Programcode@users.noreply.github.com>
1 parent 4f9e0a0 commit d141363

7 files changed

Lines changed: 265 additions & 40 deletions

File tree

‎src/hooks/use-session-storage-sync.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { useEffect } from 'react';
2-
import { useSessionStore } from '@/lib/stores/session-store';
2+
import { useSessionStore, type SessionState } from '@/lib/stores/session-store';
33

44
const SESSION_STORAGE_KEY = 'debate-trainer-storage';
55

66
interface PersistedSessionPayload {
7-
state?: Partial<ReturnType<typeof useSessionStore.getState>>;
7+
state?: Partial<SessionState>;
8+
}
9+
10+
/** Another tab's snapshot, or null when it belongs to a different account. */
11+
export function persistedSessionFromStorage(newValue: string, currentOwner: string | null): Partial<SessionState> | null {
12+
const payload = JSON.parse(newValue) as PersistedSessionPayload;
13+
if (!payload.state) return null;
14+
const incomingOwner = payload.state.ownerUserId ?? null;
15+
if (currentOwner && incomingOwner && incomingOwner !== currentOwner) return null;
16+
return payload.state;
817
}
918

1019
export function useSessionStorageSync() {
@@ -13,9 +22,8 @@ export function useSessionStorageSync() {
1322
if (event.key !== SESSION_STORAGE_KEY || !event.newValue) return;
1423

1524
try {
16-
const payload = JSON.parse(event.newValue) as PersistedSessionPayload;
17-
if (!payload.state) return;
18-
useSessionStore.setState(payload.state);
25+
const next = persistedSessionFromStorage(event.newValue, useSessionStore.getState().ownerUserId);
26+
if (next) useSessionStore.setState(next);
1927
} catch (error) {
2028
console.warn('[SessionStore] Failed to sync persisted state from another tab:', error);
2129
}

‎src/lib/stores/debate-store.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ interface DebateState {
5757
setOneOff: (oneOff: boolean) => void;
5858
openDialog: (dialog: DebateDialog, motion?: string) => void;
5959
resetLocalData: () => void;
60+
/** Drops another account's debate list before the server copy loads. */
61+
prepareForUser: (userId: string) => void;
6062
}
6163

6264
export function normalizeMotion(motion: string): string {
@@ -244,11 +246,18 @@ export const useDebateStore = create<DebateState>()(
244246
hydrationEpoch += 1;
245247
set({ ownerUserId: null, debates: [], currentDebateId: null, oneOff: false, hydrated: false, dialog: null, dialogMotion: '' });
246248
},
249+
250+
prepareForUser: (userId) => {
251+
if (get().ownerUserId === userId) return;
252+
hydrationEpoch += 1;
253+
set({ ownerUserId: userId, debates: [], currentDebateId: null, oneOff: false, hydrated: false, dialog: null, dialogMotion: '' });
254+
},
247255
}),
248256
{
249257
name: 'debatelab-current-debate',
250258
storage: createJSONStorage(() => localStorage),
251259
version: 1,
260+
merge: mergePersistedDebateState,
252261
partialize: (state) => ({
253262
ownerUserId: state.ownerUserId,
254263
debates: state.debates,
@@ -258,6 +267,18 @@ export const useDebateStore = create<DebateState>()(
258267
),
259268
);
260269

270+
/**
271+
* A rehydrate that finishes after sign-in must not restore another account's
272+
* debate list. Lists saved before an owner was recorded stay only until this
273+
* account is bound.
274+
*/
275+
export function mergePersistedDebateState(persistedState: unknown, currentState: DebateState): DebateState {
276+
const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial<DebateState> : {};
277+
const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null;
278+
if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState;
279+
return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId };
280+
}
281+
261282
export function selectCurrentDebate(state: Pick<DebateState, 'debates' | 'currentDebateId'>): Debate | null {
262283
return state.debates.find((debate) => debate.id === state.currentDebateId) ?? null;
263284
}

‎src/lib/stores/session-store.ts‎

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ export interface FlowchartData {
121121
}
122122

123123
export interface SessionState {
124+
/** Account these persisted drafts belong to. Null until sign-in binds them. */
125+
ownerUserId: string | null;
124126
generateResult: GenerateOutput | null;
125127
generateLoading: boolean;
126128
/** The Generate section being regenerated; kept here so it survives leaving the page. */
@@ -229,6 +231,12 @@ export interface SessionState {
229231
serverHydration: 'pending' | 'partial' | 'complete';
230232
clearAllData: () => Promise<void>;
231233
resetLocalData: () => void;
234+
/**
235+
* Binds persisted drafts to the signed-in account. A different account, or
236+
* drafts saved before accounts were recorded, is cleared first so one
237+
* browser cannot show the previous person's work.
238+
*/
239+
prepareForUser: (userId: string) => void;
232240
clearSession: () => void;
233241

234242
pendingFlowchartData: FlowchartData | null;
@@ -279,6 +287,53 @@ function normalizeFolder(folder?: string | null) {
279287
return trimmed || 'General';
280288
}
281289

290+
/** Device-only work that must not cross accounts. Server lists reload after this. */
291+
function sessionWorkForAccount(ownerUserId: string | null): Partial<SessionState> {
292+
return {
293+
ownerUserId,
294+
serverHydration: 'pending',
295+
savedCases: [],
296+
savedAttacks: [],
297+
savedBrainstorms: [],
298+
savedFactoids: [],
299+
savedChats: [],
300+
savedFlowcharts: [],
301+
savedCrossfires: [],
302+
autosavedCases: [],
303+
autosavedAttacks: [],
304+
autosavedBrainstorms: [],
305+
autosavedFactoids: [],
306+
askChatId: null,
307+
askChatDebateId: null,
308+
askPersistedCount: 0,
309+
sourceFolders: ['General'],
310+
sourceLibrary: [],
311+
generateResult: null,
312+
attackResult: null,
313+
brainstormResult: null,
314+
factoidsResult: null,
315+
askMessages: [],
316+
askStreamingAssistant: null,
317+
pendingFlowchartData: null,
318+
generateForm: {},
319+
attackForm: {},
320+
brainstormForm: {},
321+
factoidsForm: {},
322+
};
323+
}
324+
325+
/**
326+
* Keeps a rehydrate that finishes after sign-in from putting another account's
327+
* snapshot back. Drafts with no owner are kept only while nobody is bound yet;
328+
* binding then clears them.
329+
*/
330+
export function mergePersistedSessionState(persistedState: unknown, currentState: SessionState): SessionState {
331+
const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial<SessionState> : {};
332+
const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null;
333+
if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState;
334+
return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId };
335+
}
336+
282337
function mergeSourceFolders(currentFolders: string[], sources: Source[] = []) {
283338
return Array.from(new Set([
284339
'General',
@@ -454,6 +509,7 @@ export function parseSourceProvenance(value: unknown): SourceProvenance | undefi
454509
export const useSessionStore = create<SessionState>()(
455510
persist(
456511
(set, get) => ({
512+
ownerUserId: null as string | null,
457513
generateResult: null as GenerateOutput | null,
458514
generateLoading: false,
459515
generateRegeneratingSection: null as string | null,
@@ -1233,38 +1289,11 @@ export const useSessionStore = create<SessionState>()(
12331289
askPersistedCount: 0,
12341290
});
12351291
},
1236-
resetLocalData: () => set({
1237-
serverHydration: 'pending',
1238-
savedCases: [],
1239-
savedAttacks: [],
1240-
savedBrainstorms: [],
1241-
savedFactoids: [],
1242-
savedChats: [],
1243-
savedFlowcharts: [],
1244-
savedCrossfires: [],
1245-
autosavedCases: [],
1246-
autosavedAttacks: [],
1247-
autosavedBrainstorms: [],
1248-
autosavedFactoids: [],
1249-
askChatId: null,
1250-
askChatDebateId: null,
1251-
askPersistedCount: 0,
1252-
sourceFolders: ['General'],
1253-
sourceLibrary: [],
1254-
generateResult: null,
1255-
attackResult: null,
1256-
brainstormResult: null,
1257-
factoidsResult: null,
1258-
askMessages: [],
1259-
askStreamingAssistant: null,
1260-
// Drafts belong to the signed-in user too; the next person on this
1261-
// browser must not see them prefilled.
1262-
pendingFlowchartData: null,
1263-
generateForm: {},
1264-
attackForm: {},
1265-
brainstormForm: {},
1266-
factoidsForm: {},
1267-
}),
1292+
resetLocalData: () => set(sessionWorkForAccount(null)),
1293+
prepareForUser: (userId) => {
1294+
if (get().ownerUserId === userId) return;
1295+
set(sessionWorkForAccount(userId));
1296+
},
12681297

12691298
clearSession: () => set({
12701299
generateResult: null,
@@ -1290,6 +1319,7 @@ export const useSessionStore = create<SessionState>()(
12901319
// older clients: the server replaces them on hydrate, and keeping them
12911320
// would resurrect purged legacy rows and debate-less sources.
12921321
version: 1,
1322+
merge: mergePersistedSessionState,
12931323
migrate: (persistedState) => {
12941324
const state = { ...(persistedState as Record<string, unknown>) };
12951325
for (const key of [

‎src/lib/stores/settings-store.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type CoreFeatureTab = Exclude<FeatureTab, 'sparring'>;
2020
type FeatureConfigs = Record<CoreFeatureTab, ProviderConfig> & Partial<Record<'sparring', ProviderConfig>>;
2121

2222
interface SettingsState {
23+
/** Account these device settings belong to. Null until sign-in binds them. */
24+
ownerUserId: string | null;
2325
language: AppLanguage;
2426
outputLanguage: AppLanguage;
2527
theme: 'light' | 'dark' | 'system';
@@ -36,6 +38,8 @@ interface SettingsState {
3638
setWikiResearchEnabled: (enabled: boolean) => void;
3739
hydrateFromServer: () => Promise<void>;
3840
resetLocalData: (preserveLanguage?: boolean) => void;
41+
/** Drops another account's prompt and model choices before the server copy loads. */
42+
prepareForUser: (userId: string) => void;
3943
}
4044

4145
const DEFAULT_PROVIDER = 'openrouter';
@@ -205,9 +209,22 @@ async function persistMigratedFeatureConfigs(
205209
}
206210
}
207211

212+
/**
213+
* A rehydrate that finishes after sign-in must not restore another account's
214+
* prompt or model choices. Settings saved before an owner was recorded stay
215+
* only until this account is bound.
216+
*/
217+
export function mergePersistedSettingsState(persistedState: unknown, currentState: SettingsState): SettingsState {
218+
const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial<SettingsState> : {};
219+
const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null;
220+
if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState;
221+
return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId };
222+
}
223+
208224
export const useSettingsStore = create<SettingsState>()(
209225
persist(
210226
(set, get) => ({
227+
ownerUserId: null as string | null,
211228
language: 'en',
212229
outputLanguage: 'en',
213230
theme: 'system',
@@ -338,6 +355,7 @@ export const useSettingsStore = create<SettingsState>()(
338355
resetLocalData: (preserveLanguage = false) => {
339356
const language = preserveLanguage ? get().language : 'en';
340357
set({
358+
ownerUserId: null,
341359
language,
342360
outputLanguage: 'en',
343361
theme: 'system',
@@ -347,10 +365,20 @@ export const useSettingsStore = create<SettingsState>()(
347365
});
348366
void applyAppLanguage(language);
349367
},
368+
prepareForUser: (userId) => {
369+
if (get().ownerUserId === userId) return;
370+
set({
371+
ownerUserId: userId,
372+
customSystemPrompt: undefined,
373+
featureConfigs: getDefaultFeatureConfigs(),
374+
wikiResearchEnabled: false,
375+
});
376+
},
350377
}),
351378
{
352379
name: 'debate-trainer-settings',
353380
version: 2,
381+
merge: mergePersistedSettingsState,
354382
migrate: (persistedState: unknown, version) => {
355383
const saved = asRecord(persistedState);
356384
if (!saved) return persistedState;

‎src/lib/supabase/AuthProvider.tsx‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
9898
const [session, setSession] = useState<Session | null>(null);
9999
const [loading, setLoading] = useState(true);
100100
const hydrateSessionStore = useSessionStore((state) => state.hydrateFromServer);
101+
const prepareSessionStore = useSessionStore((state) => state.prepareForUser);
101102
const hydrateSettingsStore = useSettingsStore((state) => state.hydrateFromServer);
103+
const prepareSettingsStore = useSettingsStore((state) => state.prepareForUser);
102104
const hydrateSparringStore = useSparringStore((state) => state.hydrateSavedSessions);
103105
const prepareSparringStore = useSparringStore((state) => state.prepareForUser);
106+
const prepareDebateStore = useDebateStore((state) => state.prepareForUser);
104107
const resetSessionData = useSessionStore((state) => state.resetLocalData);
105108
// A tool still writing for the previous account stops with its data.
106109
const resetSessionStore = useCallback(() => {
@@ -120,7 +123,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
120123
const hydrateStores = (userId?: string) => {
121124
if (!userId || hydratedUserId === userId) return;
122125
hydratedUserId = userId;
126+
// Bind before any paint of protected pages. A full-page sign-in (magic
127+
// link, Google) never sees the previous account's in-tab sign-out, so
128+
// the persisted snapshot has to be checked against this user here.
129+
prepareSessionStore(userId);
130+
prepareSettingsStore(userId);
123131
prepareSparringStore(userId);
132+
prepareDebateStore(userId);
124133
Promise.resolve(hydrateSessionStore()).catch((err) => console.warn('[Auth] hydrateSessionStore failed:', err));
125134
Promise.resolve(hydrateSettingsStore()).catch((err) => console.warn('[Auth] hydrateSettingsStore failed:', err));
126135
Promise.resolve(hydrateSparringStore()).catch((err) => console.warn('[Auth] hydrateSparringStore failed:', err));
@@ -176,7 +185,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
176185
setSession(oauthSession);
177186
currentUserIdRef.current = oauthSession.user?.id;
178187
setJamUserId(oauthSession.user?.id);
179-
setLoading(false);
180188
hydrateStores(oauthSession.user?.id);
181189
return;
182190
}
@@ -224,7 +232,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
224232
setJamUserId(nextUserId);
225233
setActiveAccessToken(newSession?.access_token ?? null, newSession?.user.id ?? null);
226234
setSession(newSession);
227-
setLoading(false);
228235

229236
// Provider access tokens are short-lived. Keep them only for this browser
230237
// session; a later export must reauthorize if no fresh token is available.
@@ -241,6 +248,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
241248
resetDebateStore();
242249
useDebateContextStore.getState().resetLocalData();
243250
clearGoogleTokens();
251+
setLoading(false);
244252
return;
245253
}
246254

@@ -254,13 +262,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
254262
}
255263

256264
hydrateStores(nextUserId);
265+
setLoading(false);
257266
});
258267

259268
return () => {
260269
mounted = false;
261270
authListener.subscription.unsubscribe();
262271
};
263-
}, [hydrateDebateStore, hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareSparringStore, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]);
272+
}, [hydrateDebateStore, hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareDebateStore, prepareSessionStore, prepareSettingsStore, prepareSparringStore, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]);
264273

265274
// sessionStorage holds this tab's Google token and sparring state.
266275
const discardTabState = useCallback(() => {

0 commit comments

Comments
 (0)