Skip to content

Commit 1189400

Browse files
authored
feat(enterprise): cloud whitelabeling for enterprise orgs (#4047)
* feat(enterprise): cloud whitelabeling for enterprise orgs * fix(enterprise): scope enterprise plan check to target org in whitelabel PUT * fix(enterprise): use isOrganizationOnEnterprisePlan for org-scoped enterprise check * fix(enterprise): allow clearing whitelabel fields and guard against empty update result * fix(enterprise): remove webp from logo accept attribute to match upload hook validation * improvement(billing): use isBillingEnabled instead of isProd for plan gate bypasses * fix(enterprise): show whitelabeling nav item when billing is enabled on non-hosted environments * fix(enterprise): accept relative paths for logoUrl since upload API returns /api/files/serve/ paths * fix(whitelabeling): prevent logo flash on refresh by hiding logo while branding loads * fix(whitelabeling): wire hover color through CSS token on tertiary buttons * fix(whitelabeling): show sim logo by default, only replace when org logo loads * fix(whitelabeling): cache org logo url in localstorage to eliminate flash on repeat visits * feat(whitelabeling): add wordmark support with drag/drop upload * updated turbo * fix(whitelabeling): defer localstorage read to effect to prevent hydration mismatch * fix(whitelabeling): use layout effect for cache read to eliminate logo flash before paint * fix(whitelabeling): cache theme css to eliminate color flash before org settings resolve * fix(whitelabeling): deduplicate HEX_COLOR_REGEX into lib/branding and remove mutation from useCallback deps * fix(whitelabeling): use cookie-based SSR cache to eliminate brand flash on all page loads * fix(whitelabeling): use !orgSettings condition to fix SSR brand cache injection React Query returns isLoading: false with data: undefined during SSR, so the previous brandingLoading condition was always false on the server — initialCache was never injected into brandConfig. Changing to !orgSettings correctly applies the cookie cache both during SSR and while the client-side query loads, eliminating the logo flash on hard refresh.
1 parent 621aa65 commit 1189400

File tree

28 files changed

+15998
-84
lines changed

28 files changed

+15998
-84
lines changed

apps/sim/app/_styles/globals.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
220220
/* Brand & state */
221221
--brand-secondary: #33b4ff;
222222
--brand-accent: #33c482;
223+
--brand-accent-hover: #2dac72;
223224
--selection: #1a5cf6;
224225
--warning: #ea580c;
225226

@@ -375,6 +376,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
375376
/* Brand & state */
376377
--brand-secondary: #33b4ff;
377378
--brand-accent: #33c482;
379+
--brand-accent-hover: #2dac72;
378380
--selection: #4b83f7;
379381
--warning: #ff6600;
380382

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { db } from '@sim/db'
2+
import { member, organization } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
8+
import { getSession } from '@/lib/auth'
9+
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
10+
import { HEX_COLOR_REGEX } from '@/lib/branding'
11+
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
12+
13+
const logger = createLogger('WhitelabelAPI')
14+
15+
const updateWhitelabelSchema = z.object({
16+
brandName: z
17+
.string()
18+
.trim()
19+
.max(64, 'Brand name must be 64 characters or fewer')
20+
.nullable()
21+
.optional(),
22+
logoUrl: z.string().min(1).nullable().optional(),
23+
wordmarkUrl: z.string().min(1).nullable().optional(),
24+
primaryColor: z
25+
.string()
26+
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)')
27+
.nullable()
28+
.optional(),
29+
primaryHoverColor: z
30+
.string()
31+
.regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color')
32+
.nullable()
33+
.optional(),
34+
accentColor: z
35+
.string()
36+
.regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color')
37+
.nullable()
38+
.optional(),
39+
accentHoverColor: z
40+
.string()
41+
.regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color')
42+
.nullable()
43+
.optional(),
44+
supportEmail: z
45+
.string()
46+
.email('Support email must be a valid email address')
47+
.nullable()
48+
.optional(),
49+
documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(),
50+
termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(),
51+
privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(),
52+
hidePoweredBySim: z.boolean().optional(),
53+
})
54+
55+
/**
56+
* GET /api/organizations/[id]/whitelabel
57+
* Returns the organization's whitelabel settings.
58+
* Accessible by any member of the organization.
59+
*/
60+
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
61+
try {
62+
const session = await getSession()
63+
64+
if (!session?.user?.id) {
65+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
66+
}
67+
68+
const { id: organizationId } = await params
69+
70+
const [memberEntry] = await db
71+
.select({ id: member.id })
72+
.from(member)
73+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
74+
.limit(1)
75+
76+
if (!memberEntry) {
77+
return NextResponse.json(
78+
{ error: 'Forbidden - Not a member of this organization' },
79+
{ status: 403 }
80+
)
81+
}
82+
83+
const [org] = await db
84+
.select({ whitelabelSettings: organization.whitelabelSettings })
85+
.from(organization)
86+
.where(eq(organization.id, organizationId))
87+
.limit(1)
88+
89+
if (!org) {
90+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
91+
}
92+
93+
return NextResponse.json({
94+
success: true,
95+
data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
96+
})
97+
} catch (error) {
98+
logger.error('Failed to get whitelabel settings', { error })
99+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
100+
}
101+
}
102+
103+
/**
104+
* PUT /api/organizations/[id]/whitelabel
105+
* Updates the organization's whitelabel settings.
106+
* Requires enterprise plan and owner/admin role.
107+
*/
108+
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
109+
try {
110+
const session = await getSession()
111+
112+
if (!session?.user?.id) {
113+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
114+
}
115+
116+
const { id: organizationId } = await params
117+
118+
const body = await request.json()
119+
const parsed = updateWhitelabelSchema.safeParse(body)
120+
121+
if (!parsed.success) {
122+
return NextResponse.json(
123+
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
124+
{ status: 400 }
125+
)
126+
}
127+
128+
const [memberEntry] = await db
129+
.select({ role: member.role })
130+
.from(member)
131+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
132+
.limit(1)
133+
134+
if (!memberEntry) {
135+
return NextResponse.json(
136+
{ error: 'Forbidden - Not a member of this organization' },
137+
{ status: 403 }
138+
)
139+
}
140+
141+
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
142+
return NextResponse.json(
143+
{ error: 'Forbidden - Only organization owners and admins can update whitelabel settings' },
144+
{ status: 403 }
145+
)
146+
}
147+
148+
const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)
149+
150+
if (!hasEnterprisePlan) {
151+
return NextResponse.json(
152+
{ error: 'Whitelabeling is available on Enterprise plans only' },
153+
{ status: 403 }
154+
)
155+
}
156+
157+
const [currentOrg] = await db
158+
.select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
159+
.from(organization)
160+
.where(eq(organization.id, organizationId))
161+
.limit(1)
162+
163+
if (!currentOrg) {
164+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
165+
}
166+
167+
const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {}
168+
const incoming = parsed.data
169+
170+
const merged: OrganizationWhitelabelSettings = { ...current }
171+
172+
for (const key of Object.keys(incoming) as Array<keyof typeof incoming>) {
173+
const value = incoming[key]
174+
if (value === null) {
175+
delete merged[key as keyof OrganizationWhitelabelSettings]
176+
} else if (value !== undefined) {
177+
;(merged as Record<string, unknown>)[key] = value
178+
}
179+
}
180+
181+
const [updated] = await db
182+
.update(organization)
183+
.set({ whitelabelSettings: merged, updatedAt: new Date() })
184+
.where(eq(organization.id, organizationId))
185+
.returning({ whitelabelSettings: organization.whitelabelSettings })
186+
187+
if (!updated) {
188+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
189+
}
190+
191+
recordAudit({
192+
workspaceId: null,
193+
actorId: session.user.id,
194+
action: AuditAction.ORGANIZATION_UPDATED,
195+
resourceType: AuditResourceType.ORGANIZATION,
196+
resourceId: organizationId,
197+
actorName: session.user.name ?? undefined,
198+
actorEmail: session.user.email ?? undefined,
199+
resourceName: currentOrg.name,
200+
description: 'Updated organization whitelabel settings',
201+
metadata: { changes: Object.keys(incoming) },
202+
request,
203+
})
204+
205+
return NextResponse.json({
206+
success: true,
207+
data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
208+
})
209+
} catch (error) {
210+
logger.error('Failed to update whitelabel settings', { error })
211+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
212+
}
213+
}
Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cookies } from 'next/headers'
12
import { ToastProvider } from '@/components/emcn'
23
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
34
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
@@ -7,31 +8,45 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
78
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
89
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
910
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'
16+
17+
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 {}
1024

