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:
MechaCat02
2026-05-30 21:49:39 +02:00
parent cc4ec76d17
commit b434c9b68d
13 changed files with 1206 additions and 3 deletions

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
listAdminUsers,
deleteAdminUser,
setUserAdmin,
type AdminUsersPage
} from '$lib/api/admin';
import { ApiError } from '$lib/api/client';
import { session } from '$lib/session.svelte';
let page: AdminUsersPage | null = $state(null);
let search = $state('');
let error: string | null = $state(null);
let busyId: string | null = $state(null);
async function load() {
error = null;
try {
page = await listAdminUsers({
search: search.trim() || undefined,
limit: 100
});
} catch (e) {
error = e instanceof ApiError ? e.message : 'load failed';
}
}
onMount(load);
async function onDelete(id: string) {
if (!confirm('Delete this user? This cannot be undone.')) return;
busyId = id;
try {
await deleteAdminUser(id);
await load();
} catch (e) {
error = e instanceof ApiError ? e.message : 'delete failed';
} finally {
busyId = null;
}
}
async function onToggleAdmin(id: string, next: boolean) {
busyId = id;
try {
await setUserAdmin(id, next);
await load();
} catch (e) {
error = e instanceof ApiError ? e.message : 'update failed';
} finally {
busyId = null;
}
}
</script>
<h1>Users</h1>
<form
onsubmit={(e) => {
e.preventDefault();
load();
}}
>
<input
type="search"
placeholder="search by username"
bind:value={search}
data-testid="admin-users-search"
/>
<button type="submit">Search</button>
</form>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if page}
<p class="total">{page.page.total ?? page.items.length} users</p>
<table>
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Created</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
{#each page.items as u (u.id)}
{@const isSelf = session.user?.id === u.id}
<tr>
<td>{u.username}{#if isSelf}<span class="self"> (you)</span>{/if}</td>
<td>
<input
type="checkbox"
checked={u.is_admin}
disabled={busyId === u.id || isSelf}
onchange={(e) => onToggleAdmin(u.id, e.currentTarget.checked)}
aria-label="admin"
/>
</td>
<td>{new Date(u.created_at).toLocaleDateString()}</td>
<td class="actions">
<button
type="button"
class="danger"
disabled={busyId === u.id || isSelf}
onclick={() => onDelete(u.id)}
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p>Loading…</p>
{/if}
<style>
h1 {
margin: 0 0 var(--space-4) 0;
}
form {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
input[type='search'] {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
}
button {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
button.danger {
color: var(--danger, #dc2626);
border-color: var(--danger, #dc2626);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.total {
color: var(--text-muted);
font-size: var(--font-sm);
margin: 0 0 var(--space-2) 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: var(--space-2);
text-align: left;
border-bottom: 1px solid var(--border);
}
.actions {
text-align: right;
}
.self {
color: var(--text-muted);
font-size: var(--font-sm);
}
.error {
color: var(--danger, #dc2626);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--danger, #dc2626);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
</style>