diff --git a/src/lib/stores/debate-context-store.ts b/src/lib/stores/debate-context-store.ts index ea743827d..874216127 100644 --- a/src/lib/stores/debate-context-store.ts +++ b/src/lib/stores/debate-context-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; import type { DebateContextFeature, DebateContextOutputKind, @@ -118,8 +119,7 @@ export const useDebateContextStore = create()((set, get) => set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, !pinned) } })); const save = async (): Promise<{ error: { message?: string; code?: string } | null }> => { if (generation !== pinGeneration) return { error: null }; - const { data } = await supabase.auth.getUser(); - const userId = data.user?.id; + const userId = await currentUserId(); if (generation !== pinGeneration) return { error: null }; if (!userId) return { error: { message: 'Sign in to pin results' } }; return pinned diff --git a/src/lib/stores/debate-store.ts b/src/lib/stores/debate-store.ts index ea536e501..ae608af02 100644 --- a/src/lib/stores/debate-store.ts +++ b/src/lib/stores/debate-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; /** A favor, En contra, both sides, or not side-specific. */ export type DebateSide = 'pro' | 'con' | 'both' | 'neutral'; @@ -95,8 +96,7 @@ function sortDebates(debates: Debate[]): Debate[] { } async function requireUserId(): Promise { - const { data } = await supabase.auth.getUser(); - const userId = data.user?.id; + const userId = await currentUserId(); if (!userId) throw new Error('Sign in to manage debates'); return userId; } @@ -126,10 +126,10 @@ export const useDebateStore = create()( hydrate: async () => { const epoch = hydrationEpoch; - const { data: { user } } = await supabase.auth.getUser(); - if (epoch !== hydrationEpoch || !user) return; - if (get().ownerUserId !== user.id) { - set({ ownerUserId: user.id, debates: [], currentDebateId: null, oneOff: false, hydrated: false }); + const userId = await currentUserId(); + if (epoch !== hydrationEpoch || !userId) return; + if (get().ownerUserId !== userId) { + set({ ownerUserId: userId, debates: [], currentDebateId: null, oneOff: false, hydrated: false }); } const writesBefore = debateWrites; const [debatesResult, currentResult] = await Promise.all([ diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 51539f496..fe8f7db79 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -7,6 +7,7 @@ import type { BrainstormOutput, BrainstormInput } from '../ai/schemas/brainstorm import type { FactoidsOutput, FactoidsInput } from '../ai/schemas/factoids'; import type { AskOutput } from '../ai/schemas/ask'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; import i18n from '@/i18n'; import { activeDebateId, useDebateStore, type DebateSide } from './debate-store'; @@ -350,8 +351,11 @@ function mergeSourceFolders(currentFolders: string[], sources: Source[] = []) { } async function getUserId() { - const { data } = await supabase.auth.getUser(); - return data.user?.id || null; + return await currentUserId(); +} + +function signInRequired(action: string): Error { + return new Error(`Sign in to ${action}`); } type EqQuery = { @@ -574,13 +578,7 @@ export const useSessionStore = create()( const place = (list: Source[], item: Source) => restoredId ? [...list, item].sort((x, y) => y.createdAt - x.createdAt) : [item, ...list]; const userId = await getUserId(); - if (!userId) { - set((state) => ({ - sourceFolders: mergeSourceFolders(state.sourceFolders, [localItem]), - sourceLibrary: place(state.sourceLibrary, localItem), - })); - return; - } + if (!userId) throw signInRequired('save this source'); const folderResult = await supabase .from('source_folders') @@ -625,14 +623,17 @@ export const useSessionStore = create()( }, createSourceFolder: async (folder) => { const normalized = normalizeFolder(folder); + const userId = await getUserId(); + if (!userId) throw signInRequired('save this folder'); + throwOnWriteError( + await supabase + .from('source_folders') + .upsert({ user_id: userId, name: normalized }, { onConflict: 'user_id,name' }), + 'save source folder', + ); set((state) => ({ sourceFolders: mergeSourceFolders([...state.sourceFolders, normalized], state.sourceLibrary), })); - const userId = await getUserId(); - if (!userId) return; - await supabase - .from('source_folders') - .upsert({ user_id: userId, name: normalized }, { onConflict: 'user_id,name' }); }, updateSource: async (id, patch) => { const current = get().sourceLibrary.find((s) => s.id === id); @@ -660,7 +661,7 @@ export const useSessionStore = create()( try { const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('save this source'); if (nextSource.folder) { const folderResult = await supabase .from('source_folders') @@ -725,7 +726,7 @@ export const useSessionStore = create()( set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => updates.get(source.id) ?? source) })); try { const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('save this source'); await Promise.all(Array.from(updates.values()).map(async (source) => { const result = await applyUserScopedIdFilter( supabase.from('sources').update({ metadata: source.metadata ?? null }), @@ -761,7 +762,7 @@ export const useSessionStore = create()( }; const userId = await getUserId(); - if (!userId) return applyRename(); + if (!userId) throw signInRequired('rename this folder'); const folderResult = await supabase .from('source_folders') .upsert({ user_id: userId, name: to }, { onConflict: 'user_id,name' }); @@ -802,7 +803,7 @@ export const useSessionStore = create()( }; const userId = await getUserId(); - if (!userId) return applyDelete(); + if (!userId) throw signInRequired('delete this folder'); if (affected.length > 0) { throwOnWriteError( await supabase.from('sources').update({ folder: 'General' }).eq('user_id', userId).eq('debate_id', debateId).eq('folder', folder), @@ -822,13 +823,12 @@ export const useSessionStore = create()( const debateId = useDebateStore.getState().currentDebateId; if (!debateId) throw new DebateRequiredError(); const userId = await getUserId(); - if (userId) { - throwOnWriteError( - await supabase.from('sources').update({ is_active: isActive }) - .eq('user_id', userId).eq('debate_id', debateId).eq('folder', normalized), - 'update source folder activity', - ); - } + if (!userId) throw signInRequired('update this folder'); + throwOnWriteError( + await supabase.from('sources').update({ is_active: isActive }) + .eq('user_id', userId).eq('debate_id', debateId).eq('folder', normalized), + 'update source folder activity', + ); set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => source.debateId === debateId && normalizeFolder(source.folder) === normalized ? { ...source, isActive } : source @@ -837,10 +837,9 @@ export const useSessionStore = create()( }, removeSource: async (id) => { const userId = await getUserId(); - if (userId) { - const result = await applyUserScopedIdFilter(supabase.from('sources').delete(), userId, id); - throwOnWriteError(result, 'delete source'); - } + if (!userId) throw signInRequired('delete this source'); + const result = await applyUserScopedIdFilter(supabase.from('sources').delete(), userId, id); + throwOnWriteError(result, 'delete source'); set((state) => ({ sourceLibrary: state.sourceLibrary.filter((source) => source.id !== id) })); }, toggleSource: async (id) => { @@ -848,12 +847,11 @@ export const useSessionStore = create()( if (!current) return; const nextValue = !current.isActive; const userId = await getUserId(); - if (userId) { - throwOnWriteError( - await applyUserScopedIdFilter(supabase.from('sources').update({ is_active: nextValue }), userId, id), - 'toggle source activity', - ); - } + if (!userId) throw signInRequired('update this source'); + throwOnWriteError( + await applyUserScopedIdFilter(supabase.from('sources').update({ is_active: nextValue }), userId, id), + 'toggle source activity', + ); set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive: nextValue } : source), })); @@ -887,12 +885,8 @@ export const useSessionStore = create()( saveCurrentCase: async (title) => { const current = get().generateResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedCases: [localItem, ...state.savedCases] })); - return; - } + if (!userId) throw signInRequired('save this case'); const { data, error } = await supabase .from('cases') .insert({ user_id: userId, title, input: {}, output: current }) @@ -948,12 +942,8 @@ export const useSessionStore = create()( saveCurrentAttack: async (title) => { const current = get().attackResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedAttacks: [localItem, ...state.savedAttacks] })); - return; - } + if (!userId) throw signInRequired('save this attack'); const { data, error } = await supabase .from('attacks') .insert({ user_id: userId, title, input: {}, output: current }) @@ -980,12 +970,8 @@ export const useSessionStore = create()( saveCurrentBrainstorm: async (title) => { const current = get().brainstormResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedBrainstorms: [localItem, ...state.savedBrainstorms] })); - return; - } + if (!userId) throw signInRequired('save this brainstorm'); const { data, error } = await supabase .from('brainstorms') .insert({ user_id: userId, title, input: {}, output: current }) @@ -1012,12 +998,8 @@ export const useSessionStore = create()( saveCurrentFactoids: async (title) => { const current = get().factoidsResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedFactoids: [localItem, ...state.savedFactoids] })); - return; - } + if (!userId) throw signInRequired('save these factoids'); const { data, error } = await supabase .from('factoids') .insert({ user_id: userId, title, input: {}, output: current }) @@ -1050,12 +1032,8 @@ export const useSessionStore = create()( saveCurrentChat: async (title) => { const current = get().askMessages; if (current.length === 0) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedChats: [localItem, ...state.savedChats] })); - return; - } + if (!userId) throw signInRequired('save this chat'); const { data: chat, error } = await supabase .from('chats') .insert({ user_id: userId, title }) @@ -1084,12 +1062,8 @@ export const useSessionStore = create()( }, saveFlowchart: async (title, data, debateId = activeDebateId()) => { - const localItem = { id: generateId(), data, createdAt: Date.now(), title, debateId, side: 'neutral' as const }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedFlowcharts: [localItem, ...state.savedFlowcharts] })); - return; - } + if (!userId) throw signInRequired('save this flowchart'); const { data: saved, error } = await supabase .from('flowcharts') .insert({ @@ -1118,7 +1092,7 @@ export const useSessionStore = create()( recordResult: async (kind, { title, input, output, debateId, side }) => { const userId = await getUserId(); - if (!userId) throw new Error('[SessionStore] Sign in to save results to a debate'); + if (!userId) throw signInRequired('save results to a debate'); const { data, error } = await supabase .from(RESULT_TABLES[kind]) .insert({ user_id: userId, debate_id: debateId, side, title: title.slice(0, 300) || 'Untitled', input: input ?? {}, output }) @@ -1135,7 +1109,7 @@ export const useSessionStore = create()( updateRecordedResult: async (kind, id, output) => { const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('update this result'); throwOnWriteError( await applyUserScopedIdFilter(supabase.from(RESULT_TABLES[kind]).update({ output }), userId, id), `update ${kind}`, @@ -1389,7 +1363,7 @@ async function syncAskChatNow(debateId: string | null): Promise { if (askMessages.length === 0 || askPersistedCount >= askMessages.length) return; if (!askChatId && !debateId) return; const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('save this chat'); // Clearing or replacing the conversation while a write is in flight must not // hand this chat or its count to the next conversation. const sameConversation = () => { diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 15f53330d..f254bcad1 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -14,6 +14,7 @@ import { } from '../../../supabase/functions/_shared/model-catalog'; import type { ProviderConfig } from '../ai/providers/types'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; type CoreFeatureTab = Exclude; @@ -164,8 +165,7 @@ async function canAccessProModels(settings: Record): Promise()( hydrateSavedSessions: async () => { if (get().hydrated) return; const epoch = hydrationEpoch; - const { data: { user } } = await supabase.auth.getUser(); + const userId = await currentUserId(); if (epoch !== hydrationEpoch) return; - if (!user) { + if (!userId) { set({ hydrated: true }); return; } @@ -505,8 +506,8 @@ export const useSparringStore = create()( deleteSavedSession: async (id) => { const previous = get().savedSessions; set({ savedSessions: previous.filter((session) => session.id !== id) }); - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; + const userId = await currentUserId(); + if (!userId) return; const { error } = await supabase.from('sparring_sessions').delete().eq('id', id); if (error) { set({ savedSessions: previous }); diff --git a/src/lib/supabase/current-user.ts b/src/lib/supabase/current-user.ts new file mode 100644 index 000000000..19897d1e6 --- /dev/null +++ b/src/lib/supabase/current-user.ts @@ -0,0 +1,40 @@ +import { getActiveUserId, supabase } from './client'; + +/** + * Who is signed in, without treating an Auth-server failure as signed out. + * + * `auth.getUser()` asks the server. When that call fails, the old stores saved + * only in this browser and told the student the save worked. The id AuthProvider + * already has, then the local session, still name the user so the write can go + * to the database (or fail in the open). + */ +function rememberedUserId(): string | null { + // Some unit tests replace the client module and omit this export. Reading a + // missing mock export throws; that is the same as "nothing remembered". + try { + if (typeof getActiveUserId !== 'function') return null; + return getActiveUserId(); + } catch { + return null; + } +} + +export async function currentUserId(): Promise { + const active = rememberedUserId(); + if (active) return active; + + const auth = supabase.auth; + if (typeof auth?.getUser === 'function') { + try { + const { data, error } = await auth.getUser(); + if (!error && data.user?.id) return data.user.id; + } catch { + // The Auth server did not answer. Fall through to the local session. + } + } + + if (typeof auth?.getSession !== 'function') return null; + const { data, error } = await auth.getSession(); + if (error) return null; + return data.session?.user?.id ?? null; +} diff --git a/src/locales/en.json b/src/locales/en.json index 7a2b4ac96..9243559a7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1601,6 +1601,7 @@ "newFolderPlaceholder": "Folder name...", "newFolderHint": "Need a new folder? Use the \"New folder\" button up top.", "createFolder": "Create folder", + "saveFailed": "Could not save this source.", "sortBy": "Sort by", "sortSmart": "Smart", "sortMostUsed": "Most used", diff --git a/src/locales/es.json b/src/locales/es.json index e03254f8e..cc3e613de 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1599,6 +1599,7 @@ "newFolderPlaceholder": "Nombre de carpeta...", "newFolderHint": "¿Necesitas una carpeta nueva? Usa el botón \"Nueva carpeta\" arriba.", "createFolder": "Crear carpeta", + "saveFailed": "No se pudo guardar esta fuente.", "sortBy": "Ordenar por", "sortSmart": "Inteligente", "sortMostUsed": "Más usadas", diff --git a/src/locales/hu.json b/src/locales/hu.json index 46a0e4953..9f3486986 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1599,6 +1599,7 @@ "newFolderPlaceholder": "Mappa neve...", "newFolderHint": "Új mappára van szükség? Használd a felső „Új mappa” gombot.", "createFolder": "Mappa létrehozása", + "saveFailed": "Ezt a forrást nem sikerült menteni.", "sortBy": "Rendezés", "sortSmart": "Intelligens", "sortMostUsed": "Legtöbbet használt", diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 9853279c4..27357364d 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSessionStore, type Source } from '@/lib/stores/session-store'; +import { DebateRequiredError, useSessionStore, type Source } from '@/lib/stores/session-store'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -243,7 +243,18 @@ export default function LibraryPage() { if (errors.title) return addTitleRef.current?.focus(); if (errors.content) return addContentRef.current?.focus(); // The Library always shows the Current Debate, even while tools run one-off. - await addSource({ ...newSource, debateId: currentDebate?.id ?? null }); + try { + await addSource({ ...newSource, debateId: currentDebate?.id ?? null }); + } catch (error) { + toast({ + title: t('libraryPage.saveFailed', 'Could not save this source.'), + description: error instanceof DebateRequiredError + ? t('debate.sourceNeedsDebate', 'Start or select a debate before saving sources.') + : undefined, + variant: 'destructive', + }); + return; + } setNewSource({ title: '', content: '', type: 'text', origin: '', folder: newSource.folder }); setIsAdding(false); }; diff --git a/src/test/stores/session-save-auth.test.ts b/src/test/stores/session-save-auth.test.ts new file mode 100644 index 000000000..fd53ab74d --- /dev/null +++ b/src/test/stores/session-save-auth.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setActiveAccessToken, supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate'; + +const DEBATE_ID = TEST_DEBATE_ID; + +function sourceRow() { + return { + id: '22222222-2222-4222-8222-222222222222', + title: 'School wifi notes', + content: 'The packet was lost.', + type: 'text', + origin: null as string | null, + folder: 'General', + metadata: null as null, + debate_id: DEBATE_ID, + is_active: true, + created_at: '2026-10-22T00:00:00.000Z', + }; +} + +describe('saves when the Auth server is down', () => { + afterEach(() => { + vi.restoreAllMocks(); + setActiveAccessToken(null, null); + resetDebates(); + useSessionStore.setState({ sourceLibrary: [], sourceFolders: ['General'], savedCases: [] }); + }); + + it('uses the local session when getUser fails', async () => { + setActiveAccessToken(null, null); + vi.spyOn(supabase.auth, 'getUser').mockResolvedValue({ + data: { user: null }, + error: { message: 'Auth unavailable', name: 'AuthRetryableFetchError', status: 503 }, + } as never); + vi.spyOn(supabase.auth, 'getSession').mockResolvedValue({ + data: { session: { user: { id: 'user-1' } } }, + error: null, + } as never); + + await expect(currentUserId()).resolves.toBe('user-1'); + expect(supabase.auth.getUser).toHaveBeenCalled(); + }); + + it('writes a source to the database instead of pretending a local copy was saved', async () => { + setActiveAccessToken('token', 'user-1'); + seedCurrentDebate(); + const getUser = vi.spyOn(supabase.auth, 'getUser').mockRejectedValue(new Error('503')); + const insert = vi.fn(() => ({ + select: () => ({ + single: async (): Promise<{ data: ReturnType; error: null }> => ({ data: sourceRow(), error: null }), + }), + })); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => { + if (table === 'source_folders') { + return { upsert: async (): Promise<{ error: null }> => ({ error: null }) } as never; + } + if (table === 'sources') return { insert } as never; + throw new Error(`unexpected table ${table}`); + }); + + await useSessionStore.getState().addSource({ title: 'School wifi notes', content: 'The packet was lost.', type: 'text' }); + + expect(getUser).not.toHaveBeenCalled(); + expect(insert).toHaveBeenCalledOnce(); + expect(useSessionStore.getState().sourceLibrary.map((source) => source.id)).toEqual([sourceRow().id]); + }); + + it('reports failure instead of keeping a source that only exists in the browser', async () => { + setActiveAccessToken(null, null); + seedCurrentDebate(); + vi.spyOn(supabase.auth, 'getUser').mockResolvedValue({ data: { user: null }, error: null } as never); + vi.spyOn(supabase.auth, 'getSession').mockResolvedValue({ data: { session: null }, error: null } as never); + const from = vi.spyOn(supabase, 'from'); + + await expect(useSessionStore.getState().addSource({ + title: 'School wifi notes', + content: 'The packet was lost.', + type: 'text', + })).rejects.toThrow(/Sign in to save this source/); + + expect(from).not.toHaveBeenCalled(); + expect(useSessionStore.getState().sourceLibrary).toEqual([]); + }); +});