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 { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
import { useAuth } from '../auth.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 }) {
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +29,7 @@ export default function SettingsPage() {
|
|||||||
const [sync, setSync] = useState<SyncState | null>(null)
|
const [sync, setSync] = useState<SyncState | null>(null)
|
||||||
|
|
||||||
const [users, setUsers] = useState<User[] | null>(null)
|
const [users, setUsers] = useState<User[] | null>(null)
|
||||||
|
const [backups, setBackups] = useState<BackupFile[] | null>(null)
|
||||||
const [newUsername, setNewUsername] = useState('')
|
const [newUsername, setNewUsername] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ export default function SettingsPage() {
|
|||||||
.listUsers()
|
.listUsers()
|
||||||
.then((res) => setUsers(res.users))
|
.then((res) => setUsers(res.users))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
void api.getBackups().then((res) => setBackups(res.backups)).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
@@ -237,6 +239,46 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</Section>
|
</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 && (
|
{user?.isAdmin && (
|
||||||
<Section title="Users">
|
<Section title="Users">
|
||||||
<ul className="space-y-1.5 text-sm">
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ vi.mock('../src/api.js', async (importOriginal) => {
|
|||||||
createUser: vi.fn(),
|
createUser: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
|
triggerBackup: vi.fn(),
|
||||||
|
getBackups: vi.fn(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,12 +58,13 @@ function stubAuthFetch(user: User) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
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(fn).mockReset()
|
||||||
}
|
}
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
|
vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
|
||||||
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
|
vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
|
||||||
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
|
vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
|
||||||
|
vi.mocked(api.getBackups).mockResolvedValue({ backups: [] } as never)
|
||||||
stubAuthFetch(admin)
|
stubAuthFetch(admin)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,4 +182,23 @@ describe('SettingsPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
|
await waitFor(() => expect(screen.getByText(/42 albums synced/i)).toBeTruthy(), { timeout: 3000 })
|
||||||
expect(vi.mocked(api.syncStatus).mock.calls.length).toBeGreaterThanOrEqual(3)
|
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