Skip to content

Commit a2d5e1a

Browse files
committed
feat(enterprise): cloud whitelabeling for enterprise orgs
1 parent efb582e commit a2d5e1a

16 files changed

Lines changed: 15619 additions & 23 deletions

File tree

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

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

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,34 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
77
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
88
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
99
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
10+
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
1011

1112
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
1213
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}
14+
<BrandingProvider>
15+
<ToastProvider>
16+
<SettingsLoader />
17+
<ProviderModelsLoader />
18+
<GlobalCommandsProvider>
19+
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
20+
<ImpersonationBanner />
21+
<WorkspacePermissionsProvider>
22+
<WorkspaceScopeSync />
23+
<div className='flex min-h-0 flex-1'>
24+
<div className='shrink-0' suppressHydrationWarning>
25+
<Sidebar />
26+
</div>
27+
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
28+
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
29+
{children}
30+
</div>
2831
</div>
2932
</div>
30-
</div>
31-
<NavTour />
32-
</WorkspacePermissionsProvider>
33-
</div>
34-
</GlobalCommandsProvider>
35-
</ToastProvider>
33+
<NavTour />
34+
</WorkspacePermissionsProvider>
35+
</div>
36+
</GlobalCommandsProvider>
37+
</ToastProvider>
38+
</BrandingProvider>
3639
)
3740
}

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

apps/sim/app/workspace/[workspaceId]/settings/navigation.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Lock,
88
LogIn,
99
Mail,
10+
Palette,
1011
Send,
1112
Server,
1213
Settings,
@@ -31,6 +32,7 @@ export type SettingsSection =
3132
| 'subscription'
3233
| 'team'
3334
| 'sso'
35+
| 'whitelabeling'
3436
| 'copilot'
3537
| 'mcp'
3638
| 'custom-tools'
@@ -162,6 +164,14 @@ export const allNavigationItems: NavigationItem[] = [
162164
requiresEnterprise: true,
163165
selfHostedOverride: isSSOEnabled,
164166
},
167+
{
168+
id: 'whitelabeling',
169+
label: 'Whitelabeling',
170+
icon: Palette,
171+
section: 'enterprise',
172+
requiresHosted: true,
173+
requiresEnterprise: true,
174+
},
165175
{
166176
id: 'admin',
167177
label: 'Admin',

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import {
8383
useImportWorkflow,
8484
useImportWorkspace,
8585
} from '@/app/workspace/[workspaceId]/w/hooks'
86-
import { getBrandConfig } from '@/ee/whitelabeling'
86+
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
8787
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
8888
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
8989
import { useTablesList } from '@/hooks/queries/tables'
@@ -337,7 +337,7 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item'
337337
* @returns Sidebar with workflows panel
338338
*/
339339
export const Sidebar = memo(function Sidebar() {
340-
const brand = getBrandConfig()
340+
const brand = useOrgBrandConfig()
341341
const params = useParams()
342342
const workspaceId = params.workspaceId as string
343343
const workflowId = params.workflowId as string | undefined
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client'
2+
3+
import { createContext, useContext, useMemo } from 'react'
4+
import type { BrandConfig } from '@/lib/branding/types'
5+
import { getBrandConfig } from '@/ee/whitelabeling/branding'
6+
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
7+
import { generateOrgThemeCSS, mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
8+
import { useOrganizations } from '@/hooks/queries/organization'
9+
10+
const BrandingContext = createContext<BrandConfig>(getBrandConfig())
11+
12+
interface BrandingProviderProps {
13+
children: React.ReactNode
14+
}
15+
16+
/**
17+
* Provides merged branding (instance env vars + org DB settings) to the workspace.
18+
* Injects CSS variable overrides when org colors are configured.
19+
*/
20+
export function BrandingProvider({ children }: BrandingProviderProps) {
21+
const { data: orgsData } = useOrganizations()
22+
const orgId = orgsData?.activeOrganization?.id
23+
const { data: orgSettings } = useWhitelabelSettings(orgId)
24+
25+
const brandConfig = useMemo(
26+
() => mergeOrgBrandConfig(orgSettings ?? null, getBrandConfig()),
27+
[orgSettings]
28+
)
29+
30+
const themeCSS = useMemo(
31+
() => (orgSettings ? generateOrgThemeCSS(orgSettings) : ''),
32+
[orgSettings]
33+
)
34+
35+
return (
36+
<BrandingContext.Provider value={brandConfig}>
37+
{themeCSS && <style>{themeCSS}</style>}
38+
{children}
39+
</BrandingContext.Provider>
40+
)
41+
}
42+
43+
/**
44+
* Returns the merged brand config (org settings overlaid on instance defaults).
45+
* Use this inside the workspace instead of `getBrandConfig()`.
46+
*/
47+
export function useOrgBrandConfig(): BrandConfig {
48+
return useContext(BrandingContext)
49+
}

0 commit comments

Comments
 (0)