feat(auth): add 2FA setup UI and login challenge step

This commit is contained in:
cesnimda
2026-07-12 21:17:09 +02:00
parent c68b49eda0
commit b85dc1ffb7
9 changed files with 702 additions and 57 deletions
+56
View File
@@ -1,4 +1,5 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
@@ -81,4 +82,59 @@ describe('LoginPage', () => {
expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com');
});
it('shows the 2FA code step when login requires two-factor, then proceeds like a normal login on success', async () => {
mockedApi.post.mockImplementation((url: string, payload?: any) => {
if (url === '/auth/login') {
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
}
if (url === '/auth/2fa/challenge') {
expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456' });
return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
renderLoginPage();
await screen.findByLabelText('Email');
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByText('Two-factor verification');
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456' }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
});
it('shows a clear message when the 2FA challenge is rate-limited', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/auth/login') {
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
}
if (url === '/auth/2fa/challenge') {
return Promise.reject({ response: { status: 429 } });
}
return Promise.resolve({ data: {} } as any);
});
renderLoginPage();
await screen.findByLabelText('Email');
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await screen.findByText('Two-factor verification');
await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Please wait a few minutes and try again.');
});
});