Skip to content
20 changes: 7 additions & 13 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}, [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ModalFooter,
ModalHeader,
Plus,
Skeleton,
UserPlus,
} from '@/components/emcn'
import { getDisplayPlanName, isFree } from '@/lib/billing/plan-helpers'
Expand Down Expand Up @@ -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'>
Expand Down Expand Up @@ -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...'}
Expand Down Expand Up @@ -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'>
Expand Down
45 changes: 29 additions & 16 deletions apps/sim/components/emcn/components/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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}
Expand Down
98 changes: 19 additions & 79 deletions apps/sim/ee/whitelabeling/components/branding-provider.tsx
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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)

useEffect(() => {
if (orgsLoading) return

if (!orgId) {
writeBrandCookie(null)
setCache(null)
return
}

if (settingsLoading) return
const { data: orgSettings } = useWhitelabelSettings(orgId)

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 effectiveOrgSettings =
orgSettings !== undefined ? orgSettings : (initialOrgSettings ?? null)

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 brandConfig = useMemo(
() => mergeOrgBrandConfig(effectiveOrgSettings, getBrandConfig()),
[effectiveOrgSettings]
)

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 }}>
Expand Down
Loading
Loading