feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled

Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
2026-05-07 18:42:20 +00:00
committed by chihlasm
parent f918b766b0
commit f1be3abcc5
123 changed files with 11563 additions and 559 deletions

View File

@@ -1,73 +1,221 @@
import { useEffect, useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
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 `/?verified=1` so the
* dashboard surfaces a toast.
* - 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 [status, setStatus] = useState<'loading' | 'success' | 'error'>(token ? 'loading' : 'error')
const [errorMessage, setErrorMessage] = useState(token ? '' : 'No verification token provided')
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
authApi.verifyEmail(token)
.then(() => setStatus('success'))
.catch((err) => {
setStatus('error')
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
setErrorMessage(detail ?? 'Verification failed')
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 with a query flag so the
// dashboard can re-surface confirmation if it wants to.
window.setTimeout(() => {
navigate('/?verified=1', { replace: true })
}, SUCCESS_REDIRECT_MS)
})
}, [token])
.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-emerald-400" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Email Verified</h1>
<p className="mt-2 text-muted-foreground">Your email has been successfully verified.</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-white',
'hover:brightness-110'
)}
>
Go to Dashboard
</Link>
</>
)}
{status === 'error' && (
<>
<XCircle className="mx-auto h-12 w-12 text-rose-500" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Verification Failed</h1>
<p className="mt-2 text-muted-foreground">{errorMessage}</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-input border border-border px-6 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover'
)}
>
Go to Dashboard
</Link>
</>
)}
<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="/?verified=1"
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&apos;re already verified
</h1>
<p className="mt-2 text-muted-foreground">
This account&apos;s email is already confirmed. No further
action needed.
</p>
<Link
to="/"
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="/"
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&apos;t include a verification token.
Try the link in your verification email again.
</p>
<Link
to="/"
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>
</div>
</>
)
}