Fix post-auth session handoff flow
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react';
|
import { Suspense, type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { AuthShell } from '@/components/auth/auth-shell';
|
import { AuthShell } from '@/components/auth/auth-shell';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useAuthHandoff } from '@/hooks/use-auth-handoff';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
function sanitizeNextPath(value: string | null) {
|
function sanitizeNextPath(value: string | null) {
|
||||||
@@ -25,7 +26,6 @@ export default function SignInPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SignInPageContent() {
|
function SignInPageContent() {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
|
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
|
||||||
const { data: rawSession, isPending } = authClient.useSession();
|
const { data: rawSession, isPending } = authClient.useSession();
|
||||||
@@ -34,18 +34,28 @@ function SignInPageContent() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [busyAction, setBusyAction] = useState<'password' | 'magic' | null>(null);
|
const [busyAction, setBusyAction] = useState<'password' | 'magic' | null>(null);
|
||||||
|
const [awaitingSession, setAwaitingSession] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleHandoffTimeout = useCallback(() => {
|
||||||
if (!isPending && session?.user?.id) {
|
setAwaitingSession(false);
|
||||||
router.replace(nextPath);
|
setHandoffError('Authentication completed, but the session was not established on this device. Please sign in again.');
|
||||||
}
|
}, []);
|
||||||
}, [isPending, nextPath, router, session]);
|
|
||||||
|
const { isHandingOff, statusText } = useAuthHandoff({
|
||||||
|
nextPath,
|
||||||
|
session,
|
||||||
|
isPending,
|
||||||
|
awaitingSession,
|
||||||
|
onTimeout: handleHandoffTimeout
|
||||||
|
});
|
||||||
|
|
||||||
const signInWithPassword = async (event: FormEvent<HTMLFormElement>) => {
|
const signInWithPassword = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setHandoffError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
setBusyAction('password');
|
setBusyAction('password');
|
||||||
|
|
||||||
@@ -62,7 +72,7 @@ function SignInPageContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(nextPath);
|
setAwaitingSession(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const signInWithMagicLink = async () => {
|
const signInWithMagicLink = async () => {
|
||||||
@@ -73,6 +83,7 @@ function SignInPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setHandoffError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
setBusyAction('magic');
|
setBusyAction('magic');
|
||||||
|
|
||||||
@@ -113,6 +124,7 @@ function SignInPageContent() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
required
|
required
|
||||||
|
disabled={busyAction !== null || isHandingOff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,14 +136,21 @@ function SignInPageContent() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
required
|
required
|
||||||
|
disabled={busyAction !== null || isHandingOff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
||||||
|
{handoffError ? <p className="text-sm text-[#ff9f9f]">{handoffError}</p> : null}
|
||||||
{message ? <p className="text-sm text-[color:var(--accent)]">{message}</p> : null}
|
{message ? <p className="text-sm text-[color:var(--accent)]">{message}</p> : null}
|
||||||
|
{statusText ? <p className="text-sm text-[color:var(--terminal-muted)]">{statusText}</p> : null}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={busyAction !== null}>
|
<Button type="submit" className="w-full" disabled={busyAction !== null || isHandingOff}>
|
||||||
{busyAction === 'password' ? 'Signing in...' : 'Sign in with password'}
|
{busyAction === 'password'
|
||||||
|
? 'Signing in...'
|
||||||
|
: isHandingOff
|
||||||
|
? 'Finishing sign-in...'
|
||||||
|
: 'Sign in with password'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -140,10 +159,14 @@ function SignInPageContent() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={busyAction !== null}
|
disabled={busyAction !== null || isHandingOff}
|
||||||
onClick={() => void signInWithMagicLink()}
|
onClick={() => void signInWithMagicLink()}
|
||||||
>
|
>
|
||||||
{busyAction === 'magic' ? 'Sending link...' : 'Send magic link'}
|
{busyAction === 'magic'
|
||||||
|
? 'Sending link...'
|
||||||
|
: isHandingOff
|
||||||
|
? 'Finishing sign-in...'
|
||||||
|
: 'Send magic link'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react';
|
import { Suspense, type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { AuthShell } from '@/components/auth/auth-shell';
|
import { AuthShell } from '@/components/auth/auth-shell';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useAuthHandoff } from '@/hooks/use-auth-handoff';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
function sanitizeNextPath(value: string | null) {
|
function sanitizeNextPath(value: string | null) {
|
||||||
@@ -25,7 +26,6 @@ export default function SignUpPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SignUpPageContent() {
|
function SignUpPageContent() {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
|
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
|
||||||
const { data: rawSession, isPending } = authClient.useSession();
|
const { data: rawSession, isPending } = authClient.useSession();
|
||||||
@@ -36,17 +36,27 @@ function SignUpPageContent() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [awaitingSession, setAwaitingSession] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleHandoffTimeout = useCallback(() => {
|
||||||
if (!isPending && session?.user?.id) {
|
setAwaitingSession(false);
|
||||||
router.replace(nextPath);
|
setHandoffError('Authentication completed, but the session was not established on this device. Please sign in again.');
|
||||||
}
|
}, []);
|
||||||
}, [isPending, nextPath, router, session]);
|
|
||||||
|
const { isHandingOff, statusText } = useAuthHandoff({
|
||||||
|
nextPath,
|
||||||
|
session,
|
||||||
|
isPending,
|
||||||
|
awaitingSession,
|
||||||
|
onTimeout: handleHandoffTimeout
|
||||||
|
});
|
||||||
|
|
||||||
const signUp = async (event: FormEvent<HTMLFormElement>) => {
|
const signUp = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setHandoffError(null);
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match.');
|
setError('Passwords do not match.');
|
||||||
@@ -69,7 +79,7 @@ function SignUpPageContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(nextPath);
|
setAwaitingSession(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,6 +104,7 @@ function SignUpPageContent() {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
required
|
required
|
||||||
|
disabled={busy || isHandingOff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,6 +116,7 @@ function SignUpPageContent() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
required
|
required
|
||||||
|
disabled={busy || isHandingOff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,6 +129,7 @@ function SignUpPageContent() {
|
|||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
|
disabled={busy || isHandingOff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,13 +142,16 @@ function SignUpPageContent() {
|
|||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
|
disabled={busy || isHandingOff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
||||||
|
{handoffError ? <p className="text-sm text-[#ff9f9f]">{handoffError}</p> : null}
|
||||||
|
{statusText ? <p className="text-sm text-[color:var(--terminal-muted)]">{statusText}</p> : null}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={busy}>
|
<Button type="submit" className="w-full" disabled={busy || isHandingOff}>
|
||||||
{busy ? 'Creating account...' : 'Create account'}
|
{busy ? 'Creating account...' : isHandingOff ? 'Finishing sign-in...' : 'Create account'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|||||||
133
e2e/auth.spec.ts
133
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) {
|
async function gotoAuthPage(page: Page, path: string) {
|
||||||
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForLoadState('networkidle');
|
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 }) => {
|
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');
|
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 gotoAuthPage(page, '/auth/signup');
|
||||||
await page.locator('input[autocomplete="name"]').fill('Playwright User');
|
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"]').first().fill(PASSWORD);
|
||||||
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
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();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ async function signUp(page: Page, testInfo: TestInfo) {
|
|||||||
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFinancialsPayload(ticker: 'MSFT' | 'JPM') {
|
function buildFinancialsPayload(ticker: 'MSFT' | 'JPM') {
|
||||||
|
|||||||
60
hooks/use-auth-handoff.ts
Normal file
60
hooks/use-auth-handoff.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user