From ac3b036c93bdb57fab6c90bac420ca4288a1b854 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 14 Mar 2026 19:12:35 -0400 Subject: [PATCH] Fix post-auth session handoff flow --- app/auth/signin/page.tsx | 49 ++++++++++---- app/auth/signup/page.tsx | 38 +++++++---- e2e/auth.spec.ts | 133 +++++++++++++++++++++++++++++++++++++- e2e/financials.spec.ts | 1 + hooks/use-auth-handoff.ts | 60 +++++++++++++++++ 5 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 hooks/use-auth-handoff.ts diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 154f47d..4888075 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -1,11 +1,12 @@ 'use client'; import Link from 'next/link'; -import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, type FormEvent, useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { AuthShell } from '@/components/auth/auth-shell'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { useAuthHandoff } from '@/hooks/use-auth-handoff'; import { authClient } from '@/lib/auth-client'; function sanitizeNextPath(value: string | null) { @@ -25,7 +26,6 @@ export default function SignInPage() { } function SignInPageContent() { - const router = useRouter(); const searchParams = useSearchParams(); const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]); const { data: rawSession, isPending } = authClient.useSession(); @@ -34,18 +34,28 @@ function SignInPageContent() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); + const [handoffError, setHandoffError] = useState(null); const [message, setMessage] = useState(null); const [busyAction, setBusyAction] = useState<'password' | 'magic' | null>(null); + const [awaitingSession, setAwaitingSession] = useState(false); - useEffect(() => { - if (!isPending && session?.user?.id) { - router.replace(nextPath); - } - }, [isPending, nextPath, router, session]); + const handleHandoffTimeout = useCallback(() => { + setAwaitingSession(false); + setHandoffError('Authentication completed, but the session was not established on this device. Please sign in again.'); + }, []); + + const { isHandingOff, statusText } = useAuthHandoff({ + nextPath, + session, + isPending, + awaitingSession, + onTimeout: handleHandoffTimeout + }); const signInWithPassword = async (event: FormEvent) => { event.preventDefault(); setError(null); + setHandoffError(null); setMessage(null); setBusyAction('password'); @@ -62,7 +72,7 @@ function SignInPageContent() { return; } - router.replace(nextPath); + setAwaitingSession(true); }; const signInWithMagicLink = async () => { @@ -73,6 +83,7 @@ function SignInPageContent() { } setError(null); + setHandoffError(null); setMessage(null); setBusyAction('magic'); @@ -113,6 +124,7 @@ function SignInPageContent() { value={email} onChange={(event) => setEmail(event.target.value)} required + disabled={busyAction !== null || isHandingOff} /> @@ -124,14 +136,21 @@ function SignInPageContent() { value={password} onChange={(event) => setPassword(event.target.value)} required + disabled={busyAction !== null || isHandingOff} /> {error ?

{error}

: null} + {handoffError ?

{handoffError}

: null} {message ?

{message}

: null} + {statusText ?

{statusText}

: null} - @@ -140,10 +159,14 @@ function SignInPageContent() { type="button" variant="secondary" className="w-full" - disabled={busyAction !== null} + disabled={busyAction !== null || isHandingOff} onClick={() => void signInWithMagicLink()} > - {busyAction === 'magic' ? 'Sending link...' : 'Send magic link'} + {busyAction === 'magic' + ? 'Sending link...' + : isHandingOff + ? 'Finishing sign-in...' + : 'Send magic link'} diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx index 6800780..e5734a9 100644 --- a/app/auth/signup/page.tsx +++ b/app/auth/signup/page.tsx @@ -1,11 +1,12 @@ 'use client'; import Link from 'next/link'; -import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, type FormEvent, useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { AuthShell } from '@/components/auth/auth-shell'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { useAuthHandoff } from '@/hooks/use-auth-handoff'; import { authClient } from '@/lib/auth-client'; function sanitizeNextPath(value: string | null) { @@ -25,7 +26,6 @@ export default function SignUpPage() { } function SignUpPageContent() { - const router = useRouter(); const searchParams = useSearchParams(); const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]); const { data: rawSession, isPending } = authClient.useSession(); @@ -36,17 +36,27 @@ function SignUpPageContent() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); + const [handoffError, setHandoffError] = useState(null); const [busy, setBusy] = useState(false); + const [awaitingSession, setAwaitingSession] = useState(false); - useEffect(() => { - if (!isPending && session?.user?.id) { - router.replace(nextPath); - } - }, [isPending, nextPath, router, session]); + const handleHandoffTimeout = useCallback(() => { + setAwaitingSession(false); + setHandoffError('Authentication completed, but the session was not established on this device. Please sign in again.'); + }, []); + + const { isHandingOff, statusText } = useAuthHandoff({ + nextPath, + session, + isPending, + awaitingSession, + onTimeout: handleHandoffTimeout + }); const signUp = async (event: FormEvent) => { event.preventDefault(); setError(null); + setHandoffError(null); if (password !== confirmPassword) { setError('Passwords do not match.'); @@ -69,7 +79,7 @@ function SignUpPageContent() { return; } - router.replace(nextPath); + setAwaitingSession(true); }; return ( @@ -94,6 +104,7 @@ function SignUpPageContent() { value={name} onChange={(event) => setName(event.target.value)} required + disabled={busy || isHandingOff} /> @@ -105,6 +116,7 @@ function SignUpPageContent() { value={email} onChange={(event) => setEmail(event.target.value)} required + disabled={busy || isHandingOff} /> @@ -117,6 +129,7 @@ function SignUpPageContent() { onChange={(event) => setPassword(event.target.value)} required minLength={8} + disabled={busy || isHandingOff} /> @@ -129,13 +142,16 @@ function SignUpPageContent() { onChange={(event) => setConfirmPassword(event.target.value)} required minLength={8} + disabled={busy || isHandingOff} /> {error ?

{error}

: null} + {handoffError ?

{handoffError}

: null} + {statusText ?

{statusText}

: null} - diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index ff0c1d3..f82919c 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -16,11 +16,49 @@ function createDeferred() { }; } +function uniqueEmail(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; +} + async function gotoAuthPage(page: Page, path: string) { await page.goto(path, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle'); } +async function signUp(page: Page, email: string, path = '/auth/signup') { + await gotoAuthPage(page, path); + await page.locator('input[autocomplete="name"]').fill('Playwright User'); + await page.locator('input[autocomplete="email"]').fill(email); + await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD); + await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); +} + +async function signIn(page: Page, email: string, path = '/auth/signin') { + await gotoAuthPage(page, path); + await page.locator('input[autocomplete="email"]').fill(email); + await page.locator('input[autocomplete="current-password"]').fill(PASSWORD); + await page.getByRole('button', { name: 'Sign in with password' }).click(); +} + +async function expectStableDashboard(page: Page) { + await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/$/, { timeout: 30_000 }); + await page.waitForTimeout(1_000); + expect(page.url()).not.toContain('/auth/signin'); +} + +async function expectStableProtectedRoute(page: Page, pattern: RegExp) { + await expect(page).toHaveURL(pattern, { timeout: 30_000 }); + await page.waitForTimeout(1_000); + expect(page.url()).not.toContain('/auth/signin'); +} + +async function signOut(page: Page) { + await page.getByRole('button', { name: 'Sign out' }).first().click(); + await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 30_000 }); +} + test('preserves the return path while switching between auth screens and shows the expected controls', async ({ page }) => { await gotoAuthPage(page, '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA'); @@ -99,7 +137,7 @@ test('shows loading affordances while sign-up is in flight', async ({ page }) => await gotoAuthPage(page, '/auth/signup'); await page.locator('input[autocomplete="name"]').fill('Playwright User'); - await page.locator('input[autocomplete="email"]').fill(`playwright-${Date.now()}@example.com`); + await page.locator('input[autocomplete="email"]').fill(uniqueEmail('playwright-loading')); await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD); await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); await page.getByRole('button', { name: 'Create account' }).click(); @@ -110,3 +148,96 @@ test('shows loading affordances while sign-up is in flight', async ({ page }) => await expect(page.getByText('Email already exists')).toBeVisible(); }); + +test('successful signup reaches the authenticated shell and stays there', async ({ page }) => { + await signUp(page, uniqueEmail('playwright-signup-success')); + await expectStableDashboard(page); +}); + +test('successful signup preserves the requested next path', async ({ page }) => { + await signUp(page, uniqueEmail('playwright-signup-next'), '/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA'); + await expectStableProtectedRoute(page, /\/analysis\?ticker=NVDA$/); +}); + +test('successful sign-in reaches the authenticated shell and stays there', async ({ page }) => { + const email = uniqueEmail('playwright-signin-success'); + + await signUp(page, email); + await expectStableDashboard(page); + await signOut(page); + + await signIn(page, email); + await expectStableDashboard(page); +}); + +test('authenticated users are redirected away from auth pages with hard navigation', async ({ page }) => { + await signUp(page, uniqueEmail('playwright-authenticated-redirect')); + await expectStableDashboard(page); + + await page.goto('/auth/signin', { waitUntil: 'domcontentloaded' }); + await expectStableDashboard(page); + + await page.goto('/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA', { waitUntil: 'domcontentloaded' }); + await expectStableProtectedRoute(page, /\/analysis\?ticker=NVDA$/); +}); + +test('shows the handoff state while waiting for the session to become visible', async ({ page }) => { + const gate = createDeferred(); + let holdSession = false; + + await page.route('**/api/auth/get-session**', async (route) => { + if (holdSession) { + await gate.promise; + } + + await route.continue(); + }); + + await gotoAuthPage(page, '/auth/signup'); + holdSession = true; + + await page.locator('input[autocomplete="name"]').fill('Playwright User'); + await page.locator('input[autocomplete="email"]').fill(uniqueEmail('playwright-handoff')); + await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD); + await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); + + await expect(page.getByRole('button', { name: 'Finishing sign-in...' })).toBeDisabled(); + await expect(page.getByText('Establishing your session and opening the workspace...')).toBeVisible(); + + gate.resolve(); + + await expectStableDashboard(page); +}); + +test('shows recovery guidance if session establishment never completes', async ({ page }) => { + let forceMissingSession = false; + + await page.route('**/api/auth/get-session**', async (route) => { + if (!forceMissingSession) { + await route.continue(); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: 'null' + }); + }); + + await gotoAuthPage(page, '/auth/signup'); + forceMissingSession = true; + + await page.locator('input[autocomplete="name"]').fill('Playwright User'); + await page.locator('input[autocomplete="email"]').fill(uniqueEmail('playwright-timeout')); + await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD); + await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); + + await expect(page.getByRole('button', { name: 'Finishing sign-in...' })).toBeDisabled(); + await expect(page.getByText('Establishing your session and opening the workspace...')).toBeVisible(); + await expect(page.getByText('Authentication completed, but the session was not established on this device. Please sign in again.')).toBeVisible({ timeout: 15_000 }); + await expect(page).toHaveURL(/\/auth\/signup$/); + await expect(page.getByRole('button', { name: 'Create account' })).toBeEnabled(); +}); diff --git a/e2e/financials.spec.ts b/e2e/financials.spec.ts index 86da3e7..4f13643 100644 --- a/e2e/financials.spec.ts +++ b/e2e/financials.spec.ts @@ -20,6 +20,7 @@ async function signUp(page: Page, testInfo: TestInfo) { await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); await page.getByRole('button', { name: 'Create account' }).click(); await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/$/, { timeout: 30_000 }); } function buildFinancialsPayload(ticker: 'MSFT' | 'JPM') { diff --git a/hooks/use-auth-handoff.ts b/hooks/use-auth-handoff.ts new file mode 100644 index 0000000..71ad976 --- /dev/null +++ b/hooks/use-auth-handoff.ts @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +const HANDOFF_TIMEOUT_MS = 10_000; + +type AuthSession = { + user?: { + id?: string; + }; +} | null; + +type UseAuthHandoffOptions = { + nextPath: string; + session: AuthSession; + isPending: boolean; + awaitingSession: boolean; + onTimeout: () => void; +}; + +export function useAuthHandoff({ + nextPath, + session, + isPending, + awaitingSession, + onTimeout +}: UseAuthHandoffOptions) { + const hasNavigatedRef = useRef(false); + const hasSession = Boolean(session?.user?.id); + + useEffect(() => { + if (typeof window === 'undefined' || isPending || !hasSession || hasNavigatedRef.current) { + return; + } + + hasNavigatedRef.current = true; + window.location.replace(nextPath); + }, [hasSession, isPending, nextPath]); + + useEffect(() => { + if (typeof window === 'undefined' || !awaitingSession || hasSession || hasNavigatedRef.current) { + return; + } + + const timeoutId = window.setTimeout(() => { + onTimeout(); + }, HANDOFF_TIMEOUT_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [awaitingSession, hasSession, onTimeout]); + + return { + isHandingOff: awaitingSession || (!isPending && hasSession), + statusText: awaitingSession + ? 'Establishing your session and opening the workspace...' + : null + }; +}