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:
@@ -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 };
|
||||
};
|
||||
@@ -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">
|
||||
← 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>
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user