feat: typed api client and auth context with setup gate
This commit is contained in:
100
web/src/api.ts
Normal file
100
web/src/api.ts
Normal file
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<T>(path: string, payload?: unknown): Promise<T> {
|
||||||
|
return request<T>(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<User>('/api/users', { username, password }),
|
||||||
|
deleteUser: (id: number) => request<{ ok: boolean }>(`/api/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getSettings: () => request<SettingsView>('/api/settings'),
|
||||||
|
putSettings: (payload: Partial<Record<'discogsToken' | 'subsonicUrl' | 'subsonicUsername' | 'subsonicPassword', string>>) =>
|
||||||
|
request<SettingsView>('/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<ReleasePreview>(`/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<CollectionResponse>(`/api/collection${qs ? `?${qs}` : ''}`)
|
||||||
|
},
|
||||||
|
addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
|
||||||
|
post<Item>('/api/collection', body),
|
||||||
|
getItem: (id: number) => request<Item>(`/api/collection/${id}`),
|
||||||
|
deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
|
||||||
|
setRip: (id: number, ripped: boolean | null) =>
|
||||||
|
request<Item>(`/api/collection/${id}/rip`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ripped }),
|
||||||
|
}),
|
||||||
|
setMatch: (id: number, albumId: number | null) =>
|
||||||
|
post<Item>(`/api/collection/${id}/match`, { albumId }),
|
||||||
|
|
||||||
|
syncStatus: () => request<SyncState>('/api/library/sync'),
|
||||||
|
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
||||||
|
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
|
||||||
|
}
|
||||||
70
web/src/auth.tsx
Normal file
70
web/src/auth.tsx
Normal file
@@ -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<void>
|
||||||
|
onSetupComplete: (user: User) => void
|
||||||
|
onLogin: (user: User) => void
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AuthStatus>('loading')
|
||||||
|
const [user, setUser] = useState<User | null>(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 (
|
||||||
|
<AuthContext.Provider value={{ status, user, setupNeeded, refresh, onSetupComplete, onLogin, onLogout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth outside AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
70
web/src/types.ts
Normal file
70
web/src/types.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
68
web/test/auth.test.tsx
Normal file
68
web/test/auth.test.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div>status:{status}</div>
|
||||||
|
{setupNeeded && <div>setup-needed</div>}
|
||||||
|
{user && <div>user:{user.username}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AuthProvider>
|
||||||
|
<Probe />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<AuthProvider>
|
||||||
|
<Probe />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<AuthProvider>
|
||||||
|
<Probe />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(screen.getByText('status:unauthenticated')).toBeTruthy())
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user