Skip to content

Commit 30d6b70

Browse files
committed
feat(workspaces): add workspace logo upload
1 parent fb4fb9e commit 30d6b70

25 files changed

Lines changed: 15760 additions & 41 deletions

File tree

apps/sim/app/api/files/authorization.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,12 @@ export async function verifyFileAccess(
114114
// Infer context from key if not explicitly provided
115115
const inferredContext = context || inferContextFromKey(cloudKey)
116116

117-
// 0. Public contexts: profile pictures and OG images are publicly accessible
118-
if (inferredContext === 'profile-pictures' || inferredContext === 'og-images') {
117+
// 0. Public contexts: profile pictures, OG images, and workspace logos are publicly accessible
118+
if (
119+
inferredContext === 'profile-pictures' ||
120+
inferredContext === 'og-images' ||
121+
inferredContext === 'workspace-logos'
122+
) {
119123
logger.info('Public file access allowed', { cloudKey, context: inferredContext })
120124
return true
121125
}

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ export async function GET(
9595
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
9696

9797
const isPublicByKeyPrefix =
98-
cloudKey.startsWith('profile-pictures/') || cloudKey.startsWith('og-images/')
98+
cloudKey.startsWith('profile-pictures/') ||
99+
cloudKey.startsWith('og-images/') ||
100+
cloudKey.startsWith('workspace-logos/')
99101

100102
if (isPublicByKeyPrefix) {
101103
const context = inferContextFromKey(cloudKey)

apps/sim/app/api/files/upload/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export async function POST(request: NextRequest) {
6464
// Context must be explicitly provided
6565
if (!contextParam) {
6666
throw new InvalidRequestError(
67-
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, or profile-pictures)'
67+
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)'
6868
)
6969
}
7070

@@ -282,7 +282,12 @@ export async function POST(request: NextRequest) {
282282
continue
283283
}
284284

285-
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
285+
if (
286+
context === 'copilot' ||
287+
context === 'chat' ||
288+
context === 'profile-pictures' ||
289+
context === 'workspace-logos'
290+
) {
286291
if (context !== 'copilot' && !isImageFileType(file.type)) {
287292
throw new InvalidRequestError(
288293
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
@@ -352,7 +357,7 @@ export async function POST(request: NextRequest) {
352357

353358
// Unknown context
354359
throw new InvalidRequestError(
355-
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, or profile-pictures`
360+
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos`
356361
)
357362
}
358363

apps/sim/app/api/workspaces/[id]/route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const patchWorkspaceSchema = z.object({
2020
.string()
2121
.regex(/^#[0-9a-fA-F]{6}$/)
2222
.optional(),
23+
logoUrl: z.string().min(1).nullable().optional(),
2324
billedAccountUserId: z.string().optional(),
2425
allowPersonalApiKeys: z.boolean().optional(),
2526
})
@@ -119,11 +120,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
119120

120121
try {
121122
const body = patchWorkspaceSchema.parse(await request.json())
122-
const { name, color, billedAccountUserId, allowPersonalApiKeys } = body
123+
const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
123124

124125
if (
125126
name === undefined &&
126127
color === undefined &&
128+
logoUrl === undefined &&
127129
billedAccountUserId === undefined &&
128130
allowPersonalApiKeys === undefined
129131
) {
@@ -150,6 +152,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
150152
updateData.color = color
151153
}
152154

155+
if (logoUrl !== undefined) {
156+
updateData.logoUrl = logoUrl
157+
}
158+
153159
if (allowPersonalApiKeys !== undefined) {
154160
updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys)
155161
}
@@ -216,6 +222,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
216222
changes: {
217223
...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }),
218224
...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }),
225+
...(logoUrl !== undefined && {
226+
logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl },
227+
}),
219228
...(allowPersonalApiKeys !== undefined && {
220229
allowPersonalApiKeys: {
221230
from: existingWorkspace.allowPersonalApiKeys,

apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ChevronDown } from '@/components/emcn/icons'
1717
import { Trash } from '@/components/emcn/icons/trash'
1818
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
1919
import { useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
20+
import type { Workspace } from '@/hooks/queries/workspace'
2021

2122
const logger = createLogger('KnowledgeHeader')
2223

@@ -48,12 +49,6 @@ interface KnowledgeHeaderProps {
4849
options?: KnowledgeHeaderOptions
4950
}
5051

51-
interface Workspace {
52-
id: string
53-
name: string
54-
permissions: 'admin' | 'write' | 'read'
55-
}
56-
5752
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
5853
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false)
5954
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)

apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { createLogger } from '@sim/logger'
3+
import type { StorageContext } from '@/lib/uploads/shared/types'
34

45
const logger = createLogger('ProfilePictureUpload')
56
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
@@ -9,6 +10,7 @@ interface UseProfilePictureUploadProps {
910
onUpload?: (url: string | null) => void
1011
onError?: (error: string) => void
1112
currentImage?: string | null
13+
context?: StorageContext
1214
}
1315

1416
/**
@@ -19,6 +21,7 @@ export function useProfilePictureUpload({
1921
onUpload,
2022
onError,
2123
currentImage,
24+
context = 'profile-pictures',
2225
}: UseProfilePictureUploadProps = {}) {
2326
const previewRef = useRef<string | null>(null)
2427
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -52,7 +55,7 @@ export function useProfilePictureUpload({
5255
try {
5356
const formData = new FormData()
5457
formData.append('file', file)
55-
formData.append('context', 'profile-pictures')
58+
formData.append('context', context)
5659

5760
const response = await fetch('/api/files/upload', {
5861
method: 'POST',
@@ -71,7 +74,7 @@ export function useProfilePictureUpload({
7174
} catch (error) {
7275
throw new Error(error instanceof Error ? error.message : 'Failed to upload profile picture')
7376
}
74-
}, [])
77+
}, [context])
7578

7679
const processFile = useCallback(
7780
async (file: File) => {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
Trash,
2828
Unlock,
2929
Upload,
30+
X,
3031
} from '@/components/emcn/icons'
3132
import { cn } from '@/lib/core/utils/cn'
3233
import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
@@ -267,6 +268,12 @@ interface ContextMenuProps {
267268
disableLock?: boolean
268269
isLocked?: boolean
269270
showDelete?: boolean
271+
onUploadLogo?: () => void
272+
onRemoveLogo?: () => void
273+
showUploadLogo?: boolean
274+
showRemoveLogo?: boolean
275+
disableUploadLogo?: boolean
276+
disableRemoveLogo?: boolean
270277
}
271278

272279
/**
@@ -315,6 +322,12 @@ export function ContextMenu({
315322
disableLock = false,
316323
isLocked = false,
317324
showDelete = true,
325+
onUploadLogo,
326+
onRemoveLogo,
327+
showUploadLogo = false,
328+
showRemoveLogo = false,
329+
disableUploadLogo = false,
330+
disableRemoveLogo = false,
318331
}: ContextMenuProps) {
319332
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
320333

@@ -368,7 +381,9 @@ export function ContextMenu({
368381
(showCreate && onCreate) ||
369382
(showCreateFolder && onCreateFolder) ||
370383
(showColorChange && onColorChange) ||
371-
(showLock && onToggleLock)
384+
(showLock && onToggleLock) ||
385+
(showUploadLogo && onUploadLogo) ||
386+
(showRemoveLogo && onRemoveLogo)
372387
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
373388

374389
return (
@@ -484,6 +499,31 @@ export function ContextMenu({
484499
/>
485500
)}
486501

502+
{showUploadLogo && onUploadLogo && (
503+
<DropdownMenuItem
504+
disabled={disableUploadLogo}
505+
onSelect={() => {
506+
onUploadLogo()
507+
onClose()
508+
}}
509+
>
510+
<Upload />
511+
Upload logo
512+
</DropdownMenuItem>
513+
)}
514+
{showRemoveLogo && onRemoveLogo && (
515+
<DropdownMenuItem
516+
disabled={disableRemoveLogo}
517+
onSelect={() => {
518+
onRemoveLogo()
519+
onClose()
520+
}}
521+
>
522+
<X />
523+
Remove logo
524+
</DropdownMenuItem>
525+
)}
526+
487527
{showLock && onToggleLock && (
488528
<DropdownMenuItem
489529
disabled={disableLock}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ interface WorkspaceHeaderProps {
6969
isImportingWorkspace: boolean
7070
/** Callback to change the workspace color */
7171
onColorChange?: (workspaceId: string, color: string) => Promise<void>
72+
/** Callback to upload a workspace logo */
73+
onUploadLogo?: (workspaceId: string) => void
74+
/** Callback to remove the workspace logo */
75+
onRemoveLogo?: (workspaceId: string) => Promise<void>
7276
/** Callback to leave the workspace */
7377
onLeaveWorkspace?: (workspaceId: string) => Promise<void>
7478
/** Whether workspace leave is in progress */
@@ -100,6 +104,8 @@ export function WorkspaceHeader({
100104
onImportWorkspace,
101105
isImportingWorkspace,
102106
onColorChange,
107+
onUploadLogo,
108+
onRemoveLogo,
103109
onLeaveWorkspace,
104110
isLeavingWorkspace,
105111
sessionUserId,
@@ -286,6 +292,16 @@ export function WorkspaceHeader({
286292
await onColorChange(capturedWorkspaceRef.current.id, color)
287293
}
288294

295+
const handleUploadLogoAction = () => {
296+
if (!capturedWorkspaceRef.current || !onUploadLogo) return
297+
onUploadLogo(capturedWorkspaceRef.current.id)
298+
}
299+
300+
const handleRemoveLogoAction = async () => {
301+
if (!capturedWorkspaceRef.current || !onRemoveLogo) return
302+
await onRemoveLogo(capturedWorkspaceRef.current.id)
303+
}
304+
289305
/**
290306
* Handle leave workspace after confirmation
291307
*/
@@ -348,12 +364,20 @@ export function WorkspaceHeader({
348364
}}
349365
>
350366
{activeWorkspaceFull ? (
351-
<div
352-
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
353-
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
354-
>
355-
{workspaceInitial}
356-
</div>
367+
activeWorkspaceFull.logoUrl ? (
368+
<img
369+
src={activeWorkspaceFull.logoUrl}
370+
alt={activeWorkspaceFull.name || 'Workspace logo'}
371+
className='h-[20px] w-[20px] flex-shrink-0 rounded-sm object-cover'
372+
/>
373+
) : (
374+
<div
375+
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
376+
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
377+
>
378+
{workspaceInitial}
379+
</div>
380+
)
357381
) : (
358382
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
359383
)}
@@ -394,14 +418,22 @@ export function WorkspaceHeader({
394418
<>
395419
<div className='flex items-center gap-2 px-0.5 py-0.5'>
396420
{activeWorkspaceFull ? (
397-
<div
398-
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
399-
style={{
400-
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
401-
}}
402-
>
403-
{workspaceInitial}
404-
</div>
421+
activeWorkspaceFull.logoUrl ? (
422+
<img
423+
src={activeWorkspaceFull.logoUrl}
424+
alt={activeWorkspaceFull.name || 'Workspace logo'}
425+
className='h-[32px] w-[32px] flex-shrink-0 rounded-md object-cover'
426+
/>
427+
) : (
428+
<div
429+
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
430+
style={{
431+
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
432+
}}
433+
>
434+
{workspaceInitial}
435+
</div>
436+
)
405437
) : (
406438
<Skeleton className='h-[32px] w-[32px] flex-shrink-0 rounded-md' />
407439
)}
@@ -578,12 +610,20 @@ export function WorkspaceHeader({
578610
disabled
579611
>
580612
{activeWorkspaceFull ? (
581-
<div
582-
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
583-
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
584-
>
585-
{workspaceInitial}
586-
</div>
613+
activeWorkspaceFull.logoUrl ? (
614+
<img
615+
src={activeWorkspaceFull.logoUrl}
616+
alt={activeWorkspaceFull.name || 'Workspace logo'}
617+
className='h-[20px] w-[20px] flex-shrink-0 rounded-sm object-cover'
618+
/>
619+
) : (
620+
<div
621+
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
622+
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
623+
>
624+
{workspaceInitial}
625+
</div>
626+
)
587627
) : (
588628
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
589629
)}
@@ -619,17 +659,23 @@ export function WorkspaceHeader({
619659
onDelete={handleDeleteAction}
620660
onLeave={handleLeaveAction}
621661
onColorChange={onColorChange ? handleColorChangeAction : undefined}
662+
onUploadLogo={onUploadLogo ? handleUploadLogoAction : undefined}
663+
onRemoveLogo={onRemoveLogo ? handleRemoveLogoAction : undefined}
622664
currentColor={capturedWorkspace?.color}
623665
showRename={true}
624666
showDuplicate={true}
625667
showExport={true}
626668
showColorChange={!!onColorChange}
669+
showUploadLogo={!!onUploadLogo}
670+
showRemoveLogo={!!onRemoveLogo && !!capturedWorkspace?.logoUrl}
627671
showLeave={!isOwner && !!onLeaveWorkspace}
628672
disableRename={!contextCanAdmin}
629673
disableDuplicate={!contextCanEdit}
630674
disableExport={!contextCanAdmin}
631675
disableDelete={!contextCanAdmin || workspaces.length <= 1}
632676
disableColorChange={!contextCanAdmin}
677+
disableUploadLogo={!contextCanAdmin}
678+
disableRemoveLogo={!contextCanAdmin}
633679
/>
634680
)
635681
})()}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ export { useSidebarResize } from './use-sidebar-resize'
1717
export { useTaskSelection } from './use-task-selection'
1818
export { useWorkflowOperations } from './use-workflow-operations'
1919
export { useWorkflowSelection } from './use-workflow-selection'
20-
export { useWorkspaceManagement } from './use-workspace-management'
20+
export { useWorkspaceLogoUpload } from './use-workspace-logo-upload'
21+
export { useWorkspaceManagement, type Workspace } from './use-workspace-management'

0 commit comments

Comments
 (0)