Skip to content

Commit 4338727

Browse files
Programcodeclaude
andauthored
Fix sign-in links, the OAuth callback and per-account state (T2: AUTH-01, 18, 17, 02, 19, 06, 07, 13) (#202)
* Fix sign-in links, the OAuth callback and per-account state (T2 auth batch) Google and the emailed sign-in link now return to /auth/callback, which holds a loading screen until the code exchange settles and then opens the deep link or /login. PKCE flow ids are appended to redirects and passed to the exchange, so a failed resend or another tab's sign-in no longer deletes the verifier a pending link needs. Auth errors in the callback URL and failed exchanges show a localized message on /login instead of a console warning. Links already in inboxes (/?code=, /app?code=) are moved onto the callback route before the first render. Sign-out uses local scope. After a sign-out, /login returns only the same account to the page it left. The onboarding tour is tracked per account, and on phones it no longer blocks the header menu. The local confirmation email template is code-only with the code out of the subject. Fixes AUTH-01, AUTH-18, AUTH-17, AUTH-02, AUTH-19, AUTH-06, AUTH-07, AUTH-13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address Codex review: hold loading for another account's callback, keep link errors - AuthProvider keeps loading while a callback exchange runs even when the browser's stored session (another account) is announced meanwhile, so the callback page never opens `next` as the wrong account. - A callback error is no longer cleared by any SIGNED_IN (a session restore fires one). When the link fails but another session survives, the callback page opens that account and shows the failure as a toast. - The onboarding store validates its stored copy on every rehydrate (merge), not only on migration. - A tour left running never opens for an account that already finished it. - RequireAuth records the last account in an effect, not during render (docs/standards/async-and-state.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the callback code in the URL until the exchange settles Codex review r2: clearing `code` and `sb_flow_id` before the exchange meant a reload during the spinner found no code and sent the user to /login with an unused code and verifier. The URL is now cleaned once the exchange succeeds, fails or times out; auth errors and empty callbacks are cleaned at once, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the callback code after an exchange timeout A timeout is not a final answer: the code may still be good, so the URL keeps it and a reload can retry, as main did before. Only a success or a definitive failure cleans it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Offer a retry when the callback exchange times out; bound the user lookup Codex review r3: - After a timeout the provider kept the code, but the callback page still navigated to /login and lost it. A timeout now has its own error kind: the page stays on the callback URL and offers Try again (a reload that reuses the code) and Back to sign-in. A late success still opens the app. The provider stays the only owner of stripping callback parameters. - confirmRestoredSession's user lookup had no deadline, and during a callback nothing else ends loading. It now times out after 4 s and, like any unanswered lookup, keeps the local session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep a stored account out of a callback; settle late exchange answers Fresh review (Claude, replacing Codex r4, which hit a 429): - While a callback signs an account in, the listener no longer checks or loads a session the browser already had. A dead stored account's lookup could sign out, or block, the account just signed in. init loads the outcome: the new account, or the stored one only if the callback fails. The blocked-session branch also respects callbackPending. - After a timeout the exchange's late answer is final: a success clears the timeout notice and opens the app; a failure shows its real reason instead of a retry that would report a missing verifier. Either way the used code leaves the URL, and only while the callback page is still showing. - The timeout page shows before an existing session would open the app, and its Back to sign-in keeps the deep link. - A dead stored session's sign-out is bounded, so it cannot hold startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let only the exchange's answer sign an account in during a callback Follow-up review: after a timeout, init restored and checked the stored account while the late exchange could still sign someone else in. A late success during that check got overwritten by the stored account (and the new account's rows loaded under the old owner), and a dead stored account could block the late success. While the code exchange has no answer, even past the timeout, the listener now ignores auth-js's events. init applies the answer itself: the new account on success, or the stored account through the usual server check once the exchange definitively failed. After a timeout nothing is restored until the late answer arrives. A late failure sets its error only while the callback page is still showing. applySession now also does what the listener did for these sign-ins: keep Google's provider token for Drive export and drop the previous account's Crossfire draft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * After a callback timeout, let the user act; guard the stored-session restore Follow-up review of 7f1fc05: - The listener ignored auth events until the slow exchange answered, so after the timeout a sign-in on /login was never seen (and never, if the request stalled for good). It now ignores them only until the exchange answers or times out. A late success arrives like any sign-in; it is applied directly only if a blocked restore dropped it. A late failure restores the stored session only if nobody signed in meanwhile. - restoreStoredSession drops its result when a different account was applied while the stored one was being checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep a newer sign-in when the restored stored account turns out dead Follow-up review of 074c9fe (Low): the guard that keeps an account signed in during the stored session's check ran only for a live stored account. It now runs before the dead branch's drop as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add callback fields to the SharedPage auth mock after merging main * Blur the paused timer button before the reset shortcut in the timer recipe (#172) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 06b73de commit 4338727

35 files changed

Lines changed: 1396 additions & 143 deletions

‎.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`), and email one-time-code sign-in through Mailpit (`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), 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, 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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log
1010
- `auth-signout` signs out from Settings, after which `/app/generate` redirects to `/login`.
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`).
13+
- `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-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`).
1315

1416
## How to get to it (user POV)
1517

@@ -27,11 +29,13 @@ Preconditions:
2729
- **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`.
2830
- **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`.
2931
- **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`.
32+
- **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.
33+
- **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`.
3034
- **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.
3135

3236
## Gotchas
3337

34-
- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab!`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. Interactive runs click `Skip Tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md).
38+
- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab!`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. The tour is tracked per account (store version 1, `completedUserIds`). A version 0 seed with `hasCompletedOnboarding: true` covers only the next account that signs in, so a second account in the same browser sees the tour. Interactive runs click `Skip Tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md).
3539
- Sessions are stored under the `debate-auth-local` localStorage key in local-preview mode. Don't reuse stub-session helpers (`e2e/helpers/auth.ts`). They fake auth and prove rendering only.
36-
- Google sign-in is not wired to the local stack. Only demo codes work here.
40+
- Google sign-in is not wired to the local stack. Demo codes, email codes and the email link work here.
3741
- The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session, and that path is covered by `src/test/auth/session-expiry.test.ts`, not by a recipe.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ test("timer-run: the speech timer starts, pauses, and resets", async ({ signedIn
8282
await page.waitForTimeout(1_500);
8383
expect(await display.textContent()).toBe(paused);
8484
await evidence.shot("01-paused");
85+
// Timer shortcuts ignore a focused button (#172), and Pause still has focus.
86+
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
8587
await page.keyboard.press("r");
8688
await expect(display).toHaveText(initial!);
8789
await evidence.shot("02-reset");

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

Lines changed: 147 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,63 @@
1-
import { expect, test } from "./fixtures";
1+
import { createHash } from "node:crypto";
2+
import type { APIRequestContext, Page } from "@playwright/test";
3+
import { createDemoUser, debateControl, deleteDemoUser, expect, test } from "./fixtures";
24

3-
// Feature map: features/auth.md. Demo-code sign-in, reload, rejection, and
5+
// Feature map: features/auth-demo-login.md. Demo-code sign-in, reload, rejection, and
46
// sign-out are covered by e2e/full-stack/auth.spec.ts, which drive also runs.
57

68
const mailer = process.env.WORKSPACE_TEST_MAILER_URL;
9+
const supabaseUrl = process.env.LOCAL_SUPABASE_URL;
710

8-
test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => {
9-
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
10-
const sentAfter = Date.now();
11-
await page.goto("/login");
12-
await page.getByRole("tab", { name: "Email" }).click();
13-
await page.getByLabel("Email address").fill(user.email);
14-
await page.getByRole("button", { name: "Send code" }).click();
15-
await expect(page.getByText("Code sent").first()).toBeVisible();
16-
await evidence.shot("01-code-sent");
17-
18-
// Read the real message from the local Mailpit inbox.
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) {
1913
let otp = "";
2014
await expect.poll(async () => {
2115
const payload = await (await request.get(`${mailer}/api/v1/messages`)).json() as { messages?: Array<Record<string, unknown>> };
2216
for (const message of payload.messages ?? []) {
23-
if (!JSON.stringify(message).includes(user.email)) continue;
17+
if (!JSON.stringify(message).includes(email)) continue;
2418
if (Date.parse(String(message.Created ?? 0)) < sentAfter - 5_000) continue;
2519
const detail = await (await request.get(`${mailer}/api/v1/message/${message.ID}`)).text();
2620
otp = detail.match(/\b(\d{6})\b/)?.[1] ?? "";
2721
if (otp) break;
2822
}
2923
return otp;
3024
}, { timeout: 30_000 }).toMatch(/^\d{6}$/);
25+
return otp;
26+
}
27+
28+
/** Every path this tab shows, kept across reloads, so a flash of /login is caught. */
29+
async function recordPaths(page: Page) {
30+
await page.addInitScript(() => {
31+
const record = () => {
32+
const seen = JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[];
33+
sessionStorage.setItem("verify-paths", JSON.stringify([...seen, location.pathname]));
34+
};
35+
for (const method of ["pushState", "replaceState"] as const) {
36+
const original = history[method].bind(history);
37+
history[method] = (...args: Parameters<History["pushState"]>) => {
38+
original(...args);
39+
record();
40+
};
41+
}
42+
record();
43+
});
44+
return {
45+
clear: () => page.evaluate(() => sessionStorage.removeItem("verify-paths")),
46+
seen: () => page.evaluate(() => JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[]),
47+
};
48+
}
49+
50+
test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => {
51+
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
52+
const sentAfter = Date.now();
53+
await page.goto("/login");
54+
await page.getByRole("tab", { name: "Email" }).click();
55+
await page.getByLabel("Email address").fill(user.email);
56+
await page.getByRole("button", { name: "Send code" }).click();
57+
await expect(page.getByText("Code sent").first()).toBeVisible();
58+
await evidence.shot("01-code-sent");
59+
60+
const otp = await readOtp(request, user.email, sentAfter);
3161
await evidence.step("Read the one-time code from Mailpit");
3262

3363
await page.getByRole("textbox", { name: "Verification code" }).fill(otp);
@@ -66,3 +96,106 @@ test("auth-session-expired: a session the server revoked signs out to the login
6696
await page.reload();
6797
await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0);
6898
});
99+
100+
test("auth-email-link: the emailed sign-in link signs in after a failed resend, and a used link says so", async ({ page, user, request, evidence }) => {
101+
test.skip(!mailer || !supabaseUrl, "Mailpit or LOCAL_SUPABASE_URL is missing; run through verify.sh drive");
102+
const paths = await recordPaths(page);
103+
const sentAfter = Date.now();
104+
await page.goto("/login");
105+
await page.getByRole("tab", { name: "Email" }).click();
106+
await page.getByLabel("Email address").fill(user.email);
107+
const otpRequest = page.waitForRequest((request) => request.url().includes("/auth/v1/otp"));
108+
await page.getByRole("button", { name: "Send code" }).click();
109+
// The link returns to the callback route with this sign-in's own flow id.
110+
const redirectTo = new URL((await otpRequest).url()).searchParams.get("redirect_to") ?? "";
111+
expect(redirectTo).toMatch(/^http:\/\/127\.0\.0\.1:18081\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/);
112+
await expect(page.getByText("Code sent").first()).toBeVisible();
113+
const otp = await readOtp(request, user.email, sentAfter);
114+
await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=<flow>")}`);
115+
116+
// AUTH-01: the resend hits Auth's rate limit, and auth-js drops that resend's verifier.
117+
await page.route("**/auth/v1/otp**", (route) => route.fulfill({
118+
status: 429,
119+
contentType: "application/json",
120+
body: JSON.stringify({ code: 429, error_code: "over_email_send_rate_limit", msg: "email rate limit exceeded" }),
121+
}));
122+
await page.getByRole("button", { name: "Resend code" }).click();
123+
await expect(page.getByText("Too many attempts. Try again later.")).toBeVisible();
124+
await page.unroute("**/auth/v1/otp**");
125+
await evidence.shot("01-resend-rate-limited");
126+
127+
// The link in prod's email: Auth's /verify with the PKCE token hash of this code.
128+
const link = `${supabaseUrl}/auth/v1/verify?${new URLSearchParams({
129+
token: `pkce_${createHash("sha224").update(user.email + otp).digest("hex")}`,
130+
type: "magiclink",
131+
redirect_to: redirectTo,
132+
})}`;
133+
await paths.clear();
134+
await page.goto(link);
135+
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
136+
await expect(debateControl(page)).toBeVisible();
137+
// AUTH-17: the callback waits for the exchange; it never shows /login on the way.
138+
const seen = await paths.seen();
139+
await evidence.json("02-paths-after-link", seen);
140+
expect(seen).toContain("/auth/callback");
141+
expect(seen).not.toContain("/login");
142+
await page.reload();
143+
await expect(page).toHaveURL(/\/app$/);
144+
await evidence.shot("02-signed-in-by-link");
145+
await evidence.step("Opened the emailed link after the failed resend; signed in on /app without passing /login");
146+
147+
// AUTH-02: the same link again, in a browser that is no longer signed in.
148+
await page.evaluate(() => {
149+
for (const key of Object.keys(localStorage)) if (/^debate-auth-[a-z]+$/.test(key)) localStorage.removeItem(key);
150+
});
151+
await page.goto(link);
152+
await expect(page).toHaveURL(/\/login$/, { timeout: 20_000 });
153+
await expect(page.getByRole("alert")).toHaveText("This sign-in link has expired or was already used. Send a new code.");
154+
await evidence.shot("03-used-link-message");
155+
await evidence.aria("03-used-link-message");
156+
await evidence.step("Opened the used link again; /login says it expired or was used");
157+
});
158+
159+
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) => {
160+
const second = await createDemoUser(`${testInfo.project.name}-second`);
161+
try {
162+
// The first account (tour already seen) leaves off on Settings and signs out.
163+
await page.goto("/app/settings");
164+
const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout"));
165+
await page.getByRole("button", { name: "Sign out" }).click();
166+
// AUTH-19: this device only.
167+
expect(new URL((await logout).url()).searchParams.get("scope")).toBe("local");
168+
await expect(page).toHaveURL(/\/login$/);
169+
await evidence.step("Signed out from /app/settings with scope=local");
170+
171+
// AUTH-06: a different account signs in on the same /login page.
172+
await page.getByRole("tab", { name: "Demo code" }).click();
173+
await page.getByRole("textbox", { name: "Demo code" }).fill(second.code);
174+
await page.getByRole("button", { name: "Sign in with demo code" }).click();
175+
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
176+
await evidence.step("Signed in as a second account; landed on /app, not /app/settings");
177+
178+
// AUTH-07: the second account gets its own tour.
179+
const tour = page.getByText("Welcome to DebateLab!");
180+
await expect(tour).toBeVisible({ timeout: 10_000 });
181+
await evidence.shot("01-second-account-tour");
182+
if (await page.getByRole("button", { name: "Open menu" }).isVisible()) {
183+
// On phones the tour must not block the header's menu: open it and use it.
184+
await page.getByRole("button", { name: "Open menu" }).click();
185+
const menu = page.getByRole("navigation", { name: "Mobile navigation" });
186+
await expect(menu).toBeVisible();
187+
await evidence.shot("02-menu-open-during-tour");
188+
await menu.getByRole("link", { name: /History/ }).click();
189+
await expect(page).toHaveURL(/\/app\/history$/);
190+
await evidence.step("Opened the menu and went to History while the tour showed");
191+
// The tour waits on /app and picks up again there.
192+
await page.goBack();
193+
await expect(tour).toBeVisible({ timeout: 10_000 });
194+
}
195+
await page.locator('[data-test-id="button-skip"]').click();
196+
await expect(tour).toBeHidden();
197+
await evidence.step("Skipped the tour");
198+
} finally {
199+
await deleteDemoUser(second);
200+
}
201+
});

