fix: avatar/logo display diagnostics and upload error handling
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m31s

Add error logging to silent catch blocks in avatar/logo URL generation,
show user avatar on admin member detail page, and surface specific error
messages for upload failures (CORS/network issues) instead of generic errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 02:11:16 +01:00
parent 335c736219
commit 78334676d0
7 changed files with 63 additions and 35 deletions

View File

@@ -47,6 +47,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { UserAvatar } from '@/components/shared/user-avatar'
import { import {
ArrowLeft, ArrowLeft,
Save, Save,
@@ -196,15 +197,18 @@ export default function MemberDetailPage() {
</div> </div>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div className="flex items-center gap-4">
<h1 className="text-2xl font-semibold tracking-tight"> <UserAvatar user={user} avatarUrl={user.avatarUrl} size="lg" />
{user.name || 'Unnamed Member'} <div>
</h1> <h1 className="text-2xl font-semibold tracking-tight">
<div className="flex items-center gap-2 mt-1"> {user.name || 'Unnamed Member'}
<p className="text-muted-foreground">{user.email}</p> </h1>
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}> <div className="flex items-center gap-2 mt-1">
{user.status === 'NONE' ? 'Not Invited' : user.status} <p className="text-muted-foreground">{user.email}</p>
</Badge> <Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
{user.status === 'NONE' ? 'Not Invited' : user.status}
</Badge>
</div>
</div> </div>
</div> </div>
{(user.status === 'NONE' || user.status === 'INVITED') && ( {(user.status === 'NONE' || user.status === 'INVITED') && (

View File

@@ -139,16 +139,23 @@ export function AvatarUpload({
}) })
// Upload cropped blob directly to storage // Upload cropped blob directly to storage
const uploadResponse = await fetch(uploadUrl, { let uploadResponse: Response
method: 'PUT', try {
body: croppedBlob, uploadResponse = await fetch(uploadUrl, {
headers: { method: 'PUT',
'Content-Type': 'image/jpeg', body: croppedBlob,
}, headers: {
}) 'Content-Type': 'image/jpeg',
},
})
} catch (fetchError) {
console.error('Avatar upload network error (possible CORS issue):', fetchError)
throw new Error('Network error uploading file. The storage server may not be reachable.')
}
if (!uploadResponse.ok) { if (!uploadResponse.ok) {
throw new Error('Failed to upload file') console.error('Avatar upload failed:', uploadResponse.status, uploadResponse.statusText)
throw new Error(`Upload failed (${uploadResponse.status}). Please try again.`)
} }
// Confirm upload // Confirm upload

View File

@@ -142,16 +142,23 @@ export function LogoUpload({
}) })
// Upload cropped blob directly to storage // Upload cropped blob directly to storage
const uploadResponse = await fetch(uploadUrl, { let uploadResponse: Response
method: 'PUT', try {
body: croppedBlob, uploadResponse = await fetch(uploadUrl, {
headers: { method: 'PUT',
'Content-Type': 'image/png', body: croppedBlob,
}, headers: {
}) 'Content-Type': 'image/png',
},
})
} catch (fetchError) {
console.error('Logo upload network error (possible CORS issue):', fetchError)
throw new Error('Network error uploading file. The storage server may not be reachable.')
}
if (!uploadResponse.ok) { if (!uploadResponse.ok) {
throw new Error('Failed to upload file') console.error('Logo upload failed:', uploadResponse.status, uploadResponse.statusText)
throw new Error(`Upload failed (${uploadResponse.status}). Please try again.`)
} }
// Confirm upload with the provider type that was used // Confirm upload with the provider type that was used

View File

@@ -128,14 +128,21 @@ export function ProjectLogoUpload({
contentType: 'image/png', contentType: 'image/png',
}) })
const uploadResponse = await fetch(uploadUrl, { let uploadResponse: Response
method: 'PUT', try {
body: croppedBlob, uploadResponse = await fetch(uploadUrl, {
headers: { 'Content-Type': 'image/png' }, method: 'PUT',
}) body: croppedBlob,
headers: { 'Content-Type': 'image/png' },
})
} catch (fetchError) {
console.error('Logo upload network error (possible CORS issue):', fetchError)
throw new Error('Network error uploading file. The storage server may not be reachable.')
}
if (!uploadResponse.ok) { if (!uploadResponse.ok) {
throw new Error('Failed to upload file') console.error('Logo upload failed:', uploadResponse.status, uploadResponse.statusText)
throw new Error(`Upload failed (${uploadResponse.status}). Please try again.`)
} }
await confirmUpload.mutateAsync({ projectId, key, providerType }) await confirmUpload.mutateAsync({ projectId, key, providerType })

View File

@@ -5,7 +5,7 @@ import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email' import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password' import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url' import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite' import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
@@ -353,7 +353,8 @@ export const userRouter = router({
}, },
}, },
}) })
return user const avatarUrl = await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider)
return { ...user, avatarUrl }
}), }),
/** /**

View File

@@ -14,7 +14,8 @@ export async function getUserAvatarUrl(
const providerType = (profileImageProvider as StorageProviderType) || 's3' const providerType = (profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType) const provider = createStorageProvider(providerType)
return await provider.getDownloadUrl(profileImageKey) return await provider.getDownloadUrl(profileImageKey)
} catch { } catch (error) {
console.error('[AvatarURL] Failed to generate URL for key:', profileImageKey, error)
return null return null
} }
} }

View File

@@ -14,7 +14,8 @@ export async function getProjectLogoUrl(
const providerType = (logoProvider as StorageProviderType) || 's3' const providerType = (logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType) const provider = createStorageProvider(providerType)
return await provider.getDownloadUrl(logoKey) return await provider.getDownloadUrl(logoKey)
} catch { } catch (error) {
console.error('[LogoURL] Failed to generate URL for key:', logoKey, error)
return null return null
} }
} }