From 7d939a4acfd64310824ccbd2c71582ed8d8f1b7c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 21:41:30 -0400 Subject: [PATCH] feat(auth): add email verification banner, wall, /verify-email page Wires up the soft 7-day email-verification grace period UX. - EmailVerificationBanner now uses the design-system warning tokens (bg-warning-dim / text-warning) and hides itself once the grace period expires, so the wall takes over without double-messaging. - EmailVerificationWall picks up data-testids on the resend and sign-out CTAs. - VerifyEmailPage gains a single-fire useRef guard (so React 19 strict-mode double-invoke doesn't burn the token), an already-verified short-circuit that skips the API call, success state with auth-store refresh + redirect to /?verified=1, and an error state with a resend CTA. Tests: banner hides past day-7, banner resend triggers API call, verify success refreshes + redirects, verify short-circuits when already verified, single-fire guard holds across remount. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/EmailVerificationWall.tsx | 2 + frontend/src/components/layout/AppLayout.tsx | 5 +- .../layout/EmailVerificationBanner.tsx | 54 +++- .../layout/__tests__/AppLayout.test.tsx | 123 +++++++++ .../EmailVerificationBanner.test.tsx | 119 ++++++++ frontend/src/pages/VerifyEmailPage.tsx | 256 ++++++++++++++---- .../pages/__tests__/VerifyEmailPage.test.tsx | 174 ++++++++++++ 7 files changed, 673 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/layout/__tests__/AppLayout.test.tsx create mode 100644 frontend/src/components/layout/__tests__/EmailVerificationBanner.test.tsx create mode 100644 frontend/src/pages/__tests__/VerifyEmailPage.test.tsx diff --git a/frontend/src/components/common/EmailVerificationWall.tsx b/frontend/src/components/common/EmailVerificationWall.tsx index abb95e62..8eb5cab2 100644 --- a/frontend/src/components/common/EmailVerificationWall.tsx +++ b/frontend/src/components/common/EmailVerificationWall.tsx @@ -67,6 +67,7 @@ export function EmailVerificationWall({ className }: EmailVerificationWallProps) type="button" onClick={handleResend} disabled={isSending} + data-testid="resend-button" className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" > {isSending && } @@ -75,6 +76,7 @@ export function EmailVerificationWall({ className }: EmailVerificationWallProps) + + 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( + + + + } /> + dashboard} /> + + + , + ) + + // Force a re-render to simulate React 19 strict-mode double-invoke. + rerender( + + + + } /> + dashboard} /> + + + , + ) + + await waitFor(() => { + expect(authApi.verifyEmail).toHaveBeenCalled() + }) + + expect(authApi.verifyEmail).toHaveBeenCalledTimes(1) + }) + + it('shows an error state with a resend CTA on invalid token', async () => { + useAuthStore.setState({ user: makeUser() }) + vi.mocked(authApi.verifyEmail).mockRejectedValue( + Object.assign(new Error('boom'), { + response: { data: { detail: 'Token expired' } }, + }), + ) + + renderPage('/verify-email?token=stale-token') + + await waitFor(() => { + expect(screen.getByText(/Verification failed/i)).toBeInTheDocument() + }) + expect(screen.getByText(/Token expired/i)).toBeInTheDocument() + expect(screen.getByTestId('resend-button')).toBeInTheDocument() + }) +})