feat(frontend): implement admin section (users + audit log)
- Layout guard redirecting non-admins to /files - User list page with create form and delete confirmation - User detail page with role/permission toggles and delete - Audit log page with filters (user, action, object type, ID, date range) - Mock data: 5 test users, 80 audit entries, full CRUD handlers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
70cbb45b01
commit
6e052efebf
115
frontend/src/routes/admin/+layout.svelte
Normal file
115
frontend/src/routes/admin/+layout.svelte
Normal file
@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const tabs = [
|
||||
{ href: '/admin/users', label: 'Users' },
|
||||
{ href: '/admin/audit', label: 'Audit log' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="admin-shell">
|
||||
<nav class="admin-nav">
|
||||
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="admin-title">Admin</span>
|
||||
<div class="tabs">
|
||||
{#each tabs as tab}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="tab"
|
||||
class:active={$page.url.pathname.startsWith(tab.href)}
|
||||
>{tab.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="admin-content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/routes/admin/+layout.ts
Normal file
12
frontend/src/routes/admin/+layout.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
|
||||
export const load = () => {
|
||||
if (!browser) return;
|
||||
const { user } = get(authStore);
|
||||
if (!user?.isAdmin) {
|
||||
redirect(307, '/files');
|
||||
}
|
||||
};
|
||||
450
frontend/src/routes/admin/audit/+page.svelte
Normal file
450
frontend/src/routes/admin/audit/+page.svelte
Normal file
@ -0,0 +1,450 @@
|
||||
<script lang="ts">
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import type { AuditEntry, AuditOffsetPage, User, UserOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 50;
|
||||
const OBJECT_TYPES = ['file', 'tag', 'category', 'pool'];
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
file_create: 'File uploaded',
|
||||
file_edit: 'File edited',
|
||||
file_delete: 'File deleted',
|
||||
file_tag_add: 'Tag added to file',
|
||||
file_tag_remove: 'Tag removed from file',
|
||||
tag_create: 'Tag created',
|
||||
tag_edit: 'Tag edited',
|
||||
tag_delete: 'Tag deleted',
|
||||
pool_create: 'Pool created',
|
||||
pool_edit: 'Pool edited',
|
||||
pool_delete: 'Pool deleted',
|
||||
category_create: 'Category created',
|
||||
category_edit: 'Category edited',
|
||||
category_delete: 'Category deleted',
|
||||
};
|
||||
|
||||
// ---- Filters ----
|
||||
let filterUserId = $state('');
|
||||
let filterAction = $state('');
|
||||
let filterObjectType = $state('');
|
||||
let filterObjectId = $state('');
|
||||
let filterFrom = $state('');
|
||||
let filterTo = $state('');
|
||||
|
||||
// ---- Data ----
|
||||
let entries = $state<AuditEntry[]>([]);
|
||||
let total = $state(0);
|
||||
let offset = $state(0);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let hasMore = $state(true);
|
||||
let initialLoaded = $state(false);
|
||||
|
||||
// ---- Users for filter dropdown ----
|
||||
let allUsers = $state<User[]>([]);
|
||||
$effect(() => {
|
||||
api.get<UserOffsetPage>('/users?limit=200').then((r) => { allUsers = r.items ?? []; }).catch(() => {});
|
||||
});
|
||||
|
||||
// All distinct action types seen across entries (populated from data)
|
||||
let knownActions = $derived([...new Set(entries.map((e) => e.action).filter(Boolean))].sort() as string[]);
|
||||
|
||||
// ---- Reset on filter change ----
|
||||
let filterKey = $derived(`${filterUserId}|${filterAction}|${filterObjectType}|${filterObjectId}|${filterFrom}|${filterTo}`);
|
||||
let prevFilterKey = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (filterKey !== prevFilterKey) {
|
||||
prevFilterKey = filterKey;
|
||||
entries = [];
|
||||
offset = 0;
|
||||
hasMore = true;
|
||||
initialLoaded = false;
|
||||
error = '';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!initialLoaded && !loading) void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (loading || !hasMore) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) });
|
||||
if (filterUserId) params.set('user_id', filterUserId);
|
||||
if (filterAction) params.set('action', filterAction);
|
||||
if (filterObjectType) params.set('object_type', filterObjectType);
|
||||
if (filterObjectId.trim()) params.set('object_id', filterObjectId.trim());
|
||||
if (filterFrom) params.set('from', new Date(filterFrom).toISOString());
|
||||
if (filterTo) params.set('to', new Date(filterTo).toISOString());
|
||||
|
||||
const res = await api.get<AuditOffsetPage>(`/audit?${params}`);
|
||||
const items = res.items ?? [];
|
||||
entries = offset === 0 ? items : [...entries, ...items];
|
||||
total = res.total ?? entries.length;
|
||||
offset = entries.length;
|
||||
hasMore = entries.length < total;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load audit log';
|
||||
} finally {
|
||||
loading = false;
|
||||
initialLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTs(iso: string | undefined | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function actionLabel(action: string | undefined | null): string {
|
||||
if (!action) return '—';
|
||||
return ACTION_LABELS[action] ?? action.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function shortId(id: string | undefined | null): string {
|
||||
if (!id) return '—';
|
||||
return id.slice(-8);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filterUserId = '';
|
||||
filterAction = '';
|
||||
filterObjectType = '';
|
||||
filterObjectId = '';
|
||||
filterFrom = '';
|
||||
filterTo = '';
|
||||
}
|
||||
|
||||
let filtersActive = $derived(
|
||||
!!(filterUserId || filterAction || filterObjectType || filterObjectId || filterFrom || filterTo)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Audit Log — Admin | Tanabata</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filters-row">
|
||||
<select class="filter-select" bind:value={filterUserId} title="Filter by user">
|
||||
<option value="">All users</option>
|
||||
{#each allUsers as u (u.id)}
|
||||
<option value={String(u.id)}>{u.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select class="filter-select" bind:value={filterAction} title="Filter by action">
|
||||
<option value="">All actions</option>
|
||||
{#each Object.keys(ACTION_LABELS) as a}
|
||||
<option value={a}>{ACTION_LABELS[a]}</option>
|
||||
{/each}
|
||||
{#each knownActions.filter((a) => !(a in ACTION_LABELS)) as a}
|
||||
<option value={a}>{a}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select class="filter-select" bind:value={filterObjectType} title="Filter by object type">
|
||||
<option value="">All objects</option>
|
||||
{#each OBJECT_TYPES as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<input
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Object ID…"
|
||||
bind:value={filterObjectId}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filters-row">
|
||||
<label class="date-label">
|
||||
From
|
||||
<input class="filter-input date" type="datetime-local" bind:value={filterFrom} />
|
||||
</label>
|
||||
<label class="date-label">
|
||||
To
|
||||
<input class="filter-input date" type="datetime-local" bind:value={filterTo} />
|
||||
</label>
|
||||
{#if filtersActive}
|
||||
<button class="clear-btn" onclick={clearFilters}>Clear filters</button>
|
||||
{/if}
|
||||
<span class="total-hint">{total} entr{total !== 1 ? 'ies' : 'y'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
{#if error}
|
||||
<p class="msg error" role="alert">{error}</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Object</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as e (e.id)}
|
||||
<tr>
|
||||
<td class="ts-cell">{formatTs(e.performed_at)}</td>
|
||||
<td class="user-cell">{e.user_name ?? '—'}</td>
|
||||
<td class="action-cell">
|
||||
<span class="action-tag" class:file={e.object_type === 'file'} class:tag={e.object_type === 'tag'} class:pool={e.object_type === 'pool'} class:cat={e.object_type === 'category'}>
|
||||
{actionLabel(e.action)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="obj-type-cell">{e.object_type ?? '—'}</td>
|
||||
<td class="obj-id-cell" title={e.object_id ?? ''}>{shortId(e.object_id)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
{#if loading}
|
||||
<tr class="loading-row">
|
||||
<td colspan="5">
|
||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
{#if !loading && initialLoaded && entries.length === 0}
|
||||
<tr>
|
||||
<td colspan="5" class="empty-cell">No entries match the current filters.</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if hasMore && !loading}
|
||||
<button class="load-more-btn" onclick={load}>Load more</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- Filters ---- */
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filter-select:focus,
|
||||
.filter-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.filter-input.date {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
background: none;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background-color: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
}
|
||||
|
||||
.total-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Table ---- */
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover td {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.ts-cell {
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-tag.file { background-color: color-mix(in srgb, var(--color-info) 12%, transparent); color: var(--color-info); }
|
||||
.action-tag.tag { background-color: color-mix(in srgb, #7ECBA1 12%, transparent); color: #7ECBA1; }
|
||||
.action-tag.pool { background-color: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); }
|
||||
.action-tag.cat { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); }
|
||||
|
||||
.obj-type-cell {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: capitalize;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.obj-id-cell {
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.loading-row td {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.load-more-btn {
|
||||
align-self: center;
|
||||
height: 32px;
|
||||
padding: 0 20px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-more-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.msg.error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
415
frontend/src/routes/admin/users/+page.svelte
Normal file
415
frontend/src/routes/admin/users/+page.svelte
Normal file
@ -0,0 +1,415 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import type { User, UserOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let total = $state(0);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// Create form
|
||||
let showCreate = $state(false);
|
||||
let newName = $state('');
|
||||
let newPassword = $state('');
|
||||
let newCanCreate = $state(false);
|
||||
let newIsAdmin = $state(false);
|
||||
let creating = $state(false);
|
||||
let createError = $state('');
|
||||
|
||||
// Delete confirm
|
||||
let confirmDeleteUser = $state<User | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.get<UserOffsetPage>(`/users?limit=${LIMIT}&offset=0`);
|
||||
users = res.items ?? [];
|
||||
total = res.total ?? users.length;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load users';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
if (!newName.trim() || !newPassword.trim()) return;
|
||||
creating = true;
|
||||
createError = '';
|
||||
try {
|
||||
const u = await api.post<User>('/users', {
|
||||
name: newName.trim(),
|
||||
password: newPassword.trim(),
|
||||
can_create: newCanCreate,
|
||||
is_admin: newIsAdmin,
|
||||
});
|
||||
users = [u, ...users];
|
||||
total++;
|
||||
showCreate = false;
|
||||
newName = '';
|
||||
newPassword = '';
|
||||
newCanCreate = false;
|
||||
newIsAdmin = false;
|
||||
} catch (e) {
|
||||
createError = e instanceof ApiError ? e.message : 'Failed to create user';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(u: User) {
|
||||
confirmDeleteUser = null;
|
||||
try {
|
||||
await api.delete(`/users/${u.id}`);
|
||||
users = users.filter((x) => x.id !== u.id);
|
||||
total--;
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Users — Admin | Tanabata</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<span class="count">{total} user{total !== 1 ? 's' : ''}</span>
|
||||
<button class="btn primary" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Cancel' : '+ New user'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div class="create-form">
|
||||
{#if createError}<p class="form-error" role="alert">{createError}</p>{/if}
|
||||
<div class="form-row">
|
||||
<input class="input" type="text" placeholder="Username" bind:value={newName} autocomplete="off" />
|
||||
<input class="input" type="password" placeholder="Password" bind:value={newPassword} autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="form-row checks">
|
||||
<label class="check-label">
|
||||
<input type="checkbox" bind:checked={newCanCreate} />
|
||||
Can create
|
||||
</label>
|
||||
<label class="check-label">
|
||||
<input type="checkbox" bind:checked={newIsAdmin} />
|
||||
Admin
|
||||
</label>
|
||||
<button
|
||||
class="btn primary"
|
||||
onclick={createUser}
|
||||
disabled={creating || !newName.trim() || !newPassword.trim()}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{:else if loading}
|
||||
<div class="loading"><span class="spinner" role="status" aria-label="Loading"></span></div>
|
||||
{:else if users.length === 0}
|
||||
<p class="empty">No users found.</p>
|
||||
{:else}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as u (u.id)}
|
||||
<tr class="user-row" class:blocked={u.is_blocked}>
|
||||
<td class="id-cell">{u.id}</td>
|
||||
<td class="name-cell">
|
||||
<button class="name-btn" onclick={() => goto(`/admin/users/${u.id}`)}>
|
||||
{u.name}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" class:admin={u.is_admin} class:creator={!u.is_admin && u.can_create}>
|
||||
{u.is_admin ? 'Admin' : u.can_create ? 'Creator' : 'Viewer'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if u.is_blocked}
|
||||
<span class="badge blocked">Blocked</span>
|
||||
{:else}
|
||||
<span class="badge active">Active</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="icon-btn" onclick={() => goto(`/admin/users/${u.id}`)} title="Edit">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M9.5 2.5l2 2-7 7H2.5v-2l7-7z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn danger" onclick={() => (confirmDeleteUser = u)} title="Delete">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M2 4h10M5 4V2.5h4V4M5.5 6.5v4M8.5 6.5v4M3 4l.8 7.5h6.4L11 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirmDeleteUser}
|
||||
<ConfirmDialog
|
||||
message="Delete user "{confirmDeleteUser.name}"? This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => deleteUser(confirmDeleteUser!)}
|
||||
onCancel={() => (confirmDeleteUser = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 16px;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-row.checks {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.check-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn.primary {
|
||||
background-color: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-row.blocked td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.id-cell {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.name-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.name-btn:hover {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.badge.admin {
|
||||
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge.creator {
|
||||
background-color: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
||||
color: #7ECBA1;
|
||||
}
|
||||
|
||||
.badge.blocked {
|
||||
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.error, .empty {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
339
frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
339
frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
@ -0,0 +1,339 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import type { User } from '$lib/api/types';
|
||||
|
||||
let userId = $derived(page.params.id);
|
||||
|
||||
let user = $state<User | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let saving = $state(false);
|
||||
let saveError = $state('');
|
||||
let saveSuccess = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
// editable fields
|
||||
let isAdmin = $state(false);
|
||||
let canCreate = $state(false);
|
||||
let isBlocked = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const id = userId;
|
||||
loading = true;
|
||||
error = '';
|
||||
void api.get<User>(`/users/${id}`).then((u) => {
|
||||
user = u;
|
||||
isAdmin = u.is_admin ?? false;
|
||||
canCreate = u.can_create ?? false;
|
||||
isBlocked = u.is_blocked ?? false;
|
||||
}).catch((e) => {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load user';
|
||||
}).finally(() => {
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (saving || !user) return;
|
||||
saving = true;
|
||||
saveError = '';
|
||||
saveSuccess = false;
|
||||
try {
|
||||
const updated = await api.patch<User>(`/users/${user.id}`, {
|
||||
is_admin: isAdmin,
|
||||
can_create: canCreate,
|
||||
is_blocked: isBlocked,
|
||||
});
|
||||
user = updated;
|
||||
saveSuccess = true;
|
||||
setTimeout(() => (saveSuccess = false), 2500);
|
||||
} catch (e) {
|
||||
saveError = e instanceof ApiError ? e.message : 'Failed to save';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
confirmDelete = false;
|
||||
deleting = true;
|
||||
try {
|
||||
await api.delete(`/users/${user!.id}`);
|
||||
goto('/admin/users');
|
||||
} catch (e) {
|
||||
saveError = e instanceof ApiError ? e.message : 'Failed to delete';
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{user?.name ?? 'User'} — Admin | Tanabata</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<button class="back-link" onclick={() => goto('/admin/users')}>
|
||||
← All users
|
||||
</button>
|
||||
|
||||
{#if error}
|
||||
<p class="msg error" role="alert">{error}</p>
|
||||
{:else if loading}
|
||||
<div class="loading"><span class="spinner" role="status" aria-label="Loading"></span></div>
|
||||
{:else if user}
|
||||
<div class="card">
|
||||
<div class="user-header">
|
||||
<span class="user-name">{user.name}</span>
|
||||
<span class="user-id">#{user.id}</span>
|
||||
</div>
|
||||
|
||||
{#if saveError}<p class="msg error" role="alert">{saveError}</p>{/if}
|
||||
{#if saveSuccess}<p class="msg success" role="status">Saved.</p>{/if}
|
||||
|
||||
<div class="section-label">Role & permissions</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<span class="toggle-label">Admin</span>
|
||||
<p class="toggle-hint">Full access to all data and admin panel.</p>
|
||||
</div>
|
||||
<button
|
||||
class="toggle" class:on={isAdmin}
|
||||
role="switch" aria-checked={isAdmin}
|
||||
onclick={() => (isAdmin = !isAdmin)}
|
||||
><span class="thumb"></span></button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<span class="toggle-label">Can create</span>
|
||||
<p class="toggle-hint">Can upload files and create tags, pools, categories.</p>
|
||||
</div>
|
||||
<button
|
||||
class="toggle" class:on={canCreate}
|
||||
role="switch" aria-checked={canCreate}
|
||||
onclick={() => (canCreate = !canCreate)}
|
||||
><span class="thumb"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Account status</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<span class="toggle-label" class:danger-label={isBlocked}>Blocked</span>
|
||||
<p class="toggle-hint">Blocked users cannot log in.</p>
|
||||
</div>
|
||||
<button
|
||||
class="toggle" class:on={isBlocked} class:danger={isBlocked}
|
||||
role="switch" aria-checked={isBlocked}
|
||||
onclick={() => (isBlocked = !isBlocked)}
|
||||
><span class="thumb"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button class="btn primary" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button class="btn danger-outline" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : 'Delete user'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirmDelete && user}
|
||||
<ConfirmDialog
|
||||
message="Delete user "{user.name}"? This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={doDelete}
|
||||
onCancel={() => (confirmDelete = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 16px;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.back-link:hover { color: var(--color-accent); }
|
||||
|
||||
.card {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: -6px;
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-label.danger-label {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.toggle-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
/* toggle switch */
|
||||
.toggle {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
border: none;
|
||||
background-color: color-mix(in srgb, var(--color-accent) 22%, var(--color-bg-primary));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.toggle.on { background-color: var(--color-accent); }
|
||||
.toggle.on.danger { background-color: var(--color-danger); }
|
||||
|
||||
.toggle .thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.toggle.on .thumb { transform: translateX(18px); }
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 7px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn.primary {
|
||||
background-color: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn.danger-outline {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn.danger-outline:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.msg {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.msg.error { color: var(--color-danger); }
|
||||
.msg.success { color: #7ECBA1; }
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
@ -50,6 +50,56 @@ const ME = {
|
||||
is_blocked: false,
|
||||
};
|
||||
|
||||
type MockUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
can_create: boolean;
|
||||
is_blocked: boolean;
|
||||
};
|
||||
|
||||
const mockUsersArr: MockUser[] = [
|
||||
{ id: 1, name: 'admin', is_admin: true, can_create: true, is_blocked: false },
|
||||
{ id: 2, name: 'alice', is_admin: false, can_create: true, is_blocked: false },
|
||||
{ id: 3, name: 'bob', is_admin: false, can_create: true, is_blocked: false },
|
||||
{ id: 4, name: 'charlie', is_admin: false, can_create: false, is_blocked: true },
|
||||
{ id: 5, name: 'diana', is_admin: false, can_create: true, is_blocked: false },
|
||||
];
|
||||
|
||||
const AUDIT_ACTIONS = [
|
||||
'file_create', 'file_edit', 'file_delete', 'file_tag_add', 'file_tag_remove',
|
||||
'tag_create', 'tag_edit', 'tag_delete', 'pool_create', 'pool_edit', 'pool_delete',
|
||||
'category_create', 'category_edit',
|
||||
];
|
||||
const AUDIT_OBJECT_TYPES = ['file', 'tag', 'pool', 'category'];
|
||||
|
||||
type MockAuditEntry = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
action: string;
|
||||
object_type: string | null;
|
||||
object_id: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
performed_at: string;
|
||||
};
|
||||
|
||||
const mockAuditLog: MockAuditEntry[] = Array.from({ length: 80 }, (_, i) => {
|
||||
const user = mockUsersArr[i % mockUsersArr.length];
|
||||
const action = AUDIT_ACTIONS[i % AUDIT_ACTIONS.length];
|
||||
const objType = AUDIT_OBJECT_TYPES[i % AUDIT_OBJECT_TYPES.length];
|
||||
return {
|
||||
id: i + 1,
|
||||
user_id: user.id,
|
||||
user_name: user.name,
|
||||
action,
|
||||
object_type: objType,
|
||||
object_id: `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`,
|
||||
details: null,
|
||||
performed_at: new Date(Date.now() - i * 1_800_000).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const THUMB_COLORS = [
|
||||
'#9592B5', '#4DC7ED', '#DB6060', '#F5E872', '#7ECBA1',
|
||||
'#E08C5A', '#A67CB8', '#5A9ED4', '#C4A44A', '#6DB89E',
|
||||
@ -853,6 +903,82 @@ export function mockApiPlugin(): Plugin {
|
||||
return json(res, 201, newPool);
|
||||
}
|
||||
|
||||
// GET /users/{id}
|
||||
const userGetMatch = path.match(/^\/users\/(\d+)$/);
|
||||
if (method === 'GET' && userGetMatch) {
|
||||
const u = mockUsersArr.find((x) => x.id === Number(userGetMatch[1]));
|
||||
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
|
||||
return json(res, 200, u);
|
||||
}
|
||||
|
||||
// PATCH /users/{id}
|
||||
const userPatchMatch = path.match(/^\/users\/(\d+)$/);
|
||||
if (method === 'PATCH' && userPatchMatch) {
|
||||
const u = mockUsersArr.find((x) => x.id === Number(userPatchMatch[1]));
|
||||
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
|
||||
const body = (await readBody(req)) as Partial<MockUser>;
|
||||
Object.assign(u, body);
|
||||
return json(res, 200, u);
|
||||
}
|
||||
|
||||
// DELETE /users/{id}
|
||||
const userDelMatch = path.match(/^\/users\/(\d+)$/);
|
||||
if (method === 'DELETE' && userDelMatch) {
|
||||
const idx = mockUsersArr.findIndex((x) => x.id === Number(userDelMatch[1]));
|
||||
if (idx >= 0) mockUsersArr.splice(idx, 1);
|
||||
return noContent(res);
|
||||
}
|
||||
|
||||
// GET /users
|
||||
if (method === 'GET' && path === '/users') {
|
||||
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
||||
const offset = Number(qs.get('offset') ?? 0);
|
||||
const items = mockUsersArr.slice(offset, offset + limit);
|
||||
return json(res, 200, { items, total: mockUsersArr.length, offset, limit });
|
||||
}
|
||||
|
||||
// POST /users
|
||||
if (method === 'POST' && path === '/users') {
|
||||
const body = (await readBody(req)) as Partial<MockUser> & { password?: string };
|
||||
const newUser: MockUser = {
|
||||
id: Math.max(...mockUsersArr.map((u) => u.id)) + 1,
|
||||
name: body.name ?? 'unnamed',
|
||||
is_admin: body.is_admin ?? false,
|
||||
can_create: body.can_create ?? false,
|
||||
is_blocked: false,
|
||||
};
|
||||
mockUsersArr.push(newUser);
|
||||
return json(res, 201, newUser);
|
||||
}
|
||||
|
||||
// GET /audit
|
||||
if (method === 'GET' && path === '/audit') {
|
||||
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
||||
const offset = Number(qs.get('offset') ?? 0);
|
||||
const filterUserId = qs.get('user_id') ? Number(qs.get('user_id')) : null;
|
||||
const filterAction = qs.get('action') ?? '';
|
||||
const filterObjectType = qs.get('object_type') ?? '';
|
||||
const filterObjectId = qs.get('object_id') ?? '';
|
||||
const filterFrom = qs.get('from') ? new Date(qs.get('from')!).getTime() : null;
|
||||
const filterTo = qs.get('to') ? new Date(qs.get('to')!).getTime() : null;
|
||||
|
||||
let filtered = mockAuditLog.filter((e) => {
|
||||
if (filterUserId !== null && e.user_id !== filterUserId) return false;
|
||||
if (filterAction && e.action !== filterAction) return false;
|
||||
if (filterObjectType && e.object_type !== filterObjectType) return false;
|
||||
if (filterObjectId && e.object_id !== filterObjectId) return false;
|
||||
const t = new Date(e.performed_at).getTime();
|
||||
if (filterFrom !== null && t < filterFrom) return false;
|
||||
if (filterTo !== null && t > filterTo) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
return json(res, 200, { items, total: filtered.length, offset, limit });
|
||||
}
|
||||
|
||||
// Fallback: 404
|
||||
return json(res, 404, { code: 'not_found', message: `Mock: no handler for ${method} ${path}` });
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user