diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx index ff5c26d38..5f4923281 100644 --- a/client/src/mobile/screens/admin/MAdmin.tsx +++ b/client/src/mobile/screens/admin/MAdmin.tsx @@ -10,6 +10,7 @@ import { Database, FileText, Github, + HardDrive, Map, Plug, Puzzle, @@ -39,6 +40,7 @@ import MAdminPackingTemplateManager from './MAdminPackingTemplateManager' import MAdminNotificationsSection from './MAdminNotificationsSection' import MAdminSettingsSection from './MAdminSettingsSection' import MAdminSheets from './MAdminSheets' +import MAdminStoragePanel from './MAdminStoragePanel' import MAdminUsersSection from './MAdminUsersSection' import { MAdminButton } from './MAdminUi' @@ -86,6 +88,7 @@ export default function MAdmin() { { id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon }, { id: 'addons', label: t('admin.tabs.addons'), icon: Blocks }, { id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle }, + ...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive }]), { id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell }, ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: Plug }] : []), // Same two the desktop list drops: releases and backup schedule belong to @@ -238,6 +241,7 @@ export default function MAdmin() { /> )} {activeTab === 'plugins' && } + {activeTab === 'storage' && } {activeTab === 'mcp-tokens' && } {activeTab === 'github' && } {activeTab === 'backup' && } diff --git a/client/src/mobile/screens/admin/MAdminStoragePanel.tsx b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx new file mode 100644 index 000000000..c8ad9a9f2 --- /dev/null +++ b/client/src/mobile/screens/admin/MAdminStoragePanel.tsx @@ -0,0 +1,468 @@ +import React, { useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { + STORAGE_BACKEND_TYPES, + STORAGE_BACKEND_TYPE_IDS, + STORAGE_CATEGORIES, + type StorageBackend, + type StorageBackendFieldDef, + type StorageBackendTypeId, + type StorageCategory, +} from '@trek/shared' +import { useTranslation } from '../../../i18n' +import { useToast } from '../../../components/shared/Toast' +import { relativeTime } from '../../../utils/relativeTime' +import { + asWireBackend, + categoriesPointingAt, + hasPlaintextSecret, + mirrorsReferencing, + removeBackend, + upsertBackend, + type StateBackend, +} from '../../../components/Admin/storage/storageModel' +import { useStorageAdmin } from '../../../components/Admin/storage/useStorageAdmin' +import MToggle from '../../components/MToggle' +import MSetPickerSheet from '../settings/MSetPickerSheet' +import MConfirmSheet from '../settings/MConfirmSheet' +import { MSetSelectRow } from '../settings/MSettingsUi' +import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' + +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 +} + +/** Same behavior contract as the desktop BackendForm, rendered on the M* primitives. */ +function MBackendForm({ + initial, + backendNames, + encryptionReady, + onCommit, + onCancel, +}: { + initial: StorageBackend | null + backendNames: string[] + encryptionReady: boolean + onCommit: (backend: StorageBackend) => void + onCancel: () => void +}): 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 [picker, setPicker] = useState(null) + + 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 + options[field.key] = field.kind === 'number' ? Number(text) : text + } + onCommit({ name: name.trim(), type, options } as StorageBackend) + } + + return ( + + + + setName(e.target.value)} spellCheck={false} autoComplete="off" /> + + {duplicate && ( +

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

