From 2d4f5b23b5f2a1c9c85a0365103f844c8dd20453 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 1 Oct 2026 15:00:57 -0400 Subject: [PATCH] Fix avatar crash when users.image is an IdP URL 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. --- .../layout/auth/profile/avatar-upload.tsx | 9 ++------- .../layout/header/controls/AvatarDropdown.tsx | 7 +------ packages/backend/convex/auth.ts | 19 +++++++++++++++++-- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/next/src/components/layout/auth/profile/avatar-upload.tsx b/apps/next/src/components/layout/auth/profile/avatar-upload.tsx index 40920f3..a9fb440 100644 --- a/apps/next/src/components/layout/auth/profile/avatar-upload.tsx +++ b/apps/next/src/components/layout/auth/profile/avatar-upload.tsx @@ -3,7 +3,7 @@ import type { Preloaded } from 'convex/react'; import type { ChangeEvent } from 'react'; import { useRef, useState } from 'react'; -import { useMutation, usePreloadedQuery, useQuery } from 'convex/react'; +import { useMutation, usePreloadedQuery } from 'convex/react'; import { Loader2, Pencil, Upload, XIcon } from 'lucide-react'; import { toast } from 'sonner'; @@ -48,11 +48,6 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { const generateUploadUrl = useMutation(api.files.generateUploadUrl); const updateUser = useMutation(api.auth.updateUser); - const currentImageUrl = useQuery( - api.files.getImageUrl, - user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip', - ); - const handleFileChange = (event: ChangeEvent) => { const file = event.target.files?.[0] ?? null; if (!file) return; @@ -117,7 +112,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { onClick={() => inputRef.current?.click()} > { const { signOut } = useAuthActions(); const user = useQuery(api.auth.getUser, {}); useAnalyticsIdentity(user?._id); - const currentImageUrl = useQuery( - api.files.getImageUrl, - user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip', - ); if (isLoading) { return ( @@ -53,7 +48,7 @@ export const AvatarDropdown = () => { { + 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; - return getUserById(ctx, userId); + const user = await getUserById(ctx, userId); + return { ...user, imageUrl: await resolveImageUrl(ctx, user.image) }; }, }); @@ -73,7 +85,10 @@ export const updateUser = mutation({ if (args.name !== undefined) patch.name = args.name; if (args.email !== undefined) patch.email = args.email; if (args.image !== undefined) { - const oldImage = user.image as Id<'_storage'> | 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);