From 4f1d0c4441946cc740710448a5e4bf9b988265d4 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 01:43:13 +0000 Subject: [PATCH] Fix login-screen UX: resend countdown, validation, demo-code mask, phone layout (T3) Resend is disabled with a countdown after each code is sent, kept in sessionStorage per address so a reload of /login keeps it. A 429 that names a wait says how long. Sending to the same address again within the wait opens the code step without a request. Each send clears old errors and notices. Sending shows a spinner and, after 4 s, says it can take a few seconds; one click makes one request, and verify and resend use the address the code went to. The forms are noValidate, so only the app's localized messages show. An empty email says it is required, and an over-long one says so instead of being cut. The demo-code field formats input as DEMO-XXXX-XXXX and gives a format error. On phones the card starts near the top. /login, RequireAuth's loading view and the auth callback views each render one
. Fixes AUTH-04, AUTH-05, AUTH-09, AUTH-10, AUTH-11, AUTH-12, VS-02, AX-06. AUTH-03 and AUTH-14 were already fixed on main; a verify recipe now covers both on the local stack. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 2 +- .../features/auth-demo-login.md | 2 + .../verify-debatelab/recipes/auth.spec.ts | 58 +++- src/components/auth/RequireAuth.tsx | 12 +- src/lib/auth-errors.ts | 20 ++ src/lib/login-form.ts | 94 ++++++ src/locales/en.json | 9 + src/locales/es.json | 9 + src/locales/hu.json | 9 + src/pages/AuthCallbackPage.tsx | 15 +- src/pages/LoginPage.tsx | 268 +++++++++++++----- src/test/auth/login-form.test.tsx | 179 ++++++++++++ src/test/auth/login-otp.test.tsx | 5 +- 13 files changed, 604 insertions(+), 78 deletions(-) create mode 100644 src/lib/login-form.ts create mode 100644 src/test/auth/login-form.test.tsx diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 9061397b3..24b834fdd 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -39,7 +39,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- ## Features -- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), and a second account on the same browser (`recipes/auth.spec.ts`). +- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), a signed-out deep link through a wrong code, the resend countdown and back, and a second account on the same browser (`recipes/auth.spec.ts`). - [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, building a flowchart from the case, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`). - [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`). - [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, and with web search (`recipes/ai-tools.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index c6c94c13d..c9ae6bf3f 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -11,6 +11,7 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log - `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`). - `auth-email-link` opens the sign-in link from the email after a resend failed with 429. It lands on `/app` through `/auth/callback` without showing `/login` on the way, and the same link opened again shows `This sign-in link has expired or was already used. Send a new code.` on `/login` (`recipes/auth.spec.ts`). +- `auth-deep-link-email-code` opens `/app/generate` signed out, lands on `/login` (one `main`, an `h1`), sends a code, and sees `Resend in 0:NN` disabled. A wrong code shows `That code is wrong or has expired. Check it or send a new one.`. After the countdown it resends (`New code sent`, the old error gone), and the new code returns to `/app/generate` (`recipes/auth.spec.ts`). - `auth-switch-account` signs out from Settings (the logout request has `scope=local`) and signs a second account in on the same `/login` page. It lands on `/app`, not on Settings, and gets its own onboarding tour. On phones the menu still opens and navigates while the tour shows (`recipes/auth.spec.ts`). ## How to get to it (user POV) @@ -30,6 +31,7 @@ Preconditions: - **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`. - **Email link.** Local email templates are code-only, so the recipe builds the link prod's email carries: `${LOCAL_SUPABASE_URL}/auth/v1/verify?token=pkce_&type=magiclink&redirect_to=`. The `redirect_to` is `http://127.0.0.1:18081/auth/callback?sb_flow_id=<32 hex>`. Route `**/auth/v1/otp**` to a 429 before clicking `Resend code` to reproduce the failed resend. +- **Resend countdown.** After each send, `Resend code` is disabled and named `Resend in 0:NN` for a minute (or as long as a 429 asked). The deadline is in sessionStorage under `debatelab:otp-resend-until:`; set it to `0` to end the wait, and the button turns back within a second (`expireResendCooldown` in `recipes/auth.spec.ts`). Sending again to the same address before then opens the code step without a request. - **Switch account.** After `Sign out`, sign the second account in on the same `/login` page. `page.goto("/login")` drops the history state that the bug depended on. Create the second user with `createDemoUser` and delete it in `finally`. - **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. diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts index e13607fb0..2db719125 100644 --- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts @@ -8,8 +8,8 @@ import { createDemoUser, debateControl, deleteDemoUser, expect, test } from "./f const mailer = process.env.WORKSPACE_TEST_MAILER_URL; const supabaseUrl = process.env.LOCAL_SUPABASE_URL; -/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox. */ -async function readOtp(request: APIRequestContext, email: string, sentAfter: number) { +/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox; `not` skips an older code. */ +async function readOtp(request: APIRequestContext, email: string, sentAfter: number, not?: string) { let otp = ""; await expect.poll(async () => { const payload = await (await request.get(`${mailer}/api/v1/messages`)).json() as { messages?: Array> }; @@ -18,6 +18,7 @@ async function readOtp(request: APIRequestContext, email: string, sentAfter: num if (Date.parse(String(message.Created ?? 0)) < sentAfter - 5_000) continue; const detail = await (await request.get(`${mailer}/api/v1/message/${message.ID}`)).text(); otp = detail.match(/\b(\d{6})\b/)?.[1] ?? ""; + if (otp === not) otp = ""; if (otp) break; } return otp; @@ -25,6 +26,17 @@ async function readOtp(request: APIRequestContext, email: string, sentAfter: num return otp; } +/** + * Ends the Resend countdown (AUTH-04) without waiting a minute. LoginPage reads + * the stored deadline every second, so the button turns back within one tick. + */ +async function expireResendCooldown(page: Page) { + await page.evaluate(() => { + for (const key of Object.keys(sessionStorage)) if (key.startsWith("debatelab:otp-resend-until:")) sessionStorage.setItem(key, "0"); + }); + await expect(page.getByRole("button", { name: "Resend code" })).toBeEnabled({ timeout: 3_000 }); +} + /** Every path this tab shows, kept across reloads, so a flash of /login is caught. */ async function recordPaths(page: Page) { await page.addInitScript(() => { @@ -114,6 +126,8 @@ test("auth-email-link: the emailed sign-in link signs in after a failed resend, await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=")}`); // AUTH-01: the resend hits Auth's rate limit, and auth-js drops that resend's verifier. + await expect(page.getByRole("button", { name: /^Resend in 0:\d\d$/ })).toBeDisabled(); + await expireResendCooldown(page); await page.route("**/auth/v1/otp**", (route) => route.fulfill({ status: 429, contentType: "application/json", @@ -156,6 +170,46 @@ test("auth-email-link: the emailed sign-in link signs in after a failed resend, await evidence.step("Opened the used link again; /login says it expired or was used"); }); +test("auth-deep-link-email-code: a signed-out deep link signs in by email code and returns to the page", async ({ page, user, request, evidence }) => { + test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); + // AUTH-14 and AX-06: the deep link goes to /login, which has one
and an

