Skip to content

Commit 5ea2275

Browse files
Programcodeclaude
andauthored
Fix login-screen UX: resend countdown, validation, demo-code mask, phone layout (T3) (#210)
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 <main>. 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 <noreply@anthropic.com>
1 parent b0b2ae7 commit 5ea2275

13 files changed

Lines changed: 604 additions & 78 deletions

File tree

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
3939

4040
## Features
4141

42-
- [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`).
42+
- [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`).
4343
- [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`).
4444
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
4545
- [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`).

‎.claude/skills/verify-debatelab/features/auth-demo-login.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log
1111
- `auth-refresh` refreshes an expired access token before an AI request.
1212
- `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`).
1313
- `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`).
14+
- `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`).
1415
- `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`).
1516

1617
## How to get to it (user POV)
@@ -30,6 +31,7 @@ Preconditions:
3031
- **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`.
3132
- **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`.
3233
- **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_<sha224(email + code)>&type=magiclink&redirect_to=<redirect_to of the /auth/v1/otp request>`. 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.
34+
- **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:<email>`; 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.
3335
- **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`.
3436
- **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.
3537

‎.claude/skills/verify-debatelab/recipes/auth.spec.ts‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { createDemoUser, debateControl, deleteDemoUser, expect, test } from "./f
88
const mailer = process.env.WORKSPACE_TEST_MAILER_URL;
99
const supabaseUrl = process.env.LOCAL_SUPABASE_URL;
1010

11-
/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox. */
12-
async function readOtp(request: APIRequestContext, email: string, sentAfter: number) {
11+
/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox; `not` skips an older code. */
12+
async function readOtp(request: APIRequestContext, email: string, sentAfter: number, not?: string) {
1313
let otp = "";
1414
await expect.poll(async () => {
1515
const payload = await (await request.get(`${mailer}/api/v1/messages`)).json() as { messages?: Array<Record<string, unknown>> };
@@ -18,13 +18,25 @@ async function readOtp(request: APIRequestContext, email: string, sentAfter: num
1818
if (Date.parse(String(message.Created ?? 0)) < sentAfter - 5_000) continue;
1919
const detail = await (await request.get(`${mailer}/api/v1/message/${message.ID}`)).text();
2020
otp = detail.match(/\b(\d{6})\b/)?.[1] ?? "";
21+
if (otp === not) otp = "";
2122
if (otp) break;
2223
}
2324
return otp;
2425
}, { timeout: 30_000 }).toMatch(/^\d{6}$/);
2526
return otp;
2627
}
2728

29+
/**
30+
* Ends the Resend countdown (AUTH-04) without waiting a minute. LoginPage reads
31+
* the stored deadline every second, so the button turns back within one tick.
32+
*/
33+
async function expireResendCooldown(page: Page) {
34+
await page.evaluate(() => {
35+
for (const key of Object.keys(sessionStorage)) if (key.startsWith("debatelab:otp-resend-until:")) sessionStorage.setItem(key, "0");
36+
});
37+
await expect(page.getByRole("button", { name: "Resend code" })).toBeEnabled({ timeout: 3_000 });
38+
}
39+
2840
/** Every path this tab shows, kept across reloads, so a flash of /login is caught. */
2941
async function recordPaths(page: Page) {
3042
await page.addInitScript(() => {
@@ -114,6 +126,8 @@ test("auth-email-link: the emailed sign-in link signs in after a failed resend,
114126
await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=<flow>")}`);
115127