‎docs/architecture.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ Admin-authored rows such as promo codes, curated sources, announcements, and gra
132132
Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive.
133133

134134
- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path.
135+
- Google and any emailed sign-in link return to `/auth/callback` (with `?next=/app/...` for a deep link; only `/app` paths are accepted). [`AuthProvider`](../src/lib/supabase/AuthProvider.tsx) exchanges the code there and keeps `loading` true until the exchange settles. The client sets `experimental.appendPkceFlowIdToRedirects`, so each sign-in keeps its own PKCE verifier and its redirect carries `sb_flow_id`; another tab's sign-in or a failed resend no longer breaks a pending link. An `error`/`error_code` from Auth, or a failed exchange, opens `/login` with a localized message. Links that still point at `/?code=` or `/app?code=` are moved onto the callback route before the first render ([`src/lib/auth-callback.ts`](../src/lib/auth-callback.ts)). Each project's Auth redirect allowlist needs `<site origin>/auth/callback**`; a redirect to the Site URL's own host passes without it.
136+
- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser.
135137
- RLS owns row visibility and mutation rights.
136138
- Shared backend helpers validate bearer tokens and admin roles.
137139
- Admin endpoints verify server-side roles.

‎e2e/login.spec.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
1111
await page.goto('/login', { waitUntil: 'domcontentloaded' });
1212