11-
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
1225
return (
13-
<ToastProvider>
14-
<SettingsLoader />
15-
<ProviderModelsLoader />
16-
<GlobalCommandsProvider>
17-
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
18-
<ImpersonationBanner />
19-
<WorkspacePermissionsProvider>
20-
<WorkspaceScopeSync />
21-
<div className='flex min-h-0 flex-1'>
22-
<div className='shrink-0' suppressHydrationWarning>
23-
<Sidebar />
24-
</div>
25-
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
26-
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
27-
{children}
26+
<BrandingProvider initialCache={initialCache}>
27+
<ToastProvider>
28+
<SettingsLoader />
29+
<ProviderModelsLoader />
30+
<GlobalCommandsProvider>
31+
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
32+
<ImpersonationBanner />
33+
<WorkspacePermissionsProvider>
34+
<WorkspaceScopeSync />
35+
<div className='flex min-h-0 flex-1'>
36+
<div className='shrink-0' suppressHydrationWarning>
37+
<Sidebar />
38+
</div>
39+
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
40+
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
41+
{children}
42+
</div>
2843
</div>
2944
</div>
30-
</div>
31-
<NavTour />
32-
</WorkspacePermissionsProvider>
33-
</div>
34-
</GlobalCommandsProvider>
35-
</ToastProvider>
45+
<NavTour />
46+
</WorkspacePermissionsProvider>
47+
</div>
48+
</GlobalCommandsProvider>
49+
</ToastProvider>
50+
</BrandingProvider>
3651
)
3752
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ const AccessControl = dynamic(
156156
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
157157
loading: () => <SettingsSectionSkeleton />,
158158
})
159+
const WhitelabelingSettings = dynamic(
160+
() =>
161+
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
162+
(m) => m.WhitelabelingSettings
163+
),
164+
{ loading: () => <SettingsSectionSkeleton /> }
165+
)
159166

160167
interface SettingsPageProps {
161168
section: SettingsSection
@@ -198,6 +205,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
198205
{isBillingEnabled && effectiveSection === 'subscription' && <Subscription />}
199206
{isBillingEnabled && effectiveSection === 'team' && <TeamManagement />}
200207
{effectiveSection === 'sso' && <SSO />}
208+
{effectiveSection === 'whitelabeling' && <WhitelabelingSettings />}
201209
{effectiveSection === 'byok' && <BYOK />}
202210
{effectiveSection === 'copilot' && <Copilot />}
203211
{effectiveSection === 'mcp' && <MCP initialServerId={mcpServerId} />}

0 commit comments

Comments
 (0)