feat: settings data section with export and backups
This commit is contained in:
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNod
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api.js'
|
||||
import { useAuth } from '../auth.js'
|
||||
import type { SettingsView, SyncState, User } from '../types.js'
|
||||
import type { BackupFile, SettingsView, SyncState, User } from '../types.js'
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -29,6 +29,7 @@ export default function SettingsPage() {
|
||||
const [sync, setSync] = useState<SyncState | null>(null)
|
||||
|
||||
const [users, setUsers] = useState<User[] | null>(null)
|
||||
const [backups, setBackups] = useState<BackupFile[] | null>(null)
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
|
||||
@@ -81,6 +82,7 @@ export default function SettingsPage() {
|
||||
.listUsers()
|
||||
.then((res) => setUsers(res.users))
|
||||
.catch(() => {})
|
||||
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
|
||||
}
|
||||
}, [user])
|
||||
|
||||
@@ -237,6 +239,46 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{user?.isAdmin && (
|
||||
<Section title="Data">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={api.exportUrl()}
|
||||
download
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Export JSON
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void api
|
||||
.triggerBackup()
|
||||
.then(() => api.getBackups())
|
||||
.then((res) => setBackups(res.backups))
|
||||
.then(() => flash('ok', 'Backup created'))
|
||||
.catch(() => flash('error', 'Backup failed'))
|
||||
}
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Back up now
|
||||
</button>
|
||||
</div>
|
||||
{backups && backups.length > 0 && (
|
||||
<ul className="space-y-1 text-xs text-neutral-400">
|
||||
{backups.map((b) => (
|
||||
<li key={b.file} className="flex justify-between gap-2">
|
||||
<span className="truncate">{b.file}</span>
|
||||
<span className="shrink-0">
|
||||
{(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{user?.isAdmin && (
|
||||
<Section title="Users">
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
|
||||
@@ -20,6 +20,8 @@ vi.mock('../src/api.js', async (importOriginal) => {
|
||||
createUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
triggerBackup: vi.fn(),
|
||||
getBackups: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -56,12 +58,13 @@ function stubAuthFetch(user: User) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout] as const) {
|
||||
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout, api.triggerBackup, api.getBackups] as const) {
|
||||
vi.mocked(fn).mockReset()
|
||||
}
|
||||
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
|
||||
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
|
||||
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
|
||||
vi.mocked(api.getBackups).mockResolvedValue({ backups: [] } as never)
|
||||
stubAuthFetch(admin)
|
||||
})
|
||||
|
||||
@@ -179,4 +182,23 @@ describe('SettingsPage', () => {
|
||||
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
|
||||
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('data section: export link, backup now, backups list', async () => {
|
||||
vi.mocked(api.triggerBackup).mockResolvedValue({ file: 'record-shop-2026-09-03-120000.db' } as never)
|
||||
vi.mocked(api.getBackups).mockResolvedValue({
|
||||
backups: [{ file: 'record-shop-2026-09-03-120000.db', sizeBytes: 12345, createdAt: '2026-09-03T12:00:00Z' }],
|
||||
} as never)
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('link', { name: /export json/i })).toHaveProperty('href')
|
||||
await userEvent.click(screen.getByRole('button', { name: /back up now/i }))
|
||||
await waitFor(() => expect(api.triggerBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(screen.getByText(/record-shop-2026-09-03-120000\.db/)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('backup failure surfaces in the flash banner', async () => {
|
||||
vi.mocked(api.triggerBackup).mockRejectedValue(new TypeError('fetch failed'))
|
||||
renderSettings()
|
||||
await userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
|
||||
await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user