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:
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user