Template
Authentik sign-ins write the picture claim URL to users.image, and both avatar components cast that value to a storage ID and passed it to files:getImageUrl, which failed argument validation and took the layout down after sign-in. getUser now returns imageUrl resolved from either a storage ID or a URL, and updateUser only deletes the previous avatar when it was an uploaded file. getImageUrl stays until the new Next image is live.
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
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<Doc<'users'>> => {
|
|
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 };
|
|
},
|
|
});
|