1313
const googleButton = page.getByRole('button', { name: 'Continue with Google' });
14-
const expectedRedirect = `${new URL(page.url()).origin}/app`;
14+
// Google returns to the callback route with this sign-in's own PKCE flow id.
15+
const expectedRedirect = new RegExp(`^${new URL(page.url()).origin.replace(/[.]/g, '\\.')}/auth/callback\\?sb_flow_id=[0-9a-f]{32}$`);
1516
await expect(googleButton).toBeVisible();
1617
await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible();
1718
await expect(page.getByRole('tab', { name: 'Demo code' })).toBeVisible();
@@ -20,7 +21,7 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
2021
await googleButton.click();
2122
const requestUrl = new URL((await requestPromise).url());
2223
expect(requestUrl.searchParams.get('provider')).toBe('google');
23-
expect(requestUrl.searchParams.get('redirect_to')).toBe(expectedRedirect);
24+
expect(requestUrl.searchParams.get('redirect_to')).toMatch(expectedRedirect);
2425
});
2526

2627
test('exchanges a demo-code magic-link hash for a Supabase session', async ({ page }) => {
@@ -55,9 +56,12 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
5556

5657
test('sends an email code and focuses the verification field', async ({ page }) => {
5758
let requestEmail: string | undefined;
59+
let redirectTo: string | null = null;
5860
let releaseOtpResponse: (() => void) | undefined;
5961
const otpResponseReleased = new Promise<void>((resolve) => { releaseOtpResponse = resolve; });
60-
await page.route('**/auth/v1/otp', async (route) => {
62+
// The request carries `?redirect_to=`, the callback the emailed link opens.
63+
await page.route('**/auth/v1/otp**', async (route) => {
64+
redirectTo = new URL(route.request().url()).searchParams.get('redirect_to');
6165
const body = route.request().postDataJSON() as { email?: unknown };
6266
requestEmail = typeof body.email === 'string' ? body.email : undefined;
6367
await otpResponseReleased;
@@ -78,6 +82,7 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
7882
await expect(page.getByText('Code sent', { exact: true })).toBeVisible();
7983
await expect(page.getByRole('textbox', { name: 'Verification code' })).toBeFocused();
8084
expect(requestEmail).toBe('student@example.com');
85+
expect(redirectTo).toMatch(/\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/);
8186
});
8287

8388
test('shows a demo-code backend error while keeping the form editable', async ({ page }) => {

0 commit comments

Comments
 (0)