feat(phase2): OAuth/Authentik integration + drag-and-drop reordering

- Add OIDC/OAuth2 login via openid-client with PKCE flow
- Auto-provision OAuth users with group mapping
- Conditional login page (OAuth/local/both based on auth mode)
- Admin OAuth test connection button
- Install svelte-dnd-action for board editor DnD
- Draggable sections and widgets with cross-section moves
- Reorder APIs with atomic Prisma transactions
- Visual drag handles and drop zone indicators
This commit is contained in:
2026-03-24 22:54:54 +03:00
parent ae114ab9ce
commit bf4e5089ee
22 changed files with 1273 additions and 257 deletions
+65 -110
View File
@@ -1,11 +1,65 @@
<script lang="ts">
import type { PageData } from './$types.js';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import DraggableBoard from '$lib/components/board/DraggableBoard.svelte';
let { data }: { data: PageData } = $props();
let showAddSection = $state(false);
let addWidgetSectionId = $state<string | null>(null);
function handleToggleAddWidget(sectionId: string) {
addWidgetSectionId = addWidgetSectionId === sectionId ? null : sectionId;
}
async function handleDeleteSection(sectionId: string) {
const formData = new FormData();
formData.set('sectionId', sectionId);
try {
await fetch(`?/deleteSection`, {
method: 'POST',
body: formData
});
await invalidateAll();
} catch (err) {
console.error('Failed to delete section:', err);
}
}
async function handleAddWidget(sectionId: string, appId: string) {
const formData = new FormData();
formData.set('sectionId', sectionId);
formData.set('type', 'app');
formData.set('appId', appId);
try {
await fetch(`?/addWidget`, {
method: 'POST',
body: formData
});
addWidgetSectionId = null;
await invalidateAll();
} catch (err) {
console.error('Failed to add widget:', err);
}
}
async function handleDeleteWidget(widgetId: string) {
const formData = new FormData();
formData.set('widgetId', widgetId);
try {
await fetch(`?/deleteWidget`, {
method: 'POST',
body: formData
});
await invalidateAll();
} catch (err) {
console.error('Failed to delete widget:', err);
}
}
</script>
<svelte:head>
@@ -92,7 +146,7 @@
</form>
</section>
<!-- Sections -->
<!-- Sections with Drag-and-Drop -->
<section class="mb-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-foreground">Sections</h2>
@@ -151,115 +205,16 @@
</div>
{/if}
{#if data.board.sections.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
<p class="text-muted-foreground">No sections yet. Add one to get started.</p>
</div>
{:else}
<div class="space-y-4">
{#each data.board.sections as section (section.id)}
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{section.title}</span>
<span class="text-xs text-muted-foreground">Order: {section.order}</span>
{#if section.icon}
<span class="text-xs text-muted-foreground">({section.icon})</span>
{/if}
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => (addWidgetSectionId = addWidgetSectionId === section.id ? null : section.id)}
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Add Widget
</button>
<form method="POST" action="?/deleteSection" use:enhance>
<input type="hidden" name="sectionId" value={section.id} />
<button
type="submit"
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Delete
</button>
</form>
</div>
</div>
{#if addWidgetSectionId === section.id}
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
<form
method="POST"
action="?/addWidget"
use:enhance={() => {
return async ({ update }) => {
await update();
addWidgetSectionId = null;
};
}}
>
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="type" value="app" />
<div>
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Select App</label>
<select
id="widget-app-{section.id}"
name="appId"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
required
>
<option value="">Choose an app...</option>
{#each data.apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</div>
<div class="mt-2">
<button
type="submit"
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Add
</button>
</div>
</form>
</div>
{/if}
<!-- Widgets list -->
{#if section.widgets.length === 0}
<p class="text-sm text-muted-foreground">No widgets in this section.</p>
{:else}
<div class="space-y-2">
{#each section.widgets as widget (widget.id)}
<div class="flex items-center justify-between rounded-lg border border-border bg-background/50 px-3 py-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium uppercase text-primary">{widget.type}</span>
{#if widget.app}
<span class="text-sm text-foreground">{widget.app.name}</span>
<span class="text-xs text-muted-foreground">({widget.app.url})</span>
{:else}
<span class="text-sm text-muted-foreground">Widget #{widget.order}</span>
{/if}
</div>
<form method="POST" action="?/deleteWidget" use:enhance>
<input type="hidden" name="widgetId" value={widget.id} />
<button
type="submit"
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Remove
</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
<DraggableBoard
boardId={data.board.id}
sections={data.board.sections}
apps={data.apps}
{addWidgetSectionId}
onToggleAddWidget={handleToggleAddWidget}
onDeleteSection={handleDeleteSection}
onAddWidget={handleAddWidget}
onDeleteWidget={handleDeleteWidget}
/>
</section>
</div>
</div>