116128
// AUTH-01: the resend hits Auth's rate limit, and auth-js drops that resend's verifier.
129+
await expect(page.getByRole("button", { name: /^Resend in 0:\d\d$/ })).toBeDisabled();
130+
await expireResendCooldown(page);
117131
await page.route("**/auth/v1/otp**", (route) => route.fulfill({
118132
status: 429,
119133
contentType: "application/json",
@@ -156,6 +170,46 @@ test("auth-email-link: the emailed sign-in link signs in after a failed resend,
156170
await evidence.step("Opened the used link again; /login says it expired or was used");
157171
});
158172

173+
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 }) => {
174+
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
175+
// AUTH-14 and AX-06: the deep link goes to /login, which has one <main> and an <h1>.
176+
await page.goto("/app/generate");
177+
await expect(page).toHaveURL(/\/login$/);
178+
await expect(page.getByRole("main")).toHaveCount(1);
179+
await expect(page.getByRole("heading", { level: 1, name: "Sign in to DebateLab" })).toBeVisible();
180+
await evidence.aria("01-login-from-deep-link");
181+
182+
let sentAfter = Date.now();
183+
await page.getByLabel("Email address").fill(user.email);
184+
await page.getByRole("button", { name: "Send code" }).click();
185+
await expect(page.getByText("Code sent", { exact: true })).toBeVisible();
186+
// AUTH-04: Resend waits out Auth's minimum gap, and says how long.
187+
await expect(page.getByRole("button", { name: /^Resend in 0:\d\d$/ })).toBeDisabled();
188+
await evidence.shot("02-code-sent-countdown");
189+
const firstOtp = await readOtp(request, user.email, sentAfter);
190+
191+
// AUTH-03: real Auth answers otp_expired for a wrong code; the message says it may be wrong.
192+
await page.getByRole("textbox", { name: "Verification code" }).fill(firstOtp === "000000" ? "111111" : "000000");
193+
await page.getByRole("button", { name: "Verify code" }).click();
194+
await expect(page.getByRole("alert")).toHaveText("That code is wrong or has expired. Check it or send a new one.");
195+
await evidence.shot("03-wrong-code");
196+
197+
// AUTH-05: a new send clears the old error.
198+
await expireResendCooldown(page);
199+
sentAfter = Date.now();
200+
await page.getByRole("button", { name: "Resend code" }).click();
201+
await expect(page.getByText("New code sent", { exact: true })).toBeVisible();
202+
await expect(page.getByRole("alert")).toHaveCount(0);
203+
const otp = await readOtp(request, user.email, sentAfter, firstOtp);
204+
await evidence.step("Wrong code refused, countdown ended, a new code sent");
205+
206+
await page.getByRole("textbox", { name: "Verification code" }).fill(otp);
207+
await page.getByRole("button", { name: "Verify code" }).click();
208+
await expect(page).toHaveURL(/\/app\/generate$/, { timeout: 20_000 });
209+
await evidence.shot("04-back-on-generate");
210+
await evidence.step("Signed in by email code and returned to /app/generate");
211+
});
212+
159213
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) => {
160214
const second = await createDemoUser(`${testInfo.project.name}-second`);
161215
try {

‎src/components/auth/RequireAuth.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useEffect, useRef } from 'react';
22
import { Navigate, useLocation } from 'react-router-dom';
3+
import { useTranslation } from 'react-i18next';
34
import { useAuth } from '@/lib/supabase/AuthProvider';
45
import { Loader2 } from 'lucide-react';
56
import { consumeWorkspaceInvite, stashWorkspaceInvite } from '@/lib/workspace/invite-handoff';
67

78
export function RequireAuth({ children }: { children: React.ReactNode }) {
89
const { user, loading } = useAuth();
910
const location = useLocation();
11+
const { t } = useTranslation();
1012
// The account this layout last showed. A sign-out here stamps the redirect
1113
// with it, so LoginPage returns only that account to the page it left.
1214
const lastUserId = useRef<string | undefined>(undefined);
@@ -15,10 +17,14 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
1517
}, [user]);
1618

1719
if (loading) {
20+
// Signed-out views have no AppLayout, so this is the page's one <main>.
1821
return (
19-
<div className="min-h-screen flex items-center justify-center bg-background">
20-
<Loader2 className="h-8 w-8 animate-spin text-primary" />
21-
</div>
22+
<main className="min-h-screen flex items-center justify-center bg-background">
23+
<div role="status">
24+
<Loader2 className="h-8 w-8 animate-spin text-primary" aria-hidden="true" />
25+
<span className="sr-only">{t('common.loading')}</span>
26+
</div>
27+
</main>
2228
);
2329
}
2430

‎src/lib/auth-errors.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TFunction } from 'i18next';
2+
import { formatWait } from '@/lib/login-form';
23

