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:
2026-04-07 00:27:44 +03:00
parent 70cbb45b01
commit 6e052efebf
6 changed files with 1457 additions and 0 deletions
@@ -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>