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' } });