import Authentik from '@auth/core/providers/authentik'; import { convexAuth, getAuthUserId, modifyAccountCredentials, retrieveAccount, } from '@convex-dev/auth/server'; import { ConvexError, v } from 'convex/values'; import type { Doc, Id } from './_generated/dataModel'; import type { QueryCtx } from './_generated/server'; import { api } from './_generated/api'; import { action, mutation, query } from './_generated/server'; import { Password, validatePassword } from './custom/auth'; export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ providers: [Authentik({ allowDangerousEmailAccountLinking: true }), Password], }); const getUserById = async ( ctx: QueryCtx, userId: Id<'users'>, ): Promise> => { const user = await ctx.db.get(userId); if (!user) throw new ConvexError('User not found.'); return user; }; const getAuthAccountById = async (ctx: QueryCtx, userId: Id<'users'>) => { const user = await ctx.db.get(userId); if (!user) throw new ConvexError('User not found.'); const authAccount = await ctx.db .query('authAccounts') .withIndex('userIdAndProvider', (q) => q.eq('userId', userId)) .first(); if (!authAccount) throw new ConvexError('Auth account not found'); return authAccount; }; export const getUserProvider = query({ args: { userId: v.optional(v.id('users')) }, handler: async (ctx, args) => { const userId = args.userId ?? (await getAuthUserId(ctx)); if (!userId) return null; const authAccount = await getAuthAccountById(ctx, userId); return authAccount.provider; }, }); /** * `users.image` holds either a Convex storage ID (avatar uploaded in-app) or a * plain URL (the `picture` claim Authentik writes on every OAuth sign-in). * Resolves either form to a URL the client can render directly. */ const resolveImageUrl = async (ctx: QueryCtx, image: string | undefined) => { if (!image) return null; const storageId = ctx.db.system.normalizeId('_storage', image); return storageId ? await ctx.storage.getUrl(storageId) : image; }; export const getUser = query({ args: { userId: v.optional(v.id('users')) }, handler: async (ctx, args) => { const userId = args.userId ?? (await getAuthUserId(ctx)); if (!userId) return null; const user = await getUserById(ctx, userId); return { ...user, imageUrl: await resolveImageUrl(ctx, user.image) }; }, }); export const updateUser = mutation({ args: { name: v.optional(v.string()), email: v.optional(v.string()), image: v.optional(v.id('_storage')), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError('Not authenticated.'); const user = await ctx.db.get(userId); if (!user) throw new ConvexError('User not found.'); const patch: Partial<{ name: string; email: string; image: Id<'_storage'>; }> = {}; if (args.name !== undefined) patch.name = args.name; if (args.email !== undefined) patch.email = args.email; if (args.image !== undefined) { // Only delete the previous avatar when it was an uploaded file, not an IdP URL. const oldImage = user.image ? ctx.db.system.normalizeId('_storage', user.image) : null; patch.image = args.image; if (oldImage && oldImage !== args.image) { await ctx.storage.delete(oldImage); } } if (Object.keys(patch).length > 0) { await ctx.db.patch(userId, patch); } return { success: true }; }, }); export const updateUserPassword = action({ args: { currentPassword: v.string(), newPassword: v.string(), }, handler: async (ctx, { currentPassword, newPassword }) => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError('Not authenticated.'); const user = await ctx.runQuery(api.auth.getUser, { userId }); if (!user?.email) throw new ConvexError('User not found.'); await retrieveAccount(ctx, { provider: 'password', account: { id: user.email, secret: currentPassword }, }); if (!validatePassword(newPassword)) throw new ConvexError('Invalid password.'); await modifyAccountCredentials(ctx, { provider: 'password', account: { id: user.email, secret: newPassword }, }); return { success: true }; }, });