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:
@@ -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`.
|
||||
|
||||
@@ -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 | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- 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
|
||||
|
||||
@@ -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>
|
||||
@@ -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">×</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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { LayoutServerLoad } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
const user = requireAdmin(event);
|
||||
|
||||
return { user };
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types.js';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/admin/users', label: 'Users' },
|
||||
{ href: '/admin/groups', label: 'Groups' },
|
||||
{ href: '/admin/settings', label: 'Settings' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
<nav class="border-b border-border bg-card">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-6 px-6 py-3">
|
||||
<a href="/" class="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
<span class="text-sm font-semibold text-card-foreground">Admin Panel</span>
|
||||
<div class="flex gap-4">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="ml-auto text-xs text-muted-foreground">
|
||||
{data.user.displayName} (admin)
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="mx-auto max-w-6xl p-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Actions, PageServerLoad } from './$types.js';
|
||||
import { superValidate, setError } from 'sveltekit-superforms';
|
||||
import { zod } from 'sveltekit-superforms/adapters';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import * as groupService from '$lib/server/services/groupService.js';
|
||||
import { createGroupSchema, updateGroupSchema } from '$lib/utils/validators.js';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const [groups, createForm, updateForm] = await Promise.all([
|
||||
groupService.findAll(),
|
||||
superValidate(zod(createGroupSchema)),
|
||||
superValidate(zod(updateGroupSchema))
|
||||
]);
|
||||
|
||||
return { groups, createForm, updateForm };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const form = await superValidate(event.request, zod(createGroupSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
return fail(400, { form });
|
||||
}
|
||||
|
||||
try {
|
||||
await groupService.create(form.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create group';
|
||||
return setError(form, '', message);
|
||||
}
|
||||
|
||||
return { form };
|
||||
},
|
||||
|
||||
update: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const groupId = formData.get('groupId') as string;
|
||||
|
||||
if (!groupId) {
|
||||
return fail(400, { error: 'Group ID is required' });
|
||||
}
|
||||
|
||||
const form = await superValidate(formData, zod(updateGroupSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
return fail(400, { form });
|
||||
}
|
||||
|
||||
try {
|
||||
await groupService.update(groupId, form.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update group';
|
||||
return setError(form, '', message);
|
||||
}
|
||||
|
||||
return { form };
|
||||
},
|
||||
|
||||
delete: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const groupId = formData.get('groupId') as string;
|
||||
|
||||
if (!groupId) {
|
||||
return fail(400, { error: 'Group ID is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await groupService.remove(groupId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete group';
|
||||
return fail(500, { error: message });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types.js';
|
||||
import GroupTable from '$lib/components/admin/GroupTable.svelte';
|
||||
import { superForm } from 'sveltekit-superforms/client';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
|
||||
const { form, errors, enhance } = superForm(data.createForm, {
|
||||
resetForm: true,
|
||||
onResult: ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
showCreateForm = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Group Management — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-card-foreground">Group Management</h1>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateForm = !showCreateForm)}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{showCreateForm ? 'Cancel' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreateForm}
|
||||
<div class="mb-6 rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">New Group</h2>
|
||||
<form method="POST" action="?/create" use:enhance class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={$form.name}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
{#if $errors.name}<span class="text-xs text-destructive">{$errors.name}</span>{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-foreground">Description</label>
|
||||
<input
|
||||
id="description"
|
||||
name="description"
|
||||
type="text"
|
||||
bind:value={$form.description}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="isDefault"
|
||||
name="isDefault"
|
||||
type="checkbox"
|
||||
bind:checked={$form.isDefault}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label for="isDefault" class="text-sm font-medium text-foreground">Default group (auto-assign new users)</label>
|
||||
</div>
|
||||
</div>
|
||||
{#if $errors._errors}
|
||||
<p class="text-sm text-destructive">{$errors._errors}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create Group
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GroupTable groups={data.groups} />
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Actions, PageServerLoad } from './$types.js';
|
||||
import { superValidate, setError } from 'sveltekit-superforms';
|
||||
import { zod } from 'sveltekit-superforms/adapters';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import { prisma } from '$lib/server/prisma.js';
|
||||
import { updateSystemSettingsSchema } from '$lib/utils/validators.js';
|
||||
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||
|
||||
async function getOrCreateSettings() {
|
||||
return prisma.systemSettings.upsert({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
update: {},
|
||||
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
|
||||
});
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const settings = await getOrCreateSettings();
|
||||
|
||||
const form = await superValidate(
|
||||
{
|
||||
authMode: settings.authMode as 'local' | 'oauth' | 'both',
|
||||
registrationEnabled: settings.registrationEnabled,
|
||||
oauthClientId: settings.oauthClientId,
|
||||
oauthClientSecret: settings.oauthClientSecret,
|
||||
oauthDiscoveryUrl: settings.oauthDiscoveryUrl,
|
||||
defaultTheme: settings.defaultTheme as 'dark' | 'light',
|
||||
defaultPrimaryColor: settings.defaultPrimaryColor,
|
||||
healthcheckDefaults: settings.healthcheckDefaults
|
||||
},
|
||||
zod(updateSystemSettingsSchema)
|
||||
);
|
||||
|
||||
return { settings, form };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const form = await superValidate(event.request, zod(updateSystemSettingsSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
return fail(400, { form });
|
||||
}
|
||||
|
||||
try {
|
||||
const data: Record<string, unknown> = {};
|
||||
const input = form.data;
|
||||
|
||||
if (input.authMode !== undefined) data.authMode = input.authMode;
|
||||
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
|
||||
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
|
||||
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
|
||||
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
|
||||
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
|
||||
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
|
||||
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
|
||||
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
update: data,
|
||||
create: {
|
||||
id: DEFAULTS.SYSTEM_SETTINGS_ID,
|
||||
...data
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update settings';
|
||||
return setError(form, '', message);
|
||||
}
|
||||
|
||||
return { form };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types.js';
|
||||
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>System Settings — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-card-foreground">System Settings</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Configure global application settings.</p>
|
||||
</div>
|
||||
|
||||
<SettingsForm form={data.form} />
|
||||
</div>
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Actions, PageServerLoad } from './$types.js';
|
||||
import { superValidate, setError } from 'sveltekit-superforms';
|
||||
import { zod } from 'sveltekit-superforms/adapters';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import * as userService from '$lib/server/services/userService.js';
|
||||
import * as groupService from '$lib/server/services/groupService.js';
|
||||
import { createUserSchema, updateUserSchema } from '$lib/utils/validators.js';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const [users, groups, createForm, updateForm] = await Promise.all([
|
||||
userService.findAll(),
|
||||
groupService.findAll(),
|
||||
superValidate(zod(createUserSchema)),
|
||||
superValidate(zod(updateUserSchema))
|
||||
]);
|
||||
|
||||
// Load group memberships for each user
|
||||
const usersWithGroups = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const userGroups = await userService.getUserGroups(user.id);
|
||||
return { ...user, groups: userGroups };
|
||||
})
|
||||
);
|
||||
|
||||
return { users: usersWithGroups, groups, createForm, updateForm };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const form = await superValidate(event.request, zod(createUserSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
return fail(400, { form });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.create(form.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create user';
|
||||
return setError(form, '', message);
|
||||
}
|
||||
|
||||
return { form };
|
||||
},
|
||||
|
||||
update: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const userId = formData.get('userId') as string;
|
||||
|
||||
if (!userId) {
|
||||
return fail(400, { error: 'User ID is required' });
|
||||
}
|
||||
|
||||
const form = await superValidate(formData, zod(updateUserSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
return fail(400, { form });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.update(userId, form.data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update user';
|
||||
return setError(form, '', message);
|
||||
}
|
||||
|
||||
return { form };
|
||||
},
|
||||
|
||||
delete: async (event) => {
|
||||
const admin = requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const userId = formData.get('userId') as string;
|
||||
|
||||
if (!userId) {
|
||||
return fail(400, { error: 'User ID is required' });
|
||||
}
|
||||
|
||||
if (userId === admin.id) {
|
||||
return fail(400, { error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.remove(userId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete user';
|
||||
return fail(500, { error: message });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
addToGroup: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const userId = formData.get('userId') as string;
|
||||
const groupId = formData.get('groupId') as string;
|
||||
|
||||
if (!userId || !groupId) {
|
||||
return fail(400, { error: 'User ID and Group ID are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await groupService.addUser(groupId, userId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to add user to group';
|
||||
return fail(500, { error: message });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
removeFromGroup: async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const formData = await event.request.formData();
|
||||
const userId = formData.get('userId') as string;
|
||||
const groupId = formData.get('groupId') as string;
|
||||
|
||||
if (!userId || !groupId) {
|
||||
return fail(400, { error: 'User ID and Group ID are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await groupService.removeUser(groupId, userId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to remove user from group';
|
||||
return fail(500, { error: message });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types.js';
|
||||
import UserTable from '$lib/components/admin/UserTable.svelte';
|
||||
import { superForm } from 'sveltekit-superforms/client';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
|
||||
const { form, errors, enhance } = superForm(data.createForm, {
|
||||
resetForm: true,
|
||||
onResult: ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
showCreateForm = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>User Management — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-card-foreground">User Management</h1>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateForm = !showCreateForm)}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{showCreateForm ? 'Cancel' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreateForm}
|
||||
<div class="mb-6 rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">New User</h2>
|
||||
<form method="POST" action="?/create" use:enhance class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
bind:value={$form.email}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
{#if $errors.email}<span class="text-xs text-destructive">{$errors.email}</span>{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="displayName" class="mb-1 block text-sm font-medium text-foreground">Display Name</label>
|
||||
<input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
bind:value={$form.displayName}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
{#if $errors.displayName}<span class="text-xs text-destructive">{$errors.displayName}</span>{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
bind:value={$form.password}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
{#if $errors.password}<span class="text-xs text-destructive">{$errors.password}</span>{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="role" class="mb-1 block text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
bind:value={$form.role}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{#if $errors._errors}
|
||||
<p class="text-sm text-destructive">{$errors._errors}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<UserTable users={data.users} groups={data.groups} />
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import { prisma } from '$lib/server/prisma.js';
|
||||
import { updateSystemSettingsSchema } from '$lib/utils/validators.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings — Get system settings. Admin only.
|
||||
*/
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
try {
|
||||
const settings = await prisma.systemSettings.upsert({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
update: {},
|
||||
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
|
||||
});
|
||||
return json(success(settings));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch settings';
|
||||
return json(error(message), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/settings — Update system settings. Admin only.
|
||||
*/
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = updateSystemSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const messages = parsed.error.errors.map((e) => e.message).join(', ');
|
||||
return json(error(messages), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data: Record<string, unknown> = {};
|
||||
const input = parsed.data;
|
||||
|
||||
if (input.authMode !== undefined) data.authMode = input.authMode;
|
||||
if (input.registrationEnabled !== undefined) data.registrationEnabled = input.registrationEnabled;
|
||||
if (input.oauthClientId !== undefined) data.oauthClientId = input.oauthClientId;
|
||||
if (input.oauthClientSecret !== undefined) data.oauthClientSecret = input.oauthClientSecret;
|
||||
if (input.oauthDiscoveryUrl !== undefined) data.oauthDiscoveryUrl = input.oauthDiscoveryUrl;
|
||||
if (input.defaultTheme !== undefined) data.defaultTheme = input.defaultTheme;
|
||||
if (input.defaultPrimaryColor !== undefined) data.defaultPrimaryColor = input.defaultPrimaryColor;
|
||||
if (input.healthcheckDefaults !== undefined) data.healthcheckDefaults = input.healthcheckDefaults;
|
||||
|
||||
const settings = await prisma.systemSettings.upsert({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
update: data,
|
||||
create: {
|
||||
id: DEFAULTS.SYSTEM_SETTINGS_ID,
|
||||
...data
|
||||
}
|
||||
});
|
||||
|
||||
return json(success(settings));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update settings';
|
||||
return json(error(message), { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import * as groupService from '$lib/server/services/groupService.js';
|
||||
import { createGroupSchema } from '$lib/utils/validators.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
|
||||
/**
|
||||
* GET /api/groups — List all groups. Admin only.
|
||||
*/
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
try {
|
||||
const groups = await groupService.findAll();
|
||||
return json(success(groups));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch groups';
|
||||
return json(error(message), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/groups — Create a new group. Admin only.
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = createGroupSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const messages = parsed.error.errors.map((e) => e.message).join(', ');
|
||||
return json(error(messages), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const group = await groupService.create(parsed.data);
|
||||
return json(success(group), { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create group';
|
||||
const status = message.includes('already exists') ? 409 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import * as groupService from '$lib/server/services/groupService.js';
|
||||
import { updateGroupSchema } from '$lib/utils/validators.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
|
||||
/**
|
||||
* GET /api/groups/:id — Get a single group by ID. Admin only.
|
||||
*/
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
try {
|
||||
const group = await groupService.findById(id);
|
||||
return json(success(group));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Group not found';
|
||||
return json(error(message), { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/groups/:id — Update a group. Admin only.
|
||||
*/
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = updateGroupSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const messages = parsed.error.errors.map((e) => e.message).join(', ');
|
||||
return json(error(messages), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const group = await groupService.update(id, parsed.data);
|
||||
return json(success(group));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update group';
|
||||
const status = message.includes('not found') ? 404 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/groups/:id — Delete a group. Admin only.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
try {
|
||||
await groupService.remove(id);
|
||||
return json(success(null));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete group';
|
||||
const status = message.includes('not found') ? 404 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/authenticate.js';
|
||||
import { prisma } from '$lib/server/prisma.js';
|
||||
import * as permissionService from '$lib/server/services/permissionService.js';
|
||||
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
|
||||
interface SearchResult {
|
||||
readonly type: 'app' | 'board';
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly category?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search?q=term — Search apps and boards, filtered by user permissions.
|
||||
*/
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const user = requireAuth(event);
|
||||
|
||||
const query = event.url.searchParams.get('q')?.trim();
|
||||
|
||||
if (!query || query.length === 0) {
|
||||
return json(success([]));
|
||||
}
|
||||
|
||||
try {
|
||||
// Search apps
|
||||
const apps = await prisma.app.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: query } },
|
||||
{ description: { contains: query } },
|
||||
{ category: { contains: query } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: 20
|
||||
});
|
||||
|
||||
// Search boards
|
||||
const boards = await prisma.board.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: query } },
|
||||
{ description: { contains: query } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
isGuestAccessible: true
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: 20
|
||||
});
|
||||
|
||||
const isAdmin = user.role === UserRole.ADMIN;
|
||||
|
||||
// Filter apps by permission
|
||||
const filteredApps: SearchResult[] = [];
|
||||
for (const app of apps) {
|
||||
if (isAdmin) {
|
||||
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
|
||||
continue;
|
||||
}
|
||||
|
||||
const check = await permissionService.checkPermission(
|
||||
EntityType.APP,
|
||||
app.id,
|
||||
user.id,
|
||||
PermissionLevel.VIEW
|
||||
);
|
||||
if (check.hasPermission) {
|
||||
filteredApps.push({ type: 'app', id: app.id, name: app.name, description: app.description, category: app.category });
|
||||
}
|
||||
}
|
||||
|
||||
// Filter boards by permission
|
||||
const filteredBoards: SearchResult[] = [];
|
||||
for (const board of boards) {
|
||||
if (isAdmin || board.isGuestAccessible) {
|
||||
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
|
||||
continue;
|
||||
}
|
||||
|
||||
const check = await permissionService.checkPermission(
|
||||
EntityType.BOARD,
|
||||
board.id,
|
||||
user.id,
|
||||
PermissionLevel.VIEW
|
||||
);
|
||||
if (check.hasPermission) {
|
||||
filteredBoards.push({ type: 'board', id: board.id, name: board.name, description: board.description });
|
||||
}
|
||||
}
|
||||
|
||||
const results: readonly SearchResult[] = [...filteredApps, ...filteredBoards];
|
||||
return json(success(results));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Search failed';
|
||||
return json(error(message), { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import * as userService from '$lib/server/services/userService.js';
|
||||
import { createUserSchema } from '$lib/utils/validators.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
|
||||
/**
|
||||
* GET /api/users — List all users. Admin only.
|
||||
*/
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
try {
|
||||
const users = await userService.findAll();
|
||||
return json(success(users));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch users';
|
||||
return json(error(message), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/users — Create a new user. Admin only.
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = createUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const messages = parsed.error.errors.map((e) => e.message).join(', ');
|
||||
return json(error(messages), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userService.create(parsed.data);
|
||||
return json(success(user), { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create user';
|
||||
const status = message.includes('already exists') ? 409 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import * as userService from '$lib/server/services/userService.js';
|
||||
import { updateUserSchema } from '$lib/utils/validators.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
|
||||
/**
|
||||
* GET /api/users/:id — Get a single user by ID. Admin only.
|
||||
*/
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
try {
|
||||
const user = await userService.findById(id);
|
||||
return json(success(user));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'User not found';
|
||||
return json(error(message), { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/users/:id — Update a user. Admin only.
|
||||
*/
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const messages = parsed.error.errors.map((e) => e.message).join(', ');
|
||||
return json(error(messages), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userService.update(id, parsed.data);
|
||||
return json(success(user));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update user';
|
||||
const status = message.includes('not found') ? 404 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/users/:id — Delete a user. Admin only.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const admin = requireAdmin(event);
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
if (id === admin.id) {
|
||||
return json(error('Cannot delete your own account'), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.remove(id);
|
||||
return json(success(null));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete user';
|
||||
const status = message.includes('not found') ? 404 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user