diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index e0d727f56..e5ce02605 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -9,6 +9,7 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log - `auth-reject` shows the alert `Demo code not found.` for an unknown code and stays on `/login`. - `auth-signout` signs out from Settings, after which `/app/generate` redirects to `/login`. - `auth-refresh` refreshes an expired access token before an AI request. +- `auth-session-expired` signs out a session the server revoked: the stored tokens are refused, so the app goes to `/login` and shows `Your session expired. Sign in again.` once (`recipes/auth.spec.ts`). ## How to get to it (user POV) @@ -26,9 +27,11 @@ Preconditions: - **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`. - **Reject.** On a signed-out page, submit `DEMO-ZZZZ-ZZZZ`. The element with role `alert` has the text `Demo code not found.`, and the URL still matches `/login`. - **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`. +- **Revoked session.** Signed in on `/app`, replace `access_token` in the `debate-auth-local` localStorage entry with a JWT the server refuses, `refresh_token` with `x`, and `expires_at` with now + 3000 s, then reload. Within a few seconds the URL matches `/login` and a `status` element says `Your session expired. Sign in again.`. A second reload no longer shows it. ## Gotchas - A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab!`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. Interactive runs click `Skip Tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md). - Sessions are stored under the `debate-auth-local` localStorage key in local-preview mode. Don't reuse stub-session helpers (`e2e/helpers/auth.ts`). They fake auth and prove rendering only. - Google sign-in is not wired to the local stack. Only demo codes work here. +- The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session, and that path is covered by `src/test/auth/session-expiry.test.ts`, not by a recipe. diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index 3e93dabea..484933dd3 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -9,6 +9,9 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a - `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal. - `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution. - `cross-ai-questions` reverses the roles: the AI asks (`mode-ai-questions-user`), the user answers, and the AI follows up. +- `cross-daily-limit` with no AI requests left, the first AI turn is refused: the daily-limit dialog opens and the page stays in setup with no clock and no `end-crossfire`. +- `cross-end-early` ending a round before any exchange returns to setup with no error. +- `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests are fewer than the round needs. ## How to get to it (user POV) @@ -27,10 +30,12 @@ Preconditions: - **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. - **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired. - **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: , debates: {motion: }}]`. +- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows (what the browser counts). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says `You have no AI requests left today`. ## Gotchas - Opponent speeches carry a server-signed integrity token. Scoring fails if the client drops it, so a verdict proves the round-trip. - Jev is faked with a fixed confident answer. `Direct answer` proves the wiring, not the quality of the judgement. - Sparring AI calls happen once per opponent turn. Keep the per-step wait near 30 s rather than one long wait for the verdict. +- The browser's remaining-requests count leaves out `rate_limited` and `jev_*` rows and cannot see global `rate_limits` rows, so it warns and never blocks. The `ai` function's 402 decides. - In `mode-ai-questions-user` the opening question is requested as soon as the round starts, after the motion dialog. Create the `aiResponse(page, "crossfire", "next-turn")` promise before clicking `start-crossfire`. diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts index ac1dc992e..8a2b551ad 100644 --- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts @@ -37,3 +37,32 @@ test("auth-email-otp: an emailed one-time code signs an existing account in", as await expect(page).toHaveURL(/\/app/); await evidence.shot("02-signed-in-by-email"); }); + +test("auth-session-expired: a session the server revoked signs out to the login page with a message", async ({ signedIn: page, evidence }) => { + await page.goto("/app"); + await expect(page).toHaveURL(/\/app$/); + // Like an account deleted in another tab or a password reset: the stored + // tokens are still there, but the server refuses both of them. + await page.evaluate(() => { + const key = Object.keys(localStorage).find((name) => /^debate-auth-[a-z]+$/.test(name)); + if (!key) throw new Error("no stored session"); + const stored = JSON.parse(localStorage.getItem(key)!) as Record; + const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })).replace(/=+$/, ""); + const claims = btoa(JSON.stringify({ sub: "00000000-0000-4000-8000-000000000000", exp: Math.floor(Date.now() / 1000) + 3000 })).replace(/=+$/, ""); + localStorage.setItem(key, JSON.stringify({ + ...stored, + access_token: `${header}.${claims}.invalid-signature`, + refresh_token: "x", + expires_at: Math.floor(Date.now() / 1000) + 3000, + })); + }); + await evidence.step("Replaced the stored session with tokens the server refuses"); + await page.reload(); + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); + await expect(page.getByRole("status").filter({ hasText: "Your session expired. Sign in again." })).toBeVisible(); + await evidence.shot("01-login-session-expired"); + await evidence.aria("01-login-session-expired"); + // The notice is for this sign-out only. + await page.reload(); + await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0); +}); diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index 921d96625..c24431ef6 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -109,3 +109,54 @@ test("cross-ai-questions: the AI questions the user, answers are judged, and the await evidence.shot("02-debrief"); await expectCrossfireSaved(await user.userId()); }); + +/** + * Today's AI requests are used up: the server's counter (what the ai function + * checks) and the usage rows (what the browser counts). A refused call and a + * Jev helper row are on the list too, and must not count as used. + */ +async function useUpDailyRequests(userId: string, limit: number) { + await admin.from("user_settings").update({ daily_limit_override: limit }).eq("user_id", userId); + const today = new Date().toISOString().slice(0, 10); + const counter = await admin.from("daily_usage_counters").upsert({ user_id: userId, usage_date: today, quota_key: "ai", request_count: limit }); + expect(counter.error).toBeNull(); + const rows = [ + ...Array.from({ length: limit }, () => ({ user_id: userId, feature: "generate", provider: "openrouter", model: "fake", status: "success" })), + { user_id: userId, feature: "ask", provider: "openrouter", model: "fake", status: "rate_limited" }, + { user_id: userId, feature: "jev_model_router", provider: "openrouter", model: "fake", status: "success" }, + ]; + const usage = await admin.from("ai_requests").insert(rows); + expect(usage.error).toBeNull(); +} + +test("cross-daily-limit and spar-daily-limit: with no requests left, Crossfire stays in setup and Sparring warns first", async ({ signedIn: page, user, evidence }) => { + await useUpDailyRequests(await user.userId(), 2); + await evidence.step("Used up the account's 2 daily AI requests"); + + await startCrossfire(page, "mode-ai-questions-user"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(dialog).toContainText("2 of 2"); + await evidence.shot("01-crossfire-limit-dialog"); + await evidence.aria("01-crossfire-limit-dialog", dialog); + // The round never started: no clock, no End button, the setup kept. + await expect(page.getByTestId("end-crossfire")).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("start-crossfire")).toBeVisible(); + await expect(page.getByTestId("crossfire-resolution")).toHaveValue(resolution); + await evidence.shot("02-crossfire-back-in-setup"); + + await page.goto("/app/sparring"); + await expect(page.getByTestId("sparring-request-shortfall")).toContainText("You have no AI requests left today"); + await evidence.shot("03-sparring-warns"); + await evidence.aria("03-sparring-warns", page.getByTestId("sparring-request-shortfall")); +}); + +test("cross-end-early: ending a round before any exchange returns to setup without an error", async ({ signedIn: page, evidence }) => { + await startCrossfire(page, "mode-question-ai"); + await expect(page.getByTestId("end-crossfire")).toBeVisible(); + await page.getByTestId("end-crossfire").click(); + await expect(page.getByTestId("start-crossfire")).toBeVisible(); + await expect(page.getByText("Complete at least one exchange before ending the session.")).toHaveCount(0); + await evidence.shot("01-back-in-setup"); +}); diff --git a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json index 57c8d554e..81d809bbc 100644 --- a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json +++ b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json @@ -5,10 +5,10 @@ "actions": [ { "name": "type", - "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"", + "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"", "target": { "role": "textbox", - "name": "Ask your debate coach...", + "name": "Message to your debate coach", "placeholder": "Ask your debate coach..." }, "value": "How do I weigh access against cost in a rebuttal?" @@ -64,7 +64,7 @@ }, { "role": "textbox", - "name": "Ask your debate coach...", + "name": "Message to your debate coach", "placeholder": "Ask your debate coach..." } ], diff --git a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json index ea2c6442a..4b5b04e69 100644 --- a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json +++ b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json @@ -5,10 +5,10 @@ "actions": [ { "name": "type", - "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"", + "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"", "target": { "role": "textbox", - "name": "Ask your debate coach...", + "name": "Message to your debate coach", "placeholder": "Ask your debate coach..." }, "value": "How do I weigh access against cost in a rebuttal?" diff --git a/docs/architecture.md b/docs/architecture.md index 5673502de..d3ebf3e95 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,10 @@ The repository is an npm workspace. The web app is the root package and still li [`src/lib/supabase/AuthProvider.tsx`](../src/lib/supabase/AuthProvider.tsx) coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are. +Discovered-source autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) keeps one Library row per page per debate. It matches on a normalized URL (host without `www.`, no scheme, fragment, tracking parameters or trailing slash) and refreshes the existing row's research metadata instead of inserting a second one. Batches run one at a time within a tab; two tabs can still race, which only a unique index would stop. + +[`src/hooks/use-subscription.ts`](../src/hooks/use-subscription.ts) estimates today's remaining AI requests from the account's `ai_requests` rows (refused calls and `jev_*` helper rows excluded) and its own `rate_limits` override. Global `rate_limits` rows are server-only, so the estimate only warns (Sparring setup) and informs (Settings); it is null, never blocking, when any read fails. The `ai` function's 402 stays the decision, and every tool shows it with the same daily-limit dialog. + ## Language behavior [`packages/core/src/languages.ts`](../packages/core/src/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and ``. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages. @@ -49,7 +53,7 @@ Interface language and AI-output language are separate settings. Interface langu [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. Admins switch projects from Settings; see [operations.md](operations.md#switching-the-frontend-to-staging). -[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. +[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. Its fetch notices when a signed-in request is refused (401 from REST or Functions, 401 or 403 from Auth's user lookup). [`session-expiry.ts`](../src/lib/supabase/session-expiry.ts) then forces a token refresh outside that request: if Auth refuses the refresh too, the session was revoked, so the browser signs out locally and the login page says the session expired. A network failure, 5xx or rate limit keeps the session. Client-side environment selection is a developer convenience, not authorization. Edge Functions and database policies must enforce access independently. 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/App.tsx b/src/App.tsx index f2ac09a73..50896a629 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { StagingBanner } from "@/components/StagingBanner"; import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; import { routeLoaders, type RouteLoader } from "@/lib/route-modules"; import type { PrerenderedRoute } from "@/lib/seo/pages"; +import { usePrivateRouteHead } from "@/lib/seo/use-page-head"; import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton"; import { useTranslation } from "react-i18next"; import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config"; @@ -212,6 +213,7 @@ function RouteContent({ children }: { children: ReactNode }) { const fallback = getRouteFallback(location.pathname); const label = fallback.labelKey ? t(fallback.labelKey, fallback.label) : fallback.label; const statusText = t("common.openingPage", { page: label }); + usePrivateRouteHead(location.pathname, label); return ( diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index b786bd6e1..8b5b465bc 100644 --- a/src/components/features/ask/AskChat.tsx +++ b/src/components/features/ask/AskChat.tsx @@ -779,6 +779,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis value={input} onChange={(e) => setInput(e.target.value)} placeholder={isListening ? t('askChat.placeholderListening') : t('askChat.placeholder')} + aria-label={t('askChat.inputLabel')} className="flex-1" /> {isLoading && onStop ? ( diff --git a/src/components/features/attack/AttackForm.tsx b/src/components/features/attack/AttackForm.tsx index c14d08d26..ad0a8f6bd 100644 --- a/src/components/features/attack/AttackForm.tsx +++ b/src/components/features/attack/AttackForm.tsx @@ -162,20 +162,24 @@ export function AttackForm({ onSubmit, isLoading, importedCase, debateContext =
- -

