mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user