M4-T08: add two-step login and TOTP settings panel (qrcode.react)

This commit is contained in:
2026-06-21 22:42:41 +02:00
parent 51da8c5716
commit ee1264b66b
10 changed files with 1405 additions and 51 deletions
+6
View File
@@ -35,6 +35,7 @@ import {
} from '@mantine/core'
import apiClient, { ApiError } from '../api/client'
import type { components } from '../api/schema.d.ts'
import { TotpSettings } from './TotpSettings'
// ---------------------------------------------------------------------------
// Types
@@ -393,6 +394,11 @@ export function ConfigPage() {
</Text>
</Stack>
)}
{/* TOTP two-factor auth management */}
<Stack mt="xl">
<TotpSettings />
</Stack>
</Container>
)
}
+174 -5
View File
@@ -1,16 +1,21 @@
/**
* Tests for LoginPage (M2-T07).
* Tests for LoginPage (M2-T07, extended M4-T08).
*
* Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
* responses without a real server. We also mock useSession so tests can control
* the authentication state.
*
* Coverage:
* 1. Renders the login form.
* 2. Successful login → invalidates session query + navigates.
* 1. Renders the login form (step 1).
* 2. Successful login (step 1) → invalidates session query + navigates.
* 3. 401 bad credentials → shows inline error, does not navigate.
* 4. Already-authenticated users visiting /login → redirected to '/'.
* 5. Unexpected error → shows generic error message.
* 6. (M4-T08) 401 with totp_required → switches to step 2 TOTP form.
* 7. (M4-T08) Step 2 submit with correct code → success + navigate.
* 8. (M4-T08) Step 2 submit with wrong code → shows error, stays on step 2.
* 9. (M4-T08) Step 2 "Back to login" button → returns to step 1.
* 10. (M4-T08) 429 response → shows "too many attempts" message.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -86,6 +91,10 @@ describe('LoginPage', () => {
mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
})
// -------------------------------------------------------------------------
// 1. Renders the form
// -------------------------------------------------------------------------
it('renders the login form with username and password fields', () => {
renderLogin()
expect(screen.getByTestId('login-form')).toBeInTheDocument()
@@ -99,8 +108,11 @@ describe('LoginPage', () => {
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Successful login
// -------------------------------------------------------------------------
it('navigates to "/" on successful login', async () => {
// Simulate a successful POST /api/auth/login response
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
response: { status: 200, ok: true },
@@ -130,10 +142,14 @@ describe('LoginPage', () => {
})
})
// -------------------------------------------------------------------------
// 3. 401 bad credentials
// -------------------------------------------------------------------------
it('shows inline error on 401 and does NOT navigate', async () => {
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: 'invalid username or password' },
response: { status: 401, ok: false },
})
@@ -154,6 +170,7 @@ describe('LoginPage', () => {
it('does not include the password in the error message', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: 'invalid username or password' },
response: { status: 401, ok: false },
})
@@ -167,6 +184,10 @@ describe('LoginPage', () => {
expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
})
// -------------------------------------------------------------------------
// 5. Unexpected error
// -------------------------------------------------------------------------
it('shows generic error on unexpected network failure', async () => {
mockPost.mockRejectedValueOnce(new Error('Network error'))
@@ -180,6 +201,10 @@ describe('LoginPage', () => {
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
})
// -------------------------------------------------------------------------
// 4. Already authenticated
// -------------------------------------------------------------------------
it('redirects already-authenticated users to "/"', async () => {
mockUseSession.mockReturnValue({
status: 'authenticated',
@@ -192,4 +217,148 @@ describe('LoginPage', () => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 6. (M4-T08) 401 with totp_required → step 2
// -------------------------------------------------------------------------
it('switches to TOTP step on 401 with totp_required=true', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
// Step 1 form should be gone
expect(screen.queryByTestId('login-form')).not.toBeInTheDocument()
// TOTP code input should be visible
expect(screen.getByTestId('totp-code-input')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 7. (M4-T08) Step 2 correct code → success
// -------------------------------------------------------------------------
it('completes login on correct TOTP code (step 2)', async () => {
// Step 1: trigger totp_required
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
// Step 2: success
mockPost.mockResolvedValueOnce({
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok456' },
response: { status: 200, ok: true },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
// Enter the TOTP code and submit step 2
fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '123456' } })
fireEvent.submit(screen.getByTestId('login-totp-form'))
await waitFor(() => {
expect(screen.getByTestId('home-page')).toBeInTheDocument()
})
// Check step 2 request included totp_code
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
body: { username: 'admin', password: 'correct-password', totp_code: '123456' },
})
})
// -------------------------------------------------------------------------
// 8. (M4-T08) Step 2 wrong code → error, stays on step 2
// -------------------------------------------------------------------------
it('shows error on wrong TOTP code and stays on step 2', async () => {
// Step 1: totp_required
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
// Step 2: wrong code → 401
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: 'invalid totp code' },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '000000' } })
fireEvent.submit(screen.getByTestId('login-totp-form'))
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
// Should still be on TOTP form
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
expect(screen.getByTestId('login-error')).toHaveTextContent(/incorrect verification code/i)
})
// -------------------------------------------------------------------------
// 9. (M4-T08) Step 2 "Back to login" → returns to step 1
// -------------------------------------------------------------------------
it('returns to step 1 when "Back to login" is clicked', async () => {
mockPost.mockResolvedValueOnce({
data: undefined,
error: { detail: { totp_required: true } },
response: { status: 401, ok: false },
})
renderLogin()
fillAndSubmit('admin', 'correct-password')
await waitFor(() => {
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-back'))
await waitFor(() => {
expect(screen.getByTestId('login-form')).toBeInTheDocument()
})
expect(screen.queryByTestId('login-totp-form')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 10. (M4-T08) 429 → "too many attempts" message
// -------------------------------------------------------------------------
it('shows "too many attempts" message on 429', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(new ApiError(429, { detail: 'Too Many Requests' }))
renderLogin()
fillAndSubmit('admin', 'password')
await waitFor(() => {
expect(screen.getByTestId('login-error')).toBeInTheDocument()
})
expect(screen.getByTestId('login-error')).toHaveTextContent(/too many login attempts/i)
})
})
+162 -45
View File
@@ -1,13 +1,21 @@
/**
* LoginPage — real login form (M2-T07).
* LoginPage — real login form (M2-T07, extended M4-T08).
*
* Behaviours:
* - Renders a Mantine form with username + password fields.
* - Renders a Mantine form with username + password fields (step 1).
* - On submit → POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
* - On success → invalidate ['session'] so SessionProvider re-fetches, then navigate to the
* originally-requested route (from location.state.from) or fall back to '/'.
* - On 401 (bad credentials) → show an inline error without leaking the password.
* - On 401 with totp_required=true → switch to step 2 (TOTP code entry).
* - On step 2 submit → POST /api/auth/login with {username, password, totp_code}.
* - On 401 plain (bad credentials / wrong TOTP code) → show inline error.
* - On 429 (rate-limited) → show "too many attempts, please try again later".
* - Already-authenticated users visiting /login → redirect to '/'.
*
* Security:
* - Password is retained in state for the step-2 re-submission but is NEVER
* written to localStorage, sessionStorage, or console.
* - totp_code / recovery codes are NEVER logged or persisted.
*/
import { useState } from 'react'
@@ -23,9 +31,10 @@ import {
Alert,
Stack,
Center,
Text,
} from '@mantine/core'
import { useSession } from '../auth/SessionProvider'
import apiClient from '../api/client'
import apiClient, { ApiError } from '../api/client'
import { setCsrfToken } from '../api/csrf'
// ---------------------------------------------------------------------------
@@ -36,6 +45,9 @@ interface LocationState {
from?: { pathname: string }
}
/** The two UI states for the login flow. */
type LoginStep = 'credentials' | 'totp'
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -48,6 +60,8 @@ export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<LoginStep>('credentials')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
@@ -57,7 +71,11 @@ export function LoginPage() {
return <Navigate to={from} replace />
}
async function handleSubmit(e: React.FormEvent) {
// ---------------------------------------------------------------------------
// handleCredentialsSubmit — step 1
// ---------------------------------------------------------------------------
async function handleCredentialsSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
@@ -68,31 +86,84 @@ export function LoginPage() {
})
if (res.response.status === 401 || !res.data) {
// Bad credentials — do not leak the password in the message.
// Check if the backend wants a TOTP code.
// openapi-fetch puts 401 body in res.error (not res.data).
// The shape is { detail: { totp_required: true } } for TOTP-required,
// or { detail: "invalid username or password" } for bad credentials.
const detail = (res.error as { detail?: unknown } | undefined)?.detail
if (detail && typeof detail === 'object' && (detail as { totp_required?: boolean }).totp_required === true) {
// Transition to step 2 — TOTP entry. Password stays in state for re-send.
setStep('totp')
setTotpCode('')
return
}
// Ordinary bad credentials.
setError('Incorrect username or password.')
return
}
// Success: store the CSRF token returned by login (same shape as session response).
// Success: store the CSRF token returned by login.
if (res.data.csrf_token) {
setCsrfToken(res.data.csrf_token)
}
// Refresh session state: invalidate the ['session'] query so SessionProvider
// picks up the new authenticated state (which may include force_password_change).
// Refresh session state.
await queryClient.invalidateQueries({ queryKey: ['session'] })
// Navigate to the originally-requested route or home.
const from = (location.state as LocationState)?.from?.pathname ?? '/'
navigate(from, { replace: true })
} catch {
// Any unexpected error (network, 5xx, etc.)
setError('Login failed. Please try again.')
} catch (err) {
if (err instanceof ApiError && err.status === 429) {
setError('Too many login attempts. Please try again later.')
} else {
setError('Login failed. Please try again.')
}
} finally {
setLoading(false)
}
}
// ---------------------------------------------------------------------------
// handleTotpSubmit — step 2
// ---------------------------------------------------------------------------
async function handleTotpSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await apiClient.POST('/api/auth/login', {
body: { username, password, totp_code: totpCode },
})
if (res.response.status === 401 || !res.data) {
// Wrong code (or expired) — stay on step 2 and show error.
setError('Incorrect verification code. Please try again.')
return
}
// Success.
if (res.data.csrf_token) {
setCsrfToken(res.data.csrf_token)
}
await queryClient.invalidateQueries({ queryKey: ['session'] })
const from = (location.state as LocationState)?.from?.pathname ?? '/'
navigate(from, { replace: true })
} catch (err) {
if (err instanceof ApiError && err.status === 429) {
setError('Too many login attempts. Please try again later.')
} else {
setError('Login failed. Please try again.')
}
} finally {
setLoading(false)
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Center mih="100vh">
<Container size="xs" w="100%">
@@ -107,39 +178,85 @@ export function LoginPage() {
</Alert>
)}
<form onSubmit={handleSubmit} data-testid="login-form">
<Stack gap="md">
<TextInput
label="Username"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
autoComplete="username"
data-testid="username-input"
/>
{step === 'credentials' && (
<form onSubmit={handleCredentialsSubmit} data-testid="login-form">
<Stack gap="md">
<TextInput
label="Username"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
autoComplete="username"
data-testid="username-input"
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="password-input"
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
autoComplete="current-password"
data-testid="password-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="login-submit"
>
Sign In
</Button>
</Stack>
</form>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="login-submit"
>
Sign In
</Button>
</Stack>
</form>
)}
{step === 'totp' && (
<form onSubmit={handleTotpSubmit} data-testid="login-totp-form">
<Stack gap="md">
<Text size="sm" c="dimmed">
Enter the 6-digit code from your authenticator app, or a recovery code.
</Text>
<TextInput
label="Verification Code"
placeholder="6-digit code or recovery code"
value={totpCode}
onChange={(e) => setTotpCode(e.currentTarget.value)}
required
autoComplete="one-time-code"
inputMode="numeric"
data-testid="totp-code-input"
/>
<Button
type="submit"
fullWidth
loading={loading}
mt="sm"
data-testid="totp-submit"
>
Verify
</Button>
<Button
variant="subtle"
size="sm"
onClick={() => {
setStep('credentials')
setError(null)
setTotpCode('')
}}
data-testid="totp-back"
>
Back to login
</Button>
</Stack>
</form>
)}
</Paper>
</Container>
</Center>
+344
View File
@@ -0,0 +1,344 @@
/**
* Tests for TotpSettings (M4-T08).
*
* Strategy: vi.mock the auth/totp module so we can control API responses
* without a real server. We also mock the api/client module for ApiError.
*
* Coverage:
* 1. Shows loading spinner while fetching TOTP status.
* 2. Shows error when TOTP status fetch fails.
* 3. Shows "Disabled" badge and enable button when TOTP is disabled.
* 4. Enable flow: clicking setup → shows QR code + recovery codes + confirm form.
* 5. Confirm with correct code → TOTP enabled (status refetched).
* 6. Confirm with wrong code → shows error, stays on confirm form.
* 7. Shows "Enabled" badge and disable form when TOTP is enabled.
* 8. Disable with password → TOTP disabled.
* 9. Disable with TOTP code → TOTP disabled.
* 10. Disable without providing password or code → shows validation error.
* 11. Setup error shows error message.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { TotpSettings } from './TotpSettings'
// ---------------------------------------------------------------------------
// Mock auth/totp
// ---------------------------------------------------------------------------
const mockGetTotpStatus = vi.fn()
const mockSetupTotp = vi.fn()
const mockEnableTotp = vi.fn()
const mockDisableTotp = vi.fn()
vi.mock('../auth/totp', () => ({
getTotpStatus: (...args: unknown[]) => mockGetTotpStatus(...args),
setupTotp: (...args: unknown[]) => mockSetupTotp(...args),
enableTotp: (...args: unknown[]) => mockEnableTotp(...args),
disableTotp: (...args: unknown[]) => mockDisableTotp(...args),
}))
// ---------------------------------------------------------------------------
// Mock api/client (for ApiError)
// ---------------------------------------------------------------------------
vi.mock('../api/client', () => ({
default: {
GET: vi.fn(),
POST: vi.fn(),
PUT: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
const MOCK_SETUP_DATA = {
secret: 'JBSWY3DPEHPK3PXP',
otpauth_uri: 'otpauth://totp/TestApp:admin?secret=JBSWY3DPEHPK3PXP&issuer=TestApp',
recovery_codes: ['abcd-1234', 'efgh-5678', 'ijkl-9012'],
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTotp() {
return renderWithProviders(<TotpSettings />, { initialPath: '/config' })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('TotpSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// 1. Loading state
// -------------------------------------------------------------------------
it('shows loading spinner while fetching TOTP status', async () => {
// Never resolves during this test
mockGetTotpStatus.mockReturnValue(new Promise(() => {}))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-loading')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 2. Error state
// -------------------------------------------------------------------------
it('shows error when TOTP status fetch fails', async () => {
mockGetTotpStatus.mockRejectedValue(new Error('Network error'))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-error')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 3. Disabled state
// -------------------------------------------------------------------------
it('shows Disabled badge and enable button when TOTP is disabled', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
})
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 4. Enable flow: setup shows QR + recovery codes + confirm form
// -------------------------------------------------------------------------
it('shows QR code, secret, and recovery codes after clicking setup', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-qr-code')).toBeInTheDocument()
})
expect(screen.getByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP')
expect(screen.getByTestId('totp-recovery-codes')).toBeInTheDocument()
expect(screen.getByTestId('totp-recovery-codes')).toHaveTextContent('abcd-1234')
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
expect(screen.getByTestId('totp-confirm-input')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 5. Confirm with correct code → enabled
// -------------------------------------------------------------------------
it('enables TOTP after confirming with correct code', async () => {
mockGetTotpStatus
.mockResolvedValueOnce({ enabled: false })
.mockResolvedValueOnce({ enabled: true })
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
mockEnableTotp.mockResolvedValue(undefined)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '123456' } })
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
await waitFor(() => {
expect(mockEnableTotp).toHaveBeenCalledWith('123456')
})
// After enable the status refetches → shows enabled
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
})
})
// -------------------------------------------------------------------------
// 6. Confirm with wrong code → error
// -------------------------------------------------------------------------
it('shows error on wrong TOTP code during enable', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
const { ApiError } = await import('../api/client')
mockEnableTotp.mockRejectedValue(new ApiError(400, { detail: 'invalid code' }))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '000000' } })
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
await waitFor(() => {
expect(screen.getByTestId('totp-confirm-error')).toBeInTheDocument()
})
// Should still show confirm form (not enabled)
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 7. Enabled state
// -------------------------------------------------------------------------
it('shows Enabled badge and disable form when TOTP is enabled', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: true })
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
})
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
expect(screen.getByTestId('totp-disable-button')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 8. Disable with password
// -------------------------------------------------------------------------
it('disables TOTP when correct password is provided', async () => {
mockGetTotpStatus
.mockResolvedValueOnce({ enabled: true })
.mockResolvedValueOnce({ enabled: false })
mockDisableTotp.mockResolvedValue(undefined)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-disable-password'), {
target: { value: 'my-password' },
})
fireEvent.submit(screen.getByTestId('totp-disable-form'))
await waitFor(() => {
expect(mockDisableTotp).toHaveBeenCalledWith({ password: 'my-password', code: null })
})
await waitFor(() => {
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
})
})
// -------------------------------------------------------------------------
// 9. Disable with TOTP code
// -------------------------------------------------------------------------
it('disables TOTP when correct TOTP code is provided', async () => {
mockGetTotpStatus
.mockResolvedValueOnce({ enabled: true })
.mockResolvedValueOnce({ enabled: false })
mockDisableTotp.mockResolvedValue(undefined)
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('totp-disable-code'), { target: { value: '654321' } })
fireEvent.submit(screen.getByTestId('totp-disable-form'))
await waitFor(() => {
expect(mockDisableTotp).toHaveBeenCalledWith({ password: null, code: '654321' })
})
})
// -------------------------------------------------------------------------
// 10. Disable without credentials → validation error
// -------------------------------------------------------------------------
it('shows validation error when disable submitted without password or code', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: true })
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
})
// Submit without filling in anything
fireEvent.submit(screen.getByTestId('totp-disable-form'))
await waitFor(() => {
expect(screen.getByTestId('totp-disable-error')).toBeInTheDocument()
})
expect(mockDisableTotp).not.toHaveBeenCalled()
})
// -------------------------------------------------------------------------
// 11. Setup error
// -------------------------------------------------------------------------
it('shows error when TOTP setup request fails', async () => {
mockGetTotpStatus.mockResolvedValue({ enabled: false })
mockSetupTotp.mockRejectedValue(new Error('Network error'))
renderTotp()
await waitFor(() => {
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('totp-setup-button'))
await waitFor(() => {
expect(screen.getByTestId('totp-enable-error')).toBeInTheDocument()
})
})
})
+360
View File
@@ -0,0 +1,360 @@
/**
* TotpSettings — TOTP two-factor auth management panel (M4-T08).
*
* Intended to be embedded in the settings/config area of the app.
* Handles the full TOTP lifecycle:
* - Status probe : GET /api/auth/totp
* - Enable flow : setup → show QR code + one-time recovery codes → confirm with code
* - Disable flow : prompt for current password or TOTP code → disable
*
* Security notes:
* - secret and recovery_codes from TotpSetupResponse are held ONLY in component
* state; they are NEVER written to localStorage, sessionStorage, or console.
* - Recovery codes are displayed exactly once (during the setup flow); after the
* user confirms with a code and TOTP is enabled the data is discarded.
* - Passwords typed in the disable form are NEVER logged or persisted.
*/
import { useState, useCallback } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Paper,
Title,
Text,
Button,
Alert,
Stack,
Group,
TextInput,
PasswordInput,
Divider,
Badge,
Loader,
Center,
Code,
List,
} from '@mantine/core'
import { QRCodeSVG } from 'qrcode.react'
import { getTotpStatus, setupTotp, enableTotp, disableTotp } from '../auth/totp'
import { ApiError } from '../api/client'
import type { TotpSetupResponse } from '../auth/totp'
// ---------------------------------------------------------------------------
// Sub-panel: Enable flow
// ---------------------------------------------------------------------------
interface EnablePanelProps {
onEnabled: () => void
}
function EnablePanel({ onEnabled }: EnablePanelProps) {
const [setupData, setSetupData] = useState<TotpSetupResponse | null>(null)
const [confirmCode, setConfirmCode] = useState('')
const [status, setStatus] = useState<'idle' | 'setup-loading' | 'confirm' | 'confirming' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
async function handleSetup() {
setStatus('setup-loading')
setErrorMsg(null)
try {
const data = await setupTotp()
// data contains one-time secret + recovery_codes — keep only in state.
setSetupData(data)
setStatus('confirm')
} catch {
setErrorMsg('Failed to start TOTP setup. Please try again.')
setStatus('error')
}
}
async function handleEnable(e: React.FormEvent) {
e.preventDefault()
setErrorMsg(null)
setStatus('confirming')
try {
await enableTotp(confirmCode)
// Discard secret + recovery codes from memory now that TOTP is enabled.
setSetupData(null)
setConfirmCode('')
onEnabled()
} catch (err) {
let msg = 'Failed to enable TOTP. Check the code and try again.'
if (err instanceof ApiError && err.status === 400) {
msg = 'Incorrect code or no pending setup. Please start the setup again.'
}
setErrorMsg(msg)
setStatus('confirm')
}
}
// ---------------------------------------------------------------------------
// Render: idle — show "Start Setup" button
// ---------------------------------------------------------------------------
if (status === 'idle' || status === 'error') {
return (
<Stack gap="sm">
<Text size="sm">
Two-factor authentication is not enabled. Enable it to require a verification code
each time you sign in.
</Text>
{errorMsg && (
<Alert color="red" data-testid="totp-enable-error">
{errorMsg}
</Alert>
)}
<Button onClick={handleSetup} data-testid="totp-setup-button">
Enable Two-Factor Authentication
</Button>
</Stack>
)
}
// ---------------------------------------------------------------------------
// Render: setup-loading
// ---------------------------------------------------------------------------
if (status === 'setup-loading') {
return (
<Center>
<Loader data-testid="totp-setup-loading" />
</Center>
)
}
// ---------------------------------------------------------------------------
// Render: confirm — show QR code, secret, recovery codes, confirmation form
// ---------------------------------------------------------------------------
if ((status === 'confirm' || status === 'confirming') && setupData) {
return (
<Stack gap="md">
<Alert color="yellow" data-testid="totp-setup-warning">
Save your recovery codes now they will only be shown once.
</Alert>
<Text size="sm" fw={500}>
1. Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy):
</Text>
<Center data-testid="totp-qr-code">
<QRCodeSVG value={setupData.otpauth_uri} size={180} />
</Center>
<Text size="sm" c="dimmed" ta="center">
Or enter the key manually:
</Text>
<Code block data-testid="totp-secret">
{setupData.secret}
</Code>
<Divider />
<Text size="sm" fw={500}>
2. Save these recovery codes in a secure place. Each code can be used once
if you lose access to your authenticator:
</Text>
<Paper withBorder p="sm" data-testid="totp-recovery-codes">
<List spacing="xs" size="sm">
{setupData.recovery_codes.map((code) => (
<List.Item key={code}>
<Code>{code}</Code>
</List.Item>
))}
</List>
</Paper>
<Divider />
<Text size="sm" fw={500}>
3. Enter the 6-digit code from your authenticator app to confirm setup:
</Text>
{errorMsg && (
<Alert color="red" data-testid="totp-confirm-error">
{errorMsg}
</Alert>
)}
<form onSubmit={handleEnable} data-testid="totp-confirm-form">
<Stack gap="sm">
<TextInput
label="Verification Code"
placeholder="6-digit code"
value={confirmCode}
onChange={(e) => setConfirmCode(e.currentTarget.value)}
required
autoComplete="one-time-code"
inputMode="numeric"
data-testid="totp-confirm-input"
/>
<Button
type="submit"
loading={status === 'confirming'}
data-testid="totp-confirm-button"
>
Confirm and Enable
</Button>
</Stack>
</form>
</Stack>
)
}
return null
}
// ---------------------------------------------------------------------------
// Sub-panel: Disable flow
// ---------------------------------------------------------------------------
interface DisablePanelProps {
onDisabled: () => void
}
function DisablePanel({ onDisabled }: DisablePanelProps) {
const [disablePassword, setDisablePassword] = useState('')
const [disableCode, setDisableCode] = useState('')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleDisable(e: React.FormEvent) {
e.preventDefault()
setErrorMsg(null)
// At least one of password or code must be provided.
if (!disablePassword && !disableCode) {
setErrorMsg('Please provide your current password or a TOTP code.')
return
}
setIsSubmitting(true)
try {
await disableTotp({
password: disablePassword || null,
code: disableCode || null,
})
setDisablePassword('')
setDisableCode('')
onDisabled()
} catch (err) {
let msg = 'Failed to disable TOTP. Please try again.'
if (err instanceof ApiError && err.status === 400) {
msg = 'Incorrect password or code. Please try again.'
}
setErrorMsg(msg)
} finally {
setIsSubmitting(false)
}
}
return (
<Stack gap="md">
<Alert color="blue" data-testid="totp-enabled-notice">
Two-factor authentication is enabled. To disable it, enter your current password
or a valid verification code.
</Alert>
{errorMsg && (
<Alert color="red" data-testid="totp-disable-error">
{errorMsg}
</Alert>
)}
<form onSubmit={handleDisable} data-testid="totp-disable-form">
<Stack gap="sm">
<PasswordInput
label="Current Password"
placeholder="Enter your password (or use a TOTP code below)"
value={disablePassword}
onChange={(e) => setDisablePassword(e.currentTarget.value)}
autoComplete="current-password"
data-testid="totp-disable-password"
/>
<Text size="xs" c="dimmed" ta="center">
or
</Text>
<TextInput
label="TOTP Code"
placeholder="6-digit code from your authenticator"
value={disableCode}
onChange={(e) => setDisableCode(e.currentTarget.value)}
autoComplete="one-time-code"
inputMode="numeric"
data-testid="totp-disable-code"
/>
<Button
type="submit"
color="red"
variant="outline"
loading={isSubmitting}
data-testid="totp-disable-button"
>
Disable Two-Factor Authentication
</Button>
</Stack>
</form>
</Stack>
)
}
// ---------------------------------------------------------------------------
// TotpSettings — main exported component
// ---------------------------------------------------------------------------
export function TotpSettings() {
const queryClient = useQueryClient()
const {
data: totpStatus,
isLoading,
isError,
} = useQuery({
queryKey: ['totp-status'],
queryFn: getTotpStatus,
// No automatic retries — the test QueryClient is configured with retry: false.
// Network errors are shown immediately via the error state.
retry: false,
})
// Invalidate the TOTP status query to refetch after enable/disable.
const refetchStatus = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['totp-status'] })
}, [queryClient])
return (
<Paper withBorder p="md" radius="md" data-testid="totp-settings">
<Group justify="space-between" mb="md" wrap="nowrap">
<Title order={4}>Two-Factor Authentication (TOTP)</Title>
{totpStatus && (
<Badge
color={totpStatus.enabled ? 'green' : 'gray'}
variant="outline"
data-testid="totp-status-badge"
>
{totpStatus.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</Group>
{isLoading && (
<Center>
<Loader data-testid="totp-status-loading" />
</Center>
)}
{isError && (
<Alert color="red" data-testid="totp-status-error">
Failed to load TOTP status. Please refresh the page.
</Alert>
)}
{totpStatus && !totpStatus.enabled && <EnablePanel onEnabled={refetchStatus} />}
{totpStatus && totpStatus.enabled && <DisablePanel onDisabled={refetchStatus} />}
</Paper>
)
}