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 @@ + + +
+ + + + + + + + + + + + {#each groups as group (group.id)} + + {#if editingGroupId === group.id} + + {:else} + + + + + + {/if} + + {/each} + +
NameDescriptionMembersDefaultActions
+
{ + return async ({ update }) => { + editingGroupId = null; + await update(); + }; + }} class="flex items-center gap-3"> + + + + + + +
+
{group.name}{group.description ?? '—'}{group._count.users} + {#if group.isDefault} + Yes + {:else} + No + {/if} + +
+ + {#if confirmDeleteId === group.id} +
{ + return async ({ update }) => { + confirmDeleteId = null; + await update(); + }; + }}> + + Confirm? + + +
+ {:else} + + {/if} +
+
+ + {#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} +
+ + + + + + + + + + + {#each permissions as perm (perm.id)} + + + + + + + {/each} + +
EntityTargetLevelAction
+ {perm.entityType}: + {getEntityName(perm.entityType, perm.entityId)} + + {perm.targetType}: + {getTargetName(perm.targetType, perm.targetId)} + + + {perm.level} + + + +
+
+ {: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 @@ + + +
+ +
+

Authentication

+
+
+ + + {#if $errors.authMode}{$errors.authMode}{/if} +
+
+ + +
+
+
+ + +
+

OAuth Configuration

+

OAuth settings are stored but not active in this MVP version.

+
+
+ + +
+
+ + +
+
+ + + {#if $errors.oauthDiscoveryUrl}{$errors.oauthDiscoveryUrl}{/if} +
+
+
+ + +
+

Theme Defaults

+
+
+ + +
+
+ +
+ + {#if $form.defaultPrimaryColor} +
+ {/if} +
+ {#if $errors.defaultPrimaryColor}{$errors.defaultPrimaryColor}{/if} +
+
+
+ + +
+

Healthcheck Defaults

+

JSON configuration for default healthcheck behavior (interval, timeout, method).

+
+ + + {#if $errors.healthcheckDefaults}{$errors.healthcheckDefaults}{/if} +
+
+ + {#if $errors._errors} +

{$errors._errors}

+ {/if} + +
+ +
+
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 @@ + + +
+ + + + + + + + + + + + + {#each users as user (user.id)} + + + + + + + + + {/each} + +
UserEmailRoleProviderGroupsActions
{user.displayName}{user.email} + {#if editingUserId === user.id} +
{ + return async ({ update }) => { + editingUserId = null; + await update(); + }; + }}> + + + + +
+ {:else} + + {user.role} + + {/if} +
{user.authProvider} +
+ {#each user.groups as group (group.id)} + + {group.name} +
+ + + +
+
+ {/each} + {#if addGroupUserId === user.id} +
{ + return async ({ update }) => { + addGroupUserId = null; + selectedGroupId = ''; + await update(); + }; + }} class="inline-flex items-center gap-1"> + + + + +
+ {:else} + + {/if} +
+
+
+ + {#if confirmDeleteId === user.id} +
{ + return async ({ update }) => { + confirmDeleteId = null; + await update(); + }; + }}> + + Confirm? + + +
+ {:else} + + {/if} +
+
+ + {#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 $errors.name}{$errors.name}{/if} +
+
+ + +
+
+ + +
+
+ {#if $errors._errors} +

{$errors._errors}

+ {/if} + +
+
+ {/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 $errors.email}{$errors.email}{/if} +
+
+ + + {#if $errors.displayName}{$errors.displayName}{/if} +
+
+ + + {#if $errors.password}{$errors.password}{/if} +
+
+ + +
+
+ {#if $errors._errors} +

{$errors._errors}

+ {/if} + +
+
+ {/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 }); + } +};