mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(client): storage admin panel and section on the phone shell
This commit is contained in:
@@ -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' && <MAdminPluginsPanel />}
|
||||
{activeTab === 'storage' && <MAdminStoragePanel />}
|
||||
{activeTab === 'mcp-tokens' && <MAdminMcpTokensPanel />}
|
||||
{activeTab === 'github' && <MAdminGitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
|
||||
{activeTab === 'backup' && <MAdminBackupPanel />}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
/** 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<StorageBackendTypeId>(initial?.type ?? 'local')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
||||
const [picker, setPicker] = useState<string | null>(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<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
|
||||
options[field.key] = field.kind === 'number' ? Number(text) : text
|
||||
}
|
||||
onCommit({ name: name.trim(), type, options } as StorageBackend)
|
||||
}
|
||||
|
||||
return (
|
||||
<MAdminCard className="space-y-3">
|
||||
<MAdminCardHead title={initial ? t('storage.form.editTitle') : t('storage.form.addTitle')} />
|
||||
<MAdminField label={t('storage.form.name')}>
|
||||
<MAdminInput value={name} onChange={(e) => setName(e.target.value)} spellCheck={false} autoComplete="off" />
|
||||
</MAdminField>
|
||||
{duplicate && (
|
||||
<p role="alert" className="font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
{initial === null && (
|
||||
<MAdminField label={t('storage.form.type')}>
|
||||
<MSetSelectRow
|
||||
label={t(`storage.type.${type}`)}
|
||||
trailing={<ChevronDown size={14} className="text-m-faint" />}
|
||||
onClick={() => setPicker('type')}
|
||||
/>
|
||||
<MSetPickerSheet
|
||||
open={picker === 'type'}
|
||||
onClose={() => 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({})
|
||||
}}
|
||||
/>
|
||||
</MAdminField>
|
||||
)}
|
||||
|
||||
{fields.map((field) => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref') {
|
||||
const current = typeof value === 'string' ? value : ''
|
||||
return (
|
||||
<MAdminField key={field.key} label={t(field.labelKey)}>
|
||||
<MSetSelectRow
|
||||
label={current || t(field.labelKey)}
|
||||
trailing={<ChevronDown size={14} className="text-m-faint" />}
|
||||
onClick={() => setPicker(`field:${field.key}`)}
|
||||
/>
|
||||
<MSetPickerSheet
|
||||
open={picker === `field:${field.key}`}
|
||||
onClose={() => setPicker(null)}
|
||||
title={t(field.labelKey)}
|
||||
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
|
||||
value={current}
|
||||
onSelect={(next) => setValue(field.key, next)}
|
||||
/>
|
||||
</MAdminField>
|
||||
)
|
||||
}
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<MAdminField key={field.key} label={t(field.labelKey)}>
|
||||
<div className="space-y-2">
|
||||
{refOptions.map((candidate) => (
|
||||
<div key={candidate} className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.8125rem] font-semibold text-m-ink">{candidate}</span>
|
||||
<MToggle
|
||||
checked={selected.includes(candidate)}
|
||||
ariaLabel={candidate}
|
||||
onChange={(checked) =>
|
||||
setValue(
|
||||
field.key,
|
||||
checked ? [...selected, candidate] : selected.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MAdminField>
|
||||
)
|
||||
}
|
||||
const text = typeof value === 'string' ? value : ''
|
||||
const shared = {
|
||||
value: text,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setValue(field.key, e.target.value),
|
||||
placeholder: field.defaultValue !== undefined ? String(field.defaultValue) : '',
|
||||
spellCheck: false,
|
||||
autoComplete: 'off',
|
||||
}
|
||||
return (
|
||||
<MAdminField key={field.key} label={t(field.labelKey)} hint={field.helpKey ? t(field.helpKey) : undefined}>
|
||||
{field.kind === 'secret' ? (
|
||||
<MAdminSecretInput {...shared} />
|
||||
) : (
|
||||
<MAdminInput {...shared} type={field.kind === 'number' ? 'number' : 'text'} />
|
||||
)}
|
||||
</MAdminField>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{blocked ? (
|
||||
<p role="alert" className="font-geist text-[0.625rem] leading-relaxed text-m-muted">
|
||||
{t('storage.form.encryptionBanner')}
|
||||
</p>
|
||||
) : (
|
||||
<MAdminButton onClick={apply} disabled={!canApply}>
|
||||
{t('storage.form.apply')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
<MAdminButton variant="ghost" onClick={onCancel}>
|
||||
{t('storage.form.cancel')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</MAdminCard>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
const [categoryPicker, setCategoryPicker] = useState<StorageCategory | null>(null)
|
||||
|
||||
if (admin.loading) {
|
||||
return (
|
||||
<MAdminCard>
|
||||
<p className="font-geist text-[0.75rem] italic text-m-faint">{t('storage.loading')}</p>
|
||||
</MAdminCard>
|
||||
)
|
||||
}
|
||||
if (!admin.state || !admin.draft) {
|
||||
return (
|
||||
<MAdminCard>
|
||||
<p role="alert" className="text-[0.8125rem] text-m-ink">{admin.loadError || t('common.error')}</p>
|
||||
</MAdminCard>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('storage.health.title')} />
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="font-geist text-[0.75rem] text-m-faint">{t('storage.health.allClear')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{state.health.replicaFailures.map((failure) => (
|
||||
<p key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-[0.75rem] text-m-ink">
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}{' '}
|
||||
<span className="text-m-faint">· {relativeTime(failure.at, locale)}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{state.seedFilePresent && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">{t('storage.health.seedFile')}</p>
|
||||
)}
|
||||
</MAdminCard>
|
||||
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('storage.backends.title')} hint={t('storage.description')} />
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => {
|
||||
const result = admin.testResults[row.name]
|
||||
return (
|
||||
<div
|
||||
key={row.name}
|
||||
data-testid={`m-storage-backend-${row.name}`}
|
||||
className="rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[0.8125rem] font-bold text-m-ink">{row.name}</span>
|
||||
<span className="rounded-full border border-[color:var(--m-rowbr)] px-2 py-[1px] font-geist text-[0.625rem] text-m-muted">
|
||||
{t(`storage.type.${row.type}`)}
|
||||
</span>
|
||||
<span className="rounded-full border border-[color:var(--m-rowbr)] px-2 py-[1px] font-geist text-[0.625rem] text-m-muted">
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: row.categories.join(', ') })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{row.source === 'env' && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">{t('storage.backends.envReadOnly')}</p>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<MAdminButton variant="ghost" onClick={() => admin.test(row.backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</MAdminButton>
|
||||
{row.source !== 'env' && (
|
||||
<MAdminButton variant="ghost" onClick={() => setEditing({ initial: row.backend, originalName: row.name })}>
|
||||
{t('storage.actions.edit')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<MAdminButton variant="danger" onClick={() => setConfirmRemove(row.name)}>
|
||||
{t('storage.actions.remove')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
</div>
|
||||
{result === 'running' ? (
|
||||
<p className="mt-2 font-geist text-[0.625rem] text-m-muted">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
result && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-[0.75rem] font-bold text-m-ink">
|
||||
{result.ok ? t('storage.test.ok') : t('storage.test.failed')}
|
||||
</p>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="font-geist text-[0.625rem] text-m-muted">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{editing === null && (
|
||||
<div className="mt-3">
|
||||
<MAdminButton onClick={() => setEditing({ initial: null, originalName: null })}>
|
||||
{t('storage.backends.add')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
</MAdminCard>
|
||||
|
||||
{editing !== null && (
|
||||
<MBackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
encryptionReady={state.encryptionReady}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('storage.categories.title')} />
|
||||
<div className="space-y-2">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
const effective = state.categories[category]
|
||||
const selected = draft.categories[category] ?? effective.backend
|
||||
const changed = selected !== effective.backend
|
||||
return (
|
||||
<MAdminField key={category} label={t(`storage.category.${category}`)}>
|
||||
<div data-testid={`m-storage-category-${category}`} className="contents">
|
||||
<MSetSelectRow
|
||||
label={
|
||||
effective.source === 'default' && selected === effective.backend
|
||||
? `${selected} (${t('storage.categories.default')})`
|
||||
: selected
|
||||
}
|
||||
trailing={<ChevronDown size={14} className="text-m-faint" />}
|
||||
onClick={() => setCategoryPicker(category)}
|
||||
/>
|
||||
</div>
|
||||
{changed && (
|
||||
<p role="alert" className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
</MAdminField>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<MSetPickerSheet
|
||||
open={categoryPicker !== null}
|
||||
onClose={() => 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)
|
||||
}}
|
||||
/>
|
||||
</MAdminCard>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<MAdminButton onClick={save} disabled={!admin.dirty} busy={admin.saving}>
|
||||
{t('storage.save')}
|
||||
</MAdminButton>
|
||||
{admin.dirty && <span className="font-geist text-[0.625rem] text-m-muted">{t('storage.unsaved')}</span>}
|
||||
</div>
|
||||
{admin.saveError && (
|
||||
<p role="alert" className="text-[0.8125rem] text-m-ink">
|
||||
{admin.saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmRemove !== null}
|
||||
onClose={() => 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)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,9 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminGitHubPanel', () => ({
|
||||
<div data-testid="github-panel">{String(isPrerelease)}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminStoragePanel', () => ({
|
||||
default: () => <div data-testid="m-admin-storage-panel" />,
|
||||
}));
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<MAdminStoragePanel />
|
||||
</>,
|
||||
);
|
||||
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<string, string> }).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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user