Fix avatar crash when users.image is an IdP URL
Build and Push Next App / quality (push) Successful in 1m19s
Build and Push Next App / build-next (push) Successful in 2m35s

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.
This commit is contained in:
Gabriel Brown committed 2026-10-01 15:01:51 -04:00
1 parent 8f09a423d4
commit 55929d8c7f
3 files changed
+20 -15

No files matched your search

@@ -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<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
@@ -117,7 +112,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
onClick={() => inputRef.current?.click()}
>
<BasedAvatar
src={currentImageUrl ?? undefined}
src={user?.imageUrl}
fullName={user?.name}
className='h-42 w-42 text-6xl font-semibold'
userIconProps={{ size: 100 }}
@@ -6,7 +6,6 @@ import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
import { api } from '@gib/backend/convex/_generated/api.js';
import {
BasedAvatar,
@@ -25,10 +24,6 @@ export const AvatarDropdown = () => {
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 = () => {
<DropdownMenu>
<DropdownMenuTrigger>
<BasedAvatar
src={currentImageUrl}
src={user?.imageUrl}
fullName={user?.name}
className='h-9 w-9'
fallbackProps={{ className: 'text-sm font-semibold' }}
+17 -2
View File
@@ -45,12 +45,24 @@ export const getUserProvider = query({
},
});
/**
* `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;
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);