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:
Waleed
2026-04-08 14:07:31 -07:00
committed by GitHub
parent 694f4a5895
commit 91ce55e547
12 changed files with 112 additions and 165 deletions

View File

@@ -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 />

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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
}, [])

View File

@@ -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'>

View File

@@ -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}

View File

@@ -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 }}>

View File

@@ -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'
/>

View File

@@ -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
),
}
}

View File

@@ -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())
}

View File

@@ -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',