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
+45 -2
View File
@@ -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>
+38
View File
@@ -261,3 +261,41 @@ export async function removeWidget(id: string) {
await findWidgetById(id);
await prisma.widget.delete({ where: { id } });
}
// --- Reorder ---
export async function reorderSections(boardId: string, sectionIds: string[]) {
await findBoardById(boardId);
const updates = sectionIds.map((id, index) =>
prisma.section.update({
where: { id },
data: { order: index }
})
);
return prisma.$transaction(updates);
}
export async function reorderWidgets(sectionId: string, widgetIds: string[]) {
await findSectionById(sectionId);
const updates = widgetIds.map((id, index) =>
prisma.widget.update({
where: { id },
data: { order: index, sectionId }
})
);
return prisma.$transaction(updates);
}
export async function moveWidget(widgetId: string, targetSectionId: string, order: number) {
await findWidgetById(widgetId);
await findSectionById(targetSectionId);
return prisma.widget.update({
where: { id: widgetId },
data: { sectionId: targetSectionId, order }
});
}
+170
View File
@@ -0,0 +1,170 @@
import * as client from 'openid-client';
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
interface OAuthConfig {
readonly clientId: string;
readonly clientSecret: string;
readonly discoveryUrl: string;
}
export interface OAuthUserInfo {
readonly sub: string;
readonly email: string;
readonly name?: string;
readonly preferred_username?: string;
readonly picture?: string;
readonly groups?: readonly string[];
}
/** Cached OIDC configuration to avoid re-discovery on every request */
let cachedConfig: client.Configuration | null = null;
let cachedConfigKey: string | null = null;
/**
* Loads OAuth settings from SystemSettings DB, falling back to env vars.
*/
async function loadOAuthConfig(): Promise<OAuthConfig> {
const settings = await prisma.systemSettings.findUnique({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
});
const clientId = settings?.oauthClientId || process.env.OAUTH_CLIENT_ID || '';
const clientSecret = settings?.oauthClientSecret || process.env.OAUTH_CLIENT_SECRET || '';
const discoveryUrl = settings?.oauthDiscoveryUrl || process.env.OAUTH_DISCOVERY_URL || '';
if (!clientId || !clientSecret || !discoveryUrl) {
throw new Error(
'OAuth is not configured. Set client ID, client secret, and discovery URL in admin settings or environment variables.'
);
}
return { clientId, clientSecret, discoveryUrl };
}
/**
* Derives the issuer URL from a discovery URL.
* If the URL ends with /.well-known/openid-configuration, strip that suffix.
* Otherwise use the URL as-is (openid-client discovery will append the well-known path).
*/
function deriveIssuerUrl(discoveryUrl: string): URL {
const wellKnownSuffix = '/.well-known/openid-configuration';
if (discoveryUrl.endsWith(wellKnownSuffix)) {
return new URL(discoveryUrl.slice(0, -wellKnownSuffix.length));
}
return new URL(discoveryUrl);
}
/**
* Returns a cached OIDC Configuration, performing discovery only when
* the OAuth settings have changed.
*/
async function getOIDCConfig(): Promise<client.Configuration> {
const oauthConfig = await loadOAuthConfig();
const cacheKey = `${oauthConfig.discoveryUrl}|${oauthConfig.clientId}`;
if (cachedConfig && cachedConfigKey === cacheKey) {
return cachedConfig;
}
const issuerUrl = deriveIssuerUrl(oauthConfig.discoveryUrl);
const config = await client.discovery(
issuerUrl,
oauthConfig.clientId,
oauthConfig.clientSecret
);
cachedConfig = config;
cachedConfigKey = cacheKey;
return config;
}
/**
* Invalidates the cached OIDC configuration, forcing re-discovery
* on the next request. Useful after admin changes OAuth settings.
*/
export function invalidateOAuthCache(): void {
cachedConfig = null;
cachedConfigKey = null;
}
/**
* Generates a PKCE code_verifier (random string).
*/
export function generateCodeVerifier(): string {
return client.randomPKCECodeVerifier();
}
/**
* Calculates the PKCE code_challenge from a code_verifier.
*/
export async function calculateCodeChallenge(codeVerifier: string): Promise<string> {
return client.calculatePKCECodeChallenge(codeVerifier);
}
/**
* Builds the authorization URL to redirect the user to the OIDC provider.
*/
export async function generateAuthUrl(
redirectUri: string,
codeChallenge: string
): Promise<string> {
const config = await getOIDCConfig();
const parameters: Record<string, string> = {
redirect_uri: redirectUri,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
};
// Add state if the server might not support PKCE
if (!config.serverMetadata().supportsPKCE()) {
parameters.state = client.randomState();
}
const url = client.buildAuthorizationUrl(config, parameters);
return url.href;
}
/**
* Exchanges an authorization code for tokens and fetches user info.
*/
export async function handleCallback(
callbackUrl: URL,
codeVerifier: string
): Promise<OAuthUserInfo> {
const config = await getOIDCConfig();
const tokens = await client.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: codeVerifier
});
// Try to get user info from the userinfo endpoint
const userInfo = await client.fetchUserInfo(config, tokens.access_token, tokens.claims()?.sub);
const email = (userInfo.email as string) || '';
if (!email) {
throw new Error('OAuth provider did not return an email address. Ensure the "email" scope is configured.');
}
return {
sub: userInfo.sub,
email,
name: (userInfo.name as string) || (userInfo.preferred_username as string) || undefined,
preferred_username: (userInfo.preferred_username as string) || undefined,
picture: (userInfo.picture as string) || undefined,
groups: Array.isArray(userInfo.groups) ? (userInfo.groups as string[]) : undefined
};
}
/**
* Tests the OAuth connection by performing OIDC discovery.
* Returns the issuer string on success, throws on failure.
*/
export async function testConnection(): Promise<string> {
const config = await getOIDCConfig();
const issuer = config.serverMetadata().issuer;
return issuer;
}
+93
View File
@@ -102,3 +102,96 @@ export async function getUserGroups(userId: string) {
export async function count() {
return prisma.user.count();
}
interface OAuthProvisionInput {
readonly email: string;
readonly displayName: string;
readonly avatarUrl?: string;
readonly groups?: readonly string[];
}
/**
* Finds an existing user by email or creates a new OAuth-provisioned user.
* - If the user exists: updates authProvider to 'oauth' and syncs display name / avatar if changed.
* - If the user does not exist: creates a new user with authProvider='oauth', null password, role='user'.
* - Maps OAuth group names to local groups when the groups claim is present.
*/
export async function findOrCreateByOAuth(input: OAuthProvisionInput) {
const existing = await prisma.user.findUnique({
where: { email: input.email },
select: { ...USER_SELECT, password: true }
});
let userId: string;
if (existing) {
// Update the existing user's OAuth-related fields if anything changed
const updates: Record<string, unknown> = { authProvider: 'oauth' };
if (input.displayName && input.displayName !== existing.displayName) {
updates.displayName = input.displayName;
}
if (input.avatarUrl !== undefined && input.avatarUrl !== existing.avatarUrl) {
updates.avatarUrl = input.avatarUrl;
}
await prisma.user.update({
where: { id: existing.id },
data: updates
});
userId = existing.id;
} else {
// Create a new OAuth user
const newUser = await prisma.user.create({
data: {
email: input.email,
password: null,
displayName: input.displayName,
avatarUrl: input.avatarUrl ?? null,
authProvider: 'oauth',
role: 'user'
},
select: USER_SELECT
});
userId = newUser.id;
}
// Sync OAuth groups to local groups if the groups claim is present
if (input.groups && input.groups.length > 0) {
await syncOAuthGroups(userId, input.groups);
}
// Return the full user record
return prisma.user.findUniqueOrThrow({
where: { id: userId },
select: USER_SELECT
});
}
/**
* Maps OAuth group names to existing local groups and syncs membership.
* Only groups that already exist locally are linked — no auto-creation.
*/
async function syncOAuthGroups(userId: string, oauthGroupNames: readonly string[]) {
// Find local groups matching the OAuth group names
const matchingGroups = await prisma.group.findMany({
where: { name: { in: [...oauthGroupNames] } },
select: { id: true }
});
if (matchingGroups.length === 0) {
return;
}
// Upsert memberships (idempotent — won't fail if already a member)
for (const group of matchingGroups) {
await prisma.userGroup.upsert({
where: {
userId_groupId: { userId, groupId: group.id }
},
update: {},
create: { userId, groupId: group.id }
});
}
}
@@ -0,0 +1,18 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { testConnection, invalidateOAuthCache } from '$lib/server/services/oauthService.js';
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
try {
// Invalidate cache so we test with current settings
invalidateOAuthCache();
const issuer = await testConnection();
return json({ success: true, issuer });
} catch (err) {
const message = err instanceof Error ? err.message : 'OAuth connection test failed';
return json({ success: false, error: message }, { status: 400 });
}
};
@@ -0,0 +1,56 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { success, error } from '$lib/server/utils/response.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
/**
* PUT /api/boards/:id/reorder — Reorder sections within a board.
* Body: { sectionIds: string[] }
*/
export const PUT: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { id } = event.params;
if (user.role !== UserRole.ADMIN) {
const result = await permissionService.checkPermission(
EntityType.BOARD,
id,
user.id,
PermissionLevel.EDIT
);
if (!result.hasPermission) {
return json(error('Insufficient permissions'), { status: 403 });
}
}
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const { sectionIds } = body as { sectionIds?: string[] };
if (!Array.isArray(sectionIds) || sectionIds.length === 0) {
return json(error('sectionIds must be a non-empty array of strings'), { status: 400 });
}
if (!sectionIds.every((sid) => typeof sid === 'string')) {
return json(error('All sectionIds must be strings'), { status: 400 });
}
try {
await boardService.reorderSections(id, sectionIds);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to reorder sections';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,56 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import * as boardService from '$lib/server/services/boardService.js';
import * as permissionService from '$lib/server/services/permissionService.js';
import { success, error } from '$lib/server/utils/response.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
/**
* PUT /api/boards/:id/sections/:sid/reorder — Reorder widgets within a section.
* Body: { widgetIds: string[] }
*/
export const PUT: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json(error('Authentication required'), { status: 401 });
}
const { id, sid } = event.params;
if (user.role !== UserRole.ADMIN) {
const result = await permissionService.checkPermission(
EntityType.BOARD,
id,
user.id,
PermissionLevel.EDIT
);
if (!result.hasPermission) {
return json(error('Insufficient permissions'), { status: 403 });
}
}
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const { widgetIds } = body as { widgetIds?: string[] };
if (!Array.isArray(widgetIds)) {
return json(error('widgetIds must be an array of strings'), { status: 400 });
}
if (!widgetIds.every((wid) => typeof wid === 'string')) {
return json(error('All widgetIds must be strings'), { status: 400 });
}
try {
await boardService.reorderWidgets(sid, widgetIds);
return json(success(null));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to reorder widgets';
const status = message.includes('not found') ? 404 : 500;
return json(error(message), { status });
}
};
@@ -0,0 +1,40 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import * as oauthService from '$lib/server/services/oauthService.js';
const COOKIE_BASE = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/'
};
export const GET: RequestHandler = async ({ cookies, url }) => {
try {
const appUrl = process.env.APP_URL || url.origin;
const redirectUri = process.env.OAUTH_REDIRECT_URI || `${appUrl}/auth/oauth/callback`;
// Generate PKCE values
const codeVerifier = oauthService.generateCodeVerifier();
const codeChallenge = await oauthService.calculateCodeChallenge(codeVerifier);
// Store code_verifier in HTTP-only cookie for the callback
cookies.set('oauth_code_verifier', codeVerifier, {
...COOKIE_BASE,
maxAge: 600 // 10 minutes — enough for the auth flow
});
// Build authorization URL and redirect
const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge);
throw redirect(302, authUrl);
} catch (err) {
// Re-throw redirects
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
throw err;
}
const message = err instanceof Error ? err.message : 'Failed to initiate OAuth login';
throw error(500, message);
}
};
+82
View File
@@ -0,0 +1,82 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import * as oauthService from '$lib/server/services/oauthService.js';
import * as userService from '$lib/server/services/userService.js';
import * as authService from '$lib/server/services/authService.js';
const COOKIE_BASE = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/'
};
export const GET: RequestHandler = async ({ url, cookies }) => {
try {
// Check for error response from the provider
const oauthError = url.searchParams.get('error');
if (oauthError) {
const description = url.searchParams.get('error_description') || oauthError;
throw new Error(`OAuth provider returned an error: ${description}`);
}
// Ensure we have an authorization code
const code = url.searchParams.get('code');
if (!code) {
throw new Error('No authorization code received from OAuth provider');
}
// Retrieve the code_verifier from the cookie
const codeVerifier = cookies.get('oauth_code_verifier');
if (!codeVerifier) {
throw new Error('OAuth session expired. Please try logging in again.');
}
// Clear the code_verifier cookie
cookies.delete('oauth_code_verifier', { path: '/' });
// Exchange the authorization code for tokens and get user info
const userInfo = await oauthService.handleCallback(url, codeVerifier);
// Find or create local user from OAuth info
const user = await userService.findOrCreateByOAuth({
email: userInfo.email,
displayName: userInfo.name || userInfo.preferred_username || userInfo.email.split('@')[0],
avatarUrl: userInfo.picture,
groups: userInfo.groups ? [...userInfo.groups] : undefined
});
// Issue local JWT tokens (same as local auth flow)
const accessToken = authService.signAccessToken({
userId: user.id,
email: user.email,
role: user.role
});
const refreshToken = authService.generateRefreshToken();
await authService.saveRefreshToken(user.id, refreshToken);
// Set session cookies
cookies.set('access_token', accessToken, {
...COOKIE_BASE,
maxAge: 900 // 15 minutes
});
cookies.set('refresh_token', refreshToken, {
...COOKIE_BASE,
maxAge: 604800 // 7 days
});
cookies.set('refresh_user_id', user.id, {
...COOKIE_BASE,
maxAge: 604800 // 7 days
});
throw redirect(302, '/');
} catch (err) {
// Re-throw redirects
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
throw err;
}
const message = err instanceof Error ? err.message : 'OAuth authentication failed';
throw error(500, message);
}
};
+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>
+11 -1
View File
@@ -5,6 +5,9 @@ import { fail, redirect } from '@sveltejs/kit';
import { loginSchema } from '$lib/utils/validators.js';
import * as userService from '$lib/server/services/userService.js';
import * as authService from '$lib/server/services/authService.js';
import { prisma } from '$lib/server/prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import type { AuthMode } from '$lib/utils/constants.js';
const COOKIE_BASE = {
httpOnly: true,
@@ -19,8 +22,15 @@ export const load: PageServerLoad = async ({ locals }) => {
throw redirect(302, '/');
}
// Load auth mode from SystemSettings
const settings = await prisma.systemSettings.findUnique({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
select: { authMode: true }
});
const authMode: AuthMode = (settings?.authMode as AuthMode) || 'local';
const form = await superValidate(zod(loginSchema));
return { form };
return { form, authMode };
};
export const actions: Actions = {
+87 -55
View File
@@ -6,6 +6,9 @@
let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form);
const showLocalForm = data.authMode === 'local' || data.authMode === 'both';
const showOAuthButton = data.authMode === 'oauth' || data.authMode === 'both';
</script>
<svelte:head>
@@ -38,62 +41,91 @@
<p class="mt-1 text-sm text-muted-foreground">Sign in to your account</p>
</div>
<form method="POST" use:enhance class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
Email
</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
bind:value={$form.email}
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="you@example.com"
/>
{#if $errors.email}
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
{/if}
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
Password
</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
bind:value={$form.password}
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="Enter your password"
/>
{#if $errors.password}
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
{/if}
</div>
<button
type="submit"
disabled={$submitting}
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{#if showOAuthButton}
<a
href="/auth/oauth/authorize"
class="flex w-full items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
>
{#if $submitting}
<span class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
Signing in...
</span>
{:else}
Sign In
{/if}
</button>
</form>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</svg>
Sign in with OAuth
</a>
{/if}
<p class="mt-6 text-center text-sm text-muted-foreground">
Don't have an account?
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
</p>
{#if showOAuthButton && showLocalForm}
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
{/if}
{#if showLocalForm}
<form method="POST" use:enhance class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
Email
</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
bind:value={$form.email}
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="you@example.com"
/>
{#if $errors.email}
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
{/if}
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
Password
</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
bind:value={$form.password}
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="Enter your password"
/>
{#if $errors.password}
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
{/if}
</div>
<button
type="submit"
disabled={$submitting}
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
<span class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
Signing in...
</span>
{:else}
Sign In
{/if}
</button>
</form>
{/if}
{#if showLocalForm}
<p class="mt-6 text-center text-sm text-muted-foreground">
Don't have an account?
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
</p>
{/if}
</div>
</main>