feat(auth): add configurable email verification enforcement

Auth:RequireEmailVerification (default off) gates whether local
register requires confirming email before login. OAuth new-user paths
are untouched -- Google/Microsoft already assert a verified email.
Adds verify-email and resend-verification-email endpoints, mirroring
the existing reset-password enumeration-avoidance and rate-limiting
patterns, plus a login-embedded resend affordance and a verify-email
landing page on the frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-13 01:22:26 +02:00
parent 0ca2f2b261
commit 904f3a8ec8
9 changed files with 528 additions and 3 deletions
@@ -0,0 +1,58 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import VerifyEmailPage from './views/VerifyEmailPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api, getApiErrorMessage } from './api';
const mockedApi = api as jest.Mocked<typeof api>;
// CRA's jest config sets resetMocks: true, which wipes the initial implementation given to
// jest.fn() in setupTests.ts before every test -- re-arm it here so error-derived text is testable.
const mockedGetApiErrorMessage = getApiErrorMessage as jest.Mock;
function renderVerifyEmailPage(search: string) {
window.history.pushState({}, '', `/verify-email${search}`);
return render(
<MemoryRouter initialEntries={[`/verify-email${search}`]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<I18nProvider>
<VerifyEmailPage />
</I18nProvider>
</MemoryRouter>,
);
}
describe('VerifyEmailPage', () => {
beforeEach(() => {
mockedApi.post.mockReset();
mockedGetApiErrorMessage.mockImplementation((e: any, fallback?: string) => {
const data = e?.response?.data;
return typeof data === 'string' && data.trim() ? data.trim() : fallback;
});
});
it('confirms the account and shows success when the link is valid', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
renderVerifyEmailPage('?userId=user-1&token=good-token');
expect(await screen.findByText('Your email has been verified. You can now sign in.')).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith('/auth/verify-email', { userId: 'user-1', token: 'good-token' });
});
it('shows an error when the link is invalid or expired', async () => {
mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: 'Invalid or expired link.' } });
renderVerifyEmailPage('?userId=user-1&token=bad-token');
expect(await screen.findByText('Invalid or expired link.')).toBeInTheDocument();
});
it('shows an error without calling the API when the link is missing userId/token', async () => {
renderVerifyEmailPage('');
expect(await screen.findByText('Missing user/token in link.')).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
});