feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

This commit is contained in:
jubnl
2026-08-20 14:08:02 +02:00
parent 0d9386f715
commit f1be56cede
2 changed files with 345 additions and 0 deletions
@@ -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<typeof BackendForm>[0];
function renderForm(overrides: Partial<FormProps> = {}) {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(
<BackendForm
initial={null}
backendNames={NAMES}
encryptionReady
onCommit={onCommit}
onCancel={onCancel}
{...overrides}
/>,
);
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');
});
});
@@ -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<string, string | string[]>
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<StorageBackendTypeId>(initial?.type ?? 'local')
const [name, setName] = useState(initial?.name ?? '')
const [values, setValues] = useState<FieldValues>(() => 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<string, unknown> = {}
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 (
<div key={field.key}>
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
<CustomSelect
value={typeof value === 'string' ? value : ''}
onChange={(next) => setValue(field.key, String(next))}
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
placeholder={t(field.labelKey)}
size="sm"
/>
</div>
)
}
if (field.kind === 'backend-ref-list') {
const selected = Array.isArray(value) ? value : []
return (
<div key={field.key}>
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
<div className="space-y-1">
{refOptions.map((candidate) => (
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
<input
type="checkbox"
checked={selected.includes(candidate)}
onChange={(e) =>
setValue(
field.key,
e.target.checked
? [...selected, candidate]
: selected.filter((existing) => existing !== candidate),
)
}
/>
{candidate}
</label>
))}
</div>
</div>
)
}
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
return (
<label key={field.key} className={LABEL_CLASS}>
{t(field.labelKey)}
<input
type={inputType}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setValue(field.key, e.target.value)}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
spellCheck={false}
autoComplete="off"
className={INPUT_CLASS}
/>
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
</label>
)
}
return (
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
<p className="text-sm font-semibold text-content">
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
</p>
<label className={LABEL_CLASS}>
{t('storage.form.name')}
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
spellCheck={false}
autoComplete="off"
className={INPUT_CLASS}
/>
</label>
{duplicate && (
<p className="text-xs text-content-faint" role="alert">
{t('storage.form.duplicateName', { name: name.trim() })}
</p>
)}
<div>
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
<CustomSelect
value={type}
onChange={(next) => {
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}
/>
</div>
{fields.map(renderField)}
<div className="flex items-center gap-3">
{blocked ? (
<p
role="alert"
className="text-sm rounded-lg border px-3 py-2 border-edge bg-surface-secondary text-content"
>
{t('storage.form.encryptionBanner')}
</p>
) : (
<button
onClick={apply}
disabled={!canApply}
style={{
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
}}
>
{t('storage.form.apply')}
</button>
)}
<button
onClick={onCancel}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
}}
>
{t('storage.form.cancel')}
</button>
</div>
</div>
)
}