mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
fix(whitelabeling): eliminate logo flash by fetching org settings server-side (#4057)
* fix(whitelabeling): eliminate logo flash by fetching org settings server-side * improvement(whitelabeling): add SVG support for logo and wordmark uploads * skelly in workspace header * remove dead code * fix(whitelabeling): hydration error, SVG support, skeleton shimmer, dead code removal * fix(whitelabeling): blob preview dep cycle and missing color fallback * fix(whitelabeling): use brand-accent as color fallback when workspace color is undefined * chore(whitelabeling): inline hasOrgBrand
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
@@ -8,22 +8,16 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
|
||||
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import {
|
||||
BRAND_COOKIE_NAME,
|
||||
type BrandCache,
|
||||
BrandingProvider,
|
||||
} from '@/ee/whitelabeling/components/branding-provider'
|
||||
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
|
||||
import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding'
|
||||
|
||||
export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies()
|
||||
let initialCache: BrandCache | null = null
|
||||
try {
|
||||
const raw = cookieStore.get(BRAND_COOKIE_NAME)?.value
|
||||
if (raw) initialCache = JSON.parse(decodeURIComponent(raw))
|
||||
} catch {}
|
||||
const session = await getSession()
|
||||
const orgId = session?.session?.activeOrganizationId
|
||||
const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null
|
||||
|
||||
return (
|
||||
<BrandingProvider initialCache={initialCache}>
|
||||
<BrandingProvider initialOrgSettings={initialOrgSettings}>
|
||||
<ToastProvider>
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
|
||||
@@ -16,6 +16,7 @@ const SECTION_TITLES: Record<string, string> = {
|
||||
subscription: 'Subscription',
|
||||
team: 'Team',
|
||||
sso: 'Single Sign-On',
|
||||
whitelabeling: 'Whitelabeling',
|
||||
copilot: 'Copilot Keys',
|
||||
mcp: 'MCP Tools',
|
||||
'custom-tools': 'Custom Tools',
|
||||
|
||||
@@ -161,7 +161,7 @@ const WhitelabelingSettings = dynamic(
|
||||
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
|
||||
(m) => m.WhitelabelingSettings
|
||||
),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
{ loading: () => <SettingsSectionSkeleton />, ssr: false }
|
||||
)
|
||||
|
||||
interface SettingsPageProps {
|
||||
|
||||
@@ -387,7 +387,7 @@ export function General() {
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/auto-connect-on-drop.mp4'
|
||||
alt='Auto-connect on drop example'
|
||||
loop={false}
|
||||
loop={true}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('ProfilePictureUpload')
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg']
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml']
|
||||
|
||||
interface UseProfilePictureUploadProps {
|
||||
onUpload?: (url: string | null) => void
|
||||
@@ -27,21 +27,19 @@ export function useProfilePictureUpload({
|
||||
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)
|
||||
if (previewRef.current && previewRef.current !== currentImage) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
previewRef.current = null
|
||||
}
|
||||
}, [currentImage, previewUrl])
|
||||
setPreviewUrl(currentImage || null)
|
||||
}, [currentImage])
|
||||
|
||||
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 `File "${file.name}" is not a supported image format. Please use PNG, JPEG, or SVG.`
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Plus,
|
||||
Skeleton,
|
||||
UserPlus,
|
||||
} from '@/components/emcn'
|
||||
import { getDisplayPlanName, isFree } from '@/lib/billing/plan-helpers'
|
||||
@@ -356,14 +357,16 @@ export function WorkspaceHeader({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
|
||||
style={{
|
||||
backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)',
|
||||
}}
|
||||
>
|
||||
{workspaceInitial}
|
||||
</div>
|
||||
{activeWorkspaceFull ? (
|
||||
<div
|
||||
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
|
||||
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
|
||||
>
|
||||
{workspaceInitial}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className='min-w-0 flex-1 truncate text-left font-base text-[var(--text-primary)] text-sm'>
|
||||
@@ -400,14 +403,18 @@ export function WorkspaceHeader({
|
||||
) : (
|
||||
<>
|
||||
<div className='flex items-center gap-2 px-0.5 py-0.5'>
|
||||
<div
|
||||
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
|
||||
style={{
|
||||
backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)',
|
||||
}}
|
||||
>
|
||||
{workspaceInitial}
|
||||
</div>
|
||||
{activeWorkspaceFull ? (
|
||||
<div
|
||||
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
|
||||
style={{
|
||||
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
|
||||
}}
|
||||
>
|
||||
{workspaceInitial}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className='h-[32px] w-[32px] flex-shrink-0 rounded-md' />
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
|
||||
{activeWorkspace?.name || 'Loading...'}
|
||||
@@ -580,12 +587,16 @@ export function WorkspaceHeader({
|
||||
title={activeWorkspace?.name || 'Loading...'}
|
||||
disabled
|
||||
>
|
||||
<div
|
||||
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
|
||||
style={{ backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)' }}
|
||||
>
|
||||
{workspaceInitial}
|
||||
</div>
|
||||
{activeWorkspaceFull ? (
|
||||
<div
|
||||
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
|
||||
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
|
||||
>
|
||||
{workspaceInitial}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className='min-w-0 flex-1 truncate text-left font-base text-[var(--text-primary)] text-sm'>
|
||||
|
||||
@@ -42,20 +42,20 @@ const Trigger = TooltipPrimitive.Trigger
|
||||
const Content = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
>(({ className, sideOffset = 6, children, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={8}
|
||||
avoidCollisions={true}
|
||||
avoidCollisions
|
||||
className={cn(
|
||||
'z-[var(--z-tooltip)] max-w-[260px] rounded-[4px] bg-[var(--tooltip-bg)] px-2 py-[3.5px] font-base text-white text-xs shadow-sm dark:text-black',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className='fill-[var(--tooltip-bg)]' />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
@@ -120,22 +120,35 @@ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov'] as const
|
||||
const Preview = ({ src, alt = '', width = 240, height, loop = true, className }: PreviewProps) => {
|
||||
const pathname = src.toLowerCase().split('?')[0].split('#')[0]
|
||||
const isVideo = VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext))
|
||||
const [isReady, setIsReady] = React.useState(!isVideo)
|
||||
|
||||
return (
|
||||
<div className={cn('-mx-2 -mb-[3.5px] mt-1 overflow-hidden rounded-b-[4px]', className)}>
|
||||
<div className={cn('-mx-[6px] -mb-[1.5px] mt-1.5 overflow-hidden rounded-[4px]', className)}>
|
||||
{isVideo ? (
|
||||
<video
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
className='block w-full'
|
||||
autoPlay
|
||||
loop={loop}
|
||||
muted
|
||||
playsInline
|
||||
preload='none'
|
||||
aria-label={alt}
|
||||
/>
|
||||
<div className='relative'>
|
||||
{!isReady && (
|
||||
<div
|
||||
className='animate-pulse bg-white/5'
|
||||
style={{ aspectRatio: height ? `${width}/${height}` : '16/9' }}
|
||||
/>
|
||||
)}
|
||||
<video
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn(
|
||||
'block w-full transition-opacity duration-200',
|
||||
isReady ? 'opacity-100' : 'absolute inset-0 opacity-0'
|
||||
)}
|
||||
autoPlay
|
||||
loop={loop}
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
aria-label={alt}
|
||||
onCanPlay={() => setIsReady(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -1,37 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { BrandConfig } from '@/lib/branding/types'
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling/branding'
|
||||
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
|
||||
import { generateOrgThemeCSS, mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
|
||||
export const BRAND_COOKIE_NAME = 'sim-wl'
|
||||
const BRAND_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
|
||||
|
||||
/**
|
||||
* Brand assets and theme CSS cached in a cookie between page loads.
|
||||
* Written client-side after org settings resolve; read server-side in the
|
||||
* workspace layout so the correct branding is baked into the initial HTML.
|
||||
*/
|
||||
export interface BrandCache {
|
||||
logoUrl?: string
|
||||
wordmarkUrl?: string
|
||||
/** Pre-generated `:root { ... }` CSS from the last resolved org settings. */
|
||||
themeCSS?: string
|
||||
}
|
||||
|
||||
function writeBrandCookie(cache: BrandCache | null): void {
|
||||
try {
|
||||
if (cache && Object.keys(cache).length > 0) {
|
||||
document.cookie = `${BRAND_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(cache))}; path=/; max-age=${BRAND_COOKIE_MAX_AGE}; SameSite=Lax`
|
||||
} else {
|
||||
document.cookie = `${BRAND_COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax`
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
interface BrandingContextValue {
|
||||
config: BrandConfig
|
||||
}
|
||||
@@ -43,69 +18,34 @@ const BrandingContext = createContext<BrandingContextValue>({
|
||||
interface BrandingProviderProps {
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Brand cache read server-side from the `sim-wl` cookie by the workspace
|
||||
* layout. When present, the server renders the correct org branding from the
|
||||
* first byte — no flash of any kind on page load or hard refresh.
|
||||
* Org whitelabel settings fetched server-side from the DB by the workspace layout.
|
||||
* Used as the source of truth until the React Query result becomes available,
|
||||
* ensuring the correct org logo appears in the initial server HTML — no flash.
|
||||
*/
|
||||
initialCache?: BrandCache | null
|
||||
initialOrgSettings?: OrganizationWhitelabelSettings | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides merged branding (instance env vars + org DB settings) to the workspace.
|
||||
* Injects a `<style>` tag with CSS variable overrides when org colors are configured.
|
||||
*
|
||||
* Flow:
|
||||
* - First visit: org logo loads after the API call resolves (one-time flash).
|
||||
* - All subsequent visits: the workspace layout reads the `sim-wl` cookie
|
||||
* server-side and passes it as `initialCache`. The server renders the correct
|
||||
* brand in the initial HTML — no flash of any kind.
|
||||
*/
|
||||
export function BrandingProvider({ children, initialCache }: BrandingProviderProps) {
|
||||
const [cache, setCache] = useState<BrandCache | null>(initialCache ?? null)
|
||||
|
||||
const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
|
||||
export function BrandingProvider({ children, initialOrgSettings }: BrandingProviderProps) {
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const orgId = orgsData?.activeOrganization?.id
|
||||
const { data: orgSettings, isLoading: settingsLoading } = useWhitelabelSettings(orgId)
|
||||
const { data: orgSettings } = useWhitelabelSettings(orgId)
|
||||
|
||||
useEffect(() => {
|
||||
if (orgsLoading) return
|
||||
const effectiveOrgSettings =
|
||||
orgSettings !== undefined ? orgSettings : (initialOrgSettings ?? null)
|
||||
|
||||
if (!orgId) {
|
||||
writeBrandCookie(null)
|
||||
setCache(null)
|
||||
return
|
||||
}
|
||||
const brandConfig = useMemo(
|
||||
() => mergeOrgBrandConfig(effectiveOrgSettings, getBrandConfig()),
|
||||
[effectiveOrgSettings]
|
||||
)
|
||||
|
||||
if (settingsLoading) return
|
||||
|
||||
const themeCSS = orgSettings ? generateOrgThemeCSS(orgSettings) : null
|
||||
const next: BrandCache = {}
|
||||
if (orgSettings?.logoUrl) next.logoUrl = orgSettings.logoUrl
|
||||
if (orgSettings?.wordmarkUrl) next.wordmarkUrl = orgSettings.wordmarkUrl
|
||||
if (themeCSS) next.themeCSS = themeCSS
|
||||
|
||||
const newCache = Object.keys(next).length > 0 ? next : null
|
||||
writeBrandCookie(newCache)
|
||||
setCache(newCache)
|
||||
}, [orgsLoading, orgId, settingsLoading, orgSettings])
|
||||
|
||||
const brandConfig = useMemo(() => {
|
||||
const base = mergeOrgBrandConfig(orgSettings ?? null, getBrandConfig())
|
||||
if (!orgSettings && cache) {
|
||||
return {
|
||||
...base,
|
||||
...(cache.logoUrl && { logoUrl: cache.logoUrl }),
|
||||
...(cache.wordmarkUrl && { wordmarkUrl: cache.wordmarkUrl }),
|
||||
}
|
||||
}
|
||||
return base
|
||||
}, [orgSettings, cache])
|
||||
|
||||
const themeCSS = useMemo(() => {
|
||||
if (orgSettings) return generateOrgThemeCSS(orgSettings)
|
||||
if (cache?.themeCSS) return cache.themeCSS
|
||||
return ''
|
||||
}, [orgSettings, cache])
|
||||
const themeCSS = useMemo(
|
||||
() => (effectiveOrgSettings ? generateOrgThemeCSS(effectiveOrgSettings) : ''),
|
||||
[effectiveOrgSettings]
|
||||
)
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={{ config: brandConfig }}>
|
||||
|
||||
@@ -298,7 +298,7 @@ export function WhitelabelingSettings() {
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SettingRow
|
||||
label='Logo'
|
||||
description='Shown in the collapsed sidebar. Square image recommended (PNG or JPEG, max 5MB).'
|
||||
description='Shown in the collapsed sidebar. Square image recommended (PNG, JPEG, or SVG, max 5MB).'
|
||||
>
|
||||
<DropZone onDrop={logoUpload.handleFileDrop} className='flex items-center gap-4'>
|
||||
<button
|
||||
@@ -345,7 +345,7 @@ export function WhitelabelingSettings() {
|
||||
<input
|
||||
ref={logoUpload.fileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg'
|
||||
accept='image/png,image/jpeg,image/jpg,image/svg+xml'
|
||||
onChange={logoUpload.handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
@@ -354,7 +354,7 @@ export function WhitelabelingSettings() {
|
||||
|
||||
<SettingRow
|
||||
label='Wordmark'
|
||||
description='Shown in the expanded sidebar. Wide image recommended (PNG or JPEG, max 5MB).'
|
||||
description='Shown in the expanded sidebar. Wide image recommended (PNG, JPEG, or SVG, max 5MB).'
|
||||
>
|
||||
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='flex items-center gap-4'>
|
||||
<button
|
||||
@@ -401,7 +401,7 @@ export function WhitelabelingSettings() {
|
||||
<input
|
||||
ref={wordmarkUpload.fileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg'
|
||||
accept='image/png,image/jpeg,image/jpg,image/svg+xml'
|
||||
onChange={wordmarkUpload.handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
|
||||
@@ -12,13 +12,6 @@ export function mergeOrgBrandConfig(
|
||||
return instanceConfig
|
||||
}
|
||||
|
||||
const hasOrgBrand = Boolean(
|
||||
orgSettings.brandName ||
|
||||
orgSettings.logoUrl ||
|
||||
orgSettings.wordmarkUrl ||
|
||||
orgSettings.primaryColor
|
||||
)
|
||||
|
||||
return {
|
||||
...instanceConfig,
|
||||
name: orgSettings.brandName || instanceConfig.name,
|
||||
@@ -35,7 +28,14 @@ export function mergeOrgBrandConfig(
|
||||
...(orgSettings.accentColor && { accentColor: orgSettings.accentColor }),
|
||||
...(orgSettings.accentHoverColor && { accentHoverColor: orgSettings.accentHoverColor }),
|
||||
},
|
||||
isWhitelabeled: instanceConfig.isWhitelabeled || hasOrgBrand,
|
||||
isWhitelabeled:
|
||||
instanceConfig.isWhitelabeled ||
|
||||
Boolean(
|
||||
orgSettings.brandName ||
|
||||
orgSettings.logoUrl ||
|
||||
orgSettings.wordmarkUrl ||
|
||||
orgSettings.primaryColor
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling/branding'
|
||||
import { mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
|
||||
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
|
||||
const logger = createLogger('OrgBranding')
|
||||
|
||||
@@ -27,11 +25,3 @@ export async function getOrgWhitelabelSettings(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the merged brand config for an org, combining instance env vars with org DB settings.
|
||||
*/
|
||||
export async function getOrgBrandConfig(orgId: string): Promise<BrandConfig> {
|
||||
const orgSettings = await getOrgWhitelabelSettings(orgId)
|
||||
return mergeOrgBrandConfig(orgSettings, getBrandConfig())
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document' | 'audio' |
|
||||
'image/png': 'image',
|
||||
'image/gif': 'image',
|
||||
'image/webp': 'image',
|
||||
// SVG is XML text, not a raster image — handled separately in createFileContent
|
||||
'image/svg+xml': 'image', // SVG upload is allowed; createFileContent handles it separately for Claude API
|
||||
|
||||
// Documents
|
||||
'application/pdf': 'document',
|
||||
|
||||
Reference in New Issue
Block a user