feat(account): added user profile pictures in settings (#1297)

* update infra and remove railway

* feat(account): add profile pictures

* Revert "update infra and remove railway"

This reverts commit e3f0c49456.

* ack PR comments, use brandConfig logo URL as default
This commit is contained in:
Waleed
2025-09-09 16:09:31 -07:00
committed by GitHub
parent ae670a7819
commit 8f7b11f089
6 changed files with 371 additions and 47 deletions

View File

@@ -12,10 +12,12 @@ import {
BLOB_CONFIG,
BLOB_COPILOT_CONFIG,
BLOB_KB_CONFIG,
BLOB_PROFILE_PICTURES_CONFIG,
S3_CHAT_CONFIG,
S3_CONFIG,
S3_COPILOT_CONFIG,
S3_KB_CONFIG,
S3_PROFILE_PICTURES_CONFIG,
} from '@/lib/uploads/setup'
import { validateFileType } from '@/lib/uploads/validation'
import { createErrorResponse, createOptionsResponse } from '@/app/api/files/utils'
@@ -30,7 +32,7 @@ interface PresignedUrlRequest {
chatId?: string
}
type UploadType = 'general' | 'knowledge-base' | 'chat' | 'copilot'
type UploadType = 'general' | 'knowledge-base' | 'chat' | 'copilot' | 'profile-pictures'
class PresignedUrlError extends Error {
constructor(
@@ -96,7 +98,9 @@ export async function POST(request: NextRequest) {
? 'chat'
: uploadTypeParam === 'copilot'
? 'copilot'
: 'general'
: uploadTypeParam === 'profile-pictures'
? 'profile-pictures'
: 'general'
if (uploadType === 'knowledge-base') {
const fileValidationError = validateFileType(fileName, contentType)
@@ -121,6 +125,21 @@ export async function POST(request: NextRequest) {
}
}
// Validate profile picture requirements
if (uploadType === 'profile-pictures') {
if (!sessionUserId?.trim()) {
throw new ValidationError(
'Authenticated user session is required for profile picture uploads'
)
}
// Only allow image uploads for profile pictures
if (!isImageFileType(contentType)) {
throw new ValidationError(
'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads'
)
}
}
if (!isUsingCloudStorage()) {
throw new StorageConfigError(
'Direct uploads are only available when cloud storage is enabled'
@@ -185,7 +204,9 @@ async function handleS3PresignedUrl(
? S3_CHAT_CONFIG
: uploadType === 'copilot'
? S3_COPILOT_CONFIG
: S3_CONFIG
: uploadType === 'profile-pictures'
? S3_PROFILE_PICTURES_CONFIG
: S3_CONFIG
if (!config.bucket || !config.region) {
throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`)
@@ -200,6 +221,8 @@ async function handleS3PresignedUrl(
prefix = 'chat/'
} else if (uploadType === 'copilot') {
prefix = `${userId}/`
} else if (uploadType === 'profile-pictures') {
prefix = `${userId}/`
}
const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}`
@@ -219,6 +242,9 @@ async function handleS3PresignedUrl(
} else if (uploadType === 'copilot') {
metadata.purpose = 'copilot'
metadata.userId = userId || ''
} else if (uploadType === 'profile-pictures') {
metadata.purpose = 'profile-pictures'
metadata.userId = userId || ''
}
const command = new PutObjectCommand({
@@ -239,9 +265,9 @@ async function handleS3PresignedUrl(
)
}
// For chat images and knowledge base files, use direct URLs since they need to be accessible by external services
// For chat images, knowledge base files, and profile pictures, use direct URLs since they need to be accessible by external services
const finalPath =
uploadType === 'chat' || uploadType === 'knowledge-base'
uploadType === 'chat' || uploadType === 'knowledge-base' || uploadType === 'profile-pictures'
? `https://${config.bucket}.s3.${config.region}.amazonaws.com/${uniqueKey}`
: `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}`
@@ -285,7 +311,9 @@ async function handleBlobPresignedUrl(
? BLOB_CHAT_CONFIG
: uploadType === 'copilot'
? BLOB_COPILOT_CONFIG
: BLOB_CONFIG
: uploadType === 'profile-pictures'
? BLOB_PROFILE_PICTURES_CONFIG
: BLOB_CONFIG
if (
!config.accountName ||
@@ -304,6 +332,8 @@ async function handleBlobPresignedUrl(
prefix = 'chat/'
} else if (uploadType === 'copilot') {
prefix = `${userId}/`
} else if (uploadType === 'profile-pictures') {
prefix = `${userId}/`
}
const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}`
@@ -339,10 +369,10 @@ async function handleBlobPresignedUrl(
const presignedUrl = `${blockBlobClient.url}?${sasToken}`
// For chat images, use direct Blob URLs since they need to be permanently accessible
// For chat images and profile pictures, use direct Blob URLs since they need to be permanently accessible
// For other files, use serve path for access control
const finalPath =
uploadType === 'chat'
uploadType === 'chat' || uploadType === 'profile-pictures'
? blockBlobClient.url
: `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}`
@@ -362,6 +392,9 @@ async function handleBlobPresignedUrl(
} else if (uploadType === 'copilot') {
uploadHeaders['x-ms-meta-purpose'] = 'copilot'
uploadHeaders['x-ms-meta-userid'] = encodeURIComponent(userId || '')
} else if (uploadType === 'profile-pictures') {
uploadHeaders['x-ms-meta-purpose'] = 'profile-pictures'
uploadHeaders['x-ms-meta-userid'] = encodeURIComponent(userId || '')
}
return NextResponse.json({

View File

@@ -12,11 +12,18 @@ const logger = createLogger('UpdateUserProfileAPI')
const UpdateProfileSchema = z
.object({
name: z.string().min(1, 'Name is required').optional(),
image: z.string().url('Invalid image URL').optional(),
})
.refine((data) => data.name !== undefined, {
message: 'Name field must be provided',
.refine((data) => data.name !== undefined || data.image !== undefined, {
message: 'At least one field (name or image) must be provided',
})
interface UpdateData {
updatedAt: Date
name?: string
image?: string | null
}
export const dynamic = 'force-dynamic'
export async function PATCH(request: NextRequest) {
@@ -36,8 +43,9 @@ export async function PATCH(request: NextRequest) {
const validatedData = UpdateProfileSchema.parse(body)
// Build update object
const updateData: any = { updatedAt: new Date() }
const updateData: UpdateData = { updatedAt: new Date() }
if (validatedData.name !== undefined) updateData.name = validatedData.name
if (validatedData.image !== undefined) updateData.image = validatedData.image
// Update user profile
const [updatedUser] = await db

View File

@@ -1,14 +1,18 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Camera } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { signOut, useSession } from '@/lib/auth-client'
import { useBrandConfig } from '@/lib/branding/branding'
import { createLogger } from '@/lib/logs/console/logger'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/hooks/use-profile-picture-upload'
import { clearUserData } from '@/stores'
const logger = createLogger('Account')
@@ -17,33 +21,81 @@ interface AccountProps {
onOpenChange: (open: boolean) => void
}
export function Account({ onOpenChange }: AccountProps) {
export function Account(_props: AccountProps) {
const router = useRouter()
const brandConfig = useBrandConfig()
// Get session data using the client hook
const { data: session, isPending, error } = useSession()
const { data: session, isPending } = useSession()
// Form states
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [userImage, setUserImage] = useState<string | null>(null)
// Loading states
const [isLoadingProfile, setIsLoadingProfile] = useState(false)
const [isUpdatingName, setIsUpdatingName] = useState(false)
// Edit states
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Reset password state
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [resetPasswordMessage, setResetPasswordMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
// Fetch user profile on component mount
const [uploadError, setUploadError] = useState<string | null>(null)
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,
handleThumbnailClick: handleProfilePictureClick,
handleFileChange: handleProfilePictureChange,
isUploading: isUploadingProfilePicture,
} = useProfilePictureUpload({
currentImage: userImage,
onUpload: async (url) => {
if (url) {
try {
await updateUserImage(url)
setUploadError(null)
} catch (error) {
setUploadError('Failed to update profile picture')
}
} else {
try {
await updateUserImage(null)
setUploadError(null)
} catch (error) {
setUploadError('Failed to remove profile picture')
}
}
},
onError: (error) => {
setUploadError(error)
setTimeout(() => setUploadError(null), 5000)
},
})
const updateUserImage = async (imageUrl: string | null) => {
try {
const response = await fetch('/api/users/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageUrl }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update profile picture')
}
setUserImage(imageUrl)
} catch (error) {
logger.error('Error updating profile image:', error)
throw error
}
}
useEffect(() => {
const fetchProfile = async () => {
if (!session?.user) return
@@ -62,7 +114,6 @@ export function Account({ onOpenChange }: AccountProps) {
setUserImage(data.user.image)
} catch (error) {
logger.error('Error fetching profile:', error)
// Fallback to session data
if (session?.user) {
setName(session.user.name || '')
setEmail(session.user.email || '')
@@ -76,7 +127,6 @@ export function Account({ onOpenChange }: AccountProps) {
fetchProfile()
}, [session])
// Focus input when entering edit mode
useEffect(() => {
if (isEditingName && inputRef.current) {
inputRef.current.focus()
@@ -172,7 +222,6 @@ export function Account({ onOpenChange }: AccountProps) {
text: 'email sent',
})
// Clear success message after 5 seconds
setTimeout(() => {
setResetPasswordMessage(null)
}, 5000)
@@ -183,7 +232,6 @@ export function Account({ onOpenChange }: AccountProps) {
text: 'error',
})
// Clear error message after 5 seconds
setTimeout(() => {
setResetPasswordMessage(null)
}, 5000)
@@ -242,31 +290,59 @@ export function Account({ onOpenChange }: AccountProps) {
<>
{/* User Info Section */}
<div className='flex items-center gap-4'>
{/* User Avatar */}
<div className='relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#802FFF]'>
{userImage ? (
<Image
src={userImage}
alt={name || 'User'}
width={40}
height={40}
className='h-full w-full object-cover'
/>
) : (
<AgentIcon className='h-5 w-5 text-white' />
)}
{/* Profile Picture Upload */}
<div className='relative'>
<div
className='group relative flex h-12 w-12 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
onClick={handleProfilePictureClick}
>
{(() => {
const imageUrl = profilePictureUrl || userImage || brandConfig.logoUrl
return imageUrl ? (
<Image
src={imageUrl}
alt={name || 'User'}
width={48}
height={48}
className='h-full w-full object-cover'
/>
) : (
<AgentIcon className='h-6 w-6 text-white' />
)
})()}
{/* Upload overlay */}
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100'>
{isUploadingProfilePicture ? (
<div className='h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<Camera className='h-5 w-5 text-white' />
)}
</div>
</div>
{/* Hidden file input */}
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
{/* User Details */}
<div className='flex flex-col'>
<h3 className='font-medium text-sm'>{name}</h3>
<div className='flex flex-1 flex-col justify-center'>
<h3 className='font-medium text-base'>{name}</h3>
<p className='font-normal text-muted-foreground text-sm'>{email}</p>
{uploadError && <p className='mt-1 text-destructive text-xs'>{uploadError}</p>}
</div>
</div>
{/* Name Field */}
<div className='flex flex-col gap-2'>
<Label htmlFor='name' className='font-normal text-muted-foreground text-xs'>
<Label htmlFor='name' className='font-normal text-muted-foreground text-sm'>
Name
</Label>
{isEditingName ? (
@@ -276,7 +352,7 @@ export function Account({ onOpenChange }: AccountProps) {
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-base outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isUpdatingName}
autoComplete='off'
@@ -286,10 +362,10 @@ export function Account({ onOpenChange }: AccountProps) {
/>
) : (
<div className='flex items-center gap-4'>
<span className='text-sm'>{name}</span>
<span className='text-base'>{name}</span>
<Button
variant='ghost'
className='h-auto p-0 font-normal text-muted-foreground text-xs transition-colors hover:bg-transparent hover:text-foreground'
className='h-auto p-0 font-normal text-muted-foreground text-sm transition-colors hover:bg-transparent hover:text-foreground'
onClick={() => setIsEditingName(true)}
>
update
@@ -301,18 +377,18 @@ export function Account({ onOpenChange }: AccountProps) {
{/* Email Field - Read Only */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs'>Email</Label>
<p className='text-sm'>{email}</p>
<Label className='font-normal text-muted-foreground text-sm'>Email</Label>
<p className='text-base'>{email}</p>
</div>
{/* Password Field */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs'>Password</Label>
<Label className='font-normal text-muted-foreground text-sm'>Password</Label>
<div className='flex items-center gap-4'>
<span className='text-sm'></span>
<span className='text-base'></span>
<Button
variant='ghost'
className={`h-auto p-0 font-normal text-xs transition-colors hover:bg-transparent ${
className={`h-auto p-0 font-normal text-sm transition-colors hover:bg-transparent ${
resetPasswordMessage
? resetPasswordMessage.type === 'success'
? 'text-green-500 hover:text-green-600'

View File

@@ -0,0 +1,193 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ProfilePictureUpload')
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg']
interface UseProfilePictureUploadProps {
onUpload?: (url: string | null) => void
onError?: (error: string) => void
currentImage?: string | null
}
export function useProfilePictureUpload({
onUpload,
onError,
currentImage,
}: UseProfilePictureUploadProps = {}) {
const previewRef = useRef<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(currentImage || null)
const [fileName, setFileName] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
useEffect(() => {
if (currentImage !== previewUrl) {
if (previewRef.current && previewRef.current !== currentImage) {
URL.revokeObjectURL(previewRef.current)
previewRef.current = null
}
setPreviewUrl(currentImage || null)
}
}, [currentImage, previewUrl])
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File "${file.name}" is too large. Maximum size is 5MB.`
}
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
return `File "${file.name}" is not a supported image format. Please use PNG or JPEG.`
}
return null
}, [])
const handleThumbnailClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const uploadFileToServer = useCallback(async (file: File): Promise<string> => {
try {
const presignedResponse = await fetch('/api/files/presigned?type=profile-pictures', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
}),
})
if (presignedResponse.ok) {
const presignedData = await presignedResponse.json()
logger.info('Presigned URL response:', presignedData)
const uploadHeaders: Record<string, string> = {
'Content-Type': file.type,
}
if (presignedData.uploadHeaders) {
Object.assign(uploadHeaders, presignedData.uploadHeaders)
}
const uploadResponse = await fetch(presignedData.uploadUrl, {
method: 'PUT',
body: file,
headers: uploadHeaders,
})
logger.info(`Upload response status: ${uploadResponse.status}`)
if (!uploadResponse.ok) {
const responseText = await uploadResponse.text()
logger.error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
throw new Error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
}
const publicUrl = presignedData.fileInfo.path
logger.info(`Profile picture uploaded successfully via direct upload: ${publicUrl}`)
return publicUrl
}
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(errorData.error || `Failed to upload file: ${response.status}`)
}
const data = await response.json()
const publicUrl = data.path
logger.info(`Profile picture uploaded successfully via server upload: ${publicUrl}`)
return publicUrl
} catch (error) {
throw new Error(error instanceof Error ? error.message : 'Failed to upload profile picture')
}
}, [])
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const validationError = validateFile(file)
if (validationError) {
onError?.(validationError)
return
}
setFileName(file.name)
const newPreviewUrl = URL.createObjectURL(file)
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
}
setPreviewUrl(newPreviewUrl)
previewRef.current = newPreviewUrl
setIsUploading(true)
try {
const serverUrl = await uploadFileToServer(file)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(serverUrl)
onUpload?.(serverUrl)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload profile picture'
onError?.(errorMessage)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(currentImage || null)
} finally {
setIsUploading(false)
}
}
},
[onUpload, onError, uploadFileToServer, validateFile, currentImage]
)
const handleRemove = useCallback(() => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
previewRef.current = null
}
setPreviewUrl(null)
setFileName(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
onUpload?.(null)
}, [onUpload])
useEffect(() => {
return () => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
}
}
}, [])
return {
previewUrl,
fileName,
fileInputRef,
handleThumbnailClick,
handleFileChange,
handleRemove,
isUploading,
}
}

View File

@@ -113,6 +113,7 @@ export const env = createEnv({
S3_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // S3 bucket for workflow execution files
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
S3_COPILOT_BUCKET_NAME: z.string().optional(), // S3 bucket for copilot files
S3_PROFILE_PICTURES_BUCKET_NAME: z.string().optional(), // S3 bucket for profile pictures
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
@@ -123,6 +124,7 @@ export const env = createEnv({
AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME: z.string().optional(), // Azure container for workflow execution files
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
AZURE_STORAGE_COPILOT_CONTAINER_NAME: z.string().optional(), // Azure container for copilot files
AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME: z.string().optional(), // Azure container for profile pictures
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users

View File

@@ -77,6 +77,18 @@ export const BLOB_COPILOT_CONFIG = {
containerName: env.AZURE_STORAGE_COPILOT_CONTAINER_NAME || '',
}
export const S3_PROFILE_PICTURES_CONFIG = {
bucket: env.S3_PROFILE_PICTURES_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_PROFILE_PICTURES_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME || '',
}
/**
* Get the current storage provider as a human-readable string
*/