-
- {status === 'loading' && (
- <>
-
-
Verifying your email...
- >
- )}
- {status === 'success' && (
- <>
-
-
Email Verified
-
Your email has been successfully verified.
-
- Go to Dashboard
-
- >
- )}
- {status === 'error' && (
- <>
-
-
Verification Failed
-
{errorMessage}
-
- Go to Dashboard
-
- >
- )}
+
+
+
+ {status === 'loading' && (
+ <>
+
+
Verifying your email…
+ >
+ )}
+
+ {status === 'success' && (
+ <>
+
+
+ Email verified
+
+
+ Redirecting you to the dashboard…
+
+
+ Go to dashboard
+
+ >
+ )}
+
+ {status === 'already-verified' && (
+ <>
+
+
+ You're already verified
+
+
+ This account's email is already confirmed. No further
+ action needed.
+
+
+ Go to dashboard
+
+ >
+ )}
+
+ {status === 'error' && (
+ <>
+
+
+ Verification failed
+
+
+ {errorMessage || 'Invalid or expired verification link'}
+
+
+
+
+ Go to dashboard
+
+
+ >
+ )}
+
+ {status === 'no-token' && (
+ <>
+
+
+ Missing verification token
+
+
+ The link you used doesn't include a verification token.
+ Try the link in your verification email again.
+
+
+ Go to dashboard
+
+ >
+ )}
+
-
>
)
}
diff --git a/frontend/src/pages/__tests__/VerifyEmailPage.test.tsx b/frontend/src/pages/__tests__/VerifyEmailPage.test.tsx
new file mode 100644
index 00000000..1c42076c
--- /dev/null
+++ b/frontend/src/pages/__tests__/VerifyEmailPage.test.tsx
@@ -0,0 +1,174 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { HelmetProvider } from 'react-helmet-async'
+
+import { VerifyEmailPage } from '../VerifyEmailPage'
+import { authApi } from '@/api/auth'
+import { useAuthStore } from '@/store/authStore'
+import type { User } from '@/types'
+
+vi.mock('@/api/auth', () => ({
+ authApi: {
+ verifyEmail: vi.fn(),
+ sendVerificationEmail: vi.fn(),
+ me: vi.fn(),
+ },
+}))
+
+vi.mock('@/lib/toast', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}))
+
+function makeUser(overrides: Partial
= {}): User {
+ return {
+ id: 'user-1',
+ email: 'test@example.com',
+ name: 'Test User',
+ role: 'engineer',
+ is_super_admin: false,
+ is_active: true,
+ must_change_password: false,
+ account_id: 'acct-1',
+ account_role: 'engineer',
+ team_id: null,
+ created_at: '2026-05-01T00:00:00Z',
+ last_login: null,
+ phone: null,
+ job_title: null,
+ timezone: 'UTC',
+ avatar_url: null,
+ email_verified_at: null,
+ ...overrides,
+ }
+}
+
+function renderPage(initialPath: string) {
+ return render(
+
+
+
+ } />
+ dashboard } />
+
+
+ ,
+ )
+}
+
+describe('VerifyEmailPage', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ useAuthStore.setState({
+ user: null,
+ token: null,
+ isAuthenticated: false,
+ })
+ vi.mocked(authApi.me).mockResolvedValue(
+ makeUser({ email_verified_at: '2026-05-06T00:00:00Z' }),
+ )
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
+
+ it('shows success and redirects on valid token', async () => {
+ useAuthStore.setState({ user: makeUser() })
+ // Override fetchUser to avoid hitting axios/XHR in jsdom — the page calls
+ // it after a successful verify to refresh `email_verified_at`.
+ useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) })
+ vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined)
+
+ renderPage('/verify-email?token=valid-token')
+
+ await waitFor(() => {
+ expect(authApi.verifyEmail).toHaveBeenCalledWith('valid-token')
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText(/Email verified/i)).toBeInTheDocument()
+ })
+
+ // Advance past the redirect delay.
+ vi.advanceTimersByTime(2000)
+
+ await waitFor(() => {
+ expect(screen.getByText('dashboard')).toBeInTheDocument()
+ })
+ })
+
+ it('shows already-verified state when user is already verified', async () => {
+ useAuthStore.setState({
+ user: makeUser({ email_verified_at: '2026-05-05T00:00:00Z' }),
+ })
+
+ renderPage('/verify-email?token=any-token')
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/already verified/i),
+ ).toBeInTheDocument()
+ })
+
+ // The verify endpoint must NOT have been called when the user is already
+ // verified — that would burn a perfectly good token for no reason.
+ expect(authApi.verifyEmail).not.toHaveBeenCalled()
+ })
+
+ it('only calls verifyEmail once even if the effect re-runs (strict-mode guard)', async () => {
+ useAuthStore.setState({ user: makeUser() })
+ useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) })
+ vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined)
+
+ const { rerender } = render(
+