+ )} + {initial === null && ( + + } + onClick={() => setPicker('type')} + /> + setPicker(null)} + title={t('storage.form.type')} + options={STORAGE_BACKEND_TYPE_IDS.map((id) => ({ value: id, label: t(`storage.type.${id}`) }))} + value={type} + onSelect={(next) => { + setType(next as StorageBackendTypeId) + setValues({}) + }} + /> + + )} + + {fields.map((field) => { + const value = values[field.key] + if (field.kind === 'backend-ref') { + const current = typeof value === 'string' ? value : '' + return ( + + } + onClick={() => setPicker(`field:${field.key}`)} + /> + setPicker(null)} + title={t(field.labelKey)} + options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))} + value={current} + onSelect={(next) => setValue(field.key, next)} + /> + + ) + } + if (field.kind === 'backend-ref-list') { + const selected = Array.isArray(value) ? value : [] + return ( + +
+ {refOptions.map((candidate) => ( +
+ {candidate} + + setValue( + field.key, + checked ? [...selected, candidate] : selected.filter((existing) => existing !== candidate), + ) + } + /> +
+ ))} +
+
+ ) + } + const text = typeof value === 'string' ? value : '' + const shared = { + value: text, + onChange: (e: React.ChangeEvent) => setValue(field.key, e.target.value), + placeholder: field.defaultValue !== undefined ? String(field.defaultValue) : '', + spellCheck: false, + autoComplete: 'off', + } + return ( + + {field.kind === 'secret' ? ( + + ) : ( + + )} + + ) + })} + +
+ {blocked ? ( +

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

+ ) : ( + + {t('storage.form.apply')} + + )} + + {t('storage.form.cancel')} + +
+
+ ) +} + +export default function MAdminStoragePanel(): React.ReactElement { + const { t, locale } = useTranslation() + const toast = useToast() + const admin = useStorageAdmin(t('common.error')) + const [editing, setEditing] = useState<{ initial: StorageBackend | null; originalName: string | null } | null>(null) + const [confirmRemove, setConfirmRemove] = useState(null) + const [categoryPicker, setCategoryPicker] = useState(null) + + if (admin.loading) { + return ( + +

{t('storage.loading')}

+
+ ) + } + if (!admin.state || !admin.draft) { + return ( + +

{admin.loadError || t('common.error')}

+
+ ) + } + const { state, draft } = admin + + const draftNames = draft.backends.map((b) => b.name) + const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draftNames])] + const stateByName = new Map(state.backends.map((b) => [b.name, b])) + + interface Row { + name: string + type: StorageBackend['type'] + source: 'built-in' | 'env' | 'settings' + backend: StorageBackend + categories: readonly string[] + } + const rows: Row[] = [] + for (const b of state.backends) { + if (draftNames.includes(b.name)) continue + if (b.source === 'settings') continue + rows.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b), categories: b.categories }) + } + for (const b of draft.backends) { + const inState = stateByName.get(b.name) as StateBackend | undefined + rows.push({ + name: b.name, + type: b.type, + source: 'settings', + backend: b, + categories: inState?.categories ?? categoriesPointingAt(draft, b.name), + }) + } + + const commitBackend = (backend: StorageBackend) => { + const renamedFrom = editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null + const base = renamedFrom ? removeBackend(draft, renamedFrom) : draft + admin.setDraft(upsertBackend(base, backend)) + setEditing(null) + } + + const removeMessage = (name: string): string => { + const assigned = categoriesPointingAt(draft, name) + const mirrors = mirrorsReferencing(draft, name) + return [ + t('storage.remove.body', { name }), + assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: assigned.join(', ') }) : '', + mirrors.length > 0 ? t('storage.remove.referencedBy', { backends: mirrors.join(', ') }) : '', + ] + .filter(Boolean) + .join(' ') + } + + const setCategory = (category: StorageCategory, backendName: string) => { + const effective = state.categories[category] + const categories = { ...draft.categories } + if (effective.source === 'default' && backendName === effective.backend) { + delete categories[category] + } else { + categories[category] = backendName + } + admin.setDraft({ ...draft, categories }) + } + + const save = async () => { + if (await admin.save()) toast.success(t('storage.saved')) + } + + return ( +
+ + + {state.health.replicaFailures.length === 0 ? ( +

{t('storage.health.allClear')}

+ ) : ( +
+ {state.health.replicaFailures.map((failure) => ( +

+ {t('storage.health.failureLine', { + op: failure.op, + key: failure.key, + backend: failure.backend, + error: failure.error, + })}{' '} + ยท {relativeTime(failure.at, locale)} +

+ ))} +
+ )} + {state.seedFilePresent && ( +

{t('storage.health.seedFile')}

+ )} +
+ + + +
+ {rows.map((row) => { + const result = admin.testResults[row.name] + return ( +
+
+ {row.name} + + {t(`storage.type.${row.type}`)} + + + {t(`storage.source.${row.source}`)} + +
+

+ {row.categories.length > 0 + ? t('storage.backends.usedBy', { categories: row.categories.join(', ') }) + : t('storage.backends.unused')} +

+ {row.source === 'env' && ( +

{t('storage.backends.envReadOnly')}

+ )} +
+ admin.test(row.backend)}> + {t('storage.actions.test')} + + {row.source !== 'env' && ( + setEditing({ initial: row.backend, originalName: row.name })}> + {t('storage.actions.edit')} + + )} + {row.source === 'settings' && ( + setConfirmRemove(row.name)}> + {t('storage.actions.remove')} + + )} +
+ {result === 'running' ? ( +

{t('storage.test.running')}

+ ) : ( + result && ( +
+

+ {result.ok ? t('storage.test.ok') : t('storage.test.failed')} +

+ {result.targets.map((target) => ( +

+ {target.ok ? 'โœ“' : 'โœ—'} {target.name} + {target.error ? ` โ€” ${target.error}` : ''} +

+ ))} +
+ ) + )} +
+ ) + })} +
+ {editing === null && ( +
+ setEditing({ initial: null, originalName: null })}> + {t('storage.backends.add')} + +
+ )} +
+ + {editing !== null && ( + setEditing(null)} + /> + )} + + + +
+ {STORAGE_CATEGORIES.map((category) => { + const effective = state.categories[category] + const selected = draft.categories[category] ?? effective.backend + const changed = selected !== effective.backend + return ( + +
+ } + onClick={() => setCategoryPicker(category)} + /> +
+ {changed && ( +

+ {t('storage.categories.reassignWarning')} +

+ )} +
+ ) + })} +
+ setCategoryPicker(null)} + title={categoryPicker ? t(`storage.category.${categoryPicker}`) : ''} + options={backendNames.map((name) => ({ value: name, label: name }))} + value={categoryPicker ? (draft.categories[categoryPicker] ?? state.categories[categoryPicker].backend) : ''} + onSelect={(name) => { + if (categoryPicker) setCategory(categoryPicker, name) + }} + /> +
+ +
+ + {t('storage.save')} + + {admin.dirty && {t('storage.unsaved')}} +
+ {admin.saveError && ( +

+ {admin.saveError} +

+ )} + + setConfirmRemove(null)} + title={t('storage.remove.title')} + message={confirmRemove ? removeMessage(confirmRemove) : ''} + confirmLabel={t('storage.actions.remove')} + cancelLabel={t('storage.form.cancel')} + danger + onConfirm={() => { + if (confirmRemove) admin.setDraft(removeBackend(draft, confirmRemove)) + setConfirmRemove(null) + }} + /> +
+ ) +} diff --git a/client/tests/unit/mobile/admin/MAdmin.test.tsx b/client/tests/unit/mobile/admin/MAdmin.test.tsx index 0193e5e8b..5a45f7f9d 100644 --- a/client/tests/unit/mobile/admin/MAdmin.test.tsx +++ b/client/tests/unit/mobile/admin/MAdmin.test.tsx @@ -55,6 +55,9 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminGitHubPanel', () => ({
{String(isPrerelease)}
), })); +vi.mock('../../../../src/mobile/screens/admin/MAdminStoragePanel', () => ({ + default: () =>
, +})); // The addon manager is reduced to the two controls MAdmin wires itself. vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({ @@ -213,6 +216,9 @@ describe('MAdmin', () => { await openSection('Plugins'); expect(screen.getByTestId('plugins-panel')).toBeInTheDocument(); + await openSection('Storage'); + expect(screen.getByTestId('m-admin-storage-panel')).toBeInTheDocument(); + await openSection('Notifications'); expect(screen.getByTestId('notifications-section')).toBeInTheDocument(); @@ -380,4 +386,11 @@ describe('MAdmin', () => { expect(root.className).not.toContain('h-full'); expect(root.className).not.toContain('overflow-y-auto'); }); + + it('FE-MOB-ADMIN-STOR-001: managed mode hides the Storage section', async () => { + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true }); + renderAdmin(); + fireEvent.click(await screen.findByRole('button', { name: 'Administration' })); + expect(screen.queryByRole('button', { name: 'Storage' })).not.toBeInTheDocument(); + }); }); diff --git a/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx new file mode 100644 index 000000000..ee354f3dd --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx @@ -0,0 +1,93 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; +import { MASKED_SETTING_VALUE, type StorageAdminState } from '@trek/shared'; +import { server } from '../../../helpers/msw/server'; +import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminStoragePanel from '../../../../src/mobile/screens/admin/MAdminStoragePanel'; + +function baseState(overrides: Partial = {}): StorageAdminState { + return { + backends: [ + { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files', 'journey', 'covers', 'avatars', 'photos', 'photos-google', 'photos-trek'] }, + { name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: ['backups'] }, + { + name: 'off-box', type: 's3', source: 'settings', + 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 }, + categories: [], + }, + ], + categories: { + files: { backend: 'uploads-local', source: 'default' }, + journey: { backend: 'uploads-local', source: 'default' }, + covers: { backend: 'uploads-local', source: 'default' }, + avatars: { backend: 'uploads-local', source: 'default' }, + places: { backend: 'uploads-local', source: 'default' }, + photos: { backend: 'uploads-local', source: 'default' }, + 'photos-google': { backend: 'uploads-local', source: 'default' }, + 'photos-trek': { backend: 'uploads-local', source: 'default' }, + backups: { backend: 'backups-local', source: 'default' }, + }, + health: { replicaFailures: [] }, + encryptionReady: true, + seedFilePresent: false, + ...overrides, + }; +} + +async function renderPanel(state: StorageAdminState = baseState()) { + server.use(http.get('/api/admin/storage', () => HttpResponse.json(state))); + render( + <> + + + , + ); + await waitFor(() => expect(screen.getByText('Backends')).toBeInTheDocument()); +} + +describe('MAdminStoragePanel', () => { + it('FE-MOB-MSTOR-001: renders every backend with type and source, env rows read-only', async () => { + await renderPanel(); + const row = screen.getByTestId('m-storage-backend-off-box'); + expect(within(row).getByText('S3')).toBeInTheDocument(); + expect(within(row).getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('No replica failures recorded.')).toBeInTheDocument(); + }); + + it('FE-MOB-MSTOR-002: reassigning a category via the picker sheet warns and saves through one PUT', async () => { + let putBody: unknown; + await renderPanel(); + server.use( + http.put('/api/admin/storage', async ({ request }) => { + putBody = await request.json(); + return HttpResponse.json(baseState()); + }), + ); + fireEvent.click(within(screen.getByTestId('m-storage-category-files')).getByRole('button')); + fireEvent.click(screen.getByRole('button', { name: 'off-box' })); // picker option; selecting closes + expect(screen.getByText(/Existing objects do not move/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByText('Storage configuration saved'); + expect((putBody as { categories: Record }).categories.files).toBe('off-box'); + }); + + it('FE-MOB-MSTOR-003: a typed plaintext secret without encryption replaces Apply with the ENCRYPTION_KEY banner', async () => { + await renderPanel(baseState({ encryptionReady: false })); + fireEvent.click(within(screen.getByTestId('m-storage-backend-off-box')).getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByPlaceholderText('us-east-1'), { target: { value: 'eu-west-1' } }); // sanity: form is open + const secret = screen.getByDisplayValue(MASKED_SETTING_VALUE); + fireEvent.change(secret, { target: { value: 'sk-new' } }); + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument(); + expect(screen.getByRole('alert').textContent).toContain('ENCRYPTION_KEY'); + }); + + it('FE-MOB-MSTOR-004: a 400 renders the server message verbatim', async () => { + await renderPanel(); + server.use(http.put('/api/admin/storage', () => HttpResponse.json({ error: 'registry says no' }, { status: 400 }))); + fireEvent.click(within(screen.getByTestId('m-storage-category-files')).getByRole('button')); + fireEvent.click(screen.getByRole('button', { name: 'off-box' })); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(await screen.findByText('registry says no')).toBeInTheDocument(); + }); +});