diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..d8d71e2 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,100 @@ +import type { + Candidate, + CollectionResponse, + DigitalAlbum, + Item, + ReleasePreview, + SettingsView, + SyncState, + User, +} from './types' + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + public detail?: string + ) { + super(detail ? `${code}: ${detail}` : code) + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + headers: { Accept: 'application/json' }, + ...init, + }) + if (!res.ok) { + let code = 'unknown_error' + let detail: string | undefined + try { + const body = (await res.json()) as { error?: string; detail?: string } + code = body.error ?? code + detail = body.detail + } catch { + // non-JSON error body + } + throw new ApiError(res.status, code, detail) + } + return (await res.json()) as T +} + +function post(path: string, payload?: unknown): Promise { + return request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }) +} + +export const api = { + setupStatus: () => request<{ needed: boolean }>('/api/setup'), + setup: (username: string, password: string) => post<{ user: User }>('/api/setup', { username, password }), + login: (username: string, password: string) => post<{ user: User }>('/api/login', { username, password }), + logout: () => post<{ ok: boolean }>('/api/logout'), + me: () => request<{ user: User }>('/api/me'), + + listUsers: () => request<{ users: User[] }>('/api/users'), + createUser: (username: string, password: string) => post('/api/users', { username, password }), + deleteUser: (id: number) => request<{ ok: boolean }>(`/api/users/${id}`, { method: 'DELETE' }), + + getSettings: () => request('/api/settings'), + putSettings: (payload: Partial>) => + request('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + + lookupBarcode: (code: string) => request<{ candidates: Candidate[] }>(`/api/lookup/barcode/${encodeURIComponent(code)}`), + lookupSearch: (q: string, format?: string) => + request<{ candidates: Candidate[] }>( + `/api/lookup/search?q=${encodeURIComponent(q)}${format ? `&format=${encodeURIComponent(format)}` : ''}` + ), + getReleasePreview: (id: number) => request(`/api/lookup/release/${id}`), + + listCollection: (params: { format?: string; ripped?: string; q?: string } = {}) => { + const usp = new URLSearchParams() + if (params.format) usp.set('format', params.format) + if (params.ripped) usp.set('ripped', params.ripped) + if (params.q) usp.set('q', params.q) + const qs = usp.toString() + return request(`/api/collection${qs ? `?${qs}` : ''}`) + }, + addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) => + post('/api/collection', body), + getItem: (id: number) => request(`/api/collection/${id}`), + deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }), + setRip: (id: number, ripped: boolean | null) => + request(`/api/collection/${id}/rip`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ripped }), + }), + setMatch: (id: number, albumId: number | null) => + post(`/api/collection/${id}/match`, { albumId }), + + syncStatus: () => request('/api/library/sync'), + startSync: () => request('/api/library/sync', { method: 'POST' }), + searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`), +} diff --git a/web/src/auth.tsx b/web/src/auth.tsx new file mode 100644 index 0000000..2bbd496 --- /dev/null +++ b/web/src/auth.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react' +import { api } from './api' +import type { User } from './types' + +export type AuthStatus = 'loading' | 'setup' | 'unauthenticated' | 'authenticated' + +interface AuthContextValue { + status: AuthStatus + user: User | null + setupNeeded: boolean + refresh: () => Promise + onSetupComplete: (user: User) => void + onLogin: (user: User) => void + onLogout: () => void +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('loading') + const [user, setUser] = useState(null) + const [setupNeeded, setSetupNeeded] = useState(false) + + const refresh = useCallback(async () => { + try { + const { needed } = await api.setupStatus() + if (needed) { + setSetupNeeded(true) + setStatus('setup') + return + } + setSetupNeeded(false) + const { user: me } = await api.me() + setUser(me) + setStatus('authenticated') + } catch { + setStatus('unauthenticated') + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const onSetupComplete = useCallback((u: User) => { + setSetupNeeded(false) + setUser(u) + setStatus('authenticated') + }, []) + const onLogin = useCallback((u: User) => { + setUser(u) + setStatus('authenticated') + }, []) + const onLogout = useCallback(() => { + setUser(null) + setStatus('unauthenticated') + }, []) + + return ( + + {children} + + ) +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth outside AuthProvider') + return ctx +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..5492bfb --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,70 @@ +// Contract types — mirrors the Fastify API (plan 1). Do not rename fields. +export interface User { + id: number + username: string + isAdmin: boolean +} +export interface SettingsView { + hasDiscogsToken: boolean + discogsTokenMasked: string | null + subsonicUrl: string | null + subsonicUsername: string | null + hasSubsonicPassword: boolean +} +export interface Candidate { + id: number + artist: string + title: string + year: number | null + formats: string[] + labels: string[] + country: string | null + catno: string | null + thumbUrl: string | null +} +export interface Release extends Candidate { + genres: string[] + tracklist: { position: string; title: string }[] + coverUrl: string | null + barcodes: string[] +} +export interface ReleasePreview { + release: Release + duplicate: boolean + ripMatch: 'ripped' | 'not_ripped' | 'ambiguous' + matchCandidates: { id: number; title: string; artist: string }[] +} +export interface Item { + id: number + discogsReleaseId: number + title: string + artist: string + year: number | null + formats: string[] + genres: string[] + labels: string[] + tracklist: { position: string; title: string }[] + catno: string | null + country: string | null + artworkUrl: string | null + barcodes: string[] + dateAdded: string + ripOverride: boolean | null + ripStatus: 'ripped' | 'not_ripped' +} +export interface CollectionResponse { + items: Item[] + counts: { total: number; ripped: number; notRipped: number } +} +export interface SyncState { + status: 'idle' | 'running' | 'done' | 'error' + error: string | null + lastSyncedAt: string | null + albums: number +} +export interface DigitalAlbum { + id: number + subsonicId: string + title: string + artist: string +} diff --git a/web/test/auth.test.tsx b/web/test/auth.test.tsx new file mode 100644 index 0000000..0c1d46e --- /dev/null +++ b/web/test/auth.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { AuthProvider, useAuth } from '../src/auth' + +function Probe() { + const { status, user, setupNeeded } = useAuth() + return ( +
+
status:{status}
+ {setupNeeded &&
setup-needed
} + {user &&
user:{user.username}
} +
+ ) +} + +const fetchMock = vi.fn() + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +function jsonOnce(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('AuthProvider', () => { + it('reports setupNeeded when the server has no users', async () => { + fetchMock.mockResolvedValueOnce(jsonOnce(200, { needed: true })) + render( + + + + ) + expect(screen.getByText('status:loading')).toBeTruthy() + await waitFor(() => expect(screen.getByText('status:setup')).toBeTruthy()) + expect(screen.getByText('setup-needed')).toBeTruthy() + expect(fetchMock).toHaveBeenCalledWith('/api/setup', expect.anything()) + }) + + it('exposes the user when /api/me succeeds', async () => { + fetchMock + .mockResolvedValueOnce(jsonOnce(200, { needed: false })) + .mockResolvedValueOnce(jsonOnce(200, { user: { id: 1, username: 'sam', isAdmin: true } })) + render( + + + + ) + await waitFor(() => expect(screen.getByText('status:authenticated')).toBeTruthy()) + expect(screen.getByText('user:sam')).toBeTruthy() + }) + + it('reports unauthenticated when /api/me is 401', async () => { + fetchMock + .mockResolvedValueOnce(jsonOnce(200, { needed: false })) + .mockResolvedValueOnce(jsonOnce(401, { error: 'unauthorized' })) + render( + + + + ) + await waitFor(() => expect(screen.getByText('status:unauthenticated')).toBeTruthy()) + }) +})