diff --git a/client/src/components/Admin/storage/BackendForm.test.tsx b/client/src/components/Admin/storage/BackendForm.test.tsx new file mode 100644 index 000000000..ea5df7472 --- /dev/null +++ b/client/src/components/Admin/storage/BackendForm.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared'; +import { fireEvent, render, screen } from '../../../../tests/helpers/render'; +import BackendForm from './BackendForm'; + +const NAMES = ['uploads-local', 'backups-local', 'off-box']; + +type FormProps = Parameters[0]; + +function renderForm(overrides: Partial = {}) { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { onCommit, onCancel }; +} + +const S3_INITIAL: StorageBackend = { + name: 'off-box', + type: 's3', + options: { + endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', + secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000, + }, +}; + +describe('BackendForm', () => { + it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => { + renderForm(); + expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument(); + expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => { + renderForm({ initial: S3_INITIAL }); + expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text'); + expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password'); + expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1'); + expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number'); + expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1'); + expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000'); + }); + + it('FE-ADMIN-STORF-003: mirror renders the composer — primary select and replica checkboxes over the other backends', () => { + renderForm({ + initial: { name: 'mir', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } }, + }); + // The composer is just the two ref kinds rendering: a select and checkboxes. + expect(screen.getByText(/Primary backend/)).toBeInTheDocument(); + const replicas = screen.getAllByRole('checkbox'); + // Own name excluded: uploads-local, backups-local, off-box minus 'mir' (not in NAMES anyway) → 3 + expect(replicas).toHaveLength(3); + expect(screen.getByRole('checkbox', { name: 'off-box' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'backups-local' })).not.toBeChecked(); + }); + + it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => { + const { onCommit } = renderForm({ initial: S3_INITIAL }); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onCommit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'off-box', + type: 's3', + options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }), + }), + ); + }); + + it('FE-ADMIN-STORF-005: a typed plaintext secret with encryption not ready replaces Apply with the ENCRYPTION_KEY banner', () => { + renderForm({ initial: S3_INITIAL, encryptionReady: false }); + // The untouched mask is fine — Apply still offered. + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } }); + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument(); + expect(screen.getByRole('alert').textContent).toContain('ENCRYPTION_KEY'); + }); + + it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => { + const { onCommit } = renderForm(); + const apply = screen.getByRole('button', { name: 'Apply' }); + expect(apply).toBeDisabled(); + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } }); + expect(apply).toBeDisabled(); // root still empty + fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } }); + expect(apply).toBeEnabled(); + fireEvent.click(apply); + expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }); + }); + + it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => { + renderForm(); + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } }); + fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } }); + expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + }); + + it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => { + const { onCommit } = renderForm(); + // Switch to s3 via the type select (CustomSelect renders options into a portal). + fireEvent.click(screen.getByText('Local')); + fireEvent.click(screen.getByText('S3')); + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } }); + fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } }); + fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } }); + fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } }); + fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + const committed = onCommit.mock.calls[0]![0] as StorageBackend; + expect(committed.options).toEqual({ + endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk', + }); + expect(committed.options).not.toHaveProperty('region'); + }); +}); diff --git a/client/src/components/Admin/storage/BackendForm.tsx b/client/src/components/Admin/storage/BackendForm.tsx new file mode 100644 index 000000000..5574c7c21 --- /dev/null +++ b/client/src/components/Admin/storage/BackendForm.tsx @@ -0,0 +1,222 @@ +import React, { useState } from 'react' +import { + STORAGE_BACKEND_TYPES, + STORAGE_BACKEND_TYPE_IDS, + type StorageBackend, + type StorageBackendFieldDef, + type StorageBackendTypeId, +} from '@trek/shared' +import { useTranslation } from '../../../i18n' +import CustomSelect from '../../shared/CustomSelect' +import { hasPlaintextSecret } from './storageModel' + +type FieldValues = Record + +function valuesOf(backend: StorageBackend | null): FieldValues { + if (!backend) return {} + const values: FieldValues = {} + for (const [key, value] of Object.entries(backend.options)) { + values[key] = Array.isArray(value) ? value : String(value) + } + return values +} + +interface BackendFormProps { + /** null = new backend */ + initial: StorageBackend | null + /** Every defined backend name — mirror-target options and the duplicate pre-check. */ + backendNames: string[] + encryptionReady: boolean + onCommit: (backend: StorageBackend) => void + onCancel: () => void +} + +const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary' +const INPUT_CLASS = + 'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content' + +/** + * Renders whatever STORAGE_BACKEND_TYPES declares, by field kind — the whole + * mirror composer is just backend-ref/backend-ref-list rendering as selects. + * A future backend type needs no changes here. + */ +export default function BackendForm({ + initial, + backendNames, + encryptionReady, + onCommit, + onCancel, +}: BackendFormProps): React.ReactElement { + const { t } = useTranslation() + const [type, setType] = useState(initial?.type ?? 'local') + const [name, setName] = useState(initial?.name ?? '') + const [values, setValues] = useState(() => valuesOf(initial)) + + const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[] + const refOptions = backendNames.filter((candidate) => candidate !== name.trim()) + const setValue = (key: string, value: string | string[]) => + setValues((prev) => ({ ...prev, [key]: value })) + + const filled = (field: StorageBackendFieldDef): boolean => { + const value = values[field.key] + if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0 + return typeof value === 'string' && value.trim() !== '' + } + const duplicate = initial === null && backendNames.includes(name.trim()) + const blocked = hasPlaintextSecret({ type, options: values }) && !encryptionReady + const canApply = + name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f)) + + const apply = () => { + const options: Record = {} + for (const field of fields) { + const value = values[field.key] + if (field.kind === 'backend-ref-list') { + options[field.key] = Array.isArray(value) ? value : [] + continue + } + const text = typeof value === 'string' ? value : '' + if (text === '' && !field.required) continue // omitted → the shared schema default applies + options[field.key] = field.kind === 'number' ? Number(text) : text + } + // The options were built from the same field defs the schema is generated + // from — this is the same sanctioned cast storageModel.asWireBackend makes. + onCommit({ name: name.trim(), type, options } as StorageBackend) + } + + const renderField = (field: StorageBackendFieldDef): React.ReactElement => { + const value = values[field.key] + if (field.kind === 'backend-ref') { + return ( +
+ {t(field.labelKey)} + setValue(field.key, String(next))} + options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))} + placeholder={t(field.labelKey)} + size="sm" + /> +
+ ) + } + if (field.kind === 'backend-ref-list') { + const selected = Array.isArray(value) ? value : [] + return ( +
+ {t(field.labelKey)} +
+ {refOptions.map((candidate) => ( + + ))} +
+
+ ) + } + const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text' + return ( + + ) + } + + return ( +
+

+ {initial ? t('storage.form.editTitle') : t('storage.form.addTitle')} +

+ + + {duplicate && ( +

+ {t('storage.form.duplicateName', { name: name.trim() })} +

+ )} + +
+ {t('storage.form.type')} + { + setType(next as StorageBackendTypeId) + setValues({}) // a different type has different fields + }} + options={STORAGE_BACKEND_TYPE_IDS.map((id) => ({ value: id, label: t(`storage.type.${id}`) }))} + size="sm" + disabled={initial !== null} + /> +
+ + {fields.map(renderField)} + +
+ {blocked ? ( +

+ {t('storage.form.encryptionBanner')} +

+ ) : ( + + )} + +
+
+ ) +}