Fix post-auth session handoff flow

This commit is contained in:
2026-03-14 19:12:35 -04:00
parent b735b864d2
commit ac3b036c93
5 changed files with 256 additions and 25 deletions

View File

@@ -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<string | null>(null);
const [handoffError, setHandoffError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(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<HTMLFormElement>) => {
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}
/>
</div>
@@ -124,14 +136,21 @@ function SignInPageContent() {
value={password}
onChange={(event) => setPassword(event.target.value)}
required
disabled={busyAction !== null || isHandingOff}
/>
</div>
{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}
{statusText ? <p className="text-sm text-[color:var(--terminal-muted)]">{statusText}</p> : null}
<Button type="submit" className="w-full" disabled={busyAction !== null}>
{busyAction === 'password' ? 'Signing in...' : 'Sign in with password'}
<Button type="submit" className="w-full" disabled={busyAction !== null || isHandingOff}>
{busyAction === 'password'
? 'Signing in...'
: isHandingOff
? 'Finishing sign-in...'
: 'Sign in with password'}
</Button>
</form>
@@ -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'}
</Button>
</div>
</AuthShell>

View File

@@ -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<string | null>(null);
const [handoffError, setHandoffError] = useState<string | null>(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<HTMLFormElement>) => {
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}
/>
</div>
@@ -105,6 +116,7 @@ function SignUpPageContent() {
value={email}
onChange={(event) => setEmail(event.target.value)}
required
disabled={busy || isHandingOff}
/>
</div>
@@ -117,6 +129,7 @@ function SignUpPageContent() {
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
disabled={busy || isHandingOff}
/>
</div>
@@ -129,13 +142,16 @@ function SignUpPageContent() {
onChange={(event) => setConfirmPassword(event.target.value)}
required
minLength={8}
disabled={busy || isHandingOff}
/>
</div>
{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}>
{busy ? 'Creating account...' : 'Create account'}
<Button type="submit" className="w-full" disabled={busy || isHandingOff}>
{busy ? 'Creating account...' : isHandingOff ? 'Finishing sign-in...' : 'Create account'}
</Button>
</form>
</AuthShell>