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:
184
frontend/src/routes/admin/users/+page.svelte
Normal file
184
frontend/src/routes/admin/users/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user