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 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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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