Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/verify-debatelab/features/auth-demo-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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_<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.
- **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.
- **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.

Expand Down
58 changes: 56 additions & 2 deletions .claude/skills/verify-debatelab/recipes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> };
Expand All @@ -18,13 +18,25 @@ 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;
}, { timeout: 30_000 }).toMatch(/^\d{6}$/);
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(() => {
Expand Down Expand Up @@ -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=<flow>")}`);

// 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",
Expand Down Expand Up @@ -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 <main> and an <h1>.
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 {
Expand Down
12 changes: 9 additions & 3 deletions src/components/auth/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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';

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<string | undefined>(undefined);
Expand All @@ -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 <main>.
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
<main className="min-h-screen flex items-center justify-center bg-background">
<div role="status">
<Loader2 className="h-8 w-8 animate-spin text-primary" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
</main>
);
}

Expand Down
20 changes: 20 additions & 0 deletions src/lib/auth-errors.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Expand All @@ -15,11 +16,27 @@ const DEMO_CODE_MESSAGES: Record<string, string> = {
const AUTH_CODES: Record<string, string> = {
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.
Expand All @@ -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');
Expand Down
94 changes: 94 additions & 0 deletions src/lib/login-form.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

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')}`;
}
9 changes: 9 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
Expand All @@ -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.",
Expand Down
Loading
Loading