diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..740acf0 --- /dev/null +++ b/frontend/src/routes/admin/+layout.svelte @@ -0,0 +1,115 @@ + + +
+ +
+ {@render children()} +
+
+ + \ No newline at end of file diff --git a/frontend/src/routes/admin/+layout.ts b/frontend/src/routes/admin/+layout.ts new file mode 100644 index 0000000..7f1ee99 --- /dev/null +++ b/frontend/src/routes/admin/+layout.ts @@ -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'); + } +}; \ No newline at end of file diff --git a/frontend/src/routes/admin/audit/+page.svelte b/frontend/src/routes/admin/audit/+page.svelte new file mode 100644 index 0000000..925d2ad --- /dev/null +++ b/frontend/src/routes/admin/audit/+page.svelte @@ -0,0 +1,450 @@ + + +Audit Log — Admin | Tanabata + +
+ +
+
+ + + + + + + +
+ +
+ + + {#if filtersActive} + + {/if} + {total} entr{total !== 1 ? 'ies' : 'y'} +
+
+ + + {#if error} + + {:else} +
+ + + + + + + + + + + + {#each entries as e (e.id)} + + + + + + + + {/each} + + {#if loading} + + + + {/if} + + {#if !loading && initialLoaded && entries.length === 0} + + + + {/if} + +
TimeUserActionObjectID
{formatTs(e.performed_at)}{e.user_name ?? '—'} + + {actionLabel(e.action)} + + {e.object_type ?? '—'}{shortId(e.object_id)}
+ +
No entries match the current filters.
+
+ + {#if hasMore && !loading} + + {/if} + {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..553c10c --- /dev/null +++ b/frontend/src/routes/admin/users/+page.svelte @@ -0,0 +1,415 @@ + + +Users — Admin | Tanabata + +
+
+ {total} user{total !== 1 ? 's' : ''} + +
+ + {#if showCreate} +
+ {#if createError}{/if} +
+ + +
+
+ + + +
+
+ {/if} + + {#if error} + + {:else if loading} +
+ {:else if users.length === 0} +

No users found.

+ {:else} + + + + + + + + + + + + {#each users as u (u.id)} + + + + + + + + {/each} + +
IDNameRoleStatus
{u.id} + + + + {u.is_admin ? 'Admin' : u.can_create ? 'Creator' : 'Viewer'} + + + {#if u.is_blocked} + Blocked + {:else} + Active + {/if} + + + +
+ {/if} +
+ +{#if confirmDeleteUser} + deleteUser(confirmDeleteUser!)} + onCancel={() => (confirmDeleteUser = null)} + /> +{/if} + + \ No newline at end of file diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte new file mode 100644 index 0000000..727179d --- /dev/null +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -0,0 +1,339 @@ + + +{user?.name ?? 'User'} — Admin | Tanabata + +
+ + + {#if error} + + {:else if loading} +
+ {:else if user} +
+
+ {user.name} + #{user.id} +
+ + {#if saveError}{/if} + {#if saveSuccess}

Saved.

{/if} + + + +
+
+
+ Admin +

Full access to all data and admin panel.

+
+ +
+ +
+
+ Can create +

Can upload files and create tags, pools, categories.

+
+ +
+
+ + + +
+
+
+ Blocked +

Blocked users cannot log in.

+
+ +
+
+ +
+ + +
+
+ {/if} +
+ +{#if confirmDelete && user} + (confirmDelete = false)} + /> +{/if} + + \ No newline at end of file diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index ea4afea..49766da 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -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 | 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; + 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 & { 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}` }); });