diff --git a/frontend/src/pages/ConfigPage.test.tsx b/frontend/src/pages/ConfigPage.test.tsx
new file mode 100644
index 0000000..52ef314
--- /dev/null
+++ b/frontend/src/pages/ConfigPage.test.tsx
@@ -0,0 +1,337 @@
+/**
+ * Tests for ConfigPage (M2-T08).
+ *
+ * Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
+ * without a real server.
+ *
+ * Coverage:
+ * 1. Renders config sections from a mocked GET /api/config response.
+ * 2. Secret fields start as empty (never display masked value).
+ * 3. Non-secret fields show their loaded values.
+ * 4. Save: updates map includes all non-secret fields and excludes untouched secrets.
+ * 5. Save: updates map includes a secret only when the user typed a new value.
+ * 6. Save success → shows success notice.
+ * 7. Save error → shows error notice.
+ * 8. SMTP test button: success state (200 result=success).
+ * 9. SMTP test button: config-error state (400/ApiError result=config-error).
+ * 10. SMTP test button: failed state (502/ApiError result=failed).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { renderWithProviders } from '../test-utils'
+import { ConfigPage } from './ConfigPage'
+
+// ---------------------------------------------------------------------------
+// Fixture: config sections
+// ---------------------------------------------------------------------------
+
+const MOCK_CONFIG = {
+ sections: [
+ {
+ name: 'General',
+ fields: [
+ { env_name: 'APP_NAME', label: 'App Name', value: 'My Home', secret: false, input_type: 'text', configured: true },
+ { env_name: 'APP_PORT', label: 'Port', value: '8000', secret: false, input_type: 'number', configured: true },
+ ],
+ },
+ {
+ name: 'SMTP',
+ fields: [
+ { env_name: 'SMTP_HOST', label: 'SMTP Host', value: 'smtp.example.com', secret: false, input_type: 'text', configured: true },
+ { env_name: 'SMTP_PASSWORD', label: 'SMTP Password', value: '', secret: true, input_type: 'password', configured: true },
+ ],
+ },
+ ],
+}
+
+// ---------------------------------------------------------------------------
+// Mock apiClient
+// ---------------------------------------------------------------------------
+
+const mockGet = vi.fn()
+const mockPut = vi.fn()
+const mockPost = vi.fn()
+
+vi.mock('../api/client', () => ({
+ default: {
+ GET: (...args: unknown[]) => mockGet(...args),
+ PUT: (...args: unknown[]) => mockPut(...args),
+ POST: (...args: unknown[]) => mockPost(...args),
+ },
+ 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(),
+}))
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function renderConfig() {
+ return renderWithProviders(, { initialPath: '/config' })
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('ConfigPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Default: GET /api/config returns the fixture
+ mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
+ })
+
+ // -------------------------------------------------------------------------
+ // 1. Renders sections
+ // -------------------------------------------------------------------------
+
+ it('renders section names and field labels', async () => {
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByText('General')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('SMTP')).toBeInTheDocument()
+ expect(screen.getByText('App Name')).toBeInTheDocument()
+ expect(screen.getByText('SMTP Host')).toBeInTheDocument()
+ expect(screen.getByText('SMTP Password')).toBeInTheDocument()
+ })
+
+ // -------------------------------------------------------------------------
+ // 2. Secret fields start empty
+ // -------------------------------------------------------------------------
+
+ it('renders secret fields with empty value (never displays masked value)', async () => {
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByText('SMTP Password')).toBeInTheDocument()
+ })
+
+ // Mantine puts data-testid on the element itself
+ const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
+ expect(secretInput.value).toBe('')
+ })
+
+ // -------------------------------------------------------------------------
+ // 3. Non-secret fields show their loaded values
+ // -------------------------------------------------------------------------
+
+ it('renders non-secret fields with their loaded values', async () => {
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('field-APP_NAME')).toBeInTheDocument()
+ })
+
+ // Mantine puts data-testid on the element itself for TextInput
+ const appNameInput = screen.getByTestId('field-APP_NAME') as HTMLInputElement
+ expect(appNameInput.value).toBe('My Home')
+
+ const smtpHostInput = screen.getByTestId('field-SMTP_HOST') as HTMLInputElement
+ expect(smtpHostInput.value).toBe('smtp.example.com')
+ })
+
+ // -------------------------------------------------------------------------
+ // 4. Save: updates includes all non-secrets, excludes untouched secrets
+ // -------------------------------------------------------------------------
+
+ it('save sends all non-secret fields and excludes untouched (blank) secrets', async () => {
+ mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
+ // After save, refetch
+ mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
+
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('config-form')).toBeInTheDocument()
+ })
+
+ // Submit without touching any field
+ fireEvent.submit(screen.getByTestId('config-form'))
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalled()
+ })
+
+ const putCall = mockPut.mock.calls[0]
+ const body = putCall[1].body as { updates: Record }
+ const updates = body.updates
+
+ // Non-secret fields MUST be present
+ expect(updates).toHaveProperty('APP_NAME', 'My Home')
+ expect(updates).toHaveProperty('APP_PORT', '8000')
+ expect(updates).toHaveProperty('SMTP_HOST', 'smtp.example.com')
+
+ // Untouched secret field MUST NOT be present
+ expect(updates).not.toHaveProperty('SMTP_PASSWORD')
+ })
+
+ // -------------------------------------------------------------------------
+ // 5. Save: updates includes secret when user typed a new value
+ // -------------------------------------------------------------------------
+
+ it('save includes a secret field when the user typed a new value', async () => {
+ mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
+ mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
+
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('field-secret-SMTP_PASSWORD')).toBeInTheDocument()
+ })
+
+ // Mantine puts data-testid on the element itself
+ const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
+ fireEvent.change(secretInput, { target: { value: 'new-secret-value' } })
+
+ fireEvent.submit(screen.getByTestId('config-form'))
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalled()
+ })
+
+ const putCall = mockPut.mock.calls[0]
+ const body = putCall[1].body as { updates: Record }
+ const updates = body.updates
+
+ // Secret MUST be included because the user typed a value
+ expect(updates).toHaveProperty('SMTP_PASSWORD', 'new-secret-value')
+ // Non-secrets still present
+ expect(updates).toHaveProperty('APP_NAME', 'My Home')
+ })
+
+ // -------------------------------------------------------------------------
+ // 6. Save success → shows success notice
+ // -------------------------------------------------------------------------
+
+ it('shows success alert after a successful save', async () => {
+ mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
+
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('config-form')).toBeInTheDocument()
+ })
+
+ fireEvent.submit(screen.getByTestId('config-form'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('save-success')).toBeInTheDocument()
+ })
+
+ expect(screen.queryByTestId('save-error')).not.toBeInTheDocument()
+ })
+
+ // -------------------------------------------------------------------------
+ // 7. Save error → shows error notice
+ // -------------------------------------------------------------------------
+
+ it('shows error alert when save fails', async () => {
+ const { ApiError } = await import('../api/client')
+ mockPut.mockRejectedValueOnce(new ApiError(422, { detail: 'invalid value' }))
+
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('config-form')).toBeInTheDocument()
+ })
+
+ fireEvent.submit(screen.getByTestId('config-form'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('save-error')).toBeInTheDocument()
+ })
+
+ expect(screen.queryByTestId('save-success')).not.toBeInTheDocument()
+ })
+
+ // -------------------------------------------------------------------------
+ // 8. SMTP test button: success state
+ // -------------------------------------------------------------------------
+
+ it('shows success alert after SMTP test succeeds', async () => {
+ mockPost.mockResolvedValueOnce({
+ data: { result: 'success', message: 'Email delivered.' },
+ response: { status: 200, ok: true },
+ })
+
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('smtp-test-button'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('smtp-result-success')).toBeInTheDocument()
+ })
+
+ expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
+ })
+
+ // -------------------------------------------------------------------------
+ // 9. SMTP test button: config-error state (400)
+ // -------------------------------------------------------------------------
+
+ it('shows config-error alert when SMTP test returns config-error', async () => {
+ const { ApiError } = await import('../api/client')
+ mockPost.mockRejectedValueOnce(
+ new ApiError(400, { result: 'config-error', message: 'SMTP host not configured.' }),
+ )
+
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('smtp-test-button'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('smtp-result-config-error')).toBeInTheDocument()
+ })
+
+ expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
+ })
+
+ // -------------------------------------------------------------------------
+ // 10. SMTP test button: failed state (502)
+ // -------------------------------------------------------------------------
+
+ it('shows failed alert when SMTP test returns failed', async () => {
+ const { ApiError } = await import('../api/client')
+ mockPost.mockRejectedValueOnce(
+ new ApiError(502, { result: 'failed', message: 'Connection refused.' }),
+ )
+
+ renderConfig()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('smtp-test-button'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('smtp-result-failed')).toBeInTheDocument()
+ })
+
+ expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx
index c13f57c..760bf33 100644
--- a/frontend/src/pages/ConfigPage.tsx
+++ b/frontend/src/pages/ConfigPage.tsx
@@ -1,19 +1,398 @@
/**
- * ConfigPage — placeholder for M2-T08.
+ * ConfigPage — config editor (M2-T08).
*
- * T08 replaces this with the real config UI: all config sections rendered as editable
- * fields, secret masking, save button (PUT /api/config), and SMTP test action.
+ * Behaviours:
+ * 1. Load config: GET /api/config → render sections (grouped) with Mantine inputs.
+ * - Non-secret fields show their value.
+ * - Secret fields render as empty PasswordInput (never show a masked value).
+ * 2. Save config: PUT /api/config with full-field submission semantics.
+ * - All non-secret fields are ALWAYS included (to avoid backend zeroing absent fields).
+ * - Secret fields are included ONLY when the user typed a new (non-empty) value.
+ * - On success: show a success notice and refetch config.
+ * - On ApiError 422: show an error notice, nothing was written.
+ * 3. SMTP test button: POST /api/config/smtp/test.
+ * - Tri-state: success / config-error / failed.
+ * - Errors read `err.body.result` from ApiError.
*/
-import { Container, Title, Text } from '@mantine/core'
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import {
+ Container,
+ Title,
+ Text,
+ TextInput,
+ PasswordInput,
+ Button,
+ Alert,
+ Stack,
+ Group,
+ Divider,
+ Loader,
+ Center,
+ Paper,
+ Badge,
+} from '@mantine/core'
+import apiClient, { ApiError } from '../api/client'
+import type { components } from '../api/schema.d.ts'
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type ConfigField = components['schemas']['ConfigField']
+type ConfigSection = components['schemas']['ConfigSection']
+
+/** SMTP test result tri-state. */
+type SmtpResult =
+ | { kind: 'success'; message: string }
+ | { kind: 'config-error'; message: string }
+ | { kind: 'failed'; message: string }
+ | null
+
+// ---------------------------------------------------------------------------
+// Hook: load config
+// ---------------------------------------------------------------------------
+
+function useConfig() {
+ return useQuery({
+ queryKey: ['config'],
+ queryFn: async () => {
+ const res = await apiClient.GET('/api/config')
+ return res.data
+ },
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Helper: build updates map for PUT /api/config
+//
+// Full-field submission semantics (§6):
+// - Non-secret fields: ALWAYS include current value (even if unchanged) so
+// the backend does not zero out absent fields.
+// - Secret fields: include ONLY when the user typed a non-empty value.
+// Blank secret = keep old value; sending blank would also keep it per
+// backend semantics, but we omit it to be explicit and avoid confusion.
+// ---------------------------------------------------------------------------
+
+function buildUpdates(
+ sections: ConfigSection[],
+ localValues: Record,
+): Record {
+ const updates: Record = {}
+
+ for (const section of sections) {
+ for (const field of section.fields) {
+ const localVal = localValues[field.env_name] ?? ''
+ if (field.secret) {
+ // Only include secret if the user typed something (non-empty).
+ if (localVal !== '') {
+ updates[field.env_name] = localVal
+ }
+ // blank secret → omit → backend keeps the existing stored value
+ } else {
+ // Non-secret: always include current local value.
+ updates[field.env_name] = localVal
+ }
+ }
+ }
+
+ return updates
+}
+
+// ---------------------------------------------------------------------------
+// ConfigFieldInput — renders a single config field
+// ---------------------------------------------------------------------------
+
+interface ConfigFieldInputProps {
+ field: ConfigField
+ value: string
+ onChange: (envName: string, value: string) => void
+}
+
+function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
+ const handleChange = (e: React.ChangeEvent) => {
+ onChange(field.env_name, e.currentTarget.value)
+ }
+
+ if (field.secret) {
+ return (
+
+ )
+ }
+
+ if (field.input_type === 'number') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// ConfigSectionPanel — one section
+// ---------------------------------------------------------------------------
+
+interface ConfigSectionPanelProps {
+ section: ConfigSection
+ localValues: Record
+ onChange: (envName: string, value: string) => void
+}
+
+function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
+ return (
+
+
+ {section.name}
+
+
+ {section.fields.map((field) => (
+
+ ))}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// SmtpTestButton — sends POST /api/config/smtp/test and displays tri-state result
+// ---------------------------------------------------------------------------
+
+interface SmtpTestButtonProps {
+ smtpResult: SmtpResult
+ setSmtpResult: (r: SmtpResult) => void
+}
+
+function SmtpTestButton({ smtpResult, setSmtpResult }: SmtpTestButtonProps) {
+ const [testing, setTesting] = useState(false)
+
+ async function handleTest() {
+ setSmtpResult(null)
+ setTesting(true)
+ try {
+ const res = await apiClient.POST('/api/config/smtp/test')
+ if (res.data) {
+ setSmtpResult({ kind: 'success', message: res.data.message })
+ }
+ } catch (err) {
+ if (err instanceof ApiError) {
+ const body = err.body as { result?: string; message?: string } | null
+ const result = body?.result
+ const message = body?.message ?? 'Unknown error'
+ if (result === 'config-error') {
+ setSmtpResult({ kind: 'config-error', message })
+ } else {
+ // result === 'failed' or any other error
+ setSmtpResult({ kind: 'failed', message })
+ }
+ } else {
+ setSmtpResult({ kind: 'failed', message: 'Unexpected error sending test email.' })
+ }
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ return (
+
+
+
+ {smtpResult?.kind === 'success' && (
+
+ Test email sent successfully. {smtpResult.message}
+
+ )}
+ {smtpResult?.kind === 'config-error' && (
+
+ SMTP configuration error — check your SMTP settings. {smtpResult.message}
+
+ )}
+ {smtpResult?.kind === 'failed' && (
+
+ Test email send failed. {smtpResult.message}
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// ConfigPage — main component
+// ---------------------------------------------------------------------------
export function ConfigPage() {
+ const queryClient = useQueryClient()
+ const { data, isLoading, isError } = useConfig()
+
+ // Local field values — mirrors the loaded config but allows user edits.
+ // Secret fields always start as empty string (never display masked values).
+ const [localValues, setLocalValues] = useState>({})
+ const [valuesInitialized, setValuesInitialized] = useState(false)
+
+ // Initialise local state once when data arrives (or re-arrives after refetch).
+ if (data && !valuesInitialized) {
+ const initial: Record = {}
+ for (const section of data.sections) {
+ for (const field of section.fields) {
+ // Secret fields start empty (never display the masked/empty backend value).
+ initial[field.env_name] = field.secret ? '' : (field.value ?? '')
+ }
+ }
+ setLocalValues(initial)
+ setValuesInitialized(true)
+ }
+
+ // Save notice state
+ const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null)
+
+ // SMTP test tri-state
+ const [smtpResult, setSmtpResult] = useState(null)
+
+ function handleChange(envName: string, value: string) {
+ setLocalValues((prev) => ({ ...prev, [envName]: value }))
+ setSaveStatus(null)
+ }
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ if (!data) return
+ const updates = buildUpdates(data.sections, localValues)
+ await apiClient.PUT('/api/config', { body: { updates } })
+ },
+ })
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ setSaveStatus(null)
+ try {
+ await saveMutation.mutateAsync()
+ setSaveStatus('success')
+ // Refetch config so the page reflects the saved state.
+ await queryClient.invalidateQueries({ queryKey: ['config'] })
+ // After refetch, reset initialised flag so local state rebuilds from fresh data.
+ setValuesInitialized(false)
+ } catch {
+ setSaveStatus('error')
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Render states
+ // ---------------------------------------------------------------------------
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (isError || !data) {
+ return (
+
+
+ Failed to load configuration. Please refresh the page.
+
+
+ )
+ }
+
+ // Detect if there is an SMTP section (to show the test button).
+ const hasSmtpSection = data.sections.some((s) =>
+ s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
+ )
+
return (
-
- Configuration
-
- Config editor — implemented in M2-T08.
-
+
+
+ Configuration
+
+ {data.sections.length} section{data.sections.length !== 1 ? 's' : ''}
+
+
+
+
+
+ {!hasSmtpSection && (
+
+
+ Configure SMTP settings to enable email notifications.
+
+
+ )}
)
}