Skip to content

Commit e47f6aa

Browse files
waleedlatif1Theodore Li
authored andcommitted
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
1 parent 096bb48 commit e47f6aa

File tree

12 files changed

+112
-165
lines changed

12 files changed

+112
-165
lines changed

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { cookies } from 'next/headers'
21
import { ToastProvider } from '@/components/emcn'
2+
import { getSession } from '@/lib/auth'
33
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
44
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
55
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -8,22 +8,16 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
88
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
99
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
1010
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
11-
import {
12-
BRAND_COOKIE_NAME,
13-
type BrandCache,
14-
BrandingProvider,
15-
} from '@/ee/whitelabeling/components/branding-provider'
11+
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
12+
import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding'
1613

1714
export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) {
18-
const cookieStore = await cookies()
19-
let initialCache: BrandCache | null = null
20-
try {
21-
const raw = cookieStore.get(BRAND_COOKIE_NAME)?.value
22-
if (raw) initialCache = JSON.parse(decodeURIComponent(raw))
23-
} catch {}
15+
const session = await getSession()
16+
const orgId = session?.session?.activeOrganizationId
17+
const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null
2418

2519
return (
26-
<BrandingProvider initialCache={initialCache}>
20+
<BrandingProvider initialOrgSettings={initialOrgSettings}>
2721
<ToastProvider>
2822
<SettingsLoader />
2923
<ProviderModelsLoader />

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const SECTION_TITLES: Record<string, string> = {
1616
subscription: 'Subscription',
1717
team: 'Team',
1818
sso: 'Single Sign-On',
19+
whitelabeling: 'Whitelabeling',
1920
copilot: 'Copilot Keys',
2021
mcp: 'MCP Tools',
2122
'custom-tools': 'Custom Tools',

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const WhitelabelingSettings = dynamic(
161161
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
162162
(m) => m.WhitelabelingSettings
163163
),
164-
{ loading: () => <SettingsSectionSkeleton /> }
164+
{ loading: () => <SettingsSectionSkeleton />, ssr: false }
165165
)
166166

167167
interface SettingsPageProps {

apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ export function General() {
387387
<Tooltip.Preview
388388
src='/tooltips/auto-connect-on-drop.mp4'
389389
alt='Auto-connect on drop example'
390-
loop={false}
390+
loop={true}
391391
/>
392392
</Tooltip.Content>
393393
</Tooltip.Root>

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger'
33

44
const logger = createLogger('ProfilePictureUpload')
55
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
6-
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg']
6+
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml']
77

88
interface UseProfilePictureUploadProps {
99
onUpload?: (url: string | null) => void
@@ -27,21 +27,19 @@ export function useProfilePictureUpload({
2727
const [isUploading, setIsUploading] = useState(false)
2828

2929
useEffect(() => {
30-
if (currentImage !== previewUrl) {
31-
if (previewRef.current && previewRef.current !== currentImage) {
32-
URL.revokeObjectURL(previewRef.current)
33-
previewRef.current = null
34-
}
35-
setPreviewUrl(currentImage || null)
30+
if (previewRef.current && previewRef.current !== currentImage) {
31+
URL.revokeObjectURL(previewRef.current)
32+
previewRef.current = null
3633
}
37-
}, [currentImage, previewUrl])
34+
setPreviewUrl(currentImage || null)
35+
}, [currentImage])
3836

3937
const validateFile = useCallback((file: File): string | null => {
4038
if (file.size > MAX_FILE_SIZE) {
4139
return `File "${file.name}" is too large. Maximum size is 5MB.`
4240
}
4341
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
44-
return `File "${file.name}" is not a supported image format. Please use PNG or JPEG.`
42+
return `File "${file.name}" is not a supported image format. Please use PNG, JPEG, or SVG.`
4543
}
4644
return null
4745
}, [])

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

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ModalFooter,
1818
ModalHeader,
1919
Plus,
20+
Skeleton,
2021
UserPlus,
2122
} from '@/components/emcn'
2223
import { getDisplayPlanName, isFree } from '@/lib/billing/plan-helpers'
@@ -356,14 +357,16 @@ export function WorkspaceHeader({
356357
}
357358
}}
358359
>
359-
<div
360-
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
361-
style={{
362-
backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)',
363-
}}
364-
>
365-
{workspaceInitial}
366-
</div>
360+
{activeWorkspaceFull ? (
361+
<div
362+
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
363+
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
364+
>
365+
{workspaceInitial}
366+
</div>
367+
) : (
368+
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
369+
)}
367370
{!isCollapsed && (
368371
<>
369372
<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({
400403
) : (
401404
<>
402405
<div className='flex items-center gap-2 px-0.5 py-0.5'>
403-
<div
404-
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
405-
style={{
406-
backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)',
407-
}}
408-
>
409-
{workspaceInitial}
410-
</div>
406+
{activeWorkspaceFull ? (
407+
<div
408+
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-md font-medium text-caption text-white'
409+
style={{
410+
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
411+
}}
412+
>
413+
{workspaceInitial}
414+
</div>
415+
) : (
416+
<Skeleton className='h-[32px] w-[32px] flex-shrink-0 rounded-md' />
417+
)}
411418
<div className='flex min-w-0 flex-1 flex-col'>
412419
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
413420
{activeWorkspace?.name || 'Loading...'}
@@ -580,12 +587,16 @@ export function WorkspaceHeader({
580587
title={activeWorkspace?.name || 'Loading...'}
581588
disabled
582589
>
583-
<div
584-
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
585-
style={{ backgroundColor: activeWorkspaceFull?.color || 'var(--brand-accent)' }}
586-
>
587-
{workspaceInitial}
588-
</div>
590+
{activeWorkspaceFull ? (
591+
<div
592+
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-caption text-white leading-none'
593+
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
594+
>
595+
{workspaceInitial}
596+
</div>
597+
) : (
598+
<Skeleton className='h-[20px] w-[20px] flex-shrink-0 rounded-sm' />
599+
)}
589600
{!isCollapsed && (
590601
<>
591602
<span className='min-w-0 flex-1 truncate text-left font-base text-[var(--text-primary)] text-sm'>

apps/sim/components/emcn/components/tooltip/tooltip.tsx

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,20 @@ const Trigger = TooltipPrimitive.Trigger
4242
const Content = React.forwardRef<
4343
React.ElementRef<typeof TooltipPrimitive.Content>,
4444
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
45-
>(({ className, sideOffset = 6, ...props }, ref) => (
45+
>(({ className, sideOffset = 6, children, ...props }, ref) => (
4646
<TooltipPrimitive.Portal>
4747
<TooltipPrimitive.Content
4848
ref={ref}
4949
sideOffset={sideOffset}
5050
collisionPadding={8}
51-
avoidCollisions={true}
51+
avoidCollisions
5252
className={cn(
5353
'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',
5454
className
5555
)}
5656
{...props}
5757
>
58-
{props.children}
58+
{children}
5959
<TooltipPrimitive.Arrow className='fill-[var(--tooltip-bg)]' />
6060
</TooltipPrimitive.Content>
6161
</TooltipPrimitive.Portal>
@@ -120,22 +120,35 @@ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov'] as const
120120
const Preview = ({ src, alt = '', width = 240, height, loop = true, className }: PreviewProps) => {
121121
const pathname = src.toLowerCase().split('?')[0].split('#')[0]
122122
const isVideo = VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext))
123+
const [isReady, setIsReady] = React.useState(!isVideo)
123124

124125
return (
125-
<div className={cn('-mx-2 -mb-[3.5px] mt-1 overflow-hidden rounded-b-[4px]', className)}>
126+
<div className={cn('-mx-[6px] -mb-[1.5px] mt-1.5 overflow-hidden rounded-[4px]', className)}>
126127
{isVideo ? (
127-
<video
128-
src={src}
129-
width={width}
130-
height={height}
131-
className='block w-full'
132-
autoPlay
133-
loop={loop}
134-
muted
135-
playsInline
136-
preload='none'
137-
aria-label={alt}
138-
/>
128+
<div className='relative'>
129+
{!isReady && (
130+
<div
131+
className='animate-pulse bg-white/5'
132+
style={{ aspectRatio: height ? `${width}/${height}` : '16/9' }}
133+
/>
134+
)}
135+
<video
136+
src={src}
137+
width={width}
138+
height={height}
139+
className={cn(
140+
'block w-full transition-opacity duration-200',
141+
isReady ? 'opacity-100' : 'absolute inset-0 opacity-0'
142+
)}
143+
autoPlay
144+
loop={loop}
145+
muted
146+
playsInline
147+
preload='auto'
148+
aria-label={alt}
149+
onCanPlay={() => setIsReady(true)}
150+
/>
151+
</div>
139152
) : (
140153
<img
141154
src={src}

apps/sim/ee/whitelabeling/components/branding-provider.tsx

Lines changed: 19 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,12 @@
11
'use client'
22

3-
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
4-
import type { BrandConfig } from '@/lib/branding/types'
3+
import { createContext, useContext, useMemo } from 'react'
4+
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
55
import { getBrandConfig } from '@/ee/whitelabeling/branding'
66
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
77
import { generateOrgThemeCSS, mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
88
import { useOrganizations } from '@/hooks/queries/organization'
99

10-
export const BRAND_COOKIE_NAME = 'sim-wl'
11-
const BRAND_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
12-
13-
/**
14-
* Brand assets and theme CSS cached in a cookie between page loads.
15-
* Written client-side after org settings resolve; read server-side in the
16-
* workspace layout so the correct branding is baked into the initial HTML.
17-
*/
18-
export interface BrandCache {
19-
logoUrl?: string
20-
wordmarkUrl?: string
21-
/** Pre-generated `:root { ... }` CSS from the last resolved org settings. */
22-
themeCSS?: string
23-
}
24-
25-
function writeBrandCookie(cache: BrandCache | null): void {
26-
try {
27-
if (cache && Object.keys(cache).length > 0) {
28-
document.cookie = `${BRAND_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(cache))}; path=/; max-age=${BRAND_COOKIE_MAX_AGE}; SameSite=Lax`
29-
} else {
30-
document.cookie = `${BRAND_COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax`
31-
}
32-
} catch {}
33-
}
34-
3510
interface BrandingContextValue {
3611
config: BrandConfig
3712
}
@@ -43,69 +18,34 @@ const BrandingContext = createContext<BrandingContextValue>({
4318
interface BrandingProviderProps {
4419
children: React.ReactNode
4520
/**
46-
* Brand cache read server-side from the `sim-wl` cookie by the workspace
47-
* layout. When present, the server renders the correct org branding from the
48-
* first byte — no flash of any kind on page load or hard refresh.
21+
* Org whitelabel settings fetched server-side from the DB by the workspace layout.
22+
* Used as the source of truth until the React Query result becomes available,
23+
* ensuring the correct org logo appears in the initial server HTML — no flash.
4924
*/
50-
initialCache?: BrandCache | null
25+
initialOrgSettings?: OrganizationWhitelabelSettings | null
5126
}
5227

5328
/**
5429
* Provides merged branding (instance env vars + org DB settings) to the workspace.
5530
* Injects a `<style>` tag with CSS variable overrides when org colors are configured.
56-
*
57-
* Flow:
58-
* - First visit: org logo loads after the API call resolves (one-time flash).
59-
* - All subsequent visits: the workspace layout reads the `sim-wl` cookie
60-
* server-side and passes it as `initialCache`. The server renders the correct
61-
* brand in the initial HTML — no flash of any kind.
6231
*/
63-
export function BrandingProvider({ children, initialCache }: BrandingProviderProps) {
64-
const [cache, setCache] = useState<BrandCache | null>(initialCache ?? null)
65-
66-
const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
32+
export function BrandingProvider({ children, initialOrgSettings }: BrandingProviderProps) {
33+
const { data: orgsData } = useOrganizations()
6734
const orgId = orgsData?.activeOrganization?.id
68-
const { data: orgSettings, isLoading: settingsLoading } = useWhitelabelSettings(orgId)
69-
70-
useEffect(() => {
71-
if (orgsLoading) return
72-
73-
if (!orgId) {
74-
writeBrandCookie(null)
75-
setCache(null)
76-
return
77-
}
78-
79-
if (settingsLoading) return
35+
const { data: orgSettings } = useWhitelabelSettings(orgId)
8036

81-
const themeCSS = orgSettings ? generateOrgThemeCSS(orgSettings) : null
82-
const next: BrandCache = {}
83-
if (orgSettings?.logoUrl) next.logoUrl = orgSettings.logoUrl
84-
if (orgSettings?.wordmarkUrl) next.wordmarkUrl = orgSettings.wordmarkUrl
85-
if (themeCSS) next.themeCSS = themeCSS
37+
const effectiveOrgSettings =
38+
orgSettings !== undefined ? orgSettings : (initialOrgSettings ?? null)
8639

87-
const newCache = Object.keys(next).length > 0 ? next : null
88-
writeBrandCookie(newCache)
89-
setCache(newCache)
90-
}, [orgsLoading, orgId, settingsLoading, orgSettings])
91-
92-
const brandConfig = useMemo(() => {
93-
const base = mergeOrgBrandConfig(orgSettings ?? null, getBrandConfig())
94-
if (!orgSettings && cache) {
95-
return {
96-
...base,
97-
...(cache.logoUrl && { logoUrl: cache.logoUrl }),
98-
...(cache.wordmarkUrl && { wordmarkUrl: cache.wordmarkUrl }),
99-
}
100-
}
101-
return base
102-
}, [orgSettings, cache])
40+
const brandConfig = useMemo(
41+
() => mergeOrgBrandConfig(effectiveOrgSettings, getBrandConfig()),
42+
[effectiveOrgSettings]
43+
)
10344

104-
const themeCSS = useMemo(() => {
105-
if (orgSettings) return generateOrgThemeCSS(orgSettings)
106-
if (cache?.themeCSS) return cache.themeCSS
107-
return ''
108-
}, [orgSettings, cache])
45+
const themeCSS = useMemo(
46+
() => (effectiveOrgSettings ? generateOrgThemeCSS(effectiveOrgSettings) : ''),
47+
[effectiveOrgSettings]
48+
)
10949

11050
return (
11151
<BrandingContext.Provider value={{ config: brandConfig }}>

0 commit comments

Comments
 (0)