/** * ConfigPage — config editor (M2-T08). * * 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 { 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 {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. )}
) }