The original public-landing routing refactor migrated WelcomeRouter, WelcomeStep1, and WelcomeStep2 post-onboarding redirects to /home, but left four sites still pointing at the old / + query-string destinations: - WelcomeStep3 `completeWizardAndExit` (Send invites) - WelcomeStep3 `handleSkipStep` (Skip) - VerifyEmailPage post-verify auto-redirect (`setTimeout`) - VerifyEmailPage success-state "Go to dashboard" Link These all worked by accident because PublicLanding redirects authed users from / to /home — so users still landed on the dashboard, but through an unnecessary mount-and-redirect flicker, and the `?welcome=true` / `?verified=1` query markers got dropped on the way. Drop both query markers — neither is read anywhere in the codebase (grepped frontend/src; the dashboard's onboarding UX is driven by `getOnboardingStatus`, not URL state). Carrying dead URL params just invites future "is this load-bearing?" investigations. Test stubs in WelcomeStep3.test.tsx and VerifyEmailPage.test.tsx moved from `<Route path="/">` to `<Route path="/home">` so the assertions verify the new destination instead of accidentally matching the old one (the previous stubs masked the partial migration). Out of scope: AcceptInvitePage and OAuthCallbackPage still use `?welcome=teammate`, but that one carries an explicit "decoded by the dashboard in Task 41" annotation and may be wired up later, so left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
7.7 KiB
TypeScript
222 lines
7.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
|
import { CheckCircle2, XCircle, Loader2, MailCheck } from 'lucide-react'
|
|
import { authApi } from '@/api/auth'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { toast } from '@/lib/toast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
type Status = 'loading' | 'success' | 'error' | 'already-verified' | 'no-token'
|
|
|
|
const SUCCESS_REDIRECT_MS = 1200
|
|
|
|
/**
|
|
* Standalone landing page for the email-verification link
|
|
* (`/verify-email?token=...`).
|
|
*
|
|
* Behavior:
|
|
* - If the user is already verified, short-circuit to a friendly
|
|
* "Already verified" state. No API call.
|
|
* - Else fire `POST /auth/email/verify` exactly once (a `useRef` guard keeps
|
|
* React 19 strict-mode double-invoke from double-firing the call). On
|
|
* success, refresh the auth store and bounce to `/home`.
|
|
* - On error, show "Invalid or expired token" + a "Resend" CTA that calls
|
|
* `POST /auth/email/send-verification`.
|
|
*/
|
|
export function VerifyEmailPage() {
|
|
const [searchParams] = useSearchParams()
|
|
const navigate = useNavigate()
|
|
const token = searchParams.get('token')
|
|
|
|
const alreadyVerified = useAuthStore(
|
|
(s) => Boolean(s.user?.email_verified_at),
|
|
)
|
|
|
|
const initialStatus: Status = alreadyVerified
|
|
? 'already-verified'
|
|
: token
|
|
? 'loading'
|
|
: 'no-token'
|
|
|
|
const [status, setStatus] = useState<Status>(initialStatus)
|
|
const [errorMessage, setErrorMessage] = useState<string>('')
|
|
const [isResending, setIsResending] = useState(false)
|
|
|
|
// Single-fire guard: React 19 strict mode runs effects twice on mount.
|
|
// Without this, the verify endpoint would burn the token on the first call
|
|
// and then 400 on the second, flashing an error past the success state.
|
|
const hasFiredRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (status !== 'loading') return
|
|
if (!token) return
|
|
if (hasFiredRef.current) return
|
|
hasFiredRef.current = true
|
|
|
|
let cancelled = false
|
|
|
|
authApi
|
|
.verifyEmail(token)
|
|
.then(async () => {
|
|
// Refresh user so `email_verified_at` is populated everywhere.
|
|
try {
|
|
await useAuthStore.getState().fetchUser()
|
|
} catch {
|
|
// Non-fatal: server confirmed verification, the local user object
|
|
// will refresh on next page load.
|
|
}
|
|
if (cancelled) return
|
|
setStatus('success')
|
|
toast.success('Email verified')
|
|
// Brief success state, then redirect to the dashboard.
|
|
window.setTimeout(() => {
|
|
navigate('/home', { replace: true })
|
|
}, SUCCESS_REDIRECT_MS)
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return
|
|
const detail = (err as { response?: { data?: { detail?: string } } })
|
|
.response?.data?.detail
|
|
setErrorMessage(detail ?? 'Invalid or expired verification link')
|
|
setStatus('error')
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [status, token, navigate])
|
|
|
|
const handleResend = async () => {
|
|
setIsResending(true)
|
|
try {
|
|
await authApi.sendVerificationEmail()
|
|
toast.success('Verification email sent — check your inbox')
|
|
} catch {
|
|
toast.error('Failed to send verification email')
|
|
} finally {
|
|
setIsResending(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageMeta
|
|
title="Verify Email"
|
|
description="Verify your ResolutionFlow email address"
|
|
/>
|
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
<div className="card-flat w-full max-w-md p-8 text-center">
|
|
{status === 'loading' && (
|
|
<>
|
|
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
|
|
<p className="mt-4 text-foreground">Verifying your email…</p>
|
|
</>
|
|
)}
|
|
|
|
{status === 'success' && (
|
|
<>
|
|
<CheckCircle2 className="mx-auto h-12 w-12 text-success" />
|
|
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
|
Email verified
|
|
</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Redirecting you to the dashboard…
|
|
</p>
|
|
<Link
|
|
to="/home"
|
|
replace
|
|
className={cn(
|
|
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
|
|
'hover:brightness-110',
|
|
)}
|
|
>
|
|
Go to dashboard
|
|
</Link>
|
|
</>
|
|
)}
|
|
|
|
{status === 'already-verified' && (
|
|
<>
|
|
<MailCheck className="mx-auto h-12 w-12 text-success" />
|
|
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
|
You're already verified
|
|
</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
This account's email is already confirmed. No further
|
|
action needed.
|
|
</p>
|
|
<Link
|
|
to="/home"
|
|
className={cn(
|
|
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
|
|
'hover:brightness-110',
|
|
)}
|
|
>
|
|
Go to dashboard
|
|
</Link>
|
|
</>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<>
|
|
<XCircle className="mx-auto h-12 w-12 text-danger" />
|
|
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
|
Verification failed
|
|
</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
{errorMessage || 'Invalid or expired verification link'}
|
|
</p>
|
|
<div className="mt-6 flex flex-col gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleResend}
|
|
disabled={isResending}
|
|
data-testid="resend-button"
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground hover:brightness-110 disabled:opacity-50"
|
|
>
|
|
{isResending && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Resend verification email
|
|
</button>
|
|
<Link
|
|
to="/home"
|
|
className={cn(
|
|
'inline-flex items-center justify-center rounded-lg border border-default bg-input px-6 py-2 text-sm font-medium text-foreground',
|
|
'hover:border-border-hover',
|
|
)}
|
|
>
|
|
Go to dashboard
|
|
</Link>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{status === 'no-token' && (
|
|
<>
|
|
<XCircle className="mx-auto h-12 w-12 text-danger" />
|
|
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
|
Missing verification token
|
|
</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
The link you used doesn't include a verification token.
|
|
Try the link in your verification email again.
|
|
</p>
|
|
<Link
|
|
to="/home"
|
|
className={cn(
|
|
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
|
|
'hover:brightness-110',
|
|
)}
|
|
>
|
|
Go to dashboard
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default VerifyEmailPage
|