Skip to content

Commit 7491d70

Browse files
waleedlatif1claude
andauthored
feat(workspaces): add workspace logo upload (#4136)
* feat(workspaces): add workspace logo upload * feat(workspaces): add workspace logo upload * fix(workspaces): validate logoUrl accepts only paths or HTTPS URLs * fix(workspaces): add admin authorization, audit log, and posthog event for workspace logo uploads * lint * fix: add WebP support and use refs pattern in useProfilePictureUpload - Add image/webp to ACCEPTED_IMAGE_TYPES in useProfilePictureUpload - Add image/webp to file input accept attributes in whitelabeling settings - Refactor useProfilePictureUpload to use refs for onUpload, onError, and currentImage callbacks, matching the established codebase pattern Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: restore cloudwatch/cloudformation files from staging These files were accidentally regressed during rebase conflict resolution, reverting changes from #4027. Restoring to staging versions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add workspace_logo_uploaded to PostHogEventMap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: separate workspaceId ref sync to prevent overwrite on re-render Split the ref sync useEffect so workspaceIdRef only updates when the workspaceId prop changes, not when onUpload/onError callbacks get new references. Prevents setTargetWorkspaceId from being overwritten by a re-render before the file upload completes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use Pick type for workspace dropdown in knowledge header The shared Workspace type requires ownerId and other fields that aren't available from the workspaces API response mapping. Use a Pick type to accurately represent the subset of fields actually constructed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: replace raw fetch with useWorkspacesQuery in knowledge header Remove useState + useEffect + fetch anti-pattern for loading workspaces. Use useWorkspacesQuery from React Query with inline filter for write/admin permissions. Eliminates ~30 lines of manual state management, any casts, and the Pick type workaround. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4375f99 commit 7491d70

File tree

26 files changed

+15178
-107
lines changed

26 files changed

+15178
-107
lines changed

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: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { sanitizeFileName } from '@/executor/constants'
44
import '@/lib/uploads/core/setup.server'
5+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
56
import { getSession } from '@/lib/auth'
7+
import { captureServerEvent } from '@/lib/posthog/server'
68
import type { StorageContext } from '@/lib/uploads/config'
79
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
810
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
@@ -64,7 +66,7 @@ export async function POST(request: NextRequest) {
6466
// Context must be explicitly provided
6567
if (!contextParam) {
6668
throw new InvalidRequestError(
67-
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, or profile-pictures)'
69+
'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)'
6870
)
6971
}
7072

@@ -282,13 +284,35 @@ export async function POST(request: NextRequest) {
282284
continue
283285
}
284286

285-
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
287+
if (
288+
context === 'copilot' ||
289+
context === 'chat' ||
290+
context === 'profile-pictures' ||
291+
context === 'workspace-logos'
292+
) {
286293
if (context !== 'copilot' && !isImageFileType(file.type)) {
287294
throw new InvalidRequestError(
288295
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
289296
)
290297
}
291298

299+
if (context === 'workspace-logos') {
300+
if (!workspaceId) {
301+
throw new InvalidRequestError('workspace-logos context requires workspaceId parameter')
302+
}
303+
const permission = await getUserEntityPermissions(
304+
session.user.id,
305+
'workspace',
306+
workspaceId
307+
)
308+
if (permission !== 'admin') {
309+
return NextResponse.json(
310+
{ error: 'Admin access required for workspace logo uploads' },
311+
{ status: 403 }
312+
)
313+
}
314+
}
315+
292316
if (context === 'chat' && workspaceId) {
293317
const permission = await getUserEntityPermissions(
294318
session.user.id,
@@ -346,13 +370,40 @@ export async function POST(request: NextRequest) {
346370
}
347371

348372
logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`)
373+
374+
if (context === 'workspace-logos' && workspaceId) {
375+
recordAudit({
376+
workspaceId,
377+
actorId: session.user.id,
378+
actorName: session.user.name,
379+
actorEmail: session.user.email,
380+
action: AuditAction.FILE_UPLOADED,
381+
resourceType: AuditResourceType.WORKSPACE,
382+
resourceId: workspaceId,
383+
description: `Uploaded workspace logo "${originalName}"`,
384+
metadata: {
385+
fileName: originalName,
386+
fileKey: fileInfo.key,
387+
fileSize: buffer.length,
388+
fileType: file.type,
389+
},
390+
request,
391+
})
392+
393+
captureServerEvent(session.user.id, 'workspace_logo_uploaded', {
394+
workspace_id: workspaceId,
395+
file_name: originalName,
396+
file_size: buffer.length,
397+
})
398+
}
399+
349400
uploadResults.push(uploadResult)
350401
continue
351402
}
352403

353404
// Unknown context
354405
throw new InvalidRequestError(
355-
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, or profile-pictures`
406+
`Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos`
356407
)
357408
}
358409

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ const patchWorkspaceSchema = z.object({
2020
.string()
2121
.regex(/^#[0-9a-fA-F]{6}$/)
2222
.optional(),
23+
logoUrl: z
24+
.string()
25+
.refine((val) => val.startsWith('/') || val.startsWith('https://'), {
26+
message: 'Logo URL must be an absolute path or HTTPS URL',
27+
})
28+
.nullable()
29+
.optional(),
2330
billedAccountUserId: z.string().optional(),
2431
allowPersonalApiKeys: z.boolean().optional(),
2532
})
@@ -119,11 +126,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
119126

120127
try {
121128
const body = patchWorkspaceSchema.parse(await request.json())
122-
const { name, color, billedAccountUserId, allowPersonalApiKeys } = body
129+
const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
123130

124131
if (
125132
name === undefined &&
126133
color === undefined &&
134+
logoUrl === undefined &&
127135
billedAccountUserId === undefined &&
128136
allowPersonalApiKeys === undefined
129137
) {
@@ -150,6 +158,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
150158
updateData.color = color
151159
}
152160

161+
if (logoUrl !== undefined) {
162+
updateData.logoUrl = logoUrl
163+
}
164+
153165
if (allowPersonalApiKeys !== undefined) {
154166
updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys)
155167
}
@@ -216,6 +228,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
216228
changes: {
217229
...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }),
218230
...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }),
231+
...(logoUrl !== undefined && {
232+
logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl },
233+
}),
219234
...(allowPersonalApiKeys !== undefined && {
220235
allowPersonalApiKeys: {
221236
from: existingWorkspace.allowPersonalApiKeys,

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

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useState } from 'react'
3+
import { useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { AlertTriangle, LibraryBig, MoreHorizontal } from 'lucide-react'
66
import Link from 'next/link'
@@ -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 { useWorkspacesQuery } from '@/hooks/queries/workspace'
2021

2122
const logger = createLogger('KnowledgeHeader')
2223

@@ -48,52 +49,18 @@ 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)
60-
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
61-
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
62-
63-
const updateKnowledgeBase = useUpdateKnowledgeBase()
6455

65-
useEffect(() => {
66-
if (!options?.knowledgeBaseId) return
67-
68-
const fetchWorkspaces = async () => {
69-
try {
70-
setIsLoadingWorkspaces(true)
71-
72-
const response = await fetch('/api/workspaces')
73-
if (!response.ok) {
74-
throw new Error('Failed to fetch workspaces')
75-
}
76-
77-
const data = await response.json()
78-
79-
const availableWorkspaces = data.workspaces
80-
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
81-
.map((ws: any) => ({
82-
id: ws.id,
83-
name: ws.name,
84-
permissions: ws.permissions,
85-
}))
86-
87-
setWorkspaces(availableWorkspaces)
88-
} catch (err) {
89-
logger.error('Error fetching workspaces:', err)
90-
} finally {
91-
setIsLoadingWorkspaces(false)
92-
}
93-
}
56+
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
57+
!!options?.knowledgeBaseId
58+
)
59+
const workspaces = allWorkspaces.filter(
60+
(ws) => ws.permissions === 'write' || ws.permissions === 'admin'
61+
)
9462

95-
fetchWorkspaces()
96-
}, [options?.knowledgeBaseId])
63+
const updateKnowledgeBase = useUpdateKnowledgeBase()
9764

9865
const handleWorkspaceChange = async (workspaceId: string | null) => {
9966
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return

0 commit comments

Comments
 (0)