feat(mvp): phase 6 - admin panel

Add admin layout with auth guard, user management (CRUD + group membership),
group management, system settings (auth mode, registration, theme, healthcheck),
permission editor component, and global search API endpoint.
This commit is contained in:
2026-03-24 21:18:06 +03:00
parent b0d77d3c29
commit c5166ba3a9
21 changed files with 1709 additions and 25 deletions
+2
View File
@@ -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 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 ## 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`. - 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`.
+2 -2
View File
@@ -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 3: Authentication System [fullstack] → [subplan](./phase-3-authentication.md)
- [x] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.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 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 7: UI Polish & Ambient Backgrounds [frontend] → [subplan](./phase-7-ui-polish.md)
- [ ] Phase 8: Integration, Testing & Deployment [fullstack] → [subplan](./phase-8-integration-deploy.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 3: Authentication | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 4: App & Healthcheck | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 4: App & Healthcheck | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 5: Board & Widgets | 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 7: UI Polish | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 8: Integration & Deploy | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 8: Integration & Deploy | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
@@ -1,6 +1,6 @@
# Phase 6: Admin Panel # Phase 6: Admin Panel
**Status:** ⬜ Not Started **Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack
@@ -9,24 +9,24 @@ Build the admin panel with user management, group management, app management, bo
## Tasks ## Tasks
- [ ] Task 1: Create `src/routes/admin/+layout.server.ts` — admin auth guard (role check) - [x] 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 - [x] 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) - [x] 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 - [x] 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) - [x] 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 - [x] 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 - [x] 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 - [x] Task 8: Create `src/routes/admin/users/+page.server.ts` — load users
- [ ] Task 9: Create `src/routes/admin/users/+page.svelte` — user management page - [x] Task 9: Create `src/routes/admin/users/+page.svelte` — user management page
- [ ] Task 10: Create `src/routes/admin/groups/+page.server.ts` — load groups - [x] Task 10: Create `src/routes/admin/groups/+page.server.ts` — load groups
- [ ] Task 11: Create `src/routes/admin/groups/+page.svelte` — group management page - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] Task 15: Create `src/lib/components/admin/GroupTable.svelte` — group list with actions
- [ ] Task 16: Create `src/lib/components/admin/SettingsForm.svelte` — settings form - [x] Task 16: Create `src/lib/components/admin/SettingsForm.svelte` — settings form
- [ ] Task 17: Create `src/lib/components/admin/PermissionEditor.svelte` — permission assignment UI - [x] 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 18: Create `src/routes/api/search/+server.ts` — global search endpoint (searches apps + boards)
## Files to Modify/Create ## Files to Modify/Create
- `src/routes/admin/+layout.server.ts` - `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 - ⚠️ Big Bang: functional but minimally styled until Phase 7
## Review Checklist ## Review Checklist
- [ ] All tasks completed - [x] All tasks completed
- [ ] Code follows project conventions - [x] Code follows project conventions
- [ ] No unintended side effects - [x] No unintended side effects
- [ ] Build passes - [ ] Build passes
- [ ] Tests pass (new + existing) - [ ] Tests pass (new + existing)
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this 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
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface GroupWithCount {
id: string;
name: string;
description: string | null;
isDefault: boolean;
createdAt: Date;
_count: { users: number };
}
let { groups }: { groups: GroupWithCount[] } = $props();
let editingGroupId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let editName = $state('');
let editDescription = $state('');
let editIsDefault = $state(false);
function startEdit(group: GroupWithCount) {
editingGroupId = group.id;
editName = group.name;
editDescription = group.description ?? '';
editIsDefault = group.isDefault;
}
</script>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">Name</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Description</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Members</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Default</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{#each groups as group (group.id)}
<tr class="border-b border-border last:border-b-0 hover:bg-muted/30">
{#if editingGroupId === group.id}
<td colspan="5" class="px-4 py-3">
<form method="POST" action="?/update" use:enhance={() => {
return async ({ update }) => {
editingGroupId = null;
await update();
};
}} class="flex items-center gap-3">
<input type="hidden" name="groupId" value={group.id} />
<input
name="name"
type="text"
bind:value={editName}
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
required
/>
<input
name="description"
type="text"
bind:value={editDescription}
placeholder="Description"
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
/>
<label class="flex items-center gap-1 text-xs text-foreground">
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
Default
</label>
<button type="submit" class="text-xs text-primary hover:underline">Save</button>
<button type="button" onclick={() => (editingGroupId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
</form>
</td>
{:else}
<td class="px-4 py-3 font-medium text-foreground">{group.name}</td>
<td class="px-4 py-3 text-muted-foreground">{group.description ?? '—'}</td>
<td class="px-4 py-3 text-muted-foreground">{group._count.users}</td>
<td class="px-4 py-3">
{#if group.isDefault}
<span class="inline-flex rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">Yes</span>
{:else}
<span class="text-xs text-muted-foreground">No</span>
{/if}
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => startEdit(group)}
class="text-xs text-primary hover:underline"
>
Edit
</button>
{#if confirmDeleteId === group.id}
<form method="POST" action="?/delete" use:enhance={() => {
return async ({ update }) => {
confirmDeleteId = null;
await update();
};
}}>
<input type="hidden" name="groupId" value={group.id} />
<span class="text-xs text-destructive">Confirm?</span>
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
</form>
{:else}
<button
type="button"
onclick={() => (confirmDeleteId = group.id)}
class="text-xs text-destructive hover:underline"
>
Delete
</button>
{/if}
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{#if groups.length === 0}
<div class="py-8 text-center text-sm text-muted-foreground">No groups found.</div>
{/if}
</div>
@@ -0,0 +1,220 @@
<script lang="ts">
import { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants.js';
interface PermissionRecord {
id: string;
entityType: string;
entityId: string;
targetType: string;
targetId: string;
level: string;
createdAt: Date;
}
interface SelectOption {
id: string;
name: string;
}
let {
permissions = [],
apps = [],
boards = [],
users = [],
groups = [],
onGrant,
onRevoke
}: {
permissions: PermissionRecord[];
apps: SelectOption[];
boards: SelectOption[];
users: SelectOption[];
groups: SelectOption[];
onGrant: (data: {
entityType: string;
entityId: string;
targetType: string;
targetId: string;
level: string;
}) => void;
onRevoke: (data: {
entityType: string;
entityId: string;
targetType: string;
targetId: string;
}) => void;
} = $props();
let selectedEntityType = $state<string>(EntityType.BOARD);
let selectedEntityId = $state('');
let selectedTargetType = $state<string>(TargetType.USER);
let selectedTargetId = $state('');
let selectedLevel = $state<string>(PermissionLevel.VIEW);
let entityOptions = $derived(
selectedEntityType === EntityType.APP ? apps : boards
);
let targetOptions = $derived(
selectedTargetType === TargetType.USER ? users : groups
);
function handleGrant() {
if (!selectedEntityId || !selectedTargetId) return;
onGrant({
entityType: selectedEntityType,
entityId: selectedEntityId,
targetType: selectedTargetType,
targetId: selectedTargetId,
level: selectedLevel
});
selectedEntityId = '';
selectedTargetId = '';
}
function handleRevoke(perm: PermissionRecord) {
onRevoke({
entityType: perm.entityType,
entityId: perm.entityId,
targetType: perm.targetType,
targetId: perm.targetId
});
}
function getEntityName(entityType: string, entityId: string): string {
const list = entityType === EntityType.APP ? apps : boards;
return list.find((e) => e.id === entityId)?.name ?? entityId;
}
function getTargetName(targetType: string, targetId: string): string {
const list = targetType === TargetType.USER ? users : groups;
return list.find((t) => t.id === targetId)?.name ?? targetId;
}
</script>
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">Grant Permission</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<div>
<label for="perm-entity-type" class="mb-1 block text-xs text-muted-foreground">Entity Type</label>
<select
id="perm-entity-type"
bind:value={selectedEntityType}
onchange={() => (selectedEntityId = '')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={EntityType.BOARD}>Board</option>
<option value={EntityType.APP}>App</option>
</select>
</div>
<div>
<label for="perm-entity" class="mb-1 block text-xs text-muted-foreground">Entity</label>
<select
id="perm-entity"
bind:value={selectedEntityId}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value="" disabled>Select...</option>
{#each entityOptions as option}
<option value={option.id}>{option.name}</option>
{/each}
</select>
</div>
<div>
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">Target Type</label>
<select
id="perm-target-type"
bind:value={selectedTargetType}
onchange={() => (selectedTargetId = '')}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={TargetType.USER}>User</option>
<option value={TargetType.GROUP}>Group</option>
</select>
</div>
<div>
<label for="perm-target" class="mb-1 block text-xs text-muted-foreground">Target</label>
<select
id="perm-target"
bind:value={selectedTargetId}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value="" disabled>Select...</option>
{#each targetOptions as option}
<option value={option.id}>{option.name}</option>
{/each}
</select>
</div>
<div>
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">Level</label>
<div class="flex gap-1">
<select
id="perm-level"
bind:value={selectedLevel}
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
>
<option value={PermissionLevel.VIEW}>View</option>
<option value={PermissionLevel.EDIT}>Edit</option>
<option value={PermissionLevel.ADMIN}>Admin</option>
</select>
<button
type="button"
onclick={handleGrant}
disabled={!selectedEntityId || !selectedTargetId}
class="shrink-0 rounded bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Grant
</button>
</div>
</div>
</div>
</div>
<!-- Existing permissions list -->
{#if permissions.length > 0}
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Entity</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Target</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Level</th>
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Action</th>
</tr>
</thead>
<tbody>
{#each permissions as perm (perm.id)}
<tr class="border-b border-border last:border-b-0">
<td class="px-4 py-2 text-foreground">
<span class="text-xs text-muted-foreground">{perm.entityType}:</span>
{getEntityName(perm.entityType, perm.entityId)}
</td>
<td class="px-4 py-2 text-foreground">
<span class="text-xs text-muted-foreground">{perm.targetType}:</span>
{getTargetName(perm.targetType, perm.targetId)}
</td>
<td class="px-4 py-2">
<span class="rounded-full bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground">
{perm.level}
</span>
</td>
<td class="px-4 py-2">
<button
type="button"
onclick={() => handleRevoke(perm)}
class="text-xs text-destructive hover:underline"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-sm text-muted-foreground">No permissions configured.</p>
{/if}
</div>
@@ -0,0 +1,158 @@
<script lang="ts">
import { superForm, type SuperValidated } from 'sveltekit-superforms/client';
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod';
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
const { form, errors, enhance, delayed } = superForm(formData);
</script>
<form method="POST" action="?/update" use:enhance class="space-y-8">
<!-- Authentication -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Authentication</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">Auth Mode</label>
<select
id="authMode"
name="authMode"
bind:value={$form.authMode}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="local">Local</option>
<option value="oauth">OAuth</option>
<option value="both">Both</option>
</select>
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
</div>
<div class="flex items-center gap-2 pt-6">
<input
id="registrationEnabled"
name="registrationEnabled"
type="checkbox"
bind:checked={$form.registrationEnabled}
class="h-4 w-4 rounded border-input"
/>
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
Allow user registration
</label>
</div>
</div>
</section>
<!-- OAuth (stored but non-functional in MVP) -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">OAuth Configuration</h2>
<p class="mb-4 text-xs text-muted-foreground">OAuth settings are stored but not active in this MVP version.</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="oauthClientId" class="mb-1 block text-sm font-medium text-foreground">Client ID</label>
<input
id="oauthClientId"
name="oauthClientId"
type="text"
bind:value={$form.oauthClientId}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="OAuth client ID"
/>
</div>
<div>
<label for="oauthClientSecret" class="mb-1 block text-sm font-medium text-foreground">Client Secret</label>
<input
id="oauthClientSecret"
name="oauthClientSecret"
type="password"
bind:value={$form.oauthClientSecret}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="OAuth client secret"
/>
</div>
<div class="sm:col-span-2">
<label for="oauthDiscoveryUrl" class="mb-1 block text-sm font-medium text-foreground">Discovery URL</label>
<input
id="oauthDiscoveryUrl"
name="oauthDiscoveryUrl"
type="url"
bind:value={$form.oauthDiscoveryUrl}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="https://example.com/.well-known/openid-configuration"
/>
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
</div>
</div>
</section>
<!-- Theme Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Theme Defaults</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">Default Theme</label>
<select
id="defaultTheme"
name="defaultTheme"
bind:value={$form.defaultTheme}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div>
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">Default Primary Color</label>
<div class="flex items-center gap-2">
<input
id="defaultPrimaryColor"
name="defaultPrimaryColor"
type="text"
bind:value={$form.defaultPrimaryColor}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="#6366f1"
pattern="^#[0-9a-fA-F]{6}$"
/>
{#if $form.defaultPrimaryColor}
<div
class="h-8 w-8 shrink-0 rounded border border-border"
style:background-color={$form.defaultPrimaryColor}
></div>
{/if}
</div>
{#if $errors.defaultPrimaryColor}<span class="text-xs text-destructive">{$errors.defaultPrimaryColor}</span>{/if}
</div>
</div>
</section>
<!-- Healthcheck Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Healthcheck Defaults</h2>
<p class="mb-4 text-xs text-muted-foreground">JSON configuration for default healthcheck behavior (interval, timeout, method).</p>
<div>
<label for="healthcheckDefaults" class="mb-1 block text-sm font-medium text-foreground">Defaults (JSON)</label>
<textarea
id="healthcheckDefaults"
name="healthcheckDefaults"
bind:value={$form.healthcheckDefaults}
rows="4"
class="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
placeholder='{"interval": 300, "timeout": 5000, "method": "GET"}'
></textarea>
{#if $errors.healthcheckDefaults}<span class="text-xs text-destructive">{$errors.healthcheckDefaults}</span>{/if}
</div>
</section>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
disabled={$delayed}
>
{$delayed ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface UserWithGroups {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
authProvider: string;
role: string;
createdAt: Date;
groups: Array<{ id: string; name: string }>;
}
interface Group {
id: string;
name: string;
description: string | null;
isDefault: boolean;
_count: { users: number };
}
let {
users,
groups
}: {
users: UserWithGroups[];
groups: Group[];
} = $props();
let editingUserId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let addGroupUserId = $state<string | null>(null);
let selectedGroupId = $state('');
</script>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">User</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Email</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Role</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Provider</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Groups</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr class="border-b border-border last:border-b-0 hover:bg-muted/30">
<td class="px-4 py-3 text-foreground">{user.displayName}</td>
<td class="px-4 py-3 text-muted-foreground">{user.email}</td>
<td class="px-4 py-3">
{#if editingUserId === user.id}
<form method="POST" action="?/update" use:enhance={() => {
return async ({ update }) => {
editingUserId = null;
await update();
};
}}>
<input type="hidden" name="userId" value={user.id} />
<select
name="role"
class="rounded border border-input bg-background px-2 py-1 text-xs text-foreground"
>
<option value="user" selected={user.role === 'user'}>User</option>
<option value="admin" selected={user.role === 'admin'}>Admin</option>
</select>
<button type="submit" class="ml-1 text-xs text-primary hover:underline">Save</button>
<button type="button" onclick={() => (editingUserId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">Cancel</button>
</form>
{:else}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {user.role === 'admin' ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'}">
{user.role}
</span>
{/if}
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">{user.authProvider}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
{#each user.groups as group (group.id)}
<span class="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs text-accent-foreground">
{group.name}
<form method="POST" action="?/removeFromGroup" use:enhance class="inline">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="groupId" value={group.id} />
<button type="submit" class="text-muted-foreground hover:text-destructive" title="Remove from group">&times;</button>
</form>
</span>
{/each}
{#if addGroupUserId === user.id}
<form method="POST" action="?/addToGroup" use:enhance={() => {
return async ({ update }) => {
addGroupUserId = null;
selectedGroupId = '';
await update();
};
}} class="inline-flex items-center gap-1">
<input type="hidden" name="userId" value={user.id} />
<select
name="groupId"
bind:value={selectedGroupId}
class="rounded border border-input bg-background px-2 py-0.5 text-xs text-foreground"
>
<option value="" disabled>Select group</option>
{#each groups.filter((g) => !user.groups.some((ug) => ug.id === g.id)) as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<button type="submit" class="text-xs text-primary hover:underline" disabled={!selectedGroupId}>Add</button>
<button type="button" onclick={() => (addGroupUserId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
</form>
{:else}
<button
type="button"
onclick={() => (addGroupUserId = user.id)}
class="rounded-full border border-dashed border-border px-2 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
>
+ Add
</button>
{/if}
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => (editingUserId = editingUserId === user.id ? null : user.id)}
class="text-xs text-primary hover:underline"
>
Edit
</button>
{#if confirmDeleteId === user.id}
<form method="POST" action="?/delete" use:enhance={() => {
return async ({ update }) => {
confirmDeleteId = null;
await update();
};
}}>
<input type="hidden" name="userId" value={user.id} />
<span class="text-xs text-destructive">Confirm?</span>
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
</form>
{:else}
<button
type="button"
onclick={() => (confirmDeleteId = user.id)}
class="text-xs text-destructive hover:underline"
>
Delete
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{#if users.length === 0}
<div class="py-8 text-center text-sm text-muted-foreground">No users found.</div>
{/if}
</div>
+8
View File
@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
export const load: LayoutServerLoad = async (event) => {
const user = requireAdmin(event);
return { user };
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types.js';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const navItems = [
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/groups', label: 'Groups' },
{ href: '/admin/settings', label: 'Settings' }
] as const;
</script>
<div class="min-h-screen bg-background text-foreground">
<nav class="border-b border-border bg-card">
<div class="mx-auto flex max-w-6xl items-center gap-6 px-6 py-3">
<a href="/" class="text-sm text-muted-foreground hover:text-foreground">
&larr; Back to Dashboard
</a>
<span class="text-sm font-semibold text-card-foreground">Admin Panel</span>
<div class="flex gap-4">
{#each navItems as item}
<a
href={item.href}
class="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
{item.label}
</a>
{/each}
</div>
<div class="ml-auto text-xs text-muted-foreground">
{data.user.displayName} (admin)
</div>
</div>
</nav>
<main class="mx-auto max-w-6xl p-6">
{@render children()}
</main>
</div>
+86
View File
@@ -0,0 +1,86 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as groupService from '$lib/server/services/groupService.js';
import { createGroupSchema, updateGroupSchema } from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const [groups, createForm, updateForm] = await Promise.all([
groupService.findAll(),
superValidate(zod(createGroupSchema)),
superValidate(zod(updateGroupSchema))
]);
return { groups, createForm, updateForm };
};
export const actions: Actions = {
create: async (event) => {
requireAdmin(event);
const form = await superValidate(event.request, zod(createGroupSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await groupService.create(form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create group';
return setError(form, '', message);
}
return { form };
},
update: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const groupId = formData.get('groupId') as string;
if (!groupId) {
return fail(400, { error: 'Group ID is required' });
}
const form = await superValidate(formData, zod(updateGroupSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await groupService.update(groupId, form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update group';
return setError(form, '', message);
}
return { form };
},
delete: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const groupId = formData.get('groupId') as string;
if (!groupId) {
return fail(400, { error: 'Group ID is required' });
}
try {
await groupService.remove(groupId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete group';
return fail(500, { error: message });
}
return { success: true };
}
};
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import type { PageData } from './$types.js';
import GroupTable from '$lib/components/admin/GroupTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
let { data }: { data: PageData } = $props();
let showCreateForm = $state(false);
const { form, errors, enhance } = superForm(data.createForm, {
resetForm: true,
onResult: ({ result }) => {
if (result.type === 'success') {
showCreateForm = false;
}
}
});
</script>
<svelte:head>
<title>Group Management — Admin</title>
</svelte:head>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">Group Management</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
>
{showCreateForm ? 'Cancel' : 'Create Group'}
</button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">New Group</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
<input
id="name"
name="name"
type="text"
bind:value={$form.name}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
{#if $errors.name}<span class="text-xs text-destructive">{$errors.name}</span>{/if}
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground">Description</label>
<input
id="description"
name="description"
type="text"
bind:value={$form.description}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<div class="flex items-center gap-2">
<input
id="isDefault"
name="isDefault"
type="checkbox"
bind:checked={$form.isDefault}
class="h-4 w-4 rounded border-input"
/>
<label for="isDefault" class="text-sm font-medium text-foreground">Default group (auto-assign new users)</label>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create Group
</button>
</form>
</div>
{/if}
<GroupTable groups={data.groups} />
</div>
+78
View File
@@ -0,0 +1,78 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { prisma } from '$lib/server/prisma.js';
import { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import { DEFAULTS } from '$lib/utils/constants.js';
async function getOrCreateSettings() {
return prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
}
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const settings = await getOrCreateSettings();
const form = await superValidate(
{
authMode: settings.authMode as 'local' | 'oauth' | 'both',
registrationEnabled: settings.registrationEnabled,
oauthClientId: settings.oauthClientId,
oauthClientSecret: settings.oauthClientSecret,
oauthDiscoveryUrl: settings.oauthDiscoveryUrl,
defaultTheme: settings.defaultTheme as 'dark' | 'light',
defaultPrimaryColor: settings.defaultPrimaryColor,
healthcheckDefaults: settings.healthcheckDefaults
},
zod(updateSystemSettingsSchema)
);
return { settings, form };
};
export const actions: Actions = {
update: async (event) => {
requireAdmin(event);
const form = await superValidate(event.request, zod(updateSystemSettingsSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
const data: Record<string, unknown> = {};
const input = form.data;
if (input.authMode !== undefined) data.authMode = input.authMode;
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: data,
create: {
id: DEFAULTS.SYSTEM_SETTINGS_ID,
...data
}
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update settings';
return setError(form, '', message);
}
return { form };
}
};
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import type { PageData } from './$types.js';
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>System Settings — Admin</title>
</svelte:head>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-card-foreground">System Settings</h1>
<p class="mt-1 text-sm text-muted-foreground">Configure global application settings.</p>
</div>
<SettingsForm form={data.form} />
</div>
+142
View File
@@ -0,0 +1,142 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import * as groupService from '$lib/server/services/groupService.js';
import { createUserSchema, updateUserSchema } from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const [users, groups, createForm, updateForm] = await Promise.all([
userService.findAll(),
groupService.findAll(),
superValidate(zod(createUserSchema)),
superValidate(zod(updateUserSchema))
]);
// Load group memberships for each user
const usersWithGroups = await Promise.all(
users.map(async (user) => {
const userGroups = await userService.getUserGroups(user.id);
return { ...user, groups: userGroups };
})
);
return { users: usersWithGroups, groups, createForm, updateForm };
};
export const actions: Actions = {
create: async (event) => {
requireAdmin(event);
const form = await superValidate(event.request, zod(createUserSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await userService.create(form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create user';
return setError(form, '', message);
}
return { form };
},
update: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
if (!userId) {
return fail(400, { error: 'User ID is required' });
}
const form = await superValidate(formData, zod(updateUserSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await userService.update(userId, form.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user';
return setError(form, '', message);
}
return { form };
},
delete: async (event) => {
const admin = requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
if (!userId) {
return fail(400, { error: 'User ID is required' });
}
if (userId === admin.id) {
return fail(400, { error: 'Cannot delete your own account' });
}
try {
await userService.remove(userId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete user';
return fail(500, { error: message });
}
return { success: true };
},
addToGroup: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
const groupId = formData.get('groupId') as string;
if (!userId || !groupId) {
return fail(400, { error: 'User ID and Group ID are required' });
}
try {
await groupService.addUser(groupId, userId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add user to group';
return fail(500, { error: message });
}
return { success: true };
},
removeFromGroup: async (event) => {
requireAdmin(event);
const formData = await event.request.formData();
const userId = formData.get('userId') as string;
const groupId = formData.get('groupId') as string;
if (!userId || !groupId) {
return fail(400, { error: 'User ID and Group ID are required' });
}
try {
await groupService.removeUser(groupId, userId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove user from group';
return fail(500, { error: message });
}
return { success: true };
}
};
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import type { PageData } from './$types.js';
import UserTable from '$lib/components/admin/UserTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
let { data }: { data: PageData } = $props();
let showCreateForm = $state(false);
const { form, errors, enhance } = superForm(data.createForm, {
resetForm: true,
onResult: ({ result }) => {
if (result.type === 'success') {
showCreateForm = false;
}
}
});
</script>
<svelte:head>
<title>User Management — Admin</title>
</svelte:head>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">User Management</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
>
{showCreateForm ? 'Cancel' : 'Create User'}
</button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">New User</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
id="email"
name="email"
type="email"
bind:value={$form.email}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
{#if $errors.email}<span class="text-xs text-destructive">{$errors.email}</span>{/if}
</div>
<div>
<label for="displayName" class="mb-1 block text-sm font-medium text-foreground">Display Name</label>
<input
id="displayName"
name="displayName"
type="text"
bind:value={$form.displayName}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
{#if $errors.displayName}<span class="text-xs text-destructive">{$errors.displayName}</span>{/if}
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label>
<input
id="password"
name="password"
type="password"
bind:value={$form.password}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
{#if $errors.password}<span class="text-xs text-destructive">{$errors.password}</span>{/if}
</div>
<div>
<label for="role" class="mb-1 block text-sm font-medium text-foreground">Role</label>
<select
id="role"
name="role"
bind:value={$form.role}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create User
</button>
</form>
</div>
{/if}
<UserTable users={data.users} groups={data.groups} />
</div>
+74
View File
@@ -0,0 +1,74 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { prisma } from '$lib/server/prisma.js';
import { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
import { DEFAULTS } from '$lib/utils/constants.js';
/**
* GET /api/admin/settings — Get system settings. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const settings = await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: {},
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
return json(success(settings));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch settings';
return json(error(message), { status: 500 });
}
};
/**
* PATCH /api/admin/settings — Update system settings. Admin only.
*/
export const PATCH: RequestHandler = async (event) => {
requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateSystemSettingsSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const data: Record<string, unknown> = {};
const input = parsed.data;
if (input.authMode !== undefined) data.authMode = input.authMode;
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
const settings = await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: data,
create: {
id: DEFAULTS.SYSTEM_SETTINGS_ID,
...data
}
});
return json(success(settings));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update settings';
return json(error(message), { status: 500 });
}
};
+50
View File
@@ -0,0 +1,50 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as groupService from '$lib/server/services/groupService.js';
import { createGroupSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/groups — List all groups. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const groups = await groupService.findAll();
return json(success(groups));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch groups';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/groups — Create a new group. Admin only.
*/
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = createGroupSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const group = await groupService.create(parsed.data);
return json(success(group), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create group';
const status = message.includes('already exists') ? 409 : 500;
return json(error(message), { status });
}
};
+72
View File
@@ -0,0 +1,72 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as groupService from '$lib/server/services/groupService.js';
import { updateGroupSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/groups/:id — Get a single group by ID. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
try {
const group = await groupService.findById(id);
return json(success(group));
} catch (err) {
const message = err instanceof Error ? err.message : 'Group not found';
return json(error(message), { status: 404 });
}
};
/**
* PATCH /api/groups/:id — Update a group. Admin only.
*/
export const PATCH: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateGroupSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const group = await groupService.update(id, parsed.data);
return json(success(group));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update group';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/groups/:id — Delete a group. Admin only.
*/
export const DELETE: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
try {
await groupService.remove(id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete group';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
+113
View File
@@ -0,0 +1,113 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { prisma } from '$lib/server/prisma.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { success, error } from '$lib/server/utils/response.js';
interface SearchResult {
readonly type: 'app' | 'board';
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly category?: string | null;
}
/**
* GET /api/search?q=term — Search apps and boards, filtered by user permissions.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
const query = event.url.searchParams.get('q')?.trim();
if (!query || query.length === 0) {
return json(success([]));
}
try {
// Search apps
const apps = await prisma.app.findMany({
where: {
OR: [
{ name: { contains: query } },
{ description: { contains: query } },
{ category: { contains: query } }
]
},
select: {
id: true,
name: true,
description: true,
category: true
},
orderBy: { name: 'asc' },
take: 20
});
// Search boards
const boards = await prisma.board.findMany({
where: {
OR: [
{ name: { contains: query } },
{ description: { contains: query } }
]
},
select: {
id: true,
name: true,
description: true,
isGuestAccessible: true
},
orderBy: { name: 'asc' },
take: 20
});
const isAdmin = user.role === UserRole.ADMIN;
// Filter apps by permission
const filteredApps: SearchResult[] = [];
for (const app of apps) {
if (isAdmin) {
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
continue;
}
const check = await permissionService.checkPermission(
EntityType.APP,
app.id,
user.id,
PermissionLevel.VIEW
);
if (check.hasPermission) {
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
}
}
// Filter boards by permission
const filteredBoards: SearchResult[] = [];
for (const board of boards) {
if (isAdmin || board.isGuestAccessible) {
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
continue;
}
const check = await permissionService.checkPermission(
EntityType.BOARD,
board.id,
user.id,
PermissionLevel.VIEW
);
if (check.hasPermission) {
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
}
}
const results: readonly SearchResult[] = [...filteredApps, ...filteredBoards];
return json(success(results));
} catch (err) {
const message = err instanceof Error ? err.message : 'Search failed';
return json(error(message), { status: 500 });
}
};
+50
View File
@@ -0,0 +1,50 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import { createUserSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/users — List all users. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const users = await userService.findAll();
return json(success(users));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch users';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/users — Create a new user. Admin only.
*/
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const user = await userService.create(parsed.data);
return json(success(user), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create user';
const status = message.includes('already exists') ? 409 : 500;
return json(error(message), { status });
}
};
+76
View File
@@ -0,0 +1,76 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as userService from '$lib/server/services/userService.js';
import { updateUserSchema } from '$lib/utils/validators.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* GET /api/users/:id — Get a single user by ID. Admin only.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
try {
const user = await userService.findById(id);
return json(success(user));
} catch (err) {
const message = err instanceof Error ? err.message : 'User not found';
return json(error(message), { status: 404 });
}
};
/**
* PATCH /api/users/:id — Update a user. Admin only.
*/
export const PATCH: RequestHandler = async (event) => {
requireAdmin(event);
const { id } = event.params;
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
try {
const user = await userService.update(id, parsed.data);
return json(success(user));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
/**
* DELETE /api/users/:id — Delete a user. Admin only.
*/
export const DELETE: RequestHandler = async (event) => {
const admin = requireAdmin(event);
const { id } = event.params;
if (id === admin.id) {
return json(error('Cannot delete your own account'), { status: 400 });
}
try {
await userService.remove(id);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete user';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};