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..ee893b3c3b9 100644 --- a/apps/sim/ee/whitelabeling/hooks/whitelabel.ts +++ b/apps/sim/ee/whitelabeling/hooks/whitelabel.ts @@ -29,19 +29,26 @@ 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 + // A server from before the field omits it; saving still enforces the entitlement. + return { isEnterprise: isEnterprise ?? true, 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..db3c051aa77 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -672,13 +672,24 @@ 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. + * It is optional so a client reading a server from before this field still parses the settings. + */ +const organizationWhitelabelReadResponseSchema = + organizationWhitelabelEnvelopeResponseSchema.extend({ + isEnterprise: z.boolean().optional(), + }) + 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 ?? {} } }, })