From fdfffce2e72f2af0e6138cc4782e9e151edc5037 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 22:42:08 -0700 Subject: [PATCH 1/2] improvement(whitelabeling): gate the settings page on the whitelabel read's entitlement The page blocked on the full organization billing read (member page plus each member's usage ledger for the period) only to check the plan name. The whitelabel read now also returns the entitlement the update enforces, as the session-policy and data-retention reads do, as an additive field beside `data` so clients from before it parse the same response. The page renders after that one read, which the workspace branding provider usually has cached, and its gate now matches what saving allows. --- .../organizations/[id]/whitelabel/route.ts | 2 +- .../components/branding-provider.tsx | 3 +- .../components/whitelabeling-settings.tsx | 30 +++++-------------- apps/sim/ee/whitelabeling/hooks/whitelabel.ts | 14 ++++++--- apps/sim/lib/api/contracts/organization.ts | 12 +++++++- .../lib/mothership/tools/server/settings.ts | 12 ++++---- .../application/configuration.test.ts | 2 +- .../application/configuration.ts | 15 ++++++---- 8 files changed, 48 insertions(+), 42 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts index 4410c6d8643..6f18ee9c73b 100644 --- a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts +++ b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts @@ -25,7 +25,7 @@ export const GET = defineInternalJsonRoute({ errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params }) => ({ organizationId: params.id }), useCase: getOrganizationWhitelabel, - present: (data) => ({ success: true, data }), + present: ({ settings, isEnterprise }) => ({ success: true, data: settings, isEnterprise }), }) export const PUT = defineInternalJsonRoute({ diff --git a/apps/sim/ee/whitelabeling/components/branding-provider.tsx b/apps/sim/ee/whitelabeling/components/branding-provider.tsx index e079cbd4609..b5e34983e95 100644 --- a/apps/sim/ee/whitelabeling/components/branding-provider.tsx +++ b/apps/sim/ee/whitelabeling/components/branding-provider.tsx @@ -38,9 +38,10 @@ export function BrandingProvider({ viewerIsHostOrganizationMember, initialOrgSettings, }: BrandingProviderProps) { - const { data: orgSettings } = useWhitelabelSettings( + const { data: orgWhitelabel } = useWhitelabelSettings( viewerIsHostOrganizationMember ? (hostOrganizationId ?? undefined) : undefined ) + const orgSettings = orgWhitelabel?.settings const effectiveOrgSettings = orgSettings !== undefined ? orgSettings : (initialOrgSettings ?? null) diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index 163375d336a..b9348bbb8ae 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -7,10 +7,8 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import Image from 'next/image' import { saveDiscardActions } from '@/components/settings/save-discard-actions' -import { isEnterprise } from '@/lib/billing/plan-helpers' import { HEX_COLOR_REGEX } from '@/lib/branding' import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { DropZone } from '@/app/workspace/[workspaceId]/components/drop-zone' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -23,7 +21,6 @@ import { useWhitelabelSettings, type WhitelabelSettingsPayload, } from '@/ee/whitelabeling/hooks/whitelabel' -import { useOrganizationBilling } from '@/hooks/queries/organization' import { useWorkspacesQuery } from '@/hooks/queries/workspace' const logger = createLogger('WhitelabelingSettings') @@ -407,18 +404,13 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel ) } +/** Gates on the entitlement returned by the whitelabel read — the same check saving enforces. */ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSettingsProps) { - const { billingEnabled } = useDeploymentShape() - const { - data: organizationBillingData, - isPending: organizationBillingLoading, - error: organizationBillingError, - } = useOrganizationBilling(orgId, { enabled: billingEnabled }) const { data: workspaces } = useWorkspacesQuery(true) const uploadWorkspaceId = workspaces?.find((workspace) => workspace.organizationId === orgId)?.id - const { data: savedSettings, error: settingsError, isLoading } = useWhitelabelSettings(orgId) + const { data: whitelabel, error: settingsError, isLoading } = useWhitelabelSettings(orgId) - if (isLoading || (billingEnabled && organizationBillingLoading)) { + if (isLoading) { return ( {getErrorMessage(settingsError, 'Failed to load whitelabeling settings')} @@ -440,24 +432,16 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe ) } - if (billingEnabled && organizationBillingData === undefined && organizationBillingError) { + if (!whitelabel.isEnterprise) { return ( - - {getErrorMessage(organizationBillingError, 'Failed to load organization billing')} - - ) - } - - if (billingEnabled && !isEnterprise(organizationBillingData?.data?.subscriptionPlan)) { - return ( - Whitelabeling is available on Enterprise plans only. + Whitelabeling requires an active Enterprise plan. ) } return ( diff --git a/apps/sim/ee/whitelabeling/hooks/whitelabel.ts b/apps/sim/ee/whitelabeling/hooks/whitelabel.ts index f005d87f8d5..384409ebdf2 100644 --- a/apps/sim/ee/whitelabeling/hooks/whitelabel.ts +++ b/apps/sim/ee/whitelabeling/hooks/whitelabel.ts @@ -29,19 +29,25 @@ export const whitelabelKeys = { settings: (orgId: string) => [...whitelabelKeys.settingsList(), orgId] as const, } +export interface OrganizationWhitelabel { + /** Whether the organization may save whitelabel settings: the entitlement the update enforces. */ + isEnterprise: boolean + settings: OrganizationWhitelabelSettings +} + async function fetchWhitelabelSettings( orgId: string, signal?: AbortSignal -): Promise { - const { data } = await requestJson(getOrganizationWhitelabelContract, { +): Promise { + const { data, isEnterprise } = await requestJson(getOrganizationWhitelabelContract, { params: { id: orgId }, signal, }) - return data + return { isEnterprise, settings: data } } /** - * Hook to fetch whitelabel settings for an organization. + * Hook to fetch an organization's whitelabel settings and whether it is entitled to change them. */ export function useWhitelabelSettings(orgId: string | undefined) { return useQuery({ diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index af0bd40f5ed..d09d780681c 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -672,13 +672,23 @@ const organizationWhitelabelEnvelopeResponseSchema = z.object({ data: organizationWhitelabelSettingsResponseSchema, }) +/** + * The read also carries the entitlement the update enforces, so the settings page can gate on it + * without a separate billing read. It sits beside `data` rather than inside it: `data` keeps the + * settings shape clients already parse, so a client from before this field reads the same response. + */ +const organizationWhitelabelReadResponseSchema = + organizationWhitelabelEnvelopeResponseSchema.extend({ + isEnterprise: z.boolean(), + }) + export const getOrganizationWhitelabelContract = defineRouteContract({ method: 'GET', path: '/api/organizations/[id]/whitelabel', params: organizationParamsSchema, response: { mode: 'json', - schema: organizationWhitelabelEnvelopeResponseSchema, + schema: organizationWhitelabelReadResponseSchema, }, }) diff --git a/apps/sim/lib/mothership/tools/server/settings.ts b/apps/sim/lib/mothership/tools/server/settings.ts index 5bac3565a39..b3bf146eda3 100644 --- a/apps/sim/lib/mothership/tools/server/settings.ts +++ b/apps/sim/lib/mothership/tools/server/settings.ts @@ -230,11 +230,13 @@ const adapters: Record = { 'organization/members': { get: readSettingsRoster }, 'organization/whitelabeling': { schema: updateOrganizationWhitelabelBodySchema.strict(), - get: (context) => - getOrganizationWhitelabel.execute({ - principal: context.principal, - input: organizationInput(context), - }), + get: async (context) => + ( + await getOrganizationWhitelabel.execute({ + principal: context.principal, + input: organizationInput(context), + }) + ).settings, update: async (context, changes) => ( await updateOrganizationWhitelabel.execute({ diff --git a/apps/sim/lib/organizations/application/configuration.test.ts b/apps/sim/lib/organizations/application/configuration.test.ts index 01c43406d97..410d6ff6b6a 100644 --- a/apps/sim/lib/organizations/application/configuration.test.ts +++ b/apps/sim/lib/organizations/application/configuration.test.ts @@ -158,7 +158,7 @@ describe('organization configuration authorization', () => { queueTableRows(organization, [{ settings: { brandName: 'Acme' } }]) await expect( getOrganizationWhitelabel.execute({ principal, input: { organizationId: 'org' } }) - ).resolves.toEqual({ brandName: 'Acme' }) + ).resolves.toEqual({ isEnterprise: true, settings: { brandName: 'Acme' } }) queueTableRows(member, []) await expect( getOrganizationWhitelabel.execute({ principal, input: { organizationId: 'org' } }) diff --git a/apps/sim/lib/organizations/application/configuration.ts b/apps/sim/lib/organizations/application/configuration.ts index 5cf44e59416..d0baeed1904 100644 --- a/apps/sim/lib/organizations/application/configuration.ts +++ b/apps/sim/lib/organizations/application/configuration.ts @@ -107,12 +107,15 @@ function configuredRetention( export const getOrganizationWhitelabel = defineOrganizationConfigurationUseCase({ operation: organizationConfigurationOperations.readWhitelabel, async execute({ input }: { input: OrganizationInput }) { - const [row] = await db - .select({ settings: organization.whitelabelSettings }) - .from(organization) - .where(eq(organization.id, input.organizationId)) - .limit(1) - return requireOrganization(row).settings ?? {} + const [[row], isEnterprise] = await Promise.all([ + db + .select({ settings: organization.whitelabelSettings }) + .from(organization) + .where(eq(organization.id, input.organizationId)) + .limit(1), + isOrganizationFeatureEntitled(input.organizationId, isWhitelabelingEnabled), + ]) + return { isEnterprise, settings: requireOrganization(row).settings ?? {} } }, }) From 104f2a686641c315007fbfbce3f3006459178721 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 22:57:55 -0700 Subject: [PATCH 2/2] fix(whitelabeling): parse the whitelabel read from a server that predates the entitlement field --- apps/sim/ee/whitelabeling/hooks/whitelabel.ts | 3 ++- apps/sim/lib/api/contracts/organization.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/ee/whitelabeling/hooks/whitelabel.ts b/apps/sim/ee/whitelabeling/hooks/whitelabel.ts index 384409ebdf2..ee893b3c3b9 100644 --- a/apps/sim/ee/whitelabeling/hooks/whitelabel.ts +++ b/apps/sim/ee/whitelabeling/hooks/whitelabel.ts @@ -43,7 +43,8 @@ async function fetchWhitelabelSettings( params: { id: orgId }, signal, }) - return { isEnterprise, settings: data } + // A server from before the field omits it; saving still enforces the entitlement. + return { isEnterprise: isEnterprise ?? true, settings: data } } /** diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index d09d780681c..db3c051aa77 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -676,10 +676,11 @@ const organizationWhitelabelEnvelopeResponseSchema = z.object({ * The read also carries the entitlement the update enforces, so the settings page can gate on it * without a separate billing read. It sits beside `data` rather than inside it: `data` keeps the * settings shape clients already parse, so a client from before this field reads the same response. + * It is optional so a client reading a server from before this field still parses the settings. */ const organizationWhitelabelReadResponseSchema = organizationWhitelabelEnvelopeResponseSchema.extend({ - isEnterprise: z.boolean(), + isEnterprise: z.boolean().optional(), }) export const getOrganizationWhitelabelContract = defineRouteContract({