mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user