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
+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 }
});
}
}