34
// Server messages from the demo-code-login Edge Function, mapped to catalog keys.
45
const DEMO_CODE_MESSAGES: Record<string, string> = {
@@ -15,11 +16,27 @@ const DEMO_CODE_MESSAGES: Record<string, string> = {
1516
const AUTH_CODES: Record<string, string> = {
1617
over_email_send_rate_limit: 'login.errors.rateLimited',
1718
over_request_rate_limit: 'login.errors.rateLimited',
19+
// Auth answers otp_expired for a mistyped code too, so the message covers both.
1820
otp_expired: 'login.errors.codeExpired',
1921
email_address_invalid: 'login.invalidEmail',
2022
validation_failed: 'login.invalidEmail',
2123
};
2224

25+
// Auth's max_frequency refusal: "For security purposes, you can only request this after 42 seconds."
26+
const RETRY_AFTER = /\bafter (\d{1,5}) seconds?\b/i;
27+
28+
/**
29+
* Seconds Auth asked to wait before sending another email, when its refusal says so.
30+
* Null for any other error, and for limits that name no wait (the hourly email cap).
31+
*/
32+
export function authRetryAfterSeconds(error: unknown): number | null {
33+
if (!(error instanceof Error)) return null;
34+
const { code, status } = error as Error & { code?: unknown; status?: unknown };
35+
if (status !== 429 && code !== 'over_email_send_rate_limit' && code !== 'over_request_rate_limit') return null;
36+
const match = RETRY_AFTER.exec(error.message);
37+
return match ? Number(match[1]) : null;
38+
}
39+
2340
/**
2441
* Turns a sign-in failure into a message in the user's language. Unknown errors get the
2542
* caller's fallback instead of raw English from the backend.
@@ -29,6 +46,9 @@ export function localizeAuthError(error: unknown, t: TFunction, fallbackKey: str
2946
const demoKey = DEMO_CODE_MESSAGES[error.message.trim()];
3047
if (demoKey) return t(demoKey, error.message);
3148

49+
const wait = authRetryAfterSeconds(error);
50+
if (wait !== null) return t('login.errors.rateLimitedWait', { time: formatWait(wait) });
51+
3252
const { code, status } = error as Error & { code?: unknown; status?: unknown };
3353
if (typeof code === 'string' && AUTH_CODES[code]) return t(AUTH_CODES[code]);
3454
if (status === 429) return t('login.errors.rateLimited');

‎src/lib/login-form.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Input rules and the resend cooldown for LoginPage's email and demo-code forms.
2+
3+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4+
// RFC 5321's limit on a forward-path, which Auth enforces too.
5+
export const EMAIL_MAX_LENGTH = 254;
6+
7+
export type EmailProblem = 'required' | 'tooLong' | 'invalid';
8+
9+
/** Why `email` (already trimmed) can't be sent, or null when it can. */
10+
export function emailProblem(email: string): EmailProblem | null {
11+
if (!email) return 'required';
12+
if (email.length > EMAIL_MAX_LENGTH) return 'tooLong';
13+
return EMAIL_PATTERN.test(email) ? null : 'invalid';
14+
}
15+
16+
export const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
17+
18+
/**
19+
* Shapes what someone types or pastes into `DEMO-XXXX-XXXX`: uppercase, letters
20+
* and digits only, with the prefix and dashes put in for them. A dash appears only
21+
* before a character, so Backspace over a trailing dash is never undone.
22+
*/
23+
export function formatDemoCode(value: string): string {
24+
const chars = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
25+
// Still typing the prefix itself.
26+
if ('DEMO'.startsWith(chars)) return chars;
27+
const body = (chars.startsWith('DEMO') ? chars.slice(4) : chars).slice(0, 8);
28+
return `DEMO-${body.slice(0, 4)}${body.length > 4 ? `-${body.slice(4)}` : ''}`;
29+
}
30+
31+
const alnumCount = (value: string) => value.toUpperCase().replace(/[^A-Z0-9]/g, '').length;
32+
33+
/**
34+
* formatDemoCode, plus where the caret goes: after the same letter or digit it
35+
* followed before, so an edit mid-code doesn't throw it to the end.
36+
*/
37+
export function formatDemoCodeAt(value: string, caret: number): { value: string; caret: number } {
38+
const formatted = formatDemoCode(value);
39+
let before = alnumCount(value.slice(0, caret));
40+
// The prefix was added for them: its four letters come before the caret too.
41+
if (formatted.startsWith('DEMO-') && !value.toUpperCase().replace(/[^A-Z0-9]/g, '').startsWith('DEMO')) before += 4;
42+
let position = 0;
43+
for (let seen = 0; position < formatted.length && seen < before; position += 1) {
44+
if (/[A-Z0-9]/.test(formatted[position])) seen += 1;
45+
}
46+
return { value: formatted, caret: position };
47+
}
48+
49+
// Auth's own minimum gap between two emails to one address (max_frequency).
50+
export const RESEND_COOLDOWN_SECONDS = 60;
51+
const COOLDOWN_KEY = 'debatelab:otp-resend-until:';
52+
// Used when sessionStorage refuses writes, so the cooldown still holds on this page.
53+
const memoryCooldowns = new Map<string, number>();
54+
55+
function cooldownKey(email: string): string {
56+
return `${COOLDOWN_KEY}${email.trim().toLowerCase()}`;
57+
}
58+
59+
/** Starts the wait before another code can be sent to `email`. Kept for this tab, across reloads. */
60+
export function startResendCooldown(email: string, seconds = RESEND_COOLDOWN_SECONDS, now = Date.now()): void {
61+
const until = now + seconds * 1000;
62+
try {
63+
sessionStorage.setItem(cooldownKey(email), String(until));
64+
} catch {
65+
// Storage is full or blocked; the cooldown then lasts until a reload.
66+
memoryCooldowns.set(cooldownKey(email), until);
67+
}
68+
}
69+
70+
/** Whole seconds left before another code can be sent to `email`; 0 when it can be sent now. */
71+
export function resendCooldownSeconds(email: string, now = Date.now()): number {
72+
let until: number;
73+
try {
74+
until = Number(sessionStorage.getItem(cooldownKey(email)) ?? memoryCooldowns.get(cooldownKey(email)) ?? 0);
75+
} catch {
76+
until = memoryCooldowns.get(cooldownKey(email)) ?? 0;
77+
}
78+
const seconds = Number.isFinite(until) ? Math.ceil((until - now) / 1000) : 0;
79+
if (seconds > 0) return Math.min(seconds, 60 * 60); // Capped, so a bad stored value can't lock the button for good.
80+
// Over: the address needn't stay in storage.
81+
memoryCooldowns.delete(cooldownKey(email));
82+
try {
83+
sessionStorage.removeItem(cooldownKey(email));
84+
} catch {
85+
// Nothing stored.
86+
}
87+
return 0;
88+
}
89+
90+
/** `0:59`, `1:05`: how a wait reads on the Resend button and in errors. */
91+
export function formatWait(seconds: number): string {
92+
const whole = Math.max(0, Math.ceil(seconds));
93+
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
94+
}

‎src/locales/en.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,9 +546,13 @@
546546
"emailHint": "We'll send a six-digit code.",
547547
"sendCode": "Send code",
548548
"sendingCode": "Sending code...",
549+
"slowSend": "This can take a few seconds.",
549550
"resendCode": "Resend code",
550551
"resendingCode": "Resending code...",
552+
"resendIn": "Resend in {{time}}",
551553
"codeSent": "Code sent",
554+
"codeResent": "New code sent",
555+
"codeRecentlySent": "We sent a code to this address a moment ago. Enter it below, or resend when the timer ends.",
552556
"codeSentDesc": "Check your email for the six-digit code.",
553557
"useDifferentEmail": "Use a different email",
554558
"otpLabel": "Verification code",
@@ -564,8 +568,12 @@
564568
"retrySignIn": "Try again",
565569
"backToSignIn": "Back to sign-in",
566570
"invalidEmail": "Enter a valid email address.",
571+
"emailRequired": "Email is required.",
572+
"emailTooLong": "That email address is too long. Use one with at most {{max}} characters.",
567573
"invalidCode": "Enter a valid six-digit code.",
568574
"invalidDemoCode": "Enter a valid demo code.",
575+
"demoCodeRequired": "Enter your demo code.",
576+
"invalidDemoCodeFormat": "Demo codes look like DEMO-AB12-CD34.",
569577
"emailOtpError": "Could not send code.",
570578
"verifyOtpError": "Could not verify code.",
571579
"demoError": "Could not sign in with demo code.",
@@ -582,6 +590,7 @@
582590
"demoExpired": "This demo code has expired.",
583591
"demoUsedUp": "This demo code has no uses left.",
584592
"rateLimited": "Too many attempts. Try again later.",
593+
"rateLimitedWait": "Too many attempts. Try again in {{time}}.",
585594
"codeExpired": "That code is wrong or has expired. Check it or send a new one.",
586595
"network": "Can't reach DebateLab. Check your connection and try again.",
587596
"linkExpired": "This sign-in link has expired or was already used. Send a new code.",

0 commit comments

Comments
 (0)