diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b9a2768..aa9ea2e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.40.0" +version = "0.41.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9a5989c..828c5d9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.40.0" +version = "0.41.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index f016478..f678c6a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.40.0", + "version": "0.41.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts new file mode 100644 index 0000000..5fc8619 --- /dev/null +++ b/frontend/src/lib/api/admin.test.ts @@ -0,0 +1,188 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance +} from 'vitest'; +import { + listAdminUsers, + deleteAdminUser, + setUserAdmin, + listAdminMangas, + listAdminChapters, + getSystemStats +} from './admin'; + +function ok(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +function envelope(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +const userFixture = { + id: 'u-1', + username: 'alice', + created_at: '2026-01-01T00:00:00Z', + is_admin: false +}; + +const mangaFixture = { + id: 'm-1', + title: 'Test', + status: 'ongoing', + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + sync_state: 'synced' as const, + chapter_count: 3, + latest_seen_at: '2026-01-02T00:00:00Z' +}; + +const systemFixture = { + disk: { + total_bytes: 1_000_000, + used_bytes: 500_000, + free_bytes: 500_000, + percent_used: 50.0 + }, + memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 }, + cpu: { percent_used: 12.3 }, + alerts: [] +}; + +describe('admin api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ---- users ---- + + it('listAdminUsers GETs /v1/admin/users and parses the paged envelope', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [userFixture], page: { limit: 50, offset: 0, total: 1 } }) + ); + const page = await listAdminUsers({ limit: 50 }); + expect(page.items).toHaveLength(1); + expect(page.items[0]).toEqual(userFixture); + expect(page.page.total).toBe(1); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/users\?limit=50$/); + }); + + it('listAdminUsers forwards search + offset query params', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 50, offset: 10, total: 0 } }) + ); + await listAdminUsers({ search: 'al', offset: 10 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('search=al'); + expect(url).toContain('offset=10'); + }); + + it('listAdminUsers surfaces 403 forbidden via ApiError.code', async () => { + fetchSpy.mockResolvedValueOnce(envelope(403, 'forbidden', 'forbidden')); + await expect(listAdminUsers()).rejects.toMatchObject({ + status: 403, + code: 'forbidden' + }); + }); + + it('deleteAdminUser DELETEs to /v1/admin/users/{id} and handles 204', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await expect(deleteAdminUser('u-1')).resolves.toBeUndefined(); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/users\/u-1$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + }); + + it('deleteAdminUser surfaces 409 conflict (self-delete / last-admin)', async () => { + fetchSpy.mockResolvedValueOnce( + envelope(409, 'conflict', 'cannot delete yourself; ask another admin') + ); + await expect(deleteAdminUser('u-1')).rejects.toMatchObject({ + status: 409, + code: 'conflict' + }); + }); + + it('setUserAdmin PATCHes is_admin and returns the updated user', async () => { + const updated = { ...userFixture, is_admin: true }; + fetchSpy.mockResolvedValueOnce(ok(updated)); + const got = await setUserAdmin('u-1', true); + expect(got).toEqual(updated); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ is_admin: true }); + }); + + // ---- mangas + chapters ---- + + it('listAdminMangas GETs /v1/admin/mangas and forwards sync_state filter', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [mangaFixture], page: { limit: 100, offset: 0, total: 1 } }) + ); + const page = await listAdminMangas({ syncState: 'in_progress', limit: 100 }); + expect(page.items[0].sync_state).toBe('synced'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('sync_state=in_progress'); + expect(url).toContain('limit=100'); + }); + + it('listAdminChapters GETs the nested chapter route', async () => { + const chapter = { + id: 'c-1', + manga_id: 'm-1', + number: 1, + title: null, + page_count: 12, + created_at: '2026-01-01T00:00:00Z', + sync_state: 'synced' as const, + latest_seen_at: null + }; + fetchSpy.mockResolvedValueOnce(ok([chapter])); + const rows = await listAdminChapters('m-1'); + expect(rows).toEqual([chapter]); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/); + }); + + // ---- system ---- + + it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => { + fetchSpy.mockResolvedValueOnce(ok(systemFixture)); + const s = await getSystemStats(); + expect(s.disk?.percent_used).toBe(50); + expect(s.memory.percent_used).toBe(50); + expect(s.cpu.percent_used).toBe(12.3); + expect(s.alerts).toEqual([]); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/system$/); + }); + + it('getSystemStats keeps disk null when backend reports a non-local store', async () => { + fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, disk: null })); + const s = await getSystemStats(); + expect(s.disk).toBeNull(); + }); +}); diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts new file mode 100644 index 0000000..556a29b --- /dev/null +++ b/frontend/src/lib/api/admin.ts @@ -0,0 +1,144 @@ +// Admin-only API client. Every endpoint here is guarded by +// RequireAdmin on the backend (session cookie only — bearer tokens +// won't reach these routes). 403s thrown here propagate up to the +// /admin layout, which renders the framework error page. + +import { request, type Page } from './client'; +import type { User } from './auth'; + +// ---- users ----------------------------------------------------------------- + +export type AdminUsersPage = { + items: User[]; + page: Page; +}; + +export type ListAdminUsersOptions = { + search?: string; + limit?: number; + offset?: number; +}; + +export async function listAdminUsers( + opts: ListAdminUsersOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.search) params.set('search', opts.search); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request(`/v1/admin/users${qs ? `?${qs}` : ''}`); +} + +export async function deleteAdminUser(id: string): Promise { + await request(`/v1/admin/users/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); +} + +export async function setUserAdmin(id: string, isAdmin: boolean): Promise { + return request(`/v1/admin/users/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ is_admin: isAdmin }) + }); +} + +// ---- mangas / chapters with sync state ------------------------------------- + +export type MangaSyncState = 'in_progress' | 'dropped' | 'synced'; + +export type AdminMangaRow = { + id: string; + title: string; + status: string; + cover_image_path: string | null; + created_at: string; + updated_at: string; + sync_state: MangaSyncState; + chapter_count: number; + latest_seen_at: string | null; +}; + +export type AdminMangasPage = { + items: AdminMangaRow[]; + page: Page; +}; + +export type ListAdminMangasOptions = { + search?: string; + syncState?: MangaSyncState; + limit?: number; + offset?: number; +}; + +export async function listAdminMangas( + opts: ListAdminMangasOptions = {} +): Promise { + const params = new URLSearchParams(); + if (opts.search) params.set('search', opts.search); + if (opts.syncState) params.set('sync_state', opts.syncState); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request(`/v1/admin/mangas${qs ? `?${qs}` : ''}`); +} + +export type ChapterSyncState = + | 'downloading' + | 'dropped' + | 'failed' + | 'not_downloaded' + | 'synced'; + +export type AdminChapterRow = { + id: string; + manga_id: string; + number: number; + title: string | null; + page_count: number; + created_at: string; + sync_state: ChapterSyncState; + latest_seen_at: string | null; +}; + +export async function listAdminChapters(mangaId: string): Promise { + return request( + `/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters` + ); +} + +// ---- system ---------------------------------------------------------------- + +export type DiskStats = { + total_bytes: number; + used_bytes: number; + free_bytes: number; + percent_used: number; +}; + +export type MemoryStats = { + total_bytes: number; + used_bytes: number; + percent_used: number; +}; + +export type CpuStats = { + percent_used: number; +}; + +export type Alert = { + level: 'warning'; + message: string; +}; + +export type SystemStats = { + disk: DiskStats | null; + memory: MemoryStats; + cpu: CpuStats; + alerts: Alert[]; +}; + +export async function getSystemStats(): Promise { + return request('/v1/admin/system'); +} diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 4102325..b57956e 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -4,6 +4,7 @@ export type User = { id: string; username: string; created_at: string; + is_admin: boolean; }; export type Credentials = { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9a9a399..97e0627 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -10,6 +10,7 @@ import Bookmark from '@lucide/svelte/icons/bookmark'; import FolderOpen from '@lucide/svelte/icons/folder-open'; import LogOut from '@lucide/svelte/icons/log-out'; + import Shield from '@lucide/svelte/icons/shield'; import '$lib/styles/tokens.css'; let { children } = $props(); @@ -86,6 +87,12 @@