From ef2bd3c9c59b142675bab6425eaa97b69d7dc9f8 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sat, 13 Jun 2026 10:21:10 +0200 Subject: [PATCH] M2-T08: build config UI (replaces Jinja config page) - GET /api/config renders sections; secret fields shown as empty password inputs - save handles full-field submission semantics: always send non-secret values, send secret only when user typed a new value (blank secret keeps old) - SMTP test button reflects tri-state (success / config-error 400 / failed 502) by reading ApiError.body.result - typed client only; responsive Mantine layout; vitest tests --- frontend/src/pages/ConfigPage.test.tsx | 337 +++++++++++++++++++++ frontend/src/pages/ConfigPage.tsx | 397 ++++++++++++++++++++++++- 2 files changed, 725 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/ConfigPage.test.tsx 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' : ''} + + + +
+ + {data.sections.map((section) => ( + + ))} + + + + {saveStatus === 'success' && ( + + Configuration saved successfully. + + )} + {saveStatus === 'error' && ( + + Failed to save configuration. Please check the values and try again. + + )} + + + + + {hasSmtpSection && ( + + )} + + +
+ + {!hasSmtpSection && ( + + + Configure SMTP settings to enable email notifications. + + + )}
) }