From a891d40aec20cf393415c50f2aaed2c1b84a3d45 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:31:07 +0000 Subject: [PATCH 01/12] Sign out a revoked session and fix the email code check (H1, M4) H1: when Supabase refuses a signed-in request (401 from REST or Functions, 401/403 from Auth's user lookup), force a token refresh outside the request. If Auth refuses that too, sign this browser out locally and show "Your session expired. Sign in again." on /login. Outages, 5xx and rate limits keep the session. M4: the code field keeps digits only, Verify waits for six digits, and a refused code says it may be wrong or expired. Co-Authored-By: Claude Opus 5.5 --- e2e/helpers/auth.ts | 10 +++ src/lib/supabase/client.ts | 43 +++++++++- src/lib/supabase/session-expiry.ts | 84 ++++++++++++++++++ src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/hu.json | 3 +- src/pages/LoginPage.tsx | 23 ++++- src/test/auth/login-otp.test.tsx | 59 +++++++++++++ src/test/auth/session-expiry.test.ts | 122 +++++++++++++++++++++++++++ 9 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 src/lib/supabase/session-expiry.ts create mode 100644 src/test/auth/login-otp.test.tsx create mode 100644 src/test/auth/session-expiry.test.ts diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 4a6e17918..f325f7b3d 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -43,6 +43,16 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {} } }, { key: `debate-auth-${env}`, value: session }); + // The bogus JWT makes data requests fail with 401, and the client then + // forces a token refresh to tell a revoked session from a stale one. Accept + // that refresh with the same stub session, so a render check keeps its + // session and never asks the real Auth server about it. + await page.route((url) => url.pathname.endsWith('/auth/v1/token') && url.searchParams.get('grant_type') === 'refresh_token', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ...session, expires_at: Math.floor(Date.now() / 1000) + HOUR_SECONDS }), + })); + // Credential-free render checks must not call the production announcement // function. Tests that exercise announcements register a more specific // route after this default. diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 1f68f532a..56b32021f 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -5,12 +5,51 @@ import { FunctionsRelayError, } from '@supabase/supabase-js'; import { getActiveEnv, SUPABASE_URL, SUPABASE_ANON_KEY } from './env'; +import { verifySessionAfterRejection } from './session-expiry'; export { SUPABASE_URL }; /** Where supabase-js keeps this project's session; each project has its own. */ export const AUTH_STORAGE_KEY = `debate-auth-${getActiveEnv()}`; +let activeAccessToken: string | null = null; +let activeUserId: string | null = null; + +/** + * Whether a Supabase answer says the server refused this browser's session. + * A 403 from REST is a policy answer, not an auth one, so only Auth's own + * user lookup counts 403. The token and logout endpoints are left out: the + * check that follows a rejection calls them itself. + */ +export function isAuthRejection(url: string, status: number): boolean { + let path: string; + try { + path = new URL(url, SUPABASE_URL).pathname; + } catch { + return false; + } + if (path === '/auth/v1/user') return status === 401 || status === 403; + if (path.startsWith('/rest/v1/') || path.startsWith('/functions/v1/')) return status === 401; + return false; +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + return input.url; +} + +// A signed-in request the server refused may mean a revoked session. supabase-js +// holds its session lock while some of these requests run, so the check (which +// takes that lock) is queued for after the request instead of run inside it. +const watchedFetch: typeof fetch = async (input, init) => { + const response = await globalThis.fetch(input, init); + if (activeAccessToken && isAuthRejection(requestUrl(input), response.status)) { + setTimeout(() => void verifySessionAfterRejection(supabase.auth), 0); + } + return response; +}; + export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: true, @@ -20,11 +59,9 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { flowType: 'pkce', storageKey: AUTH_STORAGE_KEY, }, + global: { fetch: watchedFetch }, }); -let activeAccessToken: string | null = null; -let activeUserId: string | null = null; - export function setActiveAccessToken(accessToken: string | null, userId: string | null = null): void { activeAccessToken = accessToken; activeUserId = userId; diff --git a/src/lib/supabase/session-expiry.ts b/src/lib/supabase/session-expiry.ts new file mode 100644 index 000000000..fab29a27e --- /dev/null +++ b/src/lib/supabase/session-expiry.ts @@ -0,0 +1,84 @@ +import { isAuthApiError, isAuthRetryableFetchError, isAuthSessionMissingError } from '@supabase/supabase-js'; +import { getActiveEnv } from './env'; + +// Beside the session itself (client.ts's AUTH_STORAGE_KEY), per project. +const EXPIRED_FLAG_KEY = `debate-auth-${getActiveEnv()}-expired`; +// After the server accepts a refresh, later refusals in the same burst are the +// old token's requests landing late. Don't refresh again for each of them. +const RECHECK_AFTER_MS = 10_000; + +type SessionAuth = { + refreshSession: () => Promise<{ error: unknown }>; + signOut: (options: { scope: 'local' }) => Promise; +}; + +/** + * Whether a failed forced refresh means the session is gone for good (revoked, + * deleted account, reset password) rather than Auth being unreachable. Network + * failures, 5xx and rate limits keep the session: that is an outage. + */ +export function refreshMeansRevoked(error: unknown): boolean { + if (!error) return false; + if (isAuthRetryableFetchError(error)) return false; + if (isAuthSessionMissingError(error)) return true; + if (isAuthApiError(error)) return error.status >= 400 && error.status < 500 && error.status !== 429; + return false; +} + +let checking = false; +let confirmedAt = Number.NEGATIVE_INFINITY; + +/** + * A request was refused for auth. Ask Auth for a new token: if it refuses that + * too, the session is dead, so sign this browser out and leave a note for the + * login page. Run it outside any supabase-js request (client.ts queues it). + */ +export async function verifySessionAfterRejection(auth: SessionAuth, now: () => number = Date.now): Promise<'kept' | 'expired' | 'skipped'> { + if (checking || now() - confirmedAt < RECHECK_AFTER_MS) return 'skipped'; + checking = true; + try { + const { error } = await auth.refreshSession(); + if (!error) confirmedAt = now(); + if (!refreshMeansRevoked(error)) return 'kept'; + markSessionExpired(); + // Local scope: the server already dropped this session, and other devices are not ours to sign out. + await auth.signOut({ scope: 'local' }); + return 'expired'; + } catch (error) { + console.warn('[Auth] Session check after a refused request failed:', error); + return 'kept'; + } finally { + checking = false; + } +} + +/** Test hook: forget the last confirmed refresh. */ +export function resetSessionExpiryCheck(): void { + checking = false; + confirmedAt = Number.NEGATIVE_INFINITY; +} + +function markSessionExpired(): void { + try { + sessionStorage.setItem(EXPIRED_FLAG_KEY, '1'); + } catch { + // Storage blocked: the login page just won't say why. + } +} + +/** Whether this tab was signed out because the server refused its session. */ +export function sessionExpiredNotice(): boolean { + try { + return sessionStorage.getItem(EXPIRED_FLAG_KEY) === '1'; + } catch { + return false; + } +} + +export function clearSessionExpiredNotice(): void { + try { + sessionStorage.removeItem(EXPIRED_FLAG_KEY); + } catch { + // Nothing to clear. + } +} diff --git a/src/locales/en.json b/src/locales/en.json index 8ad79672d..9267fdf85 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -545,6 +545,7 @@ "failed": "Could not start sign in", "tryAgain": "Please try again.", "backHome": "Back to DebateLab", + "sessionExpired": "Your session expired. Sign in again.", "errors": { "demoNotFound": "Demo code not found.", "demoUnusable": "This demo code cannot be used.", @@ -552,7 +553,7 @@ "demoExpired": "This demo code has expired.", "demoUsedUp": "This demo code has no uses left.", "rateLimited": "Too many attempts. Try again later.", - "codeExpired": "That code has expired. Send a new one.", + "codeExpired": "That code is wrong or has expired. Check it or send a new one.", "network": "Can't reach DebateLab. Check your connection and try again." } }, diff --git a/src/locales/es.json b/src/locales/es.json index e775892d9..a23fccd47 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -545,6 +545,7 @@ "failed": "No se pudo iniciar sesión", "tryAgain": "Inténtalo de nuevo.", "backHome": "Volver a DebateLab", + "sessionExpired": "Tu sesión ha caducado. Vuelve a iniciar sesión.", "errors": { "demoNotFound": "No se encontró el código de demostración.", "demoUnusable": "Este código de demostración no se puede usar.", @@ -552,7 +553,7 @@ "demoExpired": "Este código de demostración ha caducado.", "demoUsedUp": "Este código de demostración ya no tiene usos.", "rateLimited": "Demasiados intentos. Inténtalo más tarde.", - "codeExpired": "Ese código ha caducado. Solicita uno nuevo.", + "codeExpired": "Ese código es incorrecto o ha caducado. Revísalo o solicita uno nuevo.", "network": "No se puede conectar con DebateLab. Revisa tu conexión e inténtalo de nuevo." } }, diff --git a/src/locales/hu.json b/src/locales/hu.json index d685cb600..9132668a8 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -545,6 +545,7 @@ "failed": "Nem sikerült elindítani a bejelentkezést", "tryAgain": "Próbáld újra.", "backHome": "Vissza a DebateLabhez", + "sessionExpired": "A munkameneted lejárt. Jelentkezz be újra.", "errors": { "demoNotFound": "Nincs ilyen demókód.", "demoUnusable": "Ez a demókód nem használható.", @@ -552,7 +553,7 @@ "demoExpired": "Ez a demókód lejárt.", "demoUsedUp": "Ezzel a demókóddal már nem lehet többször belépni.", "rateLimited": "Túl sok próbálkozás. Próbáld újra később.", - "codeExpired": "Ez a kód lejárt. Kérj újat.", + "codeExpired": "Ez a kód hibás vagy lejárt. Ellenőrizd, vagy kérj újat.", "network": "A DebateLab nem érhető el. Ellenőrizd a kapcsolatot, és próbáld újra." } }, diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index cf2931dab..f07fcf9f7 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -11,12 +11,19 @@ import { useAuth } from '@/lib/supabase/AuthProvider'; import { useToast } from '@/hooks/use-toast'; import { consumeWorkspaceInvite } from '@/lib/workspace/invite-handoff'; import { localizeAuthError } from '@/lib/auth-errors'; +import { clearSessionExpiredNotice, sessionExpiredNotice } from '@/lib/supabase/session-expiry'; type AuthMode = 'email' | 'demo'; type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error'; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/; +const OTP_LENGTH = 6; + +/** Email codes are digits only. Pasted spaces, dashes and letters never reach Auth. */ +function otpDigits(value: string): string { + return value.replace(/\D/g, '').slice(0, OTP_LENGTH); +} function isAuthMode(value: string): value is AuthMode { return value === 'email' || value === 'demo'; @@ -32,6 +39,8 @@ export default function LoginPage() { const [emailSent, setEmailSent] = useState(false); const [demoCode, setDemoCode] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + // Set when the server refused this tab's session and it was signed out. + const [sessionExpired] = useState(sessionExpiredNotice); const otpInputRef = useRef(null); const emailInputRef = useRef(null); const demoInputRef = useRef(null); @@ -45,6 +54,10 @@ export default function LoginPage() { if (user) navigate(consumeWorkspaceInvite() ?? from, { replace: true }); }, [user, navigate, from]); + useEffect(() => { + if (sessionExpired) clearSessionExpiredNotice(); + }, [sessionExpired]); + useEffect(() => { if (authStatus === 'code-sent') otpInputRef.current?.focus(); }, [authStatus]); @@ -179,6 +192,11 @@ export default function LoginPage() { {t('login.subtitle')} + {sessionExpired && ( +

+ {t('login.sessionExpired', 'Your session expired. Sign in again.')} +

+ )} @@ -212,9 +230,8 @@ export default function LoginPage() { autoComplete="one-time-code" placeholder={t('login.verificationCodePlaceholder', '123456')} pattern="[0-9]*" - maxLength={6} value={otp} - onChange={(event) => setOtp(event.target.value)} + onChange={(event) => setOtp(otpDigits(event.target.value))} aria-describedby={describedBy('login-otp-hint', 'login-otp-error')} aria-invalid={authStatus === 'error'} disabled={isSubmitting || authStatus === 'success'} @@ -224,7 +241,7 @@ export default function LoginPage() { {t('login.codeSentDesc', 'Check your email for the six-digit code.')}

- + {requestShortfall && ( +

+ {remainingRequests === 0 + ? t('dailyLimit.sparringNone') + : t('dailyLimit.sparringShort', { needed: totalRequests, remaining: remainingRequests })} +

+ )} {!isOnline &&

{t('sparring.setup.offlineCannotStart', 'Reconnect to start a round. Your setup is kept while you are offline.')}

}

{t('sparring.setup.draftNotice', 'Your draft is saved locally as you type. You can pause without losing your place.')}

diff --git a/src/components/features/subscription/SubscriptionErrorModals.tsx b/src/components/features/subscription/SubscriptionErrorModals.tsx new file mode 100644 index 000000000..782fc46f5 --- /dev/null +++ b/src/components/features/subscription/SubscriptionErrorModals.tsx @@ -0,0 +1,29 @@ +import type { useSubscriptionError } from '@/hooks/use-subscription-error'; +import { LimitReachedModal } from './LimitReachedModal'; +import { ModelRestrictedModal } from './ModelRestrictedModal'; +import { PricingModal } from './PricingModal'; + +/** + * The daily-limit, Pro-model and pricing dialogs a tool opens from useSubscriptionError. + * Each mounts only while open, so a tool that never hits a limit never loads plan data for them. + */ +export function SubscriptionErrorModals({ subError }: { subError: ReturnType }) { + return ( + <> + {subError.limitModalOpen && { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} + />} + {subError.modelModalOpen && { subError.setModelModalOpen(false); subError.setPricingModalOpen(true); }} + />} + {subError.pricingModalOpen && } + + ); +} diff --git a/src/components/features/subscription/index.ts b/src/components/features/subscription/index.ts index 91600de15..a5ac7f632 100644 --- a/src/components/features/subscription/index.ts +++ b/src/components/features/subscription/index.ts @@ -1,3 +1,4 @@ export { PricingModal } from './PricingModal'; export { LimitReachedModal } from './LimitReachedModal'; export { ModelRestrictedModal } from './ModelRestrictedModal'; +export { SubscriptionErrorModals } from './SubscriptionErrorModals'; diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts index 4a599dee2..6119fca68 100644 --- a/src/hooks/use-subscription.ts +++ b/src/hooks/use-subscription.ts @@ -12,10 +12,14 @@ interface SubscriptionState { hasStripeBilling: boolean; dailyLimit: number; dailyUsed: number; + /** AI requests left today, or null while it is unknown (loading, failed read, signed out). Never block on null. */ + remainingToday: number | null; trialEndsAt: string | null; gracePeriodEndsAt: string | null; loading: boolean; refresh: () => Promise; + /** Re-reads usage and returns what is left today, or null when it cannot be read. */ + checkRemainingToday: () => Promise; } interface BillingRow { @@ -24,7 +28,9 @@ interface BillingRow { current_period_end?: string | null; } -type SubscriptionSnapshot = Omit; +type SubscriptionSnapshot = Omit & { + usageKnown: boolean; +}; const SUBSCRIPTION_STALE_TIME_MS = 60_000; const SIGNED_OUT_SNAPSHOT: SubscriptionSnapshot = { @@ -32,6 +38,7 @@ const SIGNED_OUT_SNAPSHOT: SubscriptionSnapshot = { hasStripeBilling: false, dailyLimit: 5, dailyUsed: 0, + usageKnown: false, trialEndsAt: null, gracePeriodEndsAt: null, }; @@ -40,6 +47,7 @@ const SIGNED_IN_FALLBACK: SubscriptionSnapshot = { hasStripeBilling: false, dailyLimit: 0, dailyUsed: 0, + usageKnown: false, trialEndsAt: null, gracePeriodEndsAt: null, }; @@ -111,24 +119,51 @@ async function fetchSubscription(userId: string): Promise } } + // The server lets a per-account rate_limits row replace the plan's limit + // (getConfiguredDailyLimit). Global rows are server-only, so the remaining + // count is an estimate: warn with it, and let the server's 402 decide. + let limitKnown = true; + if (data && !(snapshot.tier === 'expired' && snapshot.dailyLimit === 0)) { + const { data: ownLimit, error: limitError } = await supabase + .from('rate_limits') + .select('limit_value') + .eq('user_id', userId) + .eq('limit_type', 'daily_requests') + .order('updated_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (limitError) limitKnown = false; + else if (typeof ownLimit?.limit_value === 'number') snapshot.dailyLimit = ownLimit.limit_value; + } + const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); + // The server counts one request per AI call it lets through (consume_daily_quota). + // Each of those logs one row under its feature; refused calls and the + // jev_* helper steps inside a call log rows too, but use no quota. const { count, error: usageError } = await supabase .from('ai_requests') .select('*', { count: 'exact', head: true }) .eq('user_id', userId) - .gte('created_at', todayStart.toISOString()); + .gte('created_at', todayStart.toISOString()) + .neq('status', 'rate_limited') + .not('feature', 'like', 'jev_%'); if (usageError) { console.warn('[useSubscription] Failed to load daily usage:', usageError.message); } - return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 }; + return { ...snapshot, dailyUsed: usageError ? 0 : count || 0, usageKnown: !usageError && limitKnown && Boolean(data) }; } catch (error) { console.warn('[useSubscription] Failed to load:', error); throw error; } } +function remainingRequests(snapshot: SubscriptionSnapshot, usageKnown: boolean): number | null { + if (!usageKnown) return null; + return Math.max(0, snapshot.dailyLimit - snapshot.dailyUsed); +} + export function useSubscription(): SubscriptionState { const { user } = useAuth(); const userId = user?.id; @@ -142,6 +177,7 @@ export function useSubscription(): SubscriptionState { ? query.isError ? SIGNED_IN_FALLBACK : query.data ?? SIGNED_IN_FALLBACK : SIGNED_OUT_SNAPSHOT; const { tier, dailyLimit, dailyUsed, trialEndsAt, gracePeriodEndsAt, hasStripeBilling } = snapshot; + const usageKnown = Boolean(userId) && !query.isError && snapshot.usageKnown; const isPro = tier === 'pro' || tier === 'legacy_free'; const isTrialActive = tier === 'trial'; @@ -155,6 +191,7 @@ export function useSubscription(): SubscriptionState { hasStripeBilling, dailyLimit, dailyUsed, + remainingToday: remainingRequests(snapshot, usageKnown), trialEndsAt, gracePeriodEndsAt, loading: Boolean(userId) && query.isPending, @@ -162,5 +199,10 @@ export function useSubscription(): SubscriptionState { if (!userId) return; await query.refetch(); }, + checkRemainingToday: async () => { + if (!userId) return null; + const result = await query.refetch(); + return result.isError || !result.data ? null : remainingRequests(result.data, result.data.usageKnown); + }, }; } diff --git a/src/locales/en.json b/src/locales/en.json index 9267fdf85..bf0b2bcdc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1351,6 +1351,12 @@ "upgrade": "Upgrade to Pro - $5/mo", "useFree": "Use low reasoning instead" }, + "dailyLimit": { + "reachedInline": "You've used today's AI requests. They refresh at midnight UTC.", + "usage": "Today: {{used}} of {{limit}} AI requests used. They refresh at midnight UTC ({{time}} your time).", + "sparringNone": "You have no AI requests left today, so the opponent can't answer. They refresh at midnight UTC.", + "sparringShort": "This round needs {{needed}} AI requests and you have {{remaining}} left today, so it would stop before the judge. They refresh at midnight UTC." + }, "pricingModal": { "title": "Upgrade to Pro", "subtitle": "Unlock higher usage limits, all 10 models, and configurable reasoning.", diff --git a/src/locales/es.json b/src/locales/es.json index a23fccd47..1fdef627f 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1351,6 +1351,12 @@ "upgrade": "Actualizar a Pro - $5/mes", "useFree": "Usar razonamiento bajo" }, + "dailyLimit": { + "reachedInline": "Has usado tus solicitudes de IA de hoy. Se renuevan a medianoche UTC.", + "usage": "Hoy: {{used}} de {{limit}} solicitudes de IA usadas. Se renuevan a medianoche UTC ({{time}} en tu hora).", + "sparringNone": "No te quedan solicitudes de IA hoy, así que el oponente no podrá responder. Se renuevan a medianoche UTC.", + "sparringShort": "Esta ronda necesita {{needed}} solicitudes de IA y hoy te quedan {{remaining}}, así que se detendría antes del juez. Se renuevan a medianoche UTC." + }, "pricingModal": { "title": "Actualizar a Pro", "subtitle": "Desbloquea límites de uso más altos, los 10 modelos y razonamiento configurable.", diff --git a/src/locales/hu.json b/src/locales/hu.json index 9132668a8..eaf1bfcec 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1351,6 +1351,12 @@ "upgrade": "Frissítés Pro-ra - $5/hó", "useFree": "Alacsony szintű érvelés használata" }, + "dailyLimit": { + "reachedInline": "Elhasználtad a mai MI-kéréseidet. Éjfélkor (UTC) frissülnek.", + "usage": "Ma: {{used}} / {{limit}} MI-kérés felhasználva. Éjfélkor (UTC) frissülnek (nálad {{time}}).", + "sparringNone": "Mára nem maradt MI-kérésed, így az ellenfél nem tud válaszolni. Éjfélkor (UTC) frissülnek.", + "sparringShort": "Ehhez a körhöz {{needed}} MI-kérés kell, de ma csak {{remaining}} maradt, így a bíró előtt megállna. Éjfélkor (UTC) frissülnek." + }, "pricingModal": { "title": "Frissítés Pro-ra", "subtitle": "Oldd fel a magasabb használati keretet, mind a 10 modellt és a beállítható érvelést.", diff --git a/src/pages/CrossfirePage.tsx b/src/pages/CrossfirePage.tsx index 8cf65f17c..87f821346 100644 --- a/src/pages/CrossfirePage.tsx +++ b/src/pages/CrossfirePage.tsx @@ -23,7 +23,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { crossfire, crossfireSignal, AIResponseError, isTimeoutError } from '@/lib/ai/client'; +import { crossfire, crossfireSignal, AIResponseError, isAISubscriptionError, isTimeoutError } from '@/lib/ai/client'; import { getUserFacingAIError } from '@/lib/ai/error-display'; import { canEndCrossfireSession, @@ -75,6 +75,8 @@ import { VoiceModeToggle } from '@/components/features/voice/VoiceModeToggle'; import { useVoiceAvailability } from '@/lib/voice/availability'; import { combineDelivery, measureDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; +import { useSubscriptionError } from '@/hooks/use-subscription-error'; +import { SubscriptionErrorModals } from '@/components/features/subscription'; const SESSION_ID_PREFIX = 'crossfire'; @@ -183,6 +185,7 @@ function classifyError(error: unknown): { } { if (isTimeoutError(error)) return { kind: 'timeout', message: error.message }; if (error instanceof AIResponseError) return { kind: 'response', message: error.message }; + if (isAISubscriptionError(error) && error.code === 'limit_reached') return { kind: 'request', message: 'dailyLimit.reachedInline' }; return { kind: 'request', message: getUserFacingAIError(error) }; } @@ -314,6 +317,8 @@ export default function CrossfirePage() { const workspace = useWorkspaceToolScope('crossfire'); const { t } = useTranslation(); const { user } = useAuth(); + const subError = useSubscriptionError(); + const { handleSubscriptionError } = subError; const location = useLocation(); const navigate = useNavigate(); const workspaceLaunch = (location.state as { workspaceLaunch?: unknown } | null)?.workspaceLaunch === true; @@ -512,12 +517,27 @@ export default function CrossfirePage() { } } catch (error) { if (controller.signal.aborted) return; + // Out of requests or not allowed this model: the same dialog every tool uses. + if (handleSubscriptionError(error) && snapshot.turns.length === 0) { + // Nothing was exchanged, so the round never really started: no clock, back to setup. + opponentVoice.stop(); + useDebateContextStore.getState().resetOverride('crossfire'); + dispatch({ + type: 'session-reset', + prefill: { + resolution: snapshot.config.resolution, + argument: snapshot.config.argument, + seedQuestions: snapshot.config.seedQuestions, + }, + }); + return; + } const classified = classifyError(error); dispatch({ type: 'request-failed', expectedReply, errorKind: classified.kind, message: classified.message }); } finally { if (requestRef.current === controller) setLiveReply(''); } - }, [modelConfig, roundDebateId, voiceActive, voiceLanguage, workspace.context]); + }, [handleSubscriptionError, modelConfig, roundDebateId, voiceActive, voiceLanguage, workspace.context]); const runDebrief = useCallback(async (snapshot: Extract) => { requestRef.current?.abort(); @@ -573,7 +593,8 @@ export default function CrossfirePage() { seedQuestions: state.config.seedQuestions, }, }); - setFormError(t('crossfire.debriefEmptyDesc')); + // Ending by hand before any exchange just leaves; only the clock running out needs explaining. + setFormError(reason === 'timer' ? t('crossfire.debriefEmptyDesc') : ''); return; } const next = crossfireReducer(state, { type: 'session-ended', now: Date.now(), reason }); @@ -697,6 +718,7 @@ export default function CrossfirePage() {