feat(frontend): /admin dashboard with users/mangas/system views (0.41.0)
Adds the SvelteKit /admin route tree backed by the admin endpoints landed in PR 1-4. Pages: Overview (alerts + summary cards), Users (list / promote-demote / delete), Mangas (list with sync state + expandable per-chapter state), System (live disk/mem/cpu bars, refreshing every 5s). Security model: the backend's RequireAdmin extractor is the actual boundary. /admin/+layout.ts calls getSystemStats() at load and translates the response — 401 → redirect to /login, 403 → throw SvelteKit error(403) which renders the framework error page. The header's "Admin" link is hidden unless `session.user?.is_admin`, but that's UX only. Carries `is_admin: boolean` through to the frontend User TS type so the header check works and so admin tables can show role per row. Vitest covers lib/api/admin.ts (10 tests: list/delete/PATCH for users, sync-state filter for mangas, nested chapter route, system disk-nullable case). Playwright is intentionally deferred until the routes stabilise — admin UI is operator-only and changes shape often in v0.
This commit is contained in:
188
frontend/src/lib/api/admin.test.ts
Normal file
188
frontend/src/lib/api/admin.test.ts
Normal file
@@ -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<typeof globalThis.fetch>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
144
frontend/src/lib/api/admin.ts
Normal file
144
frontend/src/lib/api/admin.ts
Normal file
@@ -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<AdminUsersPage> {
|
||||
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<AdminUsersPage>(`/v1/admin/users${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string): Promise<void> {
|
||||
await request<void>(`/v1/admin/users/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function setUserAdmin(id: string, isAdmin: boolean): Promise<User> {
|
||||
return request<User>(`/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<AdminMangasPage> {
|
||||
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<AdminMangasPage>(`/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<AdminChapterRow[]> {
|
||||
return request<AdminChapterRow[]>(
|
||||
`/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<SystemStats> {
|
||||
return request<SystemStats>('/v1/admin/system');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export type Credentials = {
|
||||
|
||||
Reference in New Issue
Block a user