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:
@@ -6,6 +6,32 @@
|
||||
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
|
||||
|
||||
const { form, errors, enhance, delayed } = superForm(formData);
|
||||
|
||||
let oauthTesting = $state(false);
|
||||
let oauthTestResult = $state('');
|
||||
let oauthTestSuccess = $state(false);
|
||||
|
||||
async function testOAuthConnection() {
|
||||
oauthTesting = true;
|
||||
oauthTestResult = '';
|
||||
oauthTestSuccess = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/oauth/test', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
oauthTestSuccess = true;
|
||||
oauthTestResult = `Connected to issuer: ${data.issuer}`;
|
||||
} else {
|
||||
oauthTestResult = data.error || 'Connection test failed';
|
||||
}
|
||||
} catch {
|
||||
oauthTestResult = 'Network error — could not reach the server';
|
||||
} finally {
|
||||
oauthTesting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/update" use:enhance class="space-y-8">
|
||||
@@ -42,10 +68,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OAuth (stored but non-functional in MVP) -->
|
||||
<!-- OAuth Configuration -->
|
||||
<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>
|
||||
<p class="mb-4 text-xs text-muted-foreground">
|
||||
Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to "OAuth" or "Both" above to enable OAuth login.
|
||||
</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>
|
||||
@@ -81,6 +109,21 @@
|
||||
/>
|
||||
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={testOAuthConnection}
|
||||
disabled={oauthTesting}
|
||||
class="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{oauthTesting ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{#if oauthTestResult}
|
||||
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-green-600 dark:text-green-400' : 'text-destructive'}">
|
||||
{oauthTestResult}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import DraggableSection from '$lib/components/section/DraggableSection.svelte';
|
||||
|
||||
interface WidgetData {
|
||||
id: string;
|
||||
type: string;
|
||||
order: number;
|
||||
config: string;
|
||||
appId: string | null;
|
||||
sectionId: string;
|
||||
app: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string | null;
|
||||
iconType: string;
|
||||
description: string | null;
|
||||
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface SectionData {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
order: number;
|
||||
isExpandedByDefault: boolean;
|
||||
widgets: WidgetData[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
boardId: string;
|
||||
sections: SectionData[];
|
||||
apps: Array<{ id: string; name: string }>;
|
||||
addWidgetSectionId: string | null;
|
||||
onToggleAddWidget: (sectionId: string) => void;
|
||||
onDeleteSection: (sectionId: string) => void;
|
||||
onAddWidget: (sectionId: string, appId: string) => void;
|
||||
onDeleteWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
boardId,
|
||||
sections: initialSections,
|
||||
apps,
|
||||
addWidgetSectionId,
|
||||
onToggleAddWidget,
|
||||
onDeleteSection,
|
||||
onAddWidget,
|
||||
onDeleteWidget
|
||||
}: Props = $props();
|
||||
|
||||
let sections = $state<SectionData[]>([...initialSections]);
|
||||
|
||||
// Keep local state in sync when parent data changes
|
||||
$effect(() => {
|
||||
sections = [...initialSections];
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleConsider(e: CustomEvent<{ items: SectionData[] }>) {
|
||||
sections = e.detail.items;
|
||||
}
|
||||
|
||||
async function handleFinalize(e: CustomEvent<{ items: SectionData[] }>) {
|
||||
sections = e.detail.items;
|
||||
const sectionIds = sections.map((s) => s.id);
|
||||
|
||||
try {
|
||||
await fetch(`/api/boards/${boardId}/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sectionIds })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to persist section reorder:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWidgetsUpdate(sectionId: string, widgets: WidgetData[]) {
|
||||
// Update local state
|
||||
sections = sections.map((s) => (s.id === sectionId ? { ...s, widgets } : s));
|
||||
|
||||
const widgetIds = widgets.map((w) => w.id);
|
||||
|
||||
try {
|
||||
await fetch(`/api/boards/${boardId}/sections/${sectionId}/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ widgetIds })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to persist widget reorder:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if 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
|
||||
use:dndzone={{ items: sections, flipDurationMs, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#each sections as section (section.id)}
|
||||
<div>
|
||||
<DraggableSection
|
||||
{section}
|
||||
{boardId}
|
||||
{apps}
|
||||
onWidgetsUpdate={handleWidgetsUpdate}
|
||||
{addWidgetSectionId}
|
||||
{onToggleAddWidget}
|
||||
{onDeleteSection}
|
||||
{onAddWidget}
|
||||
{onDeleteWidget}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import DraggableWidget from '$lib/components/widget/DraggableWidget.svelte';
|
||||
|
||||
interface WidgetData {
|
||||
id: string;
|
||||
type: string;
|
||||
order: number;
|
||||
config: string;
|
||||
appId: string | null;
|
||||
sectionId: string;
|
||||
app: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string | null;
|
||||
iconType: string;
|
||||
description: string | null;
|
||||
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface SectionData {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
order: number;
|
||||
isExpandedByDefault: boolean;
|
||||
widgets: WidgetData[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
section: SectionData;
|
||||
boardId: string;
|
||||
apps: Array<{ id: string; name: string }>;
|
||||
onWidgetsUpdate: (sectionId: string, widgets: WidgetData[]) => void;
|
||||
addWidgetSectionId: string | null;
|
||||
onToggleAddWidget: (sectionId: string) => void;
|
||||
onDeleteSection: (sectionId: string) => void;
|
||||
onAddWidget: (sectionId: string, appId: string) => void;
|
||||
onDeleteWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
section,
|
||||
boardId,
|
||||
apps,
|
||||
onWidgetsUpdate,
|
||||
addWidgetSectionId,
|
||||
onToggleAddWidget,
|
||||
onDeleteSection,
|
||||
onAddWidget,
|
||||
onDeleteWidget
|
||||
}: Props = $props();
|
||||
|
||||
let widgets = $state<WidgetData[]>([...section.widgets]);
|
||||
|
||||
// Keep local state in sync when parent data changes
|
||||
$effect(() => {
|
||||
widgets = [...section.widgets];
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleConsider(e: CustomEvent<{ items: WidgetData[] }>) {
|
||||
widgets = e.detail.items;
|
||||
}
|
||||
|
||||
function handleFinalize(e: CustomEvent<{ items: WidgetData[] }>) {
|
||||
widgets = e.detail.items;
|
||||
onWidgetsUpdate(section.id, widgets);
|
||||
}
|
||||
|
||||
let selectedAppId = $state('');
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<!-- Section drag handle -->
|
||||
<div
|
||||
class="flex shrink-0 cursor-grab items-center px-1 text-muted-foreground transition-opacity active:cursor-grabbing"
|
||||
aria-label="Drag to reorder section"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="9" cy="5" r="1" />
|
||||
<circle cx="9" cy="12" r="1" />
|
||||
<circle cx="9" cy="19" r="1" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<circle cx="15" cy="12" r="1" />
|
||||
<circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<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={() => onToggleAddWidget(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>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteSection(section.id)}
|
||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if addWidgetSectionId === section.id}
|
||||
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
||||
<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}"
|
||||
bind:value={selectedAppId}
|
||||
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"
|
||||
>
|
||||
<option value="">Choose an app...</option>
|
||||
{#each apps as app (app.id)}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (selectedAppId) {
|
||||
onAddWidget(section.id, selectedAppId);
|
||||
selectedAppId = '';
|
||||
}
|
||||
}}
|
||||
disabled={!selectedAppId}
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Widgets drop zone -->
|
||||
{#if widgets.length === 0}
|
||||
<div
|
||||
use:dndzone={{ items: widgets, flipDurationMs, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
class="min-h-[48px] rounded-lg border-2 border-dashed border-border/50 p-2 transition-colors"
|
||||
>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
No widgets. Drag widgets here or add one above.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
use:dndzone={{ items: widgets, flipDurationMs, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
class="min-h-[48px] space-y-2 rounded-lg border-2 border-dashed border-transparent p-1 transition-colors"
|
||||
>
|
||||
{#each widgets as widget (widget.id)}
|
||||
<div class="rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<DraggableWidget>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteWidget(widget.id)}
|
||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</DraggableWidget>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="group/widget relative flex items-center gap-2">
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="flex h-full shrink-0 cursor-grab items-center px-1 text-muted-foreground opacity-0 transition-opacity group-hover/widget:opacity-100 active:cursor-grabbing"
|
||||
aria-label="Drag to reorder widget"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="9" cy="5" r="1" />
|
||||
<circle cx="9" cy="12" r="1" />
|
||||
<circle cx="9" cy="19" r="1" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<circle cx="15" cy="12" r="1" />
|
||||
<circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Widget content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user