feat: settings page with integrations, sync control and user management
This commit is contained in:
@@ -1,14 +1,265 @@
|
|||||||
import { useAuth } from '../auth'
|
import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { api } from '../api.js'
|
||||||
|
import { useAuth } from '../auth.js'
|
||||||
|
import type { SettingsView, SyncState, User } from '../types.js'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
const { user, onLogout } = useAuth()
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="space-y-3 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-neutral-400">Settings</h2>
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
|
||||||
<p>Account</p>
|
{children}
|
||||||
<p className="text-neutral-400">
|
|
||||||
{user?.username} {user?.isAdmin ? '(admin)' : ''}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputCls = 'w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm'
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user, onLogout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [view, setView] = useState<SettingsView | null>(null)
|
||||||
|
const [discogsToken, setDiscogsToken] = useState('')
|
||||||
|
const [subsonicUrl, setSubsonicUrl] = useState('')
|
||||||
|
const [subsonicUsername, setSubsonicUsername] = useState('')
|
||||||
|
const [subsonicPassword, setSubsonicPassword] = useState('')
|
||||||
|
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const [sync, setSync] = useState<SyncState | null>(null)
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<User[] | null>(null)
|
||||||
|
const [newUsername, setNewUsername] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
|
||||||
|
const refreshSettings = useCallback(() => {
|
||||||
|
void api
|
||||||
|
.getSettings()
|
||||||
|
.then((v) => {
|
||||||
|
setView(v)
|
||||||
|
setSubsonicUrl(v.subsonicUrl ?? '')
|
||||||
|
setSubsonicUsername(v.subsonicUsername ?? '')
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSettings()
|
||||||
|
}, [refreshSettings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
const poll = (): void => {
|
||||||
|
void api
|
||||||
|
.syncStatus()
|
||||||
|
.then((s) => {
|
||||||
|
if (alive) setSync(s)
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
.then((s) => {
|
||||||
|
if (alive && s?.status === 'running') setTimeout(poll, 2000)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.isAdmin) {
|
||||||
|
void api
|
||||||
|
.listUsers()
|
||||||
|
.then((res) => setUsers(res.users))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
function flash(kind: 'ok' | 'error', text: string) {
|
||||||
|
setMessage({ kind, text })
|
||||||
|
setTimeout(() => setMessage(null), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDiscogs(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
void api
|
||||||
|
.putSettings({ discogsToken: discogsToken })
|
||||||
|
.then((v) => {
|
||||||
|
setView(v)
|
||||||
|
setDiscogsToken('')
|
||||||
|
flash('ok', 'Token saved')
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSubsonic(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
void api
|
||||||
|
.putSettings({ subsonicUrl, subsonicUsername, subsonicPassword })
|
||||||
|
.then((v) => {
|
||||||
|
setView(v)
|
||||||
|
setSubsonicPassword('')
|
||||||
|
flash('ok', 'Music server saved — syncing library')
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUser(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
void api
|
||||||
|
.createUser(newUsername, newPassword)
|
||||||
|
.then((created) => {
|
||||||
|
setUsers((u) => [...(u ?? []), created])
|
||||||
|
setNewUsername('')
|
||||||
|
setNewPassword('')
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Could not add user'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUser(id: number, username: string) {
|
||||||
|
void api.deleteUser(id).then(() => setUsers((u) => (u ?? []).filter((x) => x.id !== id || x.username !== username)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
|
{message && (
|
||||||
|
<p className={`rounded-xl px-4 py-3 text-sm ${message.kind === 'ok' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Account">
|
||||||
|
<p className="text-sm">
|
||||||
|
{user?.username} {user?.isAdmin && <span className="text-neutral-500">(admin)</span>}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void api.logout().then(() => {
|
||||||
|
onLogout()
|
||||||
|
navigate('/login')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Discogs">
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Personal access token from discogs.com → Settings → Developers.{' '}
|
||||||
|
{view?.hasDiscogsToken && `Current: ${view.discogsTokenMasked}`}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={saveDiscogs} className="space-y-2">
|
||||||
|
<label className="block text-sm">
|
||||||
|
Discogs token
|
||||||
|
<input
|
||||||
|
value={discogsToken}
|
||||||
|
onChange={(e) => setDiscogsToken(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={view?.hasDiscogsToken ? 'Leave empty to keep, clear to remove' : 'Paste token'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
|
||||||
|
Save Discogs
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Music server (Subsonic)">
|
||||||
|
<form onSubmit={saveSubsonic} className="space-y-2">
|
||||||
|
<label className="block text-sm">
|
||||||
|
Subsonic URL
|
||||||
|
<input value={subsonicUrl} onChange={(e) => setSubsonicUrl(e.target.value)} className={inputCls} placeholder="http://navidrome.local" />
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
Subsonic username
|
||||||
|
<input value={subsonicUsername} onChange={(e) => setSubsonicUsername(e.target.value)} className={inputCls} autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
Subsonic password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={subsonicPassword}
|
||||||
|
onChange={(e) => setSubsonicPassword(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={view?.hasSubsonicPassword ? 'Saved — type to change' : ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
|
||||||
|
Save Subsonic
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Library sync">
|
||||||
|
{sync && (
|
||||||
|
<p className="text-sm text-neutral-300">
|
||||||
|
{sync.status === 'running' && 'Syncing…'}
|
||||||
|
{sync.status === 'done' && `${sync.albums} albums synced`}
|
||||||
|
{sync.status === 'error' && <span className="text-red-400">Sync failed: {sync.error}</span>}
|
||||||
|
{sync.status === 'idle' && 'Not synced yet'}
|
||||||
|
{sync.lastSyncedAt && (
|
||||||
|
<span className="text-neutral-500"> · last {new Date(sync.lastSyncedAt).toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void api.startSync().then(setSync).then(() => setTimeout(() => void api.syncStatus().then(setSync), 500))}
|
||||||
|
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Section title="Users">
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{(users ?? []).map((u) => (
|
||||||
|
<li key={u.id} className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{u.username} {u.isAdmin && <span className="text-neutral-500">(admin)</span>}
|
||||||
|
</span>
|
||||||
|
{!u.isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeUser(u.id, u.username)}
|
||||||
|
className="text-xs text-red-400"
|
||||||
|
aria-label={`remove ${u.username}`}
|
||||||
|
>
|
||||||
|
remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<form onSubmit={addUser} className="space-y-2 border-t border-neutral-800 pt-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
New username
|
||||||
|
<input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className={inputCls} autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
New password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-neutral-950">
|
||||||
|
Add user
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
150
web/test/settings.test.tsx
Normal file
150
web/test/settings.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from '../src/auth.js'
|
||||||
|
import SettingsPage from '../src/pages/SettingsPage.js'
|
||||||
|
import type { SettingsView, SyncState, User } from '../src/types.js'
|
||||||
|
|
||||||
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/api.js')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
getSettings: vi.fn(),
|
||||||
|
putSettings: vi.fn(),
|
||||||
|
syncStatus: vi.fn(),
|
||||||
|
startSync: vi.fn(),
|
||||||
|
listUsers: vi.fn(),
|
||||||
|
createUser: vi.fn(),
|
||||||
|
deleteUser: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { api } from '../src/api.js'
|
||||||
|
|
||||||
|
const emptyView: SettingsView = {
|
||||||
|
hasDiscogsToken: false,
|
||||||
|
discogsTokenMasked: null,
|
||||||
|
subsonicUrl: null,
|
||||||
|
subsonicUsername: null,
|
||||||
|
hasSubsonicPassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleSync: SyncState = { status: 'idle', error: null, lastSyncedAt: null, albums: 0 }
|
||||||
|
|
||||||
|
const admin: User = { id: 1, username: 'sam', isAdmin: true }
|
||||||
|
|
||||||
|
function jsonOk(body: unknown) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthProvider reads /api/setup and /api/me via fetch; stub an authed session
|
||||||
|
function stubAuthFetch(user: User) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url === '/api/setup') return jsonOk({ needed: false })
|
||||||
|
return jsonOk({ user })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout] 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)
|
||||||
|
stubAuthFetch(admin)
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderSettings() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<SettingsPage />
|
||||||
|
</AuthProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SettingsPage', () => {
|
||||||
|
it('saves the discogs token and shows the mask', async () => {
|
||||||
|
vi.mocked(api.putSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/discogs token/i), 'abcdef0123456789')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /save discogs/i }))
|
||||||
|
await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: 'abcdef0123456789' }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/token saved/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the discogs token with an empty save', async () => {
|
||||||
|
vi.mocked(api.getSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
|
||||||
|
vi.mocked(api.putSettings).mockResolvedValue(emptyView as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /save discogs/i }))
|
||||||
|
await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: '' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves subsonic config and surfaces validation errors', async () => {
|
||||||
|
const err = new (await import('../src/api.js')).ApiError(400, 'subsonic_unreachable', 'could not reach http://x')
|
||||||
|
vi.mocked(api.putSettings).mockRejectedValue(err)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://x')
|
||||||
|
await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam')
|
||||||
|
await userEvent.type(screen.getByLabelText(/subsonic password/i), 'pass')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /save subsonic/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/could not reach http:\/\/x/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows sync state and triggers a sync', async () => {
|
||||||
|
vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never)
|
||||||
|
vi.mocked(api.syncStatus).mockResolvedValue({
|
||||||
|
status: 'done',
|
||||||
|
error: null,
|
||||||
|
lastSyncedAt: '2026-08-29T12:00:00.000Z',
|
||||||
|
albums: 503,
|
||||||
|
} as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
|
||||||
|
await waitFor(() => expect(api.startSync).toHaveBeenCalled())
|
||||||
|
await waitFor(() => expect(screen.getByText(/503 albums/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin manages users', async () => {
|
||||||
|
vi.mocked(api.createUser).mockResolvedValue({ id: 2, username: 'bob', isAdmin: false } as never)
|
||||||
|
renderSettings()
|
||||||
|
expect((await screen.findAllByText('sam')).length).toBeGreaterThan(0)
|
||||||
|
await userEvent.type(screen.getByLabelText(/new username/i), 'bob')
|
||||||
|
await userEvent.type(screen.getByLabelText(/new password/i), 'bobpass123')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add user/i }))
|
||||||
|
await waitFor(() => expect(api.createUser).toHaveBeenCalledWith('bob', 'bobpass123'))
|
||||||
|
expect(await screen.findByText('bob')).toBeTruthy()
|
||||||
|
|
||||||
|
vi.mocked(api.deleteUser).mockResolvedValue({ ok: true } as never)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /remove bob/i }))
|
||||||
|
await waitFor(() => expect(api.deleteUser).toHaveBeenCalledWith(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides user management from non-admins', async () => {
|
||||||
|
stubAuthFetch({ id: 2, username: 'bob', isAdmin: false })
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByText('bob')).toBeTruthy()
|
||||||
|
expect(screen.queryByLabelText(/discogs token/i)).toBeTruthy()
|
||||||
|
expect(screen.queryByLabelText(/new username/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout button calls the api', async () => {
|
||||||
|
vi.mocked(api.logout).mockResolvedValue({ ok: true } as never)
|
||||||
|
renderSettings()
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /log out/i }))
|
||||||
|
expect(api.logout).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user