Email Verification System/Phase 5 of 530-45 minutes

Frontend UI Components

Phase 5: Frontend UI Components

Overview

Build the user-facing components for email verification: the verification page that handles the token, resend functionality, and verification status indicators.

Requirements

Functional:

  • β€’Verification page handles token from URL
  • β€’Shows clear success/error states
  • β€’Resend button with cooldown
  • β€’Verification banner for unverified users

Non-functional:

  • β€’Loading states for all async actions
  • β€’Accessible error messages
  • β€’Mobile-responsive design

Implementation

Verification Page

// src/app/verify-email/page.tsx
"use client"

import { useSearchParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import Link from 'next/link'

type Status = 'loading' | 'success' | 'error' | 'expired' | 'no-token'

export default function VerifyEmailPage() {
  const searchParams = useSearchParams()
  const router = useRouter()
  const [status, setStatus] = useState<Status>('loading')
  const [message, setMessage] = useState('')

  useEffect(() => {
    const token = searchParams.get('token')
    
    if (!token) {
      setStatus('no-token')
      setMessage('No verification token provided')
      return
    }

    verifyToken(token)
  }, [searchParams])

  async function verifyToken(token: string) {
    try {
      const res = await fetch('/api/auth/verify-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token }),
      })

      const data = await res.json()

      if (res.ok) {
        setStatus('success')
        setMessage('Your email has been verified!')
        // Redirect to dashboard after success
        setTimeout(() => router.push('/dashboard'), 3000)
      } else {
        if (data.error?.includes('expired')) {
          setStatus('expired')
          setMessage('This verification link has expired')
        } else {
          setStatus('error')
          setMessage(data.error || 'Verification failed')
        }
      }
    } catch {
      setStatus('error')
      setMessage('Something went wrong. Please try again.')
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center p-4">
      <div className="max-w-md w-full text-center space-y-6">
        {status === 'loading' && <LoadingState />}
        {status === 'success' && <SuccessState message={message} />}
        {status === 'error' && <ErrorState message={message} />}
        {status === 'expired' && <ExpiredState />}
        {status === 'no-token' && <NoTokenState />}
      </div>
    </div>
  )
}

function LoadingState() {
  return (
    <>
      <div className="animate-spin h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto" />
      <p className="text-gray-600">Verifying your email...</p>
    </>
  )
}

function SuccessState({ message }: { message: string }) {
  return (
    <>
      <div className="h-16 w-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
        <svg className="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
        </svg>
      </div>
      <h1 className="text-2xl font-semibold text-gray-900">{message}</h1>
      <p className="text-gray-600">Redirecting you to the dashboard...</p>
    </>
  )
}

function ErrorState({ message }: { message: string }) {
  return (
    <>
      <div className="h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
        <svg className="h-8 w-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
        </svg>
      </div>
      <h1 className="text-2xl font-semibold text-gray-900">Verification Failed</h1>
      <p className="text-gray-600">{message}</p>
      <Link href="/login" className="text-blue-600 hover:underline">
        Return to login
      </Link>
    </>
  )
}

function ExpiredState() {
  return (
    <>
      <div className="h-16 w-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto">
        <svg className="h-8 w-8 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      </div>
      <h1 className="text-2xl font-semibold text-gray-900">Link Expired</h1>
      <p className="text-gray-600">This verification link has expired. Please request a new one.</p>
      <ResendButton />
    </>
  )
}

function NoTokenState() {
  return (
    <>
      <h1 className="text-2xl font-semibold text-gray-900">Missing Token</h1>
      <p className="text-gray-600">
        No verification token found. Please check your email for the verification link.
      </p>
      <Link href="/login" className="text-blue-600 hover:underline">
        Return to login
      </Link>
    </>
  )
}

Resend Button Component

// src/components/auth/ResendButton.tsx
"use client"

import { useState, useEffect } from 'react'

const COOLDOWN_SECONDS = 60

export function ResendButton() {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
  const [cooldown, setCooldown] = useState(0)
  const [errorMessage, setErrorMessage] = useState('')

  useEffect(() => {
    if (cooldown > 0) {
      const timer = setTimeout(() => setCooldown(c => c - 1), 1000)
      return () => clearTimeout(timer)
    }
  }, [cooldown])

  async function handleResend() {
    if (cooldown > 0) return

    setStatus('loading')
    setErrorMessage('')

    try {
      const res = await fetch('/api/auth/send-verification', {
        method: 'POST',
      })

      const data = await res.json()

      if (res.ok) {
        setStatus('success')
        setCooldown(COOLDOWN_SECONDS)
      } else {
        setStatus('error')
        setErrorMessage(data.error || 'Failed to send email')
      }
    } catch {
      setStatus('error')
      setErrorMessage('Something went wrong')
    }
  }

  const isDisabled = status === 'loading' || cooldown > 0

  return (
    <div className="space-y-2">
      <button
        onClick={handleResend}
        disabled={isDisabled}
        className={`
          px-4 py-2 rounded-lg font-medium transition-colors
          ${isDisabled 
            ? 'bg-gray-200 text-gray-500 cursor-not-allowed' 
            : 'bg-blue-600 text-white hover:bg-blue-700'
          }
        `}
      >
        {status === 'loading' && 'Sending...'}
        {status === 'success' && cooldown > 0 && `Resend in ${cooldown}s`}
        {status === 'error' && 'Try Again'}
        {status === 'idle' && 'Resend Verification Email'}
        {status === 'success' && cooldown === 0 && 'Resend Again'}
      </button>

      {status === 'success' && (
        <p className="text-sm text-green-600">Verification email sent! Check your inbox.</p>
      )}

      {status === 'error' && (
        <p className="text-sm text-red-600">{errorMessage}</p>
      )}
    </div>
  )
}

Verification Banner

// src/components/auth/VerificationBanner.tsx
"use client"

import { useState } from 'react'
import { ResendButton } from './ResendButton'

interface VerificationBannerProps {
  email?: string
}

export function VerificationBanner({ email }: VerificationBannerProps) {
  const [dismissed, setDismissed] = useState(false)

  if (dismissed) return null

  return (
    <div className="bg-yellow-50 border-b border-yellow-100">
      <div className="max-w-7xl mx-auto px-4 py-3">
        <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
          <div className="flex items-start gap-3">
            <svg className="h-5 w-5 text-yellow-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
            </svg>
            <div>
              <p className="text-sm font-medium text-yellow-800">
                Please verify your email
              </p>
              <p className="text-sm text-yellow-700">
                {email 
                  ? `We sent a verification link to ${email}` 
                  : 'Check your email for the verification link'
                }
              </p>
            </div>
          </div>
          
          <div className="flex items-center gap-4">
            <ResendButton />
            <button
              onClick={() => setDismissed(true)}
              className="text-yellow-600 hover:text-yellow-800"
              aria-label="Dismiss"
            >
              <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
              </svg>
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

Hook for Verification Status

// src/hooks/useVerificationStatus.ts
"use client"

import { useState, useEffect } from 'react'

export function useVerificationStatus() {
  const [isVerified, setIsVerified] = useState<boolean | null>(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    checkStatus()
  }, [])

  async function checkStatus() {
    try {
      const res = await fetch('/api/auth/verification-status')
      const data = await res.json()
      setIsVerified(data.verified)
    } catch {
      setIsVerified(null)
    } finally {
      setIsLoading(false)
    }
  }

  return { isVerified, isLoading, refresh: checkStatus }
}

Integration Example

// src/app/dashboard/layout.tsx
import { getServerSession } from '@/lib/auth'
import { VerificationBanner } from '@/components/auth/VerificationBanner'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await getServerSession()
  const isVerified = session?.user?.emailVerifiedAt !== null

  return (
    <div>
      {!isVerified && <VerificationBanner email={session?.user?.email} />}
      {children}
    </div>
  )
}

Styling Notes

The examples use Tailwind CSS. Adapt the classes to your design system:

TailwindCSS Equivalent
bg-green-100background: #dcfce7
text-green-500color: #22c55e
rounded-fullborder-radius: 9999px
animate-spinanimation: spin 1s linear infinite

Success Criteria

  • β€’ Token from URL is captured and sent to API
  • β€’ Loading state shown during verification
  • β€’ Success redirects to dashboard
  • β€’ Expired link shows resend option
  • β€’ Resend has cooldown to prevent spam
  • β€’ Banner shows for unverified users
  • β€’ All states are accessible (aria labels, focus states)

Related Files

  • β€’Create: src/app/verify-email/page.tsx
  • β€’Create: src/components/auth/ResendButton.tsx
  • β€’Create: src/components/auth/VerificationBanner.tsx
  • β€’Create: src/hooks/useVerificationStatus.ts
  • β€’Modify: Dashboard layout for banner integration