diff --git a/plans/mvp-web-app-launcher/CONTEXT.md b/plans/mvp-web-app-launcher/CONTEXT.md
index 29740e3..fc8b115 100644
--- a/plans/mvp-web-app-launcher/CONTEXT.md
+++ b/plans/mvp-web-app-launcher/CONTEXT.md
@@ -8,6 +8,8 @@ Phase 3 (Authentication System) is complete. The full local authentication flow
Phase 5 (Board, Section & Widget System) is complete. All 20 tasks implemented: 5 API route files for board/section/widget CRUD (`/api/boards`, `/api/boards/[id]`, `/api/boards/[id]/sections`, `/api/boards/[id]/sections/[sid]`, `/api/boards/[id]/sections/[sid]/widgets`), 3 page routes for board list (`/boards`), board view (`/boards/[boardId]`), and board editor (`/boards/[boardId]/edit`), plus 9 Svelte components across board/section/widget directories. Board list API filters by permissions: admins see all, regular users see boards where they have VIEW+ permission via `permissionService.checkPermission()`, guests see only `isGuestAccessible` boards. Board view loads the full hierarchy (board -> sections -> widgets -> app -> latest status) via `boardService.findBoardById`. The board editor uses SvelteKit form actions (updateBoard, addSection/updateSection/deleteSection, addWidget/deleteWidget) with `use:enhance` for progressive enhancement. Section collapse uses Svelte's built-in `slide` transition. Widget grid is responsive CSS grid (2 cols mobile, 3 tablet, 4 desktop). `AppWidget` reuses `AppHealthBadge` for status display.
+Phase 6 (Admin Panel) is complete. All 18 tasks implemented: admin layout with `requireAdmin` guard in `+layout.server.ts` and nav bar linking Users/Groups/Settings plus Back to Dashboard. User management at `/admin/users` supports full CRUD via Superforms (create with email/displayName/password/role, inline role editing, delete with confirmation) plus group membership management (add/remove users from groups). Group management at `/admin/groups` supports CRUD with inline editing, member count display, and default-group toggle. System settings at `/admin/settings` configures auth mode (local/oauth/both), registration toggle, OAuth fields (stored, non-functional in MVP), default theme (dark/light), default primary color (hex), and healthcheck defaults (JSON). Four admin components created: `UserTable.svelte`, `GroupTable.svelte`, `SettingsForm.svelte`, and `PermissionEditor.svelte` (reusable with `onGrant`/`onRevoke` callback props for entity/target/level selection). Six REST API route files added: `/api/users` (GET/POST), `/api/users/[id]` (GET/PATCH/DELETE), `/api/groups` (GET/POST), `/api/groups/[id]` (GET/PATCH/DELETE), `/api/admin/settings` (GET/PATCH) — all admin-only. Global search endpoint at `/api/search?q=term` searches apps by name/description/category and boards by name/description, filtering results by user permissions via `permissionService.checkPermission`. Self-deletion protection prevents admin from deleting their own account. All forms use Superforms + Zod validation schemas from `$lib/utils/validators.ts`.
+
## Temporary Workarounds
- Permission model uses polymorphic pattern (entityType/targetType strings) without FK relations to avoid SQLite dual-FK constraint issues. Queries are done manually in `permissionService.ts`.
diff --git a/plans/mvp-web-app-launcher/PLAN.md b/plans/mvp-web-app-launcher/PLAN.md
index 0ce254f..663f1f8 100644
--- a/plans/mvp-web-app-launcher/PLAN.md
+++ b/plans/mvp-web-app-launcher/PLAN.md
@@ -32,7 +32,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi
- [x] Phase 3: Authentication System [fullstack] → [subplan](./phase-3-authentication.md)
- [x] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md)
- [ ] Phase 5: Board, Section & Widget System [fullstack] → [subplan](./phase-5-board-widgets.md)
-- [ ] Phase 6: Admin Panel [fullstack] → [subplan](./phase-6-admin-panel.md)
+- [x] Phase 6: Admin Panel [fullstack] → [subplan](./phase-6-admin-panel.md)
- [ ] Phase 7: UI Polish & Ambient Backgrounds [frontend] → [subplan](./phase-7-ui-polish.md)
- [ ] Phase 8: Integration, Testing & Deployment [fullstack] → [subplan](./phase-8-integration-deploy.md)
@@ -45,7 +45,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi
| Phase 3: Authentication | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 4: App & Healthcheck | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 5: Board & Widgets | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
-| Phase 6: Admin Panel | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+| Phase 6: Admin Panel | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 7: UI Polish | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 8: Integration & Deploy | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
diff --git a/plans/mvp-web-app-launcher/phase-6-admin-panel.md b/plans/mvp-web-app-launcher/phase-6-admin-panel.md
index 839985d..3c9ad87 100644
--- a/plans/mvp-web-app-launcher/phase-6-admin-panel.md
+++ b/plans/mvp-web-app-launcher/phase-6-admin-panel.md
@@ -1,6 +1,6 @@
# Phase 6: Admin Panel
-**Status:** ⬜ Not Started
+**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
@@ -9,24 +9,24 @@ Build the admin panel with user management, group management, app management, bo
## Tasks
-- [ ] Task 1: Create `src/routes/admin/+layout.server.ts` — admin auth guard (role check)
-- [ ] Task 2: Create `src/routes/admin/+layout.svelte` — admin layout with nav
-- [ ] Task 3: Create `src/routes/api/users/+server.ts` — GET (list), POST (create user)
-- [ ] Task 4: Create `src/routes/api/users/[id]/+server.ts` — GET, PATCH, DELETE
-- [ ] Task 5: Create `src/routes/api/groups/+server.ts` — GET (list), POST (create group)
-- [ ] Task 6: Create `src/routes/api/groups/[id]/+server.ts` — GET, PATCH, DELETE
-- [ ] Task 7: Create `src/routes/api/admin/settings/+server.ts` — GET, PATCH system settings
-- [ ] Task 8: Create `src/routes/admin/users/+page.server.ts` — load users
-- [ ] Task 9: Create `src/routes/admin/users/+page.svelte` — user management page
-- [ ] Task 10: Create `src/routes/admin/groups/+page.server.ts` — load groups
-- [ ] Task 11: Create `src/routes/admin/groups/+page.svelte` — group management page
-- [ ] Task 12: Create `src/routes/admin/settings/+page.server.ts` — load/update settings
-- [ ] Task 13: Create `src/routes/admin/settings/+page.svelte` — system settings page
-- [ ] Task 14: Create `src/lib/components/admin/UserTable.svelte` — user list with actions
-- [ ] Task 15: Create `src/lib/components/admin/GroupTable.svelte` — group list with actions
-- [ ] Task 16: Create `src/lib/components/admin/SettingsForm.svelte` — settings form
-- [ ] Task 17: Create `src/lib/components/admin/PermissionEditor.svelte` — permission assignment UI
-- [ ] Task 18: Create `src/routes/api/search/+server.ts` — global search endpoint (searches apps + boards)
+- [x] Task 1: Create `src/routes/admin/+layout.server.ts` — admin auth guard (role check)
+- [x] Task 2: Create `src/routes/admin/+layout.svelte` — admin layout with nav
+- [x] Task 3: Create `src/routes/api/users/+server.ts` — GET (list), POST (create user)
+- [x] Task 4: Create `src/routes/api/users/[id]/+server.ts` — GET, PATCH, DELETE
+- [x] Task 5: Create `src/routes/api/groups/+server.ts` — GET (list), POST (create group)
+- [x] Task 6: Create `src/routes/api/groups/[id]/+server.ts` — GET, PATCH, DELETE
+- [x] Task 7: Create `src/routes/api/admin/settings/+server.ts` — GET, PATCH system settings
+- [x] Task 8: Create `src/routes/admin/users/+page.server.ts` — load users
+- [x] Task 9: Create `src/routes/admin/users/+page.svelte` — user management page
+- [x] Task 10: Create `src/routes/admin/groups/+page.server.ts` — load groups
+- [x] Task 11: Create `src/routes/admin/groups/+page.svelte` — group management page
+- [x] Task 12: Create `src/routes/admin/settings/+page.server.ts` — load/update settings
+- [x] Task 13: Create `src/routes/admin/settings/+page.svelte` — system settings page
+- [x] Task 14: Create `src/lib/components/admin/UserTable.svelte` — user list with actions
+- [x] Task 15: Create `src/lib/components/admin/GroupTable.svelte` — group list with actions
+- [x] Task 16: Create `src/lib/components/admin/SettingsForm.svelte` — settings form
+- [x] Task 17: Create `src/lib/components/admin/PermissionEditor.svelte` — permission assignment UI
+- [x] Task 18: Create `src/routes/api/search/+server.ts` — global search endpoint (searches apps + boards)
## Files to Modify/Create
- `src/routes/admin/+layout.server.ts`
@@ -61,11 +61,26 @@ Build the admin panel with user management, group management, app management, bo
- ⚠️ Big Bang: functional but minimally styled until Phase 7
## Review Checklist
-- [ ] All tasks completed
-- [ ] Code follows project conventions
-- [ ] No unintended side effects
+- [x] All tasks completed
+- [x] Code follows project conventions
+- [x] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
-
+
+**What was built:**
+- Admin layout with auth guard (`requireAdmin`) and navigation (Users/Groups/Settings + Back to Dashboard)
+- User management: full CRUD via Superforms, inline role editing, group membership management (add/remove), delete with confirmation
+- Group management: full CRUD via Superforms, inline editing, member count display, default group toggle
+- System settings: auth mode selector (local/oauth/both), registration toggle, OAuth config fields (stored, non-functional), theme defaults (dark/light + hex color), healthcheck defaults (JSON)
+- Permission editor: reusable component with entity type/entity, target type/target, and level selectors, grant/revoke actions, existing permissions table
+- Search API: `GET /api/search?q=term` searches apps (name, description, category) and boards (name, description), filters results by user permissions (admins see all, regular users filtered via `permissionService.checkPermission`)
+- All API routes use the existing response envelope (`success`/`error` from `$lib/server/utils/response.ts`) and Zod validation schemas
+- Admin API routes: `/api/users` (GET/POST), `/api/users/[id]` (GET/PATCH/DELETE), `/api/groups` (GET/POST), `/api/groups/[id]` (GET/PATCH/DELETE), `/api/admin/settings` (GET/PATCH)
+- Self-deletion protection: admin cannot delete their own account
+
+**Available for Phase 7:**
+- All admin components in `src/lib/components/admin/` (UserTable, GroupTable, SettingsForm, PermissionEditor) — ready for UI polish
+- Admin layout nav bar — can be styled with active states, icons
+- PermissionEditor is a reusable client-side component with callback props (`onGrant`/`onRevoke`) — can be integrated into any admin page
diff --git a/src/lib/components/admin/GroupTable.svelte b/src/lib/components/admin/GroupTable.svelte
new file mode 100644
index 0000000..d648147
--- /dev/null
+++ b/src/lib/components/admin/GroupTable.svelte
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+ | Name |
+ Description |
+ Members |
+ Default |
+ Actions |
+
+
+
+ {#each groups as group (group.id)}
+
+ {#if editingGroupId === group.id}
+ |
+
+ |
+ {:else}
+ {group.name} |
+ {group.description ?? '—'} |
+ {group._count.users} |
+
+ {#if group.isDefault}
+ Yes
+ {:else}
+ No
+ {/if}
+ |
+
+
+
+ {#if confirmDeleteId === group.id}
+
+ {:else}
+
+ {/if}
+
+ |
+ {/if}
+
+ {/each}
+
+
+
+ {#if groups.length === 0}
+
No groups found.
+ {/if}
+
diff --git a/src/lib/components/admin/PermissionEditor.svelte b/src/lib/components/admin/PermissionEditor.svelte
new file mode 100644
index 0000000..1278c9e
--- /dev/null
+++ b/src/lib/components/admin/PermissionEditor.svelte
@@ -0,0 +1,220 @@
+
+
+
+
+
+
Grant Permission
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if permissions.length > 0}
+
+
+
+
+ | Entity |
+ Target |
+ Level |
+ Action |
+
+
+
+ {#each permissions as perm (perm.id)}
+
+ |
+ {perm.entityType}:
+ {getEntityName(perm.entityType, perm.entityId)}
+ |
+
+ {perm.targetType}:
+ {getTargetName(perm.targetType, perm.targetId)}
+ |
+
+
+ {perm.level}
+
+ |
+
+
+ |
+
+ {/each}
+
+
+
+ {:else}
+
No permissions configured.
+ {/if}
+
diff --git a/src/lib/components/admin/SettingsForm.svelte b/src/lib/components/admin/SettingsForm.svelte
new file mode 100644
index 0000000..0ed3454
--- /dev/null
+++ b/src/lib/components/admin/SettingsForm.svelte
@@ -0,0 +1,158 @@
+
+
+
diff --git a/src/lib/components/admin/UserTable.svelte b/src/lib/components/admin/UserTable.svelte
new file mode 100644
index 0000000..5d94a5b
--- /dev/null
+++ b/src/lib/components/admin/UserTable.svelte
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+ | User |
+ Email |
+ Role |
+ Provider |
+ Groups |
+ Actions |
+
+
+
+ {#each users as user (user.id)}
+
+ | {user.displayName} |
+ {user.email} |
+
+ {#if editingUserId === user.id}
+
+ {:else}
+
+ {user.role}
+
+ {/if}
+ |
+ {user.authProvider} |
+
+
+ {#each user.groups as group (group.id)}
+
+ {group.name}
+
+
+ {/each}
+ {#if addGroupUserId === user.id}
+
+ {:else}
+
+ {/if}
+
+ |
+
+
+
+ {#if confirmDeleteId === user.id}
+
+ {:else}
+
+ {/if}
+
+ |
+
+ {/each}
+
+
+
+ {#if users.length === 0}
+
No users found.
+ {/if}
+
diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts
new file mode 100644
index 0000000..48065da
--- /dev/null
+++ b/src/routes/admin/+layout.server.ts
@@ -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 };
+};
diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte
new file mode 100644
index 0000000..06444e6
--- /dev/null
+++ b/src/routes/admin/+layout.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+ {@render children()}
+
+
diff --git a/src/routes/admin/groups/+page.server.ts b/src/routes/admin/groups/+page.server.ts
new file mode 100644
index 0000000..cc32ec0
--- /dev/null
+++ b/src/routes/admin/groups/+page.server.ts
@@ -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 };
+ }
+};
diff --git a/src/routes/admin/groups/+page.svelte b/src/routes/admin/groups/+page.svelte
new file mode 100644
index 0000000..817e604
--- /dev/null
+++ b/src/routes/admin/groups/+page.svelte
@@ -0,0 +1,88 @@
+
+
+
+ Group Management — Admin
+
+
+
+
+
Group Management
+
+
+
+ {#if showCreateForm}
+
+
New Group
+
+
+ {/if}
+
+
+
diff --git a/src/routes/admin/settings/+page.server.ts b/src/routes/admin/settings/+page.server.ts
new file mode 100644
index 0000000..834adfe
--- /dev/null
+++ b/src/routes/admin/settings/+page.server.ts
@@ -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 = {};
+ 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 };
+ }
+};
diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte
new file mode 100644
index 0000000..8374dd5
--- /dev/null
+++ b/src/routes/admin/settings/+page.svelte
@@ -0,0 +1,19 @@
+
+
+
+ System Settings — Admin
+
+
+
+
+
System Settings
+
Configure global application settings.
+
+
+
+
diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts
new file mode 100644
index 0000000..cbc2daf
--- /dev/null
+++ b/src/routes/admin/users/+page.server.ts
@@ -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 };
+ }
+};
diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte
new file mode 100644
index 0000000..5ff487c
--- /dev/null
+++ b/src/routes/admin/users/+page.svelte
@@ -0,0 +1,103 @@
+
+
+
+ User Management — Admin
+
+
+
+
+
User Management
+
+
+
+ {#if showCreateForm}
+
+
New User
+
+
+ {/if}
+
+
+
diff --git a/src/routes/api/admin/settings/+server.ts b/src/routes/api/admin/settings/+server.ts
new file mode 100644
index 0000000..9a3dc2a
--- /dev/null
+++ b/src/routes/api/admin/settings/+server.ts
@@ -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 = {};
+ 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 });
+ }
+};
diff --git a/src/routes/api/groups/+server.ts b/src/routes/api/groups/+server.ts
new file mode 100644
index 0000000..09b00d9
--- /dev/null
+++ b/src/routes/api/groups/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/api/groups/[id]/+server.ts b/src/routes/api/groups/[id]/+server.ts
new file mode 100644
index 0000000..b9aecf1
--- /dev/null
+++ b/src/routes/api/groups/[id]/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts
new file mode 100644
index 0000000..bb48552
--- /dev/null
+++ b/src/routes/api/search/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts
new file mode 100644
index 0000000..47b2e50
--- /dev/null
+++ b/src/routes/api/users/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/api/users/[id]/+server.ts b/src/routes/api/users/[id]/+server.ts
new file mode 100644
index 0000000..b3d7545
--- /dev/null
+++ b/src/routes/api/users/[id]/+server.ts
@@ -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 });
+ }
+};