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
This commit is contained in:
@@ -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(<ConfigPage />, { 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 <input> 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 <input> 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<string, string> }
|
||||||
|
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 <input> 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<string, string> }
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
* Behaviours:
|
||||||
* fields, secret masking, save button (PUT /api/config), and SMTP test action.
|
* 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<string, string>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const updates: Record<string, string> = {}
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
onChange(field.env_name, e.currentTarget.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.secret) {
|
||||||
|
return (
|
||||||
|
<PasswordInput
|
||||||
|
label={field.label}
|
||||||
|
placeholder={field.configured ? '(configured — leave blank to keep)' : 'Enter value'}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid={`field-secret-${field.env_name}`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.input_type === 'number') {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label={field.label}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid={`field-${field.env_name}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label={field.label}
|
||||||
|
type={field.input_type === 'email' ? 'email' : 'text'}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid={`field-${field.env_name}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfigSectionPanel — one section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ConfigSectionPanelProps {
|
||||||
|
section: ConfigSection
|
||||||
|
localValues: Record<string, string>
|
||||||
|
onChange: (envName: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
{section.name}
|
||||||
|
</Title>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{section.fields.map((field) => (
|
||||||
|
<ConfigFieldInput
|
||||||
|
key={field.env_name}
|
||||||
|
field={field}
|
||||||
|
value={localValues[field.env_name] ?? ''}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
loading={testing}
|
||||||
|
data-testid="smtp-test-button"
|
||||||
|
>
|
||||||
|
Send Test Email
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{smtpResult?.kind === 'success' && (
|
||||||
|
<Alert color="green" data-testid="smtp-result-success">
|
||||||
|
Test email sent successfully. {smtpResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{smtpResult?.kind === 'config-error' && (
|
||||||
|
<Alert color="orange" data-testid="smtp-result-config-error">
|
||||||
|
SMTP configuration error — check your SMTP settings. {smtpResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{smtpResult?.kind === 'failed' && (
|
||||||
|
<Alert color="red" data-testid="smtp-result-failed">
|
||||||
|
Test email send failed. {smtpResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfigPage — main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function ConfigPage() {
|
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<Record<string, string>>({})
|
||||||
|
const [valuesInitialized, setValuesInitialized] = useState(false)
|
||||||
|
|
||||||
|
// Initialise local state once when data arrives (or re-arrives after refetch).
|
||||||
|
if (data && !valuesInitialized) {
|
||||||
|
const initial: Record<string, string> = {}
|
||||||
|
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<SmtpResult>(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 (
|
||||||
|
<Center pt="xl">
|
||||||
|
<Loader data-testid="config-loading" />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
return (
|
return (
|
||||||
<Container pt="xl">
|
<Container pt="xl">
|
||||||
<Title order={2}>Configuration</Title>
|
<Alert color="red" data-testid="config-load-error">
|
||||||
<Text c="dimmed" mt="sm">
|
Failed to load configuration. Please refresh the page.
|
||||||
Config editor — implemented in M2-T08.
|
</Alert>
|
||||||
</Text>
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
|
||||||
|
<Group justify="space-between" mb="lg" wrap="nowrap">
|
||||||
|
<Title order={2}>Configuration</Title>
|
||||||
|
<Badge variant="outline" color="gray" size="sm">
|
||||||
|
{data.sections.length} section{data.sections.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} data-testid="config-form">
|
||||||
|
<Stack gap="lg">
|
||||||
|
{data.sections.map((section) => (
|
||||||
|
<ConfigSectionPanel
|
||||||
|
key={section.name}
|
||||||
|
section={section}
|
||||||
|
localValues={localValues}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{saveStatus === 'success' && (
|
||||||
|
<Alert color="green" data-testid="save-success">
|
||||||
|
Configuration saved successfully.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<Alert color="red" data-testid="save-error">
|
||||||
|
Failed to save configuration. Please check the values and try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
data-testid="config-save-button"
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasSmtpSection && (
|
||||||
|
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!hasSmtpSection && (
|
||||||
|
<Stack mt="md">
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Configure SMTP settings to enable email notifications.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user