. + await page.goto("/app/generate"); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole("main")).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1, name: "Sign in to DebateLab" })).toBeVisible(); + await evidence.aria("01-login-from-deep-link"); + + let sentAfter = Date.now(); + await page.getByLabel("Email address").fill(user.email); + await page.getByRole("button", { name: "Send code" }).click(); + await expect(page.getByText("Code sent", { exact: true })).toBeVisible(); + // AUTH-04: Resend waits out Auth's minimum gap, and says how long. + await expect(page.getByRole("button", { name: /^Resend in 0:\d\d$/ })).toBeDisabled(); + await evidence.shot("02-code-sent-countdown"); + const firstOtp = await readOtp(request, user.email, sentAfter); + + // AUTH-03: real Auth answers otp_expired for a wrong code; the message says it may be wrong. + await page.getByRole("textbox", { name: "Verification code" }).fill(firstOtp === "000000" ? "111111" : "000000"); + await page.getByRole("button", { name: "Verify code" }).click(); + await expect(page.getByRole("alert")).toHaveText("That code is wrong or has expired. Check it or send a new one."); + await evidence.shot("03-wrong-code"); + + // AUTH-05: a new send clears the old error. + await expireResendCooldown(page); + sentAfter = Date.now(); + await page.getByRole("button", { name: "Resend code" }).click(); + await expect(page.getByText("New code sent", { exact: true })).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + const otp = await readOtp(request, user.email, sentAfter, firstOtp); + await evidence.step("Wrong code refused, countdown ended, a new code sent"); + + await page.getByRole("textbox", { name: "Verification code" }).fill(otp); + await page.getByRole("button", { name: "Verify code" }).click(); + await expect(page).toHaveURL(/\/app\/generate$/, { timeout: 20_000 }); + await evidence.shot("04-back-on-generate"); + await evidence.step("Signed in by email code and returned to /app/generate"); +}); + test("auth-switch-account: the next account on this browser starts on /app, gets its own tour, and sign-out stays local", async ({ signedIn: page, evidence }, testInfo) => { const second = await createDemoUser(`${testInfo.project.name}-second`); try { diff --git a/src/components/auth/RequireAuth.tsx b/src/components/auth/RequireAuth.tsx index 4aa42f6ef..e1a8c66da 100644 --- a/src/components/auth/RequireAuth.tsx +++ b/src/components/auth/RequireAuth.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { Loader2 } from 'lucide-react'; import { consumeWorkspaceInvite, stashWorkspaceInvite } from '@/lib/workspace/invite-handoff'; @@ -7,6 +8,7 @@ import { consumeWorkspaceInvite, stashWorkspaceInvite } from '@/lib/workspace/in export function RequireAuth({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const location = useLocation(); + const { t } = useTranslation(); // The account this layout last showed. A sign-out here stamps the redirect // with it, so LoginPage returns only that account to the page it left. const lastUserId = useRef(undefined); @@ -15,10 +17,14 @@ export function RequireAuth({ children }: { children: React.ReactNode }) { }, [user]); if (loading) { + // Signed-out views have no AppLayout, so this is the page's one
. return ( -
- -
+
+
+
+
); } diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts index d93451da3..18cb5246f 100644 --- a/src/lib/auth-errors.ts +++ b/src/lib/auth-errors.ts @@ -1,4 +1,5 @@ import type { TFunction } from 'i18next'; +import { formatWait } from '@/lib/login-form'; // Server messages from the demo-code-login Edge Function, mapped to catalog keys. const DEMO_CODE_MESSAGES: Record = { @@ -15,11 +16,27 @@ const DEMO_CODE_MESSAGES: Record = { const AUTH_CODES: Record = { over_email_send_rate_limit: 'login.errors.rateLimited', over_request_rate_limit: 'login.errors.rateLimited', + // Auth answers otp_expired for a mistyped code too, so the message covers both. otp_expired: 'login.errors.codeExpired', email_address_invalid: 'login.invalidEmail', validation_failed: 'login.invalidEmail', }; +// Auth's max_frequency refusal: "For security purposes, you can only request this after 42 seconds." +const RETRY_AFTER = /\bafter (\d{1,5}) seconds?\b/i; + +/** + * Seconds Auth asked to wait before sending another email, when its refusal says so. + * Null for any other error, and for limits that name no wait (the hourly email cap). + */ +export function authRetryAfterSeconds(error: unknown): number | null { + if (!(error instanceof Error)) return null; + const { code, status } = error as Error & { code?: unknown; status?: unknown }; + if (status !== 429 && code !== 'over_email_send_rate_limit' && code !== 'over_request_rate_limit') return null; + const match = RETRY_AFTER.exec(error.message); + return match ? Number(match[1]) : null; +} + /** * Turns a sign-in failure into a message in the user's language. Unknown errors get the * caller's fallback instead of raw English from the backend. @@ -29,6 +46,9 @@ export function localizeAuthError(error: unknown, t: TFunction, fallbackKey: str const demoKey = DEMO_CODE_MESSAGES[error.message.trim()]; if (demoKey) return t(demoKey, error.message); + const wait = authRetryAfterSeconds(error); + if (wait !== null) return t('login.errors.rateLimitedWait', { time: formatWait(wait) }); + const { code, status } = error as Error & { code?: unknown; status?: unknown }; if (typeof code === 'string' && AUTH_CODES[code]) return t(AUTH_CODES[code]); if (status === 429) return t('login.errors.rateLimited'); diff --git a/src/lib/login-form.ts b/src/lib/login-form.ts new file mode 100644 index 000000000..768c3752c --- /dev/null +++ b/src/lib/login-form.ts @@ -0,0 +1,94 @@ +// Input rules and the resend cooldown for LoginPage's email and demo-code forms. + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// RFC 5321's limit on a forward-path, which Auth enforces too. +export const EMAIL_MAX_LENGTH = 254; + +export type EmailProblem = 'required' | 'tooLong' | 'invalid'; + +/** Why `email` (already trimmed) can't be sent, or null when it can. */ +export function emailProblem(email: string): EmailProblem | null { + if (!email) return 'required'; + if (email.length > EMAIL_MAX_LENGTH) return 'tooLong'; + return EMAIL_PATTERN.test(email) ? null : 'invalid'; +} + +export const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/; + +/** + * Shapes what someone types or pastes into `DEMO-XXXX-XXXX`: uppercase, letters + * and digits only, with the prefix and dashes put in for them. A dash appears only + * before a character, so Backspace over a trailing dash is never undone. + */ +export function formatDemoCode(value: string): string { + const chars = value.toUpperCase().replace(/[^A-Z0-9]/g, ''); + // Still typing the prefix itself. + if ('DEMO'.startsWith(chars)) return chars; + const body = (chars.startsWith('DEMO') ? chars.slice(4) : chars).slice(0, 8); + return `DEMO-${body.slice(0, 4)}${body.length > 4 ? `-${body.slice(4)}` : ''}`; +} + +const alnumCount = (value: string) => value.toUpperCase().replace(/[^A-Z0-9]/g, '').length; + +/** + * formatDemoCode, plus where the caret goes: after the same letter or digit it + * followed before, so an edit mid-code doesn't throw it to the end. + */ +export function formatDemoCodeAt(value: string, caret: number): { value: string; caret: number } { + const formatted = formatDemoCode(value); + let before = alnumCount(value.slice(0, caret)); + // The prefix was added for them: its four letters come before the caret too. + if (formatted.startsWith('DEMO-') && !value.toUpperCase().replace(/[^A-Z0-9]/g, '').startsWith('DEMO')) before += 4; + let position = 0; + for (let seen = 0; position < formatted.length && seen < before; position += 1) { + if (/[A-Z0-9]/.test(formatted[position])) seen += 1; + } + return { value: formatted, caret: position }; +} + +// Auth's own minimum gap between two emails to one address (max_frequency). +export const RESEND_COOLDOWN_SECONDS = 60; +const COOLDOWN_KEY = 'debatelab:otp-resend-until:'; +// Used when sessionStorage refuses writes, so the cooldown still holds on this page. +const memoryCooldowns = new Map(); + +function cooldownKey(email: string): string { + return `${COOLDOWN_KEY}${email.trim().toLowerCase()}`; +} + +/** Starts the wait before another code can be sent to `email`. Kept for this tab, across reloads. */ +export function startResendCooldown(email: string, seconds = RESEND_COOLDOWN_SECONDS, now = Date.now()): void { + const until = now + seconds * 1000; + try { + sessionStorage.setItem(cooldownKey(email), String(until)); + } catch { + // Storage is full or blocked; the cooldown then lasts until a reload. + memoryCooldowns.set(cooldownKey(email), until); + } +} + +/** Whole seconds left before another code can be sent to `email`; 0 when it can be sent now. */ +export function resendCooldownSeconds(email: string, now = Date.now()): number { + let until: number; + try { + until = Number(sessionStorage.getItem(cooldownKey(email)) ?? memoryCooldowns.get(cooldownKey(email)) ?? 0); + } catch { + until = memoryCooldowns.get(cooldownKey(email)) ?? 0; + } + const seconds = Number.isFinite(until) ? Math.ceil((until - now) / 1000) : 0; + if (seconds > 0) return Math.min(seconds, 60 * 60); // Capped, so a bad stored value can't lock the button for good. + // Over: the address needn't stay in storage. + memoryCooldowns.delete(cooldownKey(email)); + try { + sessionStorage.removeItem(cooldownKey(email)); + } catch { + // Nothing stored. + } + return 0; +} + +/** `0:59`, `1:05`: how a wait reads on the Resend button and in errors. */ +export function formatWait(seconds: number): string { + const whole = Math.max(0, Math.ceil(seconds)); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; +} diff --git a/src/locales/en.json b/src/locales/en.json index 611c73c94..304162cfb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -546,9 +546,13 @@ "emailHint": "We'll send a six-digit code.", "sendCode": "Send code", "sendingCode": "Sending code...", + "slowSend": "This can take a few seconds.", "resendCode": "Resend code", "resendingCode": "Resending code...", + "resendIn": "Resend in {{time}}", "codeSent": "Code sent", + "codeResent": "New code sent", + "codeRecentlySent": "We sent a code to this address a moment ago. Enter it below, or resend when the timer ends.", "codeSentDesc": "Check your email for the six-digit code.", "useDifferentEmail": "Use a different email", "otpLabel": "Verification code", @@ -564,8 +568,12 @@ "retrySignIn": "Try again", "backToSignIn": "Back to sign-in", "invalidEmail": "Enter a valid email address.", + "emailRequired": "Email is required.", + "emailTooLong": "That email address is too long. Use one with at most {{max}} characters.", "invalidCode": "Enter a valid six-digit code.", "invalidDemoCode": "Enter a valid demo code.", + "demoCodeRequired": "Enter your demo code.", + "invalidDemoCodeFormat": "Demo codes look like DEMO-AB12-CD34.", "emailOtpError": "Could not send code.", "verifyOtpError": "Could not verify code.", "demoError": "Could not sign in with demo code.", @@ -582,6 +590,7 @@ "demoExpired": "This demo code has expired.", "demoUsedUp": "This demo code has no uses left.", "rateLimited": "Too many attempts. Try again later.", + "rateLimitedWait": "Too many attempts. Try again in {{time}}.", "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.", "linkExpired": "This sign-in link has expired or was already used. Send a new code.", diff --git a/src/locales/es.json b/src/locales/es.json index 3244db590..d1a70bae0 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -546,9 +546,13 @@ "emailHint": "Te enviaremos un código de seis dígitos.", "sendCode": "Enviar código", "sendingCode": "Enviando código...", + "slowSend": "Esto puede tardar unos segundos.", "resendCode": "Reenviar código", "resendingCode": "Reenviando código...", + "resendIn": "Reenviar en {{time}}", "codeSent": "Código enviado", + "codeResent": "Nuevo código enviado", + "codeRecentlySent": "Hace un momento enviamos un código a esta dirección. Introdúcelo abajo o pide uno nuevo cuando termine la cuenta atrás.", "codeSentDesc": "Revisa tu email para encontrar el código de seis dígitos.", "useDifferentEmail": "Usar otro email", "otpLabel": "Código de verificación", @@ -564,8 +568,12 @@ "retrySignIn": "Reintentar", "backToSignIn": "Volver al inicio de sesión", "invalidEmail": "Introduce una dirección de email válida.", + "emailRequired": "El email es obligatorio.", + "emailTooLong": "Esa dirección de email es demasiado larga. Usa una de {{max}} caracteres como máximo.", "invalidCode": "Introduce un código válido de seis dígitos.", "invalidDemoCode": "Introduce un código demo válido.", + "demoCodeRequired": "Introduce tu código demo.", + "invalidDemoCodeFormat": "Los códigos demo tienen este formato: DEMO-AB12-CD34.", "emailOtpError": "No se pudo enviar el código.", "verifyOtpError": "No se pudo verificar el código.", "demoError": "No se pudo iniciar sesión con el código demo.", @@ -582,6 +590,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.", + "rateLimitedWait": "Demasiados intentos. Inténtalo de nuevo en {{time}}.", "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.", "linkExpired": "Este enlace de inicio de sesión ha caducado o ya se usó. Solicita un código nuevo.", diff --git a/src/locales/hu.json b/src/locales/hu.json index a1792ccc6..275ccb56d 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -546,9 +546,13 @@ "emailHint": "Hatjegyű kódot küldünk e-mailben.", "sendCode": "Kód küldése", "sendingCode": "Kód küldése...", + "slowSend": "Ez eltarthat néhány másodpercig.", "resendCode": "Kód újraküldése", "resendingCode": "Kód újraküldése...", + "resendIn": "Újraküldés {{time}} múlva", "codeSent": "Kód elküldve", + "codeResent": "Új kód elküldve", + "codeRecentlySent": "Az imént küldtünk kódot erre a címre. Írd be lent, vagy kérj újat, ha letelt a visszaszámlálás.", "codeSentDesc": "Nézd meg az e-mailedet a hatjegyű kódért.", "useDifferentEmail": "Másik e-mail használata", "otpLabel": "Ellenőrző kód", @@ -564,8 +568,12 @@ "retrySignIn": "Újrapróbálás", "backToSignIn": "Vissza a bejelentkezéshez", "invalidEmail": "Adj meg érvényes e-mail-címet.", + "emailRequired": "Az e-mail-cím megadása kötelező.", + "emailTooLong": "Ez az e-mail-cím túl hosszú. Legfeljebb {{max}} karakteres címet adj meg.", "invalidCode": "Adj meg érvényes, hatjegyű kódot.", "invalidDemoCode": "Adj meg érvényes demókódot.", + "demoCodeRequired": "Add meg a demókódodat.", + "invalidDemoCodeFormat": "A demókód formátuma: DEMO-AB12-CD34.", "emailOtpError": "Nem sikerült elküldeni a kódot.", "verifyOtpError": "A kód ellenőrzése nem sikerült.", "demoError": "Nem sikerült demókóddal bejelentkezni.", @@ -582,6 +590,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.", + "rateLimitedWait": "Túl sok próbálkozás. Próbáld újra {{time}} múlva.", "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.", "linkExpired": "Ez a bejelentkezési link lejárt, vagy már felhasználták. Kérj új kódot.", diff --git a/src/pages/AuthCallbackPage.tsx b/src/pages/AuthCallbackPage.tsx index 3f4ac83ac..f7f49e407 100644 --- a/src/pages/AuthCallbackPage.tsx +++ b/src/pages/AuthCallbackPage.tsx @@ -29,10 +29,12 @@ export default function AuthCallbackPage() { if (loading) { return ( -
-
+
+
+
+
); } @@ -41,7 +43,8 @@ export default function AuthCallbackPage() { // Checked before `user`: a session this browser already had must not hide it. if (callbackError === 'timed-out') { return ( -
+
+

{t('login.title')}

{t('login.errors.callbackTimedOut')}

@@ -49,7 +52,7 @@ export default function AuthCallbackPage() { {t('login.backToSignIn')}
-
+
); } diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index beaee465d..34f44011d 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -6,21 +6,45 @@ import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Loader2 } from 'lucide-react'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { useToast } from '@/hooks/use-toast'; import { useExternalRedirectPending } from '@/hooks/use-external-redirect-pending'; import { consumeWorkspaceInvite } from '@/lib/workspace/invite-handoff'; -import { localizeAuthError } from '@/lib/auth-errors'; +import { authRetryAfterSeconds, localizeAuthError } from '@/lib/auth-errors'; import { clearSessionExpiredNotice, sessionExpiredNotice } from '@/lib/supabase/session-expiry'; import { AUTH_CALLBACK_ERROR_KEYS } from '@/lib/auth-callback'; +import { + DEMO_CODE_PATTERN, + EMAIL_MAX_LENGTH, + emailProblem, + formatDemoCodeAt, + formatWait, + resendCooldownSeconds, + startResendCooldown, + type EmailProblem, +} from '@/lib/login-form'; type AuthMode = 'email' | 'demo'; type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error'; +/** What the code step says about the code: just sent, sent again, or sent from this tab a moment ago. */ +type SentNotice = 'sent' | 'resent' | 'recent'; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/; const OTP_LENGTH = 6; +// Sending a code often takes 3–11 s; after this long the button says so. +const SLOW_SEND_MS = 4000; + +const EMAIL_PROBLEM_KEYS: Record = { + required: 'login.emailRequired', + tooLong: 'login.emailTooLong', + invalid: 'login.invalidEmail', +}; + +const SENT_NOTICE_KEYS: Record = { + sent: 'login.codeSent', + resent: 'login.codeResent', + recent: 'login.codeRecentlySent', +}; /** Email codes are digits only. Pasted spaces, dashes and letters never reach Auth. */ function otpDigits(value: string): string { @@ -31,6 +55,22 @@ function isAuthMode(value: string): value is AuthMode { return value === 'email' || value === 'demo'; } +/** + * Seconds before `address` can get another code, read again every second while + * it is above zero. `refresh` reads the clock now, after a cooldown starts. + */ +function useResendCountdown(address: string, active: boolean): [number, () => void] { + const [now, setNow] = useState(() => Date.now()); + const seconds = active ? resendCooldownSeconds(address, now) : 0; + const running = seconds > 0; + useEffect(() => { + if (!running) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [running]); + return [seconds, () => setNow(Date.now())]; +} + /** RequireAuth's redirect: the page that needed sign-in, and whose session just ended there. */ type LoginRouteState = { from?: { pathname?: string }; fromUserId?: string } | null; @@ -44,11 +84,20 @@ export default function LoginPage() { const [emailSent, setEmailSent] = useState(false); const [demoCode, setDemoCode] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + // Auth refused a resend and named a wait: the error goes when the button's countdown ends. + const [waitError, setWaitError] = useState(false); + // The address the code went to, fixed when it was sent: verify and resend use it. + const [sentTo, setSentTo] = useState(''); + const [sentNotice, setSentNotice] = useState('sent'); + const [slowSend, setSlowSend] = useState(false); // Set when the server refused this tab's session and it was signed out. - const [sessionExpired] = useState(sessionExpiredNotice); + const [sessionExpired, setSessionExpired] = useState(sessionExpiredNotice); // Why the sign-in link or Google sign-in that opened this page failed. Kept - // for this visit; the provider's copy is cleared so it doesn't come back. - const [linkError] = useState(callbackError); + // until the next send; the provider's copy is cleared so it doesn't come back. + const [linkError, setLinkError] = useState(callbackError); + // One click sends one request: Enter in the field can't start a second while one runs. + const sendingRef = useRef(false); + const [resendIn, refreshClock] = useResendCountdown(sentTo, emailSent); const otpInputRef = useRef(null); const emailInputRef = useRef(null); const demoInputRef = useRef(null); @@ -80,6 +129,15 @@ export default function LoginPage() { if (authStatus === 'code-sent') otpInputRef.current?.focus(); }, [authStatus]); + useEffect(() => { + if (authStatus !== 'submitting') return; + const timer = window.setTimeout(() => setSlowSend(true), SLOW_SEND_MS); + return () => { + window.clearTimeout(timer); + setSlowSend(false); + }; + }, [authStatus]); + // An error is shown under the field it is about, and focus moves there. useEffect(() => { if (authStatus !== 'error') return; @@ -99,99 +157,169 @@ export default function LoginPage() { } }; + // Notices from before this attempt (an old error, a failed link, an ended session) go. + const clearNotices = () => { + setErrorMessage(''); + setWaitError(false); + setLinkError(null); + setSessionExpired(false); + }; + const resetAuthState = (mode: AuthMode) => { + clearNotices(); setAuthMode(mode); setAuthStatus('idle'); - setErrorMessage(''); setOtp(''); setEmailSent(false); }; + const showError = (message: string) => { + setWaitError(false); + setAuthStatus('error'); + setErrorMessage(message); + }; + + const showCodeStep = (notice: SentNotice, address: string) => { + setSentTo(address); + setOtp(''); + setSentNotice(notice); + setEmailSent(true); + setAuthStatus('code-sent'); + refreshClock(); + }; + + /** Sends a code to `address`. Returns once the request settles; never runs two at once. */ + const sendCode = async (address: string, isResend: boolean) => { + if (sendingRef.current) return; + sendingRef.current = true; + clearNotices(); + setAuthStatus('submitting'); + try { + await signInWithEmailOtp(address, { next }); + startResendCooldown(address); + showCodeStep(isResend ? 'resent' : 'sent', address); + } catch (error) { + const wait = authRetryAfterSeconds(error); + if (wait !== null) startResendCooldown(address, wait); + if (wait !== null && !isResend) { + // Auth only asks for a wait when it sent this address a code moments ago + // (from another tab, or before the cooldown was stored): that code works. + showCodeStep('recent', address); + return; + } + refreshClock(); + if (wait !== null) { + // The time is fixed in the alert, so a screen reader hears it once; the button counts down. + showError(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.')); + setWaitError(true); + return; + } + // The hourly email cap names no wait; the button still holds off a minute. + if (isResend && (error as { status?: unknown } | null)?.status === 429) startResendCooldown(address); + showError(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.')); + } finally { + sendingRef.current = false; + } + }; + const handleEmailSubmit = async (event: React.FormEvent) => { event.preventDefault(); + if (sendingRef.current) return; const normalizedEmail = email.trim(); - if (!EMAIL_PATTERN.test(normalizedEmail)) { - setAuthStatus('error'); - setErrorMessage(t('login.invalidEmail', 'Enter a valid email address.')); + const problem = emailProblem(normalizedEmail); + if (problem) { + showError(t(EMAIL_PROBLEM_KEYS[problem], { max: EMAIL_MAX_LENGTH })); return; } - - setErrorMessage(''); - setAuthStatus('submitting'); - try { - await signInWithEmailOtp(normalizedEmail, { next }); - setOtp(''); - setEmailSent(true); - setAuthStatus('code-sent'); - } catch (error) { - setAuthStatus('error'); - setErrorMessage(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.')); + // This tab sent this address a code less than a minute ago (perhaps before a + // reload). Sending again would only be refused, so ask for that code instead. + if (resendCooldownSeconds(normalizedEmail) > 0) { + clearNotices(); + showCodeStep('recent', normalizedEmail); + return; } + await sendCode(normalizedEmail, false); }; const handleOtpSubmit = async (event: React.FormEvent) => { event.preventDefault(); - const normalizedEmail = email.trim(); const normalizedOtp = otp.trim(); if (!/^\d{6}$/.test(normalizedOtp)) { - setAuthStatus('error'); - setErrorMessage(t('login.invalidCode', 'Enter a valid six-digit code.')); + showError(t('login.invalidCode', 'Enter a valid six-digit code.')); return; } setErrorMessage(''); + setWaitError(false); setAuthStatus('verifying'); try { - await verifyEmailOtp(normalizedEmail, normalizedOtp); + await verifyEmailOtp(sentTo, normalizedOtp); setAuthStatus('success'); } catch (error) { - setAuthStatus('error'); - setErrorMessage(localizeAuthError(error, t, 'login.verifyOtpError', 'Could not verify code.')); + showError(localizeAuthError(error, t, 'login.verifyOtpError', 'Could not verify code.')); } }; const handleResend = async () => { - setErrorMessage(''); - setAuthStatus('submitting'); - try { - await signInWithEmailOtp(email.trim(), { next }); - setOtp(''); - setAuthStatus('code-sent'); - } catch (error) { - setAuthStatus('error'); - setErrorMessage(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.')); - } + if (resendCooldownSeconds(sentTo) > 0) return; + await sendCode(sentTo, true); }; const handleDemoSubmit = async (event: React.FormEvent) => { event.preventDefault(); - const normalizedCode = demoCode.trim().toUpperCase().replace(/\s+/g, ''); - if (!DEMO_CODE_PATTERN.test(normalizedCode)) { - setAuthStatus('error'); - setErrorMessage(t('login.invalidDemoCode', 'Enter a valid demo code.')); + if (sendingRef.current) return; + // The field already holds the masked code. + if (!DEMO_CODE_PATTERN.test(demoCode)) { + showError(demoCode + ? t('login.invalidDemoCodeFormat', 'Demo codes look like DEMO-AB12-CD34.') + : t('login.demoCodeRequired', 'Enter your demo code.')); return; } - setErrorMessage(''); + sendingRef.current = true; + clearNotices(); setAuthStatus('submitting'); try { - await signInWithDemoCode(normalizedCode); + await signInWithDemoCode(demoCode); setAuthStatus('success'); } catch (error) { - setAuthStatus('error'); - setErrorMessage(localizeAuthError(error, t, 'login.demoError', 'Could not sign in with demo code.')); + showError(localizeAuthError(error, t, 'login.demoError', 'Could not sign in with demo code.')); + } finally { + sendingRef.current = false; } }; const isSubmitting = authStatus === 'submitting' || authStatus === 'verifying'; - const hasError = authStatus === 'error' && Boolean(errorMessage); + const shownError = waitError && resendIn === 0 ? '' : errorMessage; + const hasError = authStatus === 'error' && Boolean(shownError); const fieldError = (id: string) => hasError && ( - + ); + const handleDemoChange = (event: React.ChangeEvent) => { + const input = event.target; + const masked = formatDemoCodeAt(input.value, input.selectionStart ?? input.value.length); + setDemoCode(masked.value); + // React puts the caret at the end when the value is rewritten; keep it where the person typed. + requestAnimationFrame(() => { + if (document.activeElement === input) input.setSelectionRange(masked.caret, masked.caret); + }); + }; const describedBy = (hintId: string, errorId: string) => hasError ? `${hintId} ${errorId}` : hintId; + const pending = (label: string) => ( + <> +

); } diff --git a/src/test/auth/login-form.test.tsx b/src/test/auth/login-form.test.tsx new file mode 100644 index 000000000..af4f95f0a --- /dev/null +++ b/src/test/auth/login-form.test.tsx @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthApiError } from '@supabase/supabase-js'; +import LoginPage from '@/pages/LoginPage'; +import { authRetryAfterSeconds } from '@/lib/auth-errors'; +import { formatDemoCode, formatDemoCodeAt } from '@/lib/login-form'; + +const auth = vi.hoisted(() => ({ + signInWithEmailOtp: vi.fn(async () => undefined), + signInWithDemoCode: vi.fn(async () => undefined), +})); + +vi.mock('@/lib/supabase/AuthProvider', () => ({ + useAuth: () => ({ + user: null as null, + signInWithGoogle: vi.fn(), + signInWithEmailOtp: auth.signInWithEmailOtp, + verifyEmailOtp: vi.fn(), + signInWithDemoCode: auth.signInWithDemoCode, + }), +})); + +function renderLogin() { + return render(); +} + +function submitEmail(value: string) { + fireEvent.change(screen.getByLabelText('Email address'), { target: { value } }); + fireEvent.click(screen.getByRole('button', { name: 'Send code' })); +} + +// Auth's max_frequency refusal, as auth-js raises it. +const tooSoon = (seconds: number) => new AuthApiError(`For security purposes, you can only request this after ${seconds} seconds.`, 429, 'over_email_send_rate_limit'); + +beforeEach(() => { + sessionStorage.clear(); + auth.signInWithEmailOtp.mockClear(); + auth.signInWithDemoCode.mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('resend cooldown (AUTH-04)', () => { + it('counts down after a send, survives a remount, and only then allows a resend', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const first = renderLogin(); + submitEmail('student@example.com'); + expect(await screen.findByRole('button', { name: 'Resend in 1:00' })).toBeDisabled(); + + act(() => { vi.advanceTimersByTime(15_000); }); + expect(screen.getByRole('button', { name: 'Resend in 0:45' })).toBeDisabled(); + + // A reload of /login: the same address goes straight to the code step, without a request. + first.unmount(); + renderLogin(); + submitEmail(' Student@Example.com '); + expect(await screen.findByText(/We sent a code to this address a moment ago/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Resend in 0:45' })).toBeDisabled(); + expect(auth.signInWithEmailOtp).toHaveBeenCalledTimes(1); + + act(() => { vi.advanceTimersByTime(45_000); }); + const resend = screen.getByRole('button', { name: 'Resend code' }); + expect(resend).toBeEnabled(); + fireEvent.click(resend); + expect(await screen.findByText('New code sent')).toBeInTheDocument(); + expect(auth.signInWithEmailOtp).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: 'Resend in 1:00' })).toBeDisabled(); + }); + + it("says how long Auth asked to wait when a resend is refused", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderLogin(); + submitEmail('student@example.com'); + await screen.findByRole('button', { name: 'Resend in 1:00' }); + act(() => { vi.advanceTimersByTime(60_000); }); + + auth.signInWithEmailOtp.mockRejectedValueOnce(tooSoon(42)); + fireEvent.click(screen.getByRole('button', { name: 'Resend code' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Try again in 0:42.'); + expect(screen.getByRole('button', { name: 'Resend in 0:42' })).toBeDisabled(); + + // The button counts down; the alert keeps its text (read once) and goes when the wait is over. + act(() => { vi.advanceTimersByTime(2_000); }); + expect(screen.getByRole('button', { name: 'Resend in 0:40' })).toBeDisabled(); + expect(screen.getByRole('alert')).toHaveTextContent('Too many attempts. Try again in 0:42.'); + act(() => { vi.advanceTimersByTime(40_000); }); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Resend code' })).toBeEnabled(); + }); + + it('opens the code step when Auth says a code went to this address moments ago', async () => { + auth.signInWithEmailOtp.mockRejectedValueOnce(tooSoon(30)); + renderLogin(); + submitEmail('student@example.com'); + expect(await screen.findByText(/We sent a code to this address a moment ago/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Resend in 0:30' })).toBeDisabled(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('locks the email field while the code is on its way', async () => { + let finishSend: () => void = () => undefined; + auth.signInWithEmailOtp.mockImplementationOnce(() => new Promise((resolve) => { finishSend = () => resolve(undefined); })); + renderLogin(); + submitEmail('a@example.com'); + // The field is locked while the code is on its way. + expect(screen.getByLabelText('Email address')).toBeDisabled(); + await act(async () => { finishSend(); }); + expect(await screen.findByRole('button', { name: 'Resend in 1:00' })).toBeDisabled(); + expect(auth.signInWithEmailOtp).toHaveBeenCalledWith('a@example.com', expect.anything()); + }); +}); + +describe('email validation (AUTH-10, AUTH-11)', () => { + it('leaves validation to the app, not the browser', () => { + const { container } = renderLogin(); + for (const form of container.querySelectorAll('form')) expect(form).toHaveAttribute('novalidate'); + }); + + it('says an empty email is required and an over-long one is too long, without sending', async () => { + renderLogin(); + submitEmail(' '); + expect(await screen.findByRole('alert')).toHaveTextContent('Email is required.'); + + const long = `${'a'.repeat(290)}@example.com`; + submitEmail(long); + expect(screen.getByLabelText('Email address')).toHaveValue(long); + expect(await screen.findByRole('alert')).toHaveTextContent('That email address is too long. Use one with at most 254 characters.'); + expect(auth.signInWithEmailOtp).not.toHaveBeenCalled(); + }); +}); + +describe('demo code (AUTH-12)', () => { + it('shapes typed and pasted input into DEMO-XXXX-XXXX', () => { + expect(formatDemoCode('d')).toBe('D'); + expect(formatDemoCode('demo')).toBe('DEMO'); + expect(formatDemoCode('demoa')).toBe('DEMO-A'); + expect(formatDemoCode('DEMO-')).toBe('DEMO'); + expect(formatDemoCode('ab12cd34')).toBe('DEMO-AB12-CD34'); + expect(formatDemoCode(' demo ab12 cd34 extra')).toBe('DEMO-AB12-CD34'); + expect(formatDemoCode('DEMO-AB12-')).toBe('DEMO-AB12'); + }); + + it('keeps the caret after the character it followed', () => { + // Delete on the middle dash: the dash comes back and the caret stays before it. + expect(formatDemoCodeAt('DEMO-AB12CD34', 9)).toEqual({ value: 'DEMO-AB12-CD34', caret: 9 }); + // A character typed mid-code moves the dash, and the caret stays after the new character. + expect(formatDemoCodeAt('DEMO-AB1X2-CD3', 9)).toEqual({ value: 'DEMO-AB1X-2CD3', caret: 9 }); + // Typing the first character without the prefix: the caret lands after it. + expect(formatDemoCodeAt('a', 1)).toEqual({ value: 'DEMO-A', caret: 6 }); + }); + + it('gives a format error for a short code and sends a well-formed one', async () => { + renderLogin(); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo code' })); + const input = await screen.findByRole('textbox', { name: 'Demo code' }); + + fireEvent.change(input, { target: { value: 'ab12' } }); + expect(input).toHaveValue('DEMO-AB12'); + fireEvent.click(screen.getByRole('button', { name: 'Sign in with demo code' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Demo codes look like DEMO-AB12-CD34.'); + expect(auth.signInWithDemoCode).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: 'demo-ab12-cd34' } }); + fireEvent.click(screen.getByRole('button', { name: 'Sign in with demo code' })); + await vi.waitFor(() => expect(auth.signInWithDemoCode).toHaveBeenCalledWith('DEMO-AB12-CD34')); + }); +}); + +describe('Auth rate-limit waits (AUTH-04)', () => { + it('reads the wait from a max_frequency refusal and nothing else', () => { + expect(authRetryAfterSeconds(tooSoon(42))).toBe(42); + // The hourly email cap names no wait. + expect(authRetryAfterSeconds(new AuthApiError('email rate limit exceeded', 429, 'over_email_send_rate_limit'))).toBeNull(); + expect(authRetryAfterSeconds(new AuthApiError('Token has expired or is invalid', 403, 'otp_expired'))).toBeNull(); + }); +}); diff --git a/src/test/auth/login-otp.test.tsx b/src/test/auth/login-otp.test.tsx index c299d9bd0..50a42340e 100644 --- a/src/test/auth/login-otp.test.tsx +++ b/src/test/auth/login-otp.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, 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'; @@ -19,6 +19,9 @@ vi.mock('@/lib/supabase/AuthProvider', () => ({ }), })); +// Each test sends to the same address; a stored resend cooldown would skip the send. +beforeEach(() => sessionStorage.clear()); + async function requestCode() { render(); fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } });