feat(mvp): phase 6 - admin panel

Add admin layout with auth guard, user management (CRUD + group membership),
group management, system settings (auth mode, registration, theme, healthcheck),
permission editor component, and global search API endpoint.
This commit is contained in:
2026-03-24 21:18:06 +03:00
parent b0d77d3c29
commit c5166ba3a9
21 changed files with 1709 additions and 25 deletions
+8
View File
@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
export const load: LayoutServerLoad = async (event) => {
const user = requireAdmin(event);
return { user };
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types.js';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const navItems = [
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/groups', label: 'Groups' },
{ href: '/admin/settings', label: 'Settings' }
] as const;
</script>
<div class="min-h-screen bg-background text-foreground">
<nav class="border-b border-border bg-card">
<div class="mx-auto flex max-w-6xl items-center gap-6 px-6 py-3">
<a href="/" class="text-sm text-muted-foreground hover:text-foreground">
&larr; Back to Dashboard
</a>
<span class="text-sm font-semibold text-card-foreground">Admin Panel</span>
<div class="flex gap-4">
{#each navItems as item}
<a
href={item.href}
class="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
{item.label}
</a>
{/each}
</div>
<div class="ml-auto text-xs text-muted-foreground">
{data.user.displayName} (admin)
</div>
</div>
</nav>
<main class="mx-auto max-w-6xl p-6">
{@render children()}
</main>
</div>
+86
View File
@@ -0,0 +1,86 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as groupService from '$lib/server/services/groupService.js';
import { createGroupSchema, updateGroupSchema } from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const [groups, createForm, updateForm] = await Promise.all([
groupService.findAll(),
superValidate(zod(createGroupSchema)),
superValidate(zod(updateGroupSchema))
]);
return { groups, createForm, updateForm };
};
export const actions: Actions = {
create: async (event) => {
requireAdmin(event);
const form = await superValidate(event.request, zod(createGroupSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await groupService.create(form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create group';
return setError(form, '', message);
}
return { form };
},
update: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const groupId = formData.get('groupId') as string;
if (!groupId) {
return fail(400, { error: 'Group ID is required' });
}
const form = await superValidate(formData, zod(updateGroupSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await groupService.update(groupId, form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update group';
return setError(form, '', message);
}
return { form };
},
delete: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const groupId = formData.get('groupId') as string;
if (!groupId) {
return fail(400, { error: 'Group ID is required' });
}
try {
await groupService.remove(groupId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete group';
return fail(500, { error: message });
}
return { success: true };
}
};
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import type { PageData } from './$types.js';
import GroupTable from '$lib/components/admin/GroupTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
let { data }: { data: PageData } = $props();
let showCreateForm = $state(false);
const { form, errors, enhance } = superForm(data.createForm, {
resetForm: true,
onResult: ({ result }) => {
if (result.type === 'success') {
showCreateForm = false;
}
}
});
</script>
<svelte:head>
<title>Group Management — Admin</title>
</svelte:head>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">Group Management</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
>
{showCreateForm ? 'Cancel' : 'Create Group'}
</button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">New Group</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
<input
id="name"
name="name"
type="text"
bind:value={$form.name}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
{#if $errors.name}<span class="text-xs text-destructive">{$errors.name}</span>{/if}
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground">Description</label>
<input
id="description"
name="description"
type="text"
bind:value={$form.description}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<div class="flex items-center gap-2">
<input
id="isDefault"
name="isDefault"
type="checkbox"
bind:checked={$form.isDefault}
class="h-4 w-4 rounded border-input"
/>
<label for="isDefault" class="text-sm font-medium text-foreground">Default group (auto-assign new users)</label>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create Group
</button>
</form>
</div>
{/if}
<GroupTable groups={data.groups} />
</div>
+78
View File
@@ -0,0 +1,78 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { prisma } from '$lib/server/prisma.js';
import { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import { DEFAULTS } from '$lib/utils/constants.js';
async function getOrCreateSettings() {
return prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
}
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const settings = await getOrCreateSettings();
const form = await superValidate(
{
authMode: settings.authMode as 'local' | 'oauth' | 'both',
registrationEnabled: settings.registrationEnabled,
oauthClientId: settings.oauthClientId,
oauthClientSecret: settings.oauthClientSecret,
oauthDiscoveryUrl: settings.oauthDiscoveryUrl,
defaultTheme: settings.defaultTheme as 'dark' | 'light',
defaultPrimaryColor: settings.defaultPrimaryColor,
healthcheckDefaults: settings.healthcheckDefaults
},
zod(updateSystemSettingsSchema)
);
return { settings, form };
};
export const actions: Actions = {
update: async (event) => {
requireAdmin(event);
const form = await superValidate(event.request, zod(updateSystemSettingsSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
const data: Record<string, unknown> = {};
const input = form.data;
if (input.authMode !== undefined) data.authMode = input.authMode;
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: data,
create: {
id: DEFAULTS.SYSTEM_SETTINGS_ID,
...data
}
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update settings';
return setError(form, '', message);
}
return { form };
}
};
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import type { PageData } from './$types.js';
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>System Settings — Admin</title>
</svelte:head>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-card-foreground">System Settings</h1>
<p class="mt-1 text-sm text-muted-foreground">Configure global application settings.</p>
</div>
<SettingsForm form={data.form} />
</div>
+142
View File
@@ -0,0 +1,142 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import * as groupService from '$lib/server/services/groupService.js';
import { createUserSchema, updateUserSchema } from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const [users, groups, createForm, updateForm] = await Promise.all([
userService.findAll(),
groupService.findAll(),
superValidate(zod(createUserSchema)),
superValidate(zod(updateUserSchema))
]);
// Load group memberships for each user
const usersWithGroups = await Promise.all(
users.map(async (user) => {
const userGroups = await userService.getUserGroups(user.id);
return { ...user, groups: userGroups };
})
);
return { users: usersWithGroups, groups, createForm, updateForm };
};
export const actions: Actions = {
create: async (event) => {
requireAdmin(event);
const form = await superValidate(event.request, zod(createUserSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await userService.create(form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create user';
return setError(form, '', message);
}
return { form };
},
update: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
if (!userId) {
return fail(400, { error: 'User ID is required' });
}
const form = await superValidate(formData, zod(updateUserSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await userService.update(userId, form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user';
return setError(form, '', message);
}
return { form };
},
delete: async (event) => {
const admin = requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
if (!userId) {
return fail(400, { error: 'User ID is required' });
}
if (userId === admin.id) {
return fail(400, { error: 'Cannot delete your own account' });
}
try {
await userService.remove(userId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete user';
return fail(500, { error: message });
}
return { success: true };
},
addToGroup: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
const groupId = formData.get('groupId') as string;
if (!userId || !groupId) {
return fail(400, { error: 'User ID and Group ID are required' });
}
try {
await groupService.addUser(groupId, userId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add user to group';
return fail(500, { error: message });
}
return { success: true };
},
removeFromGroup: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
const groupId = formData.get('groupId') as string;
if (!userId || !groupId) {
return fail(400, { error: 'User ID and Group ID are required' });
}
try {
await groupService.removeUser(groupId, userId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove user from group';
return fail(500, { error: message });
}
return { success: true };
}
};
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import type { PageData } from './$types.js';
import UserTable from '$lib/components/admin/UserTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
let { data }: { data: PageData } = $props();
let showCreateForm = $state(false);
const { form, errors, enhance } = superForm(data.createForm, {
resetForm: true,
onResult: ({ result }) => {
if (result.type === 'success') {
showCreateForm = false;
}
}
});
</script>
<svelte:head>
<title>User Management — Admin</title>
</svelte:head>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">User Management</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
>
{showCreateForm ? 'Cancel' : 'Create User'}
</button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">New User</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
id="email"
name="email"
type="email"
bind:value={$form.email}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
{#if $errors.email}<span class="text-xs text-destructive">{$errors.email}</span>{/if}
</div>
<div>
<label for="displayName" class="mb-1 block text-sm font-medium text-foreground">Display Name</label>
<input
id="displayName"
name="displayName"
type="text"
bind:value={$form.displayName}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
{#if $errors.displayName}<span class="text-xs text-destructive">{$errors.displayName}</span>{/if}
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label>
<input
id="password"
name="password"
type="password"
bind:value={$form.password}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
{#if $errors.password}<span class="text-xs text-destructive">{$errors.password}</span>{/if}
</div>
<div>
<label for="role" class="mb-1 block text-sm font-medium text-foreground">Role</label>
<select
id="role"
name="role"
bind:value={$form.role}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create User
</button>
</form>
</div>
{/if}
<UserTable users={data.users} groups={data.groups} />
</div>
+74
View File
@@ -0,0 +1,74 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { prisma } from '$lib/server/prisma.js';
import { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { DEFAULTS } from '$lib/utils/constants.js';
/**
* GET /api/admin/settings — Get system settings. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const settings = await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
return json(success(settings));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch settings';
return json(error(message), { status: 500 });
}
};
/**
* PATCH /api/admin/settings — Update system settings. Admin only.
*/
export const PATCH: RequestHandler = async (event) => {
requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateSystemSettingsSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const data: Record<string, unknown> = {};
const input = parsed.data;
if (input.authMode !== undefined) data.authMode = input.authMode;
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
const settings = await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: data,
create: {
id: DEFAULTS.SYSTEM_SETTINGS_ID,
...data
}
});
return json(success(settings));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update settings';
return json(error(message), { status: 500 });
}
};
+50
View File
@@ -0,0 +1,50 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as groupService from '$lib/server/services/groupService.js';
import { createGroupSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/groups — List all groups. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const groups = await groupService.findAll();
return json(success(groups));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch groups';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/groups — Create a new group. Admin only.
*/
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = createGroupSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const group = await groupService.create(parsed.data);
return json(success(group), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create group';
const status = message.includes('already exists') ? 409 : 500;
return json(error(message), { status });
}
};
+72
View File
@@ -0,0 +1,72 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as groupService from '$lib/server/services/groupService.js';
import { updateGroupSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/groups/:id — Get a single group by ID. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
try {
const group = await groupService.findById(id);
return json(success(group));
} catch (err) {
const message = err instanceof Error ? err.message : 'Group not found';
return json(error(message), { status: 404 });
}
};
/**
* PATCH /api/groups/:id — Update a group. Admin only.
*/
export const PATCH: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateGroupSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const group = await groupService.update(id, parsed.data);
return json(success(group));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update group';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/groups/:id — Delete a group. Admin only.
*/
export const DELETE: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
try {
await groupService.remove(id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete group';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
+113
View File
@@ -0,0 +1,113 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { prisma } from '$lib/server/prisma.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { success, error } from '$lib/server/utils/response.js';
interface SearchResult {
readonly type: 'app' | 'board';
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly category?: string | null;
}
/**
* GET /api/search?q=term — Search apps and boards, filtered by user permissions.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
const query = event.url.searchParams.get('q')?.trim();
if (!query || query.length === 0) {
return json(success([]));
}
try {
// Search apps
const apps = await prisma.app.findMany({
where: {
OR: [
{ name: { contains: query } },
{ description: { contains: query } },
{ category: { contains: query } }
]
},
select: {
id: true,
name: true,
description: true,
category: true
},
orderBy: { name: 'asc' },
take: 20
});
// Search boards
const boards = await prisma.board.findMany({
where: {
OR: [
{ name: { contains: query } },
{ description: { contains: query } }
]
},
select: {
id: true,
name: true,
description: true,
isGuestAccessible: true
},
orderBy: { name: 'asc' },
take: 20
});
const isAdmin = user.role === UserRole.ADMIN;
// Filter apps by permission
const filteredApps: SearchResult[] = [];
for (const app of apps) {
if (isAdmin) {
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
continue;
}
const check = await permissionService.checkPermission(
EntityType.APP,
app.id,
user.id,
PermissionLevel.VIEW
);
if (check.hasPermission) {
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
}
}
// Filter boards by permission
const filteredBoards: SearchResult[] = [];
for (const board of boards) {
if (isAdmin || board.isGuestAccessible) {
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
continue;
}
const check = await permissionService.checkPermission(
EntityType.BOARD,
board.id,
user.id,
PermissionLevel.VIEW
);
if (check.hasPermission) {
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
}
}
const results: readonly SearchResult[] = [...filteredApps, ...filteredBoards];
return json(success(results));
} catch (err) {
const message = err instanceof Error ? err.message : 'Search failed';
return json(error(message), { status: 500 });
}
};
+50
View File
@@ -0,0 +1,50 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import { createUserSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/users — List all users. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const users = await userService.findAll();
return json(success(users));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch users';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/users — Create a new user. Admin only.
*/
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const user = await userService.create(parsed.data);
return json(success(user), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create user';
const status = message.includes('already exists') ? 409 : 500;
return json(error(message), { status });
}
};
+76
View File
@@ -0,0 +1,76 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import { updateUserSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/users/:id — Get a single user by ID. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
try {
const user = await userService.findById(id);
return json(success(user));
} catch (err) {
const message = err instanceof Error ? err.message : 'User not found';
return json(error(message), { status: 404 });
}
};
/**
* PATCH /api/users/:id — Update a user. Admin only.
*/
export const PATCH: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const user = await userService.update(id, parsed.data);
return json(success(user));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/users/:id — Delete a user. Admin only.
*/
export const DELETE: RequestHandler = async (event) => {
const admin = requireAdmin(event);
const { id } = event.params;
if (id === admin.id) {
return json(error('Cannot delete your own account'), { status: 400 });
}
try {
await userService.remove(id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete user';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};