From 6b85f1187127bd72f560f47e98927322f90d181c Mon Sep 17 00:00:00 2001 From: Samu Date: Sun, 30 Aug 2026 00:00:44 +0200 Subject: [PATCH] feat: settings page with integrations, sync control and user management --- web/src/pages/SettingsPage.tsx | 269 +++++++++++++++++++++++++++++++-- web/test/settings.test.tsx | 150 ++++++++++++++++++ 2 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 web/test/settings.test.tsx diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 04f323c..8665631 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -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() { - const { user, onLogout } = useAuth() +function Section({ title, children }: { title: string; children: ReactNode }) { return ( -
-

Settings

-

Account

-

- {user?.username} {user?.isAdmin ? '(admin)' : ''} -

+
+

{title}

+ {children}
) } + +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(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(null) + + const [users, setUsers] = useState(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 ( +
+

Settings

+ {message && ( +

+ {message.text} +

+ )} + +
+

+ {user?.username} {user?.isAdmin && (admin)} +

+ +
+ +
+

+ Personal access token from discogs.com → Settings → Developers.{' '} + {view?.hasDiscogsToken && `Current: ${view.discogsTokenMasked}`} +

+
+ + +
+
+ +
+
+ + + + +
+
+ +
+ {sync && ( +

+ {sync.status === 'running' && 'Syncing…'} + {sync.status === 'done' && `${sync.albums} albums synced`} + {sync.status === 'error' && Sync failed: {sync.error}} + {sync.status === 'idle' && 'Not synced yet'} + {sync.lastSyncedAt && ( + · last {new Date(sync.lastSyncedAt).toLocaleString()} + )} +

+ )} + +
+ + {user?.isAdmin && ( +
+
    + {(users ?? []).map((u) => ( +
  • + + {u.username} {u.isAdmin && (admin)} + + {!u.isAdmin && ( + + )} +
  • + ))} +
+
+ + + +
+
+ )} +
+ ) +} diff --git a/web/test/settings.test.tsx b/web/test/settings.test.tsx new file mode 100644 index 0000000..1c9ca6b --- /dev/null +++ b/web/test/settings.test.tsx @@ -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() + 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( + + + + + + ) +} + +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() + }) +})