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
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface GroupWithCount {
id: string;
name: string;
description: string | null;
isDefault: boolean;
createdAt: Date;
_count: { users: number };
}
let { groups }: { groups: GroupWithCount[] } = $props();
let editingGroupId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let editName = $state('');
let editDescription = $state('');
let editIsDefault = $state(false);
function startEdit(group: GroupWithCount) {
editingGroupId = group.id;
editName = group.name;
editDescription = group.description ?? '';
editIsDefault = group.isDefault;
}
</script>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">Name</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Description</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Members</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Default</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{#each groups as group (group.id)}
<tr class="border-b border-border last:border-b-0 hover:bg-muted/30">
{#if editingGroupId === group.id}
<td colspan="5" class="px-4 py-3">
<form method="POST" action="?/update" use:enhance={() => {
return async ({ update }) => {
editingGroupId = null;
await update();
};
}} class="flex items-center gap-3">
<input type="hidden" name="groupId" value={group.id} />
<input
name="name"
type="text"
bind:value={editName}
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
required
/>
<input
name="description"
type="text"
bind:value={editDescription}
placeholder="Description"
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
/>
<label class="flex items-center gap-1 text-xs text-foreground">
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
Default
</label>
<button type="submit" class="text-xs text-primary hover:underline">Save</button>
<button type="button" onclick={() => (editingGroupId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
</form>
</td>
{:else}
<td class="px-4 py-3 font-medium text-foreground">{group.name}</td>
<td class="px-4 py-3 text-muted-foreground">{group.description ?? '—'}</td>
<td class="px-4 py-3 text-muted-foreground">{group._count.users}</td>
<td class="px-4 py-3">
{#if group.isDefault}
<span class="inline-flex rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">Yes</span>
{:else}
<span class="text-xs text-muted-foreground">No</span>
{/if}
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => startEdit(group)}
class="text-xs text-primary hover:underline"
>
Edit
</button>
{#if confirmDeleteId === group.id}
<form method="POST" action="?/delete" use:enhance={() => {
return async ({ update }) => {
confirmDeleteId = null;
await update();
};
}}>
<input type="hidden" name="groupId" value={group.id} />
<span class="text-xs text-destructive">Confirm?</span>
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
</form>
{:else}
<button
type="button"
onclick={() => (confirmDeleteId = group.id)}
class="text-xs text-destructive hover:underline"
>
Delete
</button>
{/if}
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{#if groups.length === 0}
<div class="py-8 text-center text-sm text-muted-foreground">No groups found.</div>
{/if}
</div>
@@ -0,0 +1,220 @@
<script lang="ts">
import { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants.js';
interface PermissionRecord {
id: string;
entityType: string;
entityId: string;
targetType: string;
targetId: string;
level: string;
createdAt: Date;
}
interface SelectOption {
id: string;
name: string;
}
let {
permissions = [],
apps = [],
boards = [],
users = [],
groups = [],
onGrant,
onRevoke
}: {
permissions: PermissionRecord[];
apps: SelectOption[];
boards: SelectOption[];
users: SelectOption[];
groups: SelectOption[];
onGrant: (data: {
entityType: string;
entityId: string;
targetType: string;
targetId: string;
level: string;
}) => void;
onRevoke: (data: {
entityType: string;
entityId: string;
targetType: string;
targetId: string;
}) => void;
} = $props();
let selectedEntityType = $state<string>(EntityType.BOARD);
let selectedEntityId = $state('');
let selectedTargetType = $state<string>(TargetType.USER);
let selectedTargetId = $state('');
let selectedLevel = $state<string>(PermissionLevel.VIEW);
let entityOptions = $derived(
selectedEntityType === EntityType.APP ? apps : boards
);
let targetOptions = $derived(
selectedTargetType === TargetType.USER ? users : groups
);
function handleGrant() {
if (!selectedEntityId || !selectedTargetId) return;
onGrant({
entityType: selectedEntityType,
entityId: selectedEntityId,
targetType: selectedTargetType,
targetId: selectedTargetId,
level: selectedLevel
});
selectedEntityId = '';
selectedTargetId = '';
}
function handleRevoke(perm: PermissionRecord) {
onRevoke({
entityType: perm.entityType,
entityId: perm.entityId,
targetType: perm.targetType,
targetId: perm.targetId
});
}
function getEntityName(entityType: string, entityId: string): string {
const list = entityType === EntityType.APP ? apps : boards;
return list.find((e) => e.id === entityId)?.name ?? entityId;
}
function getTargetName(targetType: string, targetId: string): string {
const list = targetType === TargetType.USER ? users : groups;
return list.find((t) => t.id === targetId)?.name ?? targetId;
}
</script>
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">Grant Permission</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<div>
<label for="perm-entity-type" class="mb-1 block text-xs text-muted-foreground">Entity Type</label>
<select
id="perm-entity-type"
bind:value={selectedEntityType}
onchange={() => (selectedEntityId = '')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={EntityType.BOARD}>Board</option>
<option value={EntityType.APP}>App</option>
</select>
</div>
<div>
<label for="perm-entity" class="mb-1 block text-xs text-muted-foreground">Entity</label>
<select
id="perm-entity"
bind:value={selectedEntityId}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value="" disabled>Select...</option>
{#each entityOptions as option}
<option value={option.id}>{option.name}</option>
{/each}
</select>
</div>
<div>
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">Target Type</label>
<select
id="perm-target-type"
bind:value={selectedTargetType}
onchange={() => (selectedTargetId = '')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={TargetType.USER}>User</option>
<option value={TargetType.GROUP}>Group</option>
</select>
</div>
<div>
<label for="perm-target" class="mb-1 block text-xs text-muted-foreground">Target</label>
<select
id="perm-target"
bind:value={selectedTargetId}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value="" disabled>Select...</option>
{#each targetOptions as option}
<option value={option.id}>{option.name}</option>
{/each}
</select>
</div>
<div>
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">Level</label>
<div class="flex gap-1">
<select
id="perm-level"
bind:value={selectedLevel}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={PermissionLevel.VIEW}>View</option>
<option value={PermissionLevel.EDIT}>Edit</option>
<option value={PermissionLevel.ADMIN}>Admin</option>
</select>
<button
type="button"
onclick={handleGrant}
disabled={!selectedEntityId || !selectedTargetId}
class="shrink-0 rounded bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Grant
</button>
</div>
</div>
</div>
</div>
<!-- Existing permissions list -->
{#if permissions.length > 0}
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Entity</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Target</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Level</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Action</th>
</tr>
</thead>
<tbody>
{#each permissions as perm (perm.id)}
<tr class="border-b border-border last:border-b-0">
<td class="px-4 py-2 text-foreground">
<span class="text-xs text-muted-foreground">{perm.entityType}:</span>
{getEntityName(perm.entityType, perm.entityId)}
</td>
<td class="px-4 py-2 text-foreground">
<span class="text-xs text-muted-foreground">{perm.targetType}:</span>
{getTargetName(perm.targetType, perm.targetId)}
</td>
<td class="px-4 py-2">
<span class="rounded-full bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground">
{perm.level}
</span>
</td>
<td class="px-4 py-2">
<button
type="button"
onclick={() => handleRevoke(perm)}
class="text-xs text-destructive hover:underline"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-sm text-muted-foreground">No permissions configured.</p>
{/if}
</div>
@@ -0,0 +1,158 @@
<script lang="ts">
import { superForm, type SuperValidated } from 'sveltekit-superforms/client';
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod';
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
const { form, errors, enhance, delayed } = superForm(formData);
</script>
<form method="POST" action="?/update" use:enhance class="space-y-8">
<!-- Authentication -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Authentication</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">Auth Mode</label>
<select
id="authMode"
name="authMode"
bind:value={$form.authMode}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="local">Local</option>
<option value="oauth">OAuth</option>
<option value="both">Both</option>
</select>
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
</div>
<div class="flex items-center gap-2 pt-6">
<input
id="registrationEnabled"
name="registrationEnabled"
type="checkbox"
bind:checked={$form.registrationEnabled}
class="h-4 w-4 rounded border-input"
/>
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
Allow user registration
</label>
</div>
</div>
</section>
<!-- OAuth (stored but non-functional in MVP) -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">OAuth Configuration</h2>
<p class="mb-4 text-xs text-muted-foreground">OAuth settings are stored but not active in this MVP version.</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="oauthClientId" class="mb-1 block text-sm font-medium text-foreground">Client ID</label>
<input
id="oauthClientId"
name="oauthClientId"
type="text"
bind:value={$form.oauthClientId}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="OAuth client ID"
/>
</div>
<div>
<label for="oauthClientSecret" class="mb-1 block text-sm font-medium text-foreground">Client Secret</label>
<input
id="oauthClientSecret"
name="oauthClientSecret"
type="password"
bind:value={$form.oauthClientSecret}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="OAuth client secret"
/>
</div>
<div class="sm:col-span-2">
<label for="oauthDiscoveryUrl" class="mb-1 block text-sm font-medium text-foreground">Discovery URL</label>
<input
id="oauthDiscoveryUrl"
name="oauthDiscoveryUrl"
type="url"
bind:value={$form.oauthDiscoveryUrl}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="https://example.com/.well-known/openid-configuration"
/>
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
</div>
</div>
</section>
<!-- Theme Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Theme Defaults</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">Default Theme</label>
<select
id="defaultTheme"
name="defaultTheme"
bind:value={$form.defaultTheme}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div>
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">Default Primary Color</label>
<div class="flex items-center gap-2">
<input
id="defaultPrimaryColor"
name="defaultPrimaryColor"
type="text"
bind:value={$form.defaultPrimaryColor}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="#6366f1"
pattern="^#[0-9a-fA-F]{6}$"
/>
{#if $form.defaultPrimaryColor}
<div
class="h-8 w-8 shrink-0 rounded border border-border"
style:background-color={$form.defaultPrimaryColor}
></div>
{/if}
</div>
{#if $errors.defaultPrimaryColor}<span class="text-xs text-destructive">{$errors.defaultPrimaryColor}</span>{/if}
</div>
</div>
</section>
<!-- Healthcheck Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Healthcheck Defaults</h2>
<p class="mb-4 text-xs text-muted-foreground">JSON configuration for default healthcheck behavior (interval, timeout, method).</p>
<div>
<label for="healthcheckDefaults" class="mb-1 block text-sm font-medium text-foreground">Defaults (JSON)</label>
<textarea
id="healthcheckDefaults"
name="healthcheckDefaults"
bind:value={$form.healthcheckDefaults}
rows="4"
class="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
placeholder='{"interval": 300, "timeout": 5000, "method": "GET"}'
></textarea>
{#if $errors.healthcheckDefaults}<span class="text-xs text-destructive">{$errors.healthcheckDefaults}</span>{/if}
</div>
</section>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
disabled={$delayed}
>
{$delayed ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface UserWithGroups {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
authProvider: string;
role: string;
createdAt: Date;
groups: Array<{ id: string; name: string }>;
}
interface Group {
id: string;
name: string;
description: string | null;
isDefault: boolean;
_count: { users: number };
}
let {
users,
groups
}: {
users: UserWithGroups[];
groups: Group[];
} = $props();
let editingUserId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let addGroupUserId = $state<string | null>(null);
let selectedGroupId = $state('');
</script>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">User</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Email</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Role</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Provider</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Groups</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr class="border-b border-border last:border-b-0 hover:bg-muted/30">
<td class="px-4 py-3 text-foreground">{user.displayName}</td>
<td class="px-4 py-3 text-muted-foreground">{user.email}</td>
<td class="px-4 py-3">
{#if editingUserId === user.id}
<form method="POST" action="?/update" use:enhance={() => {
return async ({ update }) => {
editingUserId = null;
await update();
};
}}>
<input type="hidden" name="userId" value={user.id} />
<select
name="role"
class="rounded border border-input bg-background px-2 py-1 text-xs text-foreground"
>
<option value="user" selected={user.role === 'user'}>User</option>
<option value="admin" selected={user.role === 'admin'}>Admin</option>
</select>
<button type="submit" class="ml-1 text-xs text-primary hover:underline">Save</button>
<button type="button" onclick={() => (editingUserId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">Cancel</button>
</form>
{:else}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {user.role === 'admin' ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'}">
{user.role}
</span>
{/if}
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">{user.authProvider}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
{#each user.groups as group (group.id)}
<span class="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs text-accent-foreground">
{group.name}
<form method="POST" action="?/removeFromGroup" use:enhance class="inline">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="groupId" value={group.id} />
<button type="submit" class="text-muted-foreground hover:text-destructive" title="Remove from group">&times;</button>
</form>
</span>
{/each}
{#if addGroupUserId === user.id}
<form method="POST" action="?/addToGroup" use:enhance={() => {
return async ({ update }) => {
addGroupUserId = null;
selectedGroupId = '';
await update();
};
}} class="inline-flex items-center gap-1">
<input type="hidden" name="userId" value={user.id} />
<select
name="groupId"
bind:value={selectedGroupId}
class="rounded border border-input bg-background px-2 py-0.5 text-xs text-foreground"
>
<option value="" disabled>Select group</option>
{#each groups.filter((g) => !user.groups.some((ug) => ug.id === g.id)) as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<button type="submit" class="text-xs text-primary hover:underline" disabled={!selectedGroupId}>Add</button>
<button type="button" onclick={() => (addGroupUserId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
</form>
{:else}
<button
type="button"
onclick={() => (addGroupUserId = user.id)}
class="rounded-full border border-dashed border-border px-2 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
>
+ Add
</button>
{/if}
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => (editingUserId = editingUserId === user.id ? null : user.id)}
class="text-xs text-primary hover:underline"
>
Edit
</button>
{#if confirmDeleteId === user.id}
<form method="POST" action="?/delete" use:enhance={() => {
return async ({ update }) => {
confirmDeleteId = null;
await update();
};
}}>
<input type="hidden" name="userId" value={user.id} />
<span class="text-xs text-destructive">Confirm?</span>
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
</form>
{:else}
<button
type="button"
onclick={() => (confirmDeleteId = user.id)}
class="text-xs text-destructive hover:underline"
>
Delete
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{#if users.length === 0}
<div class="py-8 text-center text-sm text-muted-foreground">No users found.</div>
{/if}
</div>
+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 });
}
};