{t('attackForm.detectFallaciesDesc')}

+ +

{t('attackForm.detectFallaciesDesc')}

setValue('detectFallacies', checked)} />
- -

{t('attackForm.analyzeWeighingDesc')}

+ +

{t('attackForm.analyzeWeighingDesc')}

setValue('analyzeWeighing', checked)} /> diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx index ada909e88..df651d15e 100644 --- a/src/components/features/sparring/SparringSetup.tsx +++ b/src/components/features/sparring/SparringSetup.tsx @@ -39,6 +39,8 @@ interface SparringSetupProps { /** Fill the motion from the Current Debate. */ debatePrefill?: boolean; onStart: (values: SparringSetupValues) => void; + /** AI requests left today, or null when unknown. A round short of requests gets a warning. */ + remainingRequests?: number | null; isStarting?: boolean; initialValues?: Partial; } @@ -49,7 +51,7 @@ const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [ { id: 'hard', name: 'Ruthless' }, ]; -export function SparringSetup({ onStart, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) { +export function SparringSetup({ onStart, remainingRequests = null, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) { const { t } = useTranslation(); const [motion, setMotion] = useState(initialValues?.motion ?? ''); const motionRef = useRef(motion); @@ -66,6 +68,8 @@ export function SparringSetup({ onStart, isStarting = false, initialValues, bann const plannedTurns = useMemo(() => buildSparringTurns(format, userSide), [format, userSide]); const aiRequests = plannedTurns.filter((turn) => turn.speaker === 'ai').length; const totalRequests = aiRequests + 1; + // An estimate (the server can raise a limit the browser cannot see), so it warns rather than blocks. + const requestShortfall = remainingRequests !== null && remainingRequests < totalRequests; const canStart = motion.trim().length >= 8 && !isStarting && isOnline; const handleSubmit = (event: React.FormEvent) => { @@ -255,6 +259,13 @@ export function SparringSetup({ onStart, isStarting = false, initialValues, bann {isStarting ? t('sparring.setup.openingRoom', 'Opening room…') : t('sparring.setup.startRound', 'Start round')} {!isStarting &&
+
); } diff --git a/src/pages/FlowchartPage.tsx b/src/pages/FlowchartPage.tsx index 97239ef82..d33da9586 100644 --- a/src/pages/FlowchartPage.tsx +++ b/src/pages/FlowchartPage.tsx @@ -105,6 +105,23 @@ const strengthTranslationKeys = { weak: 'flowchart.strengthWeak', }; +// React Flow 11 writes its keyboard help for nodes and edges in English, with no +// prop to change it (v12's ariaLabelConfig has one). That text is static, so +// React never writes it again after mount; the translated help takes its place. +function LocalizedA11yDescriptions(): null { + const { t } = useTranslation(); + const rfId = useStore((state) => state.rfId); + useEffect(() => { + const replace = (id: string, text: string) => { + const element = document.getElementById(id); + if (element && element.textContent !== text) element.textContent = text; + }; + replace(`react-flow__node-desc-${rfId}`, t('flowchart.a11yNodeHelp')); + replace(`react-flow__edge-desc-${rfId}`, t('flowchart.a11yEdgeHelp')); + }, [rfId, t]); + return null; +} + // React Flow's own control buttons have fixed English names, so the canvas // renders the same four with translated ones. function LocalizedControls() { @@ -269,6 +286,18 @@ export default function FlowchartPage() { const [nodes, setNodes, onNodesChange] = useNodesState(createInitialNodes(t)); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + // Screen readers name an edge by the nodes it joins, not by internal ids. + // Only the canvas gets these; saved charts keep plain edges. + // Keyed on ids and titles, so dragging a node does not rebuild every edge. + const nodeTitlesKey = JSON.stringify(nodes.map((node) => [node.id, (node.data as FlowNodeData | undefined)?.label ?? ''])); + const nodeTitles = useMemo(() => new Map(JSON.parse(nodeTitlesKey) as Array<[string, string]>), [nodeTitlesKey]); + const labelledEdges = useMemo(() => edges.map((edge) => ({ + ...edge, + ariaLabel: t('flowchart.edgeAriaLabel', { + source: nodeTitles.get(edge.source) || edge.source, + target: nodeTitles.get(edge.target) || edge.target, + }), + })), [edges, nodeTitles, t]); // Save/Load dialog states const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); @@ -792,7 +821,7 @@ export default function FlowchartPage() { zoomOnScroll={false} preventScrolling={false} nodes={nodes} - edges={edges} + edges={labelledEdges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} @@ -804,6 +833,7 @@ export default function FlowchartPage() { attributionPosition="bottom-left" > + { diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 27357364d..b586b7c0a 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -74,14 +74,15 @@ function SourceRow({ {source.type === 'url' ? : }
-
+ {/* On a phone the title gets two lines of its own; one truncated line left a few letters. */} +
@@ -91,13 +92,14 @@ function SourceRow({ target="_blank" rel="noopener noreferrer" onClick={(event) => event.stopPropagation()} - className="shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-primary" + className="mt-0.5 shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-primary sm:mt-0" aria-label={t('libraryPage.openOrigin', { title: source.title, defaultValue: 'Open {{title}} in a new tab' })} >
@@ -135,7 +137,7 @@ function SourceRow({
event.stopPropagation()}> + {/* On touch the switch's own tap area grows to 44px tall, like the Checkbox's. */} onToggle(source.id)} aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })} 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.')}

-
); } @@ -379,6 +391,7 @@ export default function SparringPage() { onRetryAi={retryAi} onRetryJudging={retryJudging} /> +
); } diff --git a/src/test/auth/login-otp.test.tsx b/src/test/auth/login-otp.test.tsx new file mode 100644 index 000000000..c299d9bd0 --- /dev/null +++ b/src/test/auth/login-otp.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthApiError } from '@supabase/supabase-js'; +import LoginPage from '@/pages/LoginPage'; + +const auth = vi.hoisted(() => ({ + signInWithEmailOtp: vi.fn(async () => undefined), + verifyEmailOtp: vi.fn(async () => undefined), +})); + +vi.mock('@/lib/supabase/AuthProvider', () => ({ + useAuth: () => ({ + user: null as null, + signInWithGoogle: vi.fn(), + signInWithEmailOtp: auth.signInWithEmailOtp, + verifyEmailOtp: auth.verifyEmailOtp, + signInWithDemoCode: vi.fn(), + }), +})); + +async function requestCode() { + render(); + fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send code' })); + return await screen.findByLabelText('Verification code'); +} + +describe('email code entry', () => { + it('keeps only digits and enables Verify at exactly six', async () => { + const input = await requestCode(); + const verify = screen.getByRole('button', { name: 'Verify code' }); + + fireEvent.change(input, { target: { value: '12ab' } }); + expect(input).toHaveValue('12'); + expect(verify).toBeDisabled(); + + fireEvent.change(input, { target: { value: '12345' } }); + expect(verify).toBeDisabled(); + + // A pasted code with a space still fills all six digits. + fireEvent.change(input, { target: { value: '123 4567' } }); + expect(input).toHaveValue('123456'); + expect(verify).toBeEnabled(); + + fireEvent.click(verify); + await waitFor(() => expect(auth.verifyEmailOtp).toHaveBeenCalledWith('student@example.com', '123456')); + }); + + it('says a refused code may be wrong, not only expired', async () => { + auth.verifyEmailOtp.mockRejectedValueOnce(new AuthApiError('Token has expired or is invalid', 403, 'otp_expired')); + const input = await requestCode(); + + fireEvent.change(input, { target: { value: '000000' } }); + fireEvent.click(screen.getByRole('button', { name: 'Verify code' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('That code is wrong or has expired. Check it or send a new one.'); + }); +}); diff --git a/src/test/auth/session-expiry.test.ts b/src/test/auth/session-expiry.test.ts new file mode 100644 index 000000000..bce80c2f0 --- /dev/null +++ b/src/test/auth/session-expiry.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthApiError, AuthRetryableFetchError, AuthSessionMissingError } from '@supabase/supabase-js'; +import { isAuthRejection, setActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client'; +import { + clearSessionExpiredNotice, + resetSessionExpiryCheck, + sessionExpiredNotice, + verifySessionAfterRejection, +} from '@/lib/supabase/session-expiry'; + +function fakeAuth(refreshError: unknown) { + return { + refreshSession: vi.fn(async () => ({ data: { user: null, session: null }, error: refreshError })), + signOut: vi.fn(async () => ({ error: null })), + }; +} + +describe('a refused session', () => { + beforeEach(() => { + resetSessionExpiryCheck(); + clearSessionExpiredNotice(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + setActiveAccessToken(null, null); + }); + + it('counts only auth refusals, not policy answers', () => { + expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/user`, 403)).toBe(true); + expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/user`, 401)).toBe(true); + expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts?select=*`, 401)).toBe(true); + expect(isAuthRejection(`${SUPABASE_URL}/functions/v1/announcements`, 401)).toBe(true); + // RLS refusals and server errors are not a dead session. + expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts`, 403)).toBe(false); + expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts`, 503)).toBe(false); + // The check itself calls these; counting them would loop. + expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, 400)).toBe(false); + expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/logout?scope=local`, 401)).toBe(false); + }); + + it('signs out locally and leaves a note when Auth refuses the refresh too', async () => { + const auth = fakeAuth(new AuthApiError('Invalid Refresh Token: Refresh Token Not Found', 400, 'refresh_token_not_found')); + + await expect(verifySessionAfterRejection(auth)).resolves.toBe('expired'); + + expect(auth.signOut).toHaveBeenCalledWith({ scope: 'local' }); + expect(sessionExpiredNotice()).toBe(true); + }); + + it('treats a session missing from storage as expired', async () => { + const auth = fakeAuth(new AuthSessionMissingError()); + await expect(verifySessionAfterRejection(auth)).resolves.toBe('expired'); + expect(auth.signOut).toHaveBeenCalledOnce(); + }); + + it.each([ + ['an Auth outage', new AuthRetryableFetchError('Service Unavailable', 503)], + ['a rate limit', new AuthApiError('Too many requests', 429, 'over_request_rate_limit')], + ['an Auth server error', new AuthApiError('Internal error', 500, 'unexpected_failure')], + ])('keeps the session on %s', async (_label, error) => { + const auth = fakeAuth(error); + + await expect(verifySessionAfterRejection(auth)).resolves.toBe('kept'); + + expect(auth.signOut).not.toHaveBeenCalled(); + expect(sessionExpiredNotice()).toBe(false); + }); + + it('keeps a session whose refresh works, and does not refresh again for the rest of the burst', async () => { + let now = 1_000_000; + const auth = fakeAuth(null); + + await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); + now += 9_000; + await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('skipped'); + now += 2_000; + await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); + + expect(auth.refreshSession).toHaveBeenCalledTimes(2); + expect(auth.signOut).not.toHaveBeenCalled(); + }); + + it('runs one check at a time', async () => { + let finish!: () => void; + const auth = { + refreshSession: vi.fn(() => new Promise<{ data: { user: null; session: null }; error: null }>((resolve) => { + finish = () => resolve({ data: { user: null, session: null }, error: null }); + })), + signOut: vi.fn(), + }; + + const first = verifySessionAfterRejection(auth); + await expect(verifySessionAfterRejection(auth)).resolves.toBe('skipped'); + finish(); + await expect(first).resolves.toBe('kept'); + expect(auth.refreshSession).toHaveBeenCalledOnce(); + }); + + it('checks the session after a refused signed-in request, and ignores signed-out ones', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{"message":"JWT expired"}', { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }))); + const refresh = vi.spyOn(supabase.auth, 'refreshSession').mockResolvedValue({ data: { user: null, session: null }, error: null } as never); + + setActiveAccessToken(null, null); + await supabase.from('flowcharts').select('id'); + await vi.runAllTimersAsync(); + expect(refresh).not.toHaveBeenCalled(); + + setActiveAccessToken('revoked-token', 'user-1'); + await supabase.from('flowcharts').select('id'); + // Not inside the request: supabase-js may still hold its session lock there. + expect(refresh).not.toHaveBeenCalled(); + await vi.runAllTimersAsync(); + expect(refresh).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/crossfire/crossfire-daily-limit.test.tsx b/src/test/crossfire/crossfire-daily-limit.test.tsx new file mode 100644 index 000000000..0d9d2f186 --- /dev/null +++ b/src/test/crossfire/crossfire-daily-limit.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + crossfire: vi.fn<(input: { operation: string }) => Promise>(), +})); + +vi.mock('@/lib/supabase/AuthProvider', () => ({ + useAuth: () => ({ user: { id: 'user-a' } }), +})); + +vi.mock('@/hooks/use-subscription', () => ({ + useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }), +})); + +vi.mock('@/lib/stores/session-store', () => ({ + useSessionStore: (selector: (state: { sourceLibrary: unknown[] }) => unknown) => selector({ sourceLibrary: [] }), +})); + +vi.mock('@/lib/stores/settings-store', () => ({ + useSettingsStore: () => ({ + getFeatureConfig: () => ({ provider: 'google', model: 'gemini-3-flash-preview' }), + outputLanguage: 'en', + }), +})); + +vi.mock('@/lib/ai/client', async () => { + const errors = await vi.importActual('@/lib/ai/errors'); + return { + AIResponseError: errors.AIResponseError, + AIProviderError: errors.AIProviderError, + AISubscriptionError: errors.AISubscriptionError, + isAISubscriptionError: errors.isAISubscriptionError, + isAIModelRestrictionError: errors.isAIModelRestrictionError, + crossfire: mocks.crossfire, + crossfireSignal: vi.fn(() => Promise.resolve(undefined)), + isTimeoutError: () => false, + }; +}); + +import CrossfirePage from '@/pages/CrossfirePage'; +import { AISubscriptionError } from '@/lib/ai/client'; +import { enableOneOffMode } from '@/test/helpers/debate'; + +function outOfRequests() { + return new AISubscriptionError("You've used all 5 of your daily requests. Upgrade to Pro for 50 daily requests.", { + statusCode: 402, + code: 'limit_reached', + limit: 5, + current: 5, + }); +} + +function startRound(mode: 'question-ai' | 'ai-questions-user') { + render(); + fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } }); + fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } }); + fireEvent.click(screen.getByTestId(`mode-${mode}`)); + fireEvent.click(screen.getByTestId('start-crossfire')); +} + +describe('Crossfire out of daily requests', () => { + beforeEach(() => { + enableOneOffMode(); + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('does not start the round or its clock when the first AI turn is refused, and opens the daily-limit dialog', async () => { + mocks.crossfire.mockRejectedValueOnce(outOfRequests()); + startRound('ai-questions-user'); + + expect(await screen.findByRole('dialog')).toHaveTextContent('5 of 5'); + expect(screen.queryByTestId('end-crossfire')).not.toBeInTheDocument(); + expect(screen.getByTestId('start-crossfire')).toBeInTheDocument(); + expect(screen.getByTestId('crossfire-resolution')).toHaveValue('Resolved: test'); + }); + + it('keeps a round that already has an exchange, so it can still be ended and debriefed', async () => { + mocks.crossfire + .mockResolvedValueOnce({ type: 'question', text: 'Who pays the cost?' }) + .mockRejectedValueOnce(outOfRequests()); + startRound('ai-questions-user'); + + fireEvent.change(await screen.findByTestId('crossfire-input'), { target: { value: 'The travellers do.' } }); + fireEvent.click(screen.getByTestId('submit-turn')); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + // Inline too, in the dialog's words rather than the server's English. + // The open dialog hides the page from the accessibility tree. + const inline = screen.getAllByRole('alert', { hidden: true }).find((alert) => alert.textContent?.includes('5 of 5 included AI uses')); + expect(inline).toBeDefined(); + expect(screen.queryByText(/Upgrade to Pro for 50 daily requests/)).not.toBeInTheDocument(); + expect(screen.getByTestId('end-crossfire')).toBeEnabled(); + }); + + it('lets a round with no exchange be ended by hand without an error', async () => { + mocks.crossfire.mockImplementation(() => new Promise(() => undefined)); + startRound('ai-questions-user'); + + fireEvent.click(await screen.findByTestId('end-crossfire')); + + expect(screen.getByTestId('start-crossfire')).toBeInTheDocument(); + expect(screen.queryByText('Complete at least one exchange before ending the session.')).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/CrossfireVoice.test.tsx b/src/test/pages/CrossfireVoice.test.tsx index 75c62e1a6..ef311c5fd 100644 --- a/src/test/pages/CrossfireVoice.test.tsx +++ b/src/test/pages/CrossfireVoice.test.tsx @@ -34,6 +34,8 @@ vi.mock('@/lib/ai/client', () => ({ crossfire: mocks.crossfire, crossfireSignal: mocks.crossfireSignal, isTimeoutError: () => false, + isAISubscriptionError: () => false, + isAIModelRestrictionError: () => false, })); vi.mock('@/lib/voice/opponent-voice', () => ({ diff --git a/src/test/pages/SparringVoice.test.tsx b/src/test/pages/SparringVoice.test.tsx index 376b8e359..fd1317350 100644 --- a/src/test/pages/SparringVoice.test.tsx +++ b/src/test/pages/SparringVoice.test.tsx @@ -24,6 +24,12 @@ vi.mock('@/lib/supabase/client', () => ({ vi.mock('@/lib/ai/client', () => ({ generateSparringTurn: generateMock, judgeSparringSession: judgeMock, + isAISubscriptionError: () => false, + isAIModelRestrictionError: () => false, +})); + +vi.mock('@/hooks/use-subscription', () => ({ + useSubscription: () => ({ remainingToday: null as number | null }), })); vi.mock('@/lib/voice/opponent-voice', () => ({