diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 1be57e4a389..e9938c14940 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -114,8 +114,12 @@ export async function verifyFileAccess( // Infer context from key if not explicitly provided const inferredContext = context || inferContextFromKey(cloudKey) - // 0. Public contexts: profile pictures and OG images are publicly accessible - if (inferredContext === 'profile-pictures' || inferredContext === 'og-images') { + // 0. Public contexts: profile pictures, OG images, and workspace logos are publicly accessible + if ( + inferredContext === 'profile-pictures' || + inferredContext === 'og-images' || + inferredContext === 'workspace-logos' + ) { logger.info('Public file access allowed', { cloudKey, context: inferredContext }) return true } diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index bc14086395a..6afa0f7abd5 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -95,7 +95,9 @@ export async function GET( const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath const isPublicByKeyPrefix = - cloudKey.startsWith('profile-pictures/') || cloudKey.startsWith('og-images/') + cloudKey.startsWith('profile-pictures/') || + cloudKey.startsWith('og-images/') || + cloudKey.startsWith('workspace-logos/') if (isPublicByKeyPrefix) { const context = inferContextFromKey(cloudKey) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index b6791a3841b..f65467e016f 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { sanitizeFileName } from '@/executor/constants' import '@/lib/uploads/core/setup.server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -64,7 +66,7 @@ export async function POST(request: NextRequest) { // Context must be explicitly provided if (!contextParam) { throw new InvalidRequestError( - 'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, or profile-pictures)' + 'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)' ) } @@ -282,13 +284,35 @@ export async function POST(request: NextRequest) { continue } - if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') { + if ( + context === 'copilot' || + context === 'chat' || + context === 'profile-pictures' || + context === 'workspace-logos' + ) { if (context !== 'copilot' && !isImageFileType(file.type)) { throw new InvalidRequestError( `Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads` ) } + if (context === 'workspace-logos') { + if (!workspaceId) { + throw new InvalidRequestError('workspace-logos context requires workspaceId parameter') + } + const permission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + if (permission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required for workspace logo uploads' }, + { status: 403 } + ) + } + } + if (context === 'chat' && workspaceId) { const permission = await getUserEntityPermissions( session.user.id, @@ -346,13 +370,40 @@ export async function POST(request: NextRequest) { } logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`) + + if (context === 'workspace-logos' && workspaceId) { + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: `Uploaded workspace logo "${originalName}"`, + metadata: { + fileName: originalName, + fileKey: fileInfo.key, + fileSize: buffer.length, + fileType: file.type, + }, + request, + }) + + captureServerEvent(session.user.id, 'workspace_logo_uploaded', { + workspace_id: workspaceId, + file_name: originalName, + file_size: buffer.length, + }) + } + uploadResults.push(uploadResult) continue } // Unknown context throw new InvalidRequestError( - `Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, or profile-pictures` + `Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos` ) } diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index d051b1fffcb..5254ddb1cc5 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -20,6 +20,13 @@ const patchWorkspaceSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6}$/) .optional(), + logoUrl: z + .string() + .refine((val) => val.startsWith('/') || val.startsWith('https://'), { + message: 'Logo URL must be an absolute path or HTTPS URL', + }) + .nullable() + .optional(), billedAccountUserId: z.string().optional(), allowPersonalApiKeys: z.boolean().optional(), }) @@ -119,11 +126,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< try { const body = patchWorkspaceSchema.parse(await request.json()) - const { name, color, billedAccountUserId, allowPersonalApiKeys } = body + const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body if ( name === undefined && color === undefined && + logoUrl === undefined && billedAccountUserId === undefined && allowPersonalApiKeys === undefined ) { @@ -150,6 +158,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< updateData.color = color } + if (logoUrl !== undefined) { + updateData.logoUrl = logoUrl + } + if (allowPersonalApiKeys !== undefined) { updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys) } @@ -216,6 +228,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< changes: { ...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }), ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }), + ...(logoUrl !== undefined && { + logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl }, + }), ...(allowPersonalApiKeys !== undefined && { allowPersonalApiKeys: { from: existingWorkspace.allowPersonalApiKeys, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx index c5880735309..1ced5321b81 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { createLogger } from '@sim/logger' import { AlertTriangle, LibraryBig, MoreHorizontal } from 'lucide-react' import Link from 'next/link' @@ -17,6 +17,7 @@ import { ChevronDown } from '@/components/emcn/icons' import { Trash } from '@/components/emcn/icons/trash' import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants' import { useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' const logger = createLogger('KnowledgeHeader') @@ -48,52 +49,18 @@ interface KnowledgeHeaderProps { options?: KnowledgeHeaderOptions } -interface Workspace { - id: string - name: string - permissions: 'admin' | 'write' | 'read' -} - export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) { const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false) const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false) - const [workspaces, setWorkspaces] = useState([]) - const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false) - - const updateKnowledgeBase = useUpdateKnowledgeBase() - useEffect(() => { - if (!options?.knowledgeBaseId) return - - const fetchWorkspaces = async () => { - try { - setIsLoadingWorkspaces(true) - - const response = await fetch('/api/workspaces') - if (!response.ok) { - throw new Error('Failed to fetch workspaces') - } - - const data = await response.json() - - const availableWorkspaces = data.workspaces - .filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin') - .map((ws: any) => ({ - id: ws.id, - name: ws.name, - permissions: ws.permissions, - })) - - setWorkspaces(availableWorkspaces) - } catch (err) { - logger.error('Error fetching workspaces:', err) - } finally { - setIsLoadingWorkspaces(false) - } - } + const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery( + !!options?.knowledgeBaseId + ) + const workspaces = allWorkspaces.filter( + (ws) => ws.permissions === 'write' || ws.permissions === 'admin' + ) - fetchWorkspaces() - }, [options?.knowledgeBaseId]) + const updateKnowledgeBase = useUpdateKnowledgeBase() const handleWorkspaceChange = async (workspaceId: string | null) => { if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index 04078386354..ff4c582e414 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -1,14 +1,17 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import type { StorageContext } from '@/lib/uploads/shared/types' const logger = createLogger('ProfilePictureUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB -const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'] +const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp'] interface UseProfilePictureUploadProps { onUpload?: (url: string | null) => void onError?: (error: string) => void currentImage?: string | null + context?: StorageContext + workspaceId?: string } /** @@ -19,13 +22,24 @@ export function useProfilePictureUpload({ onUpload, onError, currentImage, + context = 'profile-pictures', + workspaceId, }: UseProfilePictureUploadProps = {}) { const previewRef = useRef(null) const fileInputRef = useRef(null) + const onUploadRef = useRef(onUpload) + const onErrorRef = useRef(onError) + const currentImageRef = useRef(currentImage) const [previewUrl, setPreviewUrl] = useState(currentImage || null) const [fileName, setFileName] = useState(null) const [isUploading, setIsUploading] = useState(false) + useEffect(() => { + onUploadRef.current = onUpload + onErrorRef.current = onError + currentImageRef.current = currentImage + }, [onUpload, onError, currentImage]) + useEffect(() => { if (previewRef.current && previewRef.current !== currentImage) { URL.revokeObjectURL(previewRef.current) @@ -39,7 +53,7 @@ export function useProfilePictureUpload({ 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, JPEG, or SVG.` + return `File "${file.name}" is not a supported image format. Please use PNG, JPEG, SVG, or WebP.` } return null }, []) @@ -48,36 +62,42 @@ export function useProfilePictureUpload({ fileInputRef.current?.click() }, []) - const uploadFileToServer = useCallback(async (file: File): Promise => { - try { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'profile-pictures') - - 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 uploadFileToServer = useCallback( + async (file: File): Promise => { + try { + const formData = new FormData() + formData.append('file', file) + formData.append('context', context) + if (workspaceId) { + formData.append('workspaceId', workspaceId) + } + + 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.fileInfo?.path || data.path || data.url + 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 data = await response.json() - const publicUrl = data.fileInfo?.path || data.path || data.url - 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') - } - }, []) + }, + [context, workspaceId] + ) const processFile = useCallback( async (file: File) => { const validationError = validateFile(file) if (validationError) { - onError?.(validationError) + onErrorRef.current?.(validationError) return } @@ -94,19 +114,19 @@ export function useProfilePictureUpload({ URL.revokeObjectURL(newPreviewUrl) previewRef.current = null setPreviewUrl(serverUrl) - onUpload?.(serverUrl) + onUploadRef.current?.(serverUrl) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to upload profile picture' - onError?.(errorMessage) + onErrorRef.current?.(errorMessage) URL.revokeObjectURL(newPreviewUrl) previewRef.current = null - setPreviewUrl(currentImage || null) + setPreviewUrl(currentImageRef.current || null) } finally { setIsUploading(false) } }, - [onUpload, onError, uploadFileToServer, validateFile, currentImage] + [uploadFileToServer, validateFile] ) const handleFileChange = useCallback( @@ -136,8 +156,8 @@ export function useProfilePictureUpload({ if (fileInputRef.current) { fileInputRef.current.value = '' } - onUpload?.(null) - }, [onUpload]) + onUploadRef.current?.(null) + }, []) useEffect(() => { return () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 31a9eb854a9..9d1c2cfde7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -27,6 +27,7 @@ import { Trash, Unlock, Upload, + X, } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { WORKFLOW_COLORS } from '@/lib/workflows/colors' @@ -267,6 +268,12 @@ interface ContextMenuProps { disableLock?: boolean isLocked?: boolean showDelete?: boolean + onUploadLogo?: () => void + onRemoveLogo?: () => void + showUploadLogo?: boolean + showRemoveLogo?: boolean + disableUploadLogo?: boolean + disableRemoveLogo?: boolean } /** @@ -315,6 +322,12 @@ export function ContextMenu({ disableLock = false, isLocked = false, showDelete = true, + onUploadLogo, + onRemoveLogo, + showUploadLogo = false, + showRemoveLogo = false, + disableUploadLogo = false, + disableRemoveLogo = false, }: ContextMenuProps) { const [hexInput, setHexInput] = useState(currentColor || '#ffffff') @@ -368,7 +381,9 @@ export function ContextMenu({ (showCreate && onCreate) || (showCreateFolder && onCreateFolder) || (showColorChange && onColorChange) || - (showLock && onToggleLock) + (showLock && onToggleLock) || + (showUploadLogo && onUploadLogo) || + (showRemoveLogo && onRemoveLogo) const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport) return ( @@ -484,6 +499,31 @@ export function ContextMenu({ /> )} + {showUploadLogo && onUploadLogo && ( + { + onUploadLogo() + onClose() + }} + > + + Upload logo + + )} + {showRemoveLogo && onRemoveLogo && ( + { + onRemoveLogo() + onClose() + }} + > + + Remove logo + + )} + {showLock && onToggleLock && ( Promise + /** Callback to upload a workspace logo */ + onUploadLogo?: (workspaceId: string) => void + /** Callback to remove the workspace logo */ + onRemoveLogo?: (workspaceId: string) => Promise /** Callback to leave the workspace */ onLeaveWorkspace?: (workspaceId: string) => Promise /** Whether workspace leave is in progress */ @@ -100,6 +104,8 @@ export function WorkspaceHeader({ onImportWorkspace, isImportingWorkspace, onColorChange, + onUploadLogo, + onRemoveLogo, onLeaveWorkspace, isLeavingWorkspace, sessionUserId, @@ -286,6 +292,16 @@ export function WorkspaceHeader({ await onColorChange(capturedWorkspaceRef.current.id, color) } + const handleUploadLogoAction = () => { + if (!capturedWorkspaceRef.current || !onUploadLogo) return + onUploadLogo(capturedWorkspaceRef.current.id) + } + + const handleRemoveLogoAction = async () => { + if (!capturedWorkspaceRef.current || !onRemoveLogo) return + await onRemoveLogo(capturedWorkspaceRef.current.id) + } + /** * Handle leave workspace after confirmation */ @@ -348,12 +364,22 @@ export function WorkspaceHeader({ }} > {activeWorkspaceFull ? ( -
- {workspaceInitial} -
+ activeWorkspaceFull.logoUrl ? ( + {activeWorkspaceFull.name + ) : ( +
+ {workspaceInitial} +
+ ) ) : ( )} @@ -394,14 +420,22 @@ export function WorkspaceHeader({ <>
{activeWorkspaceFull ? ( -
- {workspaceInitial} -
+ activeWorkspaceFull.logoUrl ? ( + {activeWorkspaceFull.name + ) : ( +
+ {workspaceInitial} +
+ ) ) : ( )} @@ -578,12 +612,20 @@ export function WorkspaceHeader({ disabled > {activeWorkspaceFull ? ( -
- {workspaceInitial} -
+ activeWorkspaceFull.logoUrl ? ( + {activeWorkspaceFull.name + ) : ( +
+ {workspaceInitial} +
+ ) ) : ( )} @@ -619,17 +661,23 @@ export function WorkspaceHeader({ onDelete={handleDeleteAction} onLeave={handleLeaveAction} onColorChange={onColorChange ? handleColorChangeAction : undefined} + onUploadLogo={onUploadLogo ? handleUploadLogoAction : undefined} + onRemoveLogo={onRemoveLogo ? handleRemoveLogoAction : undefined} currentColor={capturedWorkspace?.color} showRename={true} showDuplicate={true} showExport={true} showColorChange={!!onColorChange} + showUploadLogo={!!onUploadLogo} + showRemoveLogo={!!onRemoveLogo && !!capturedWorkspace?.logoUrl} showLeave={!isOwner && !!onLeaveWorkspace} disableRename={!contextCanAdmin} disableDuplicate={!contextCanEdit} disableExport={!contextCanAdmin} disableDelete={!contextCanAdmin || workspaces.length <= 1} disableColorChange={!contextCanAdmin} + disableUploadLogo={!contextCanAdmin} + disableRemoveLogo={!contextCanAdmin} /> ) })()} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts index 62066f471bf..fc467147942 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts @@ -17,4 +17,5 @@ export { useSidebarResize } from './use-sidebar-resize' export { useTaskSelection } from './use-task-selection' export { useWorkflowOperations } from './use-workflow-operations' export { useWorkflowSelection } from './use-workflow-selection' +export { useWorkspaceLogoUpload } from './use-workspace-logo-upload' export { useWorkspaceManagement } from './use-workspace-management' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts new file mode 100644 index 00000000000..86d99fd1b4d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' + +const logger = createLogger('WorkspaceLogoUpload') +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp'] + +interface UseWorkspaceLogoUploadProps { + workspaceId?: string + currentLogoUrl?: string | null + onUpload?: (url: string | null) => void + onError?: (error: string) => void +} + +/** + * Hook for handling workspace logo upload functionality. + * Manages file validation, preview generation, and server upload. + */ +export function useWorkspaceLogoUpload({ + workspaceId, + currentLogoUrl, + onUpload, + onError, +}: UseWorkspaceLogoUploadProps = {}) { + const previewRef = useRef(null) + const fileInputRef = useRef(null) + const onUploadRef = useRef(onUpload) + const onErrorRef = useRef(onError) + const currentLogoUrlRef = useRef(currentLogoUrl) + const workspaceIdRef = useRef(workspaceId) + const [previewUrl, setPreviewUrl] = useState(currentLogoUrl || null) + const [isUploading, setIsUploading] = useState(false) + + useEffect(() => { + onUploadRef.current = onUpload + onErrorRef.current = onError + currentLogoUrlRef.current = currentLogoUrl + }, [onUpload, onError, currentLogoUrl]) + + useEffect(() => { + workspaceIdRef.current = workspaceId + }, [workspaceId]) + + useEffect(() => { + if (previewRef.current && previewRef.current !== currentLogoUrl) { + URL.revokeObjectURL(previewRef.current) + previewRef.current = null + } + setPreviewUrl(currentLogoUrl || null) + }, [currentLogoUrl]) + + 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, JPEG, SVG, or WebP.` + } + return null + }, []) + + const uploadFileToServer = useCallback(async (file: File): Promise => { + const formData = new FormData() + formData.append('file', file) + formData.append('context', 'workspace-logos') + if (workspaceIdRef.current) { + formData.append('workspaceId', workspaceIdRef.current) + } + + 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.fileInfo?.path || data.path || data.url + logger.info(`Workspace logo uploaded successfully: ${publicUrl}`) + return publicUrl + }, []) + + const processFile = useCallback( + async (file: File) => { + const validationError = validateFile(file) + if (validationError) { + onErrorRef.current?.(validationError) + return + } + + 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) + onUploadRef.current?.(serverUrl) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to upload workspace logo' + onErrorRef.current?.(errorMessage) + URL.revokeObjectURL(newPreviewUrl) + previewRef.current = null + setPreviewUrl(currentLogoUrlRef.current || null) + } finally { + setIsUploading(false) + } + }, + [uploadFileToServer, validateFile] + ) + + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) processFile(file) + if (event.target) event.target.value = '' + }, + [processFile] + ) + + const setTargetWorkspaceId = useCallback((id: string) => { + workspaceIdRef.current = id + }, []) + + const handleRemove = useCallback(() => { + if (previewRef.current) { + URL.revokeObjectURL(previewRef.current) + previewRef.current = null + } + setPreviewUrl(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + onUploadRef.current?.(null) + }, []) + + useEffect(() => { + return () => { + if (previewRef.current) { + URL.revokeObjectURL(previewRef.current) + } + } + }, []) + + return { + previewUrl, + fileInputRef, + handleFileChange, + handleRemove, + setTargetWorkspaceId, + isUploading, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts index 7a28da07ab4..c1de8ef85f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts @@ -127,7 +127,10 @@ export function useWorkspaceManagement({ }, [workspaces, isWorkspacesLoading, isWorkspacesFetching]) const updateWorkspace = useCallback( - async (workspaceId: string, updates: { name?: string; color?: string }): Promise => { + async ( + workspaceId: string, + updates: { name?: string; color?: string; logoUrl?: string | null } + ): Promise => { try { await updateWorkspaceMutation.mutateAsync({ workspaceId, ...updates }) logger.info('Successfully updated workspace:', updates) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 258d09f4c38..b69d79df761 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -71,6 +71,7 @@ import { useSidebarResize, useTaskSelection, useWorkflowOperations, + useWorkspaceLogoUpload, useWorkspaceManagement, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { @@ -96,6 +97,7 @@ import { useTasks, } from '@/hooks/queries/tasks' import { useUpdateWorkflow } from '@/hooks/queries/workflows' +import type { Workspace } from '@/hooks/queries/workspace' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -460,6 +462,24 @@ export const Sidebar = memo(function Sidebar() { sessionUserId: sessionData?.user?.id, }) + const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) + const logoTargetWorkspaceIdRef = useRef(workspaceId) + + const { + fileInputRef: logoFileInputRef, + handleFileChange: handleLogoFileChange, + setTargetWorkspaceId: setLogoTargetWorkspaceId, + } = useWorkspaceLogoUpload({ + workspaceId, + currentLogoUrl: activeWorkspaceFull?.logoUrl, + onUpload: (url) => { + updateWorkspace(logoTargetWorkspaceIdRef.current, { logoUrl: url }) + }, + onError: (error) => { + logger.error('Workspace logo upload error:', error) + }, + }) + const { handleMouseDown, isResizing } = useSidebarResize() const { @@ -980,7 +1000,7 @@ export const Sidebar = memo(function Sidebar() { }, []) const handleWorkspaceSwitch = useCallback( - async (workspace: { id: string; name: string; ownerId: string; role?: string }) => { + async (workspace: Workspace) => { if (workspace.id === workspaceId) { setIsWorkspaceMenuOpen(false) return @@ -1017,6 +1037,22 @@ export const Sidebar = memo(function Sidebar() { [updateWorkspace] ) + const handleUploadLogo = useCallback( + (workspaceIdToUpdate: string) => { + logoTargetWorkspaceIdRef.current = workspaceIdToUpdate + setLogoTargetWorkspaceId(workspaceIdToUpdate) + logoFileInputRef.current?.click() + }, + [logoFileInputRef, setLogoTargetWorkspaceId] + ) + + const handleRemoveLogo = useCallback( + async (workspaceIdToUpdate: string) => { + await updateWorkspace(workspaceIdToUpdate, { logoUrl: null }) + }, + [updateWorkspace] + ) + const handleDeleteWorkspace = useCallback( async (workspaceIdToDelete: string) => { const workspaceToDelete = workspaces.find((w) => w.id === workspaceIdToDelete) @@ -1232,6 +1268,13 @@ export const Sidebar = memo(function Sidebar() { return ( <> +