Skip to content

Commit 4533132

Browse files
committed
Merge remote-tracking branch 'origin/main' into t8-ship-tmp
2 parents 91a6a5c + 5ea2275 commit 4533132

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
@@ -548,9 +548,13 @@
548548
"emailHint": "We'll send a six-digit code.",
549549
"sendCode": "Send code",
550550
"sendingCode": "Sending code...",
551+
"slowSend": "This can take a few seconds.",
551552
"resendCode": "Resend code",
552553
"resendingCode": "Resending code...",
554+
"resendIn": "Resend in {{time}}",
553555
"codeSent": "Code sent",
556+
"codeResent": "New code sent",
557+
"codeRecentlySent": "We sent a code to this address a moment ago. Enter it below, or resend when the timer ends.",
554558
"codeSentDesc": "Check your email for the six-digit code.",
555559
"useDifferentEmail": "Use a different email",
556560
"otpLabel": "Verification code",
@@ -566,8 +570,12 @@
566570
"retrySignIn": "Try again",
567571
"backToSignIn": "Back to sign-in",
568572
"invalidEmail": "Enter a valid email address.",
573+
"emailRequired": "Email is required.",
574+
"emailTooLong": "That email address is too long. Use one with at most {{max}} characters.",
569575
"invalidCode": "Enter a valid six-digit code.",
570576
"invalidDemoCode": "Enter a valid demo code.",
577+
"demoCodeRequired": "Enter your demo code.",
578+
"invalidDemoCodeFormat": "Demo codes look like DEMO-AB12-CD34.",
571579
"emailOtpError": "Could not send code.",
572580
"verifyOtpError": "Could not verify code.",
573581
"demoError": "Could not sign in with demo code.",
@@ -584,6 +592,7 @@
584592
"demoExpired": "This demo code has expired.",
585593
"demoUsedUp": "This demo code has no uses left.",
586594
"rateLimited": "Too many attempts. Try again later.",
595+
"rateLimitedWait": "Too many attempts. Try again in {{time}}.",
587596
"codeExpired": "That code is wrong or has expired. Check it or send a new one.",
588597
"network": "Can't reach DebateLab. Check your connection and try again.",
589598
"linkExpired": "This sign-in link has expired or was already used. Send a new code.",

0 commit comments

Comments
 (0)