mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Compare commits
15 Commits
fix/log-so
...
v0.6.31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf233bb497 | ||
|
|
4700590e64 | ||
|
|
1189400167 | ||
|
|
621aa65b91 | ||
|
|
c21876ab40 | ||
|
|
a1173ee712 | ||
|
|
579d240cee | ||
|
|
d7da35ba0b | ||
|
|
d6ec115348 | ||
|
|
3f508e445f | ||
|
|
316bc8cdcc | ||
|
|
d889f32697 | ||
|
|
28af223a9f | ||
|
|
a54dcbe949 | ||
|
|
0b9019d9a2 |
@@ -220,6 +220,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
/* Brand & state */
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-accent: #33c482;
|
||||
--brand-accent-hover: #2dac72;
|
||||
--selection: #1a5cf6;
|
||||
--warning: #ea580c;
|
||||
|
||||
@@ -375,6 +376,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
/* Brand & state */
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-accent: #33c482;
|
||||
--brand-accent-hover: #2dac72;
|
||||
--selection: #4b83f7;
|
||||
--warning: #ff6600;
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { headers } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
const logger = createLogger('SocketTokenAPI')
|
||||
|
||||
export async function POST() {
|
||||
if (isAuthDisabled) {
|
||||
return NextResponse.json({ token: 'anonymous-socket-token' })
|
||||
@@ -19,7 +22,11 @@ export async function POST() {
|
||||
}
|
||||
|
||||
return NextResponse.json({ token: response.token })
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate socket token', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
213
apps/sim/app/api/organizations/[id]/whitelabel/route.ts
Normal file
213
apps/sim/app/api/organizations/[id]/whitelabel/route.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import { HEX_COLOR_REGEX } from '@/lib/branding'
|
||||
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
|
||||
const logger = createLogger('WhitelabelAPI')
|
||||
|
||||
const updateWhitelabelSchema = z.object({
|
||||
brandName: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(64, 'Brand name must be 64 characters or fewer')
|
||||
.nullable()
|
||||
.optional(),
|
||||
logoUrl: z.string().min(1).nullable().optional(),
|
||||
wordmarkUrl: z.string().min(1).nullable().optional(),
|
||||
primaryColor: z
|
||||
.string()
|
||||
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)')
|
||||
.nullable()
|
||||
.optional(),
|
||||
primaryHoverColor: z
|
||||
.string()
|
||||
.regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color')
|
||||
.nullable()
|
||||
.optional(),
|
||||
accentColor: z
|
||||
.string()
|
||||
.regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color')
|
||||
.nullable()
|
||||
.optional(),
|
||||
accentHoverColor: z
|
||||
.string()
|
||||
.regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color')
|
||||
.nullable()
|
||||
.optional(),
|
||||
supportEmail: z
|
||||
.string()
|
||||
.email('Support email must be a valid email address')
|
||||
.nullable()
|
||||
.optional(),
|
||||
documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(),
|
||||
termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(),
|
||||
privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(),
|
||||
hidePoweredBySim: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/whitelabel
|
||||
* Returns the organization's whitelabel settings.
|
||||
* Accessible by any member of the organization.
|
||||
*/
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({ whitelabelSettings: organization.whitelabelSettings })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get whitelabel settings', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]/whitelabel
|
||||
* Updates the organization's whitelabel settings.
|
||||
* Requires enterprise plan and owner/admin role.
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = updateWhitelabelSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Only organization owners and admins can update whitelabel settings' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
|
||||
if (!hasEnterprisePlan) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Whitelabeling is available on Enterprise plans only' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [currentOrg] = await db
|
||||
.select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!currentOrg) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {}
|
||||
const incoming = parsed.data
|
||||
|
||||
const merged: OrganizationWhitelabelSettings = { ...current }
|
||||
|
||||
for (const key of Object.keys(incoming) as Array<keyof typeof incoming>) {
|
||||
const value = incoming[key]
|
||||
if (value === null) {
|
||||
delete merged[key as keyof OrganizationWhitelabelSettings]
|
||||
} else if (value !== undefined) {
|
||||
;(merged as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(organization)
|
||||
.set({ whitelabelSettings: merged, updatedAt: new Date() })
|
||||
.where(eq(organization.id, organizationId))
|
||||
.returning({ whitelabelSettings: organization.whitelabelSettings })
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORGANIZATION_UPDATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: currentOrg.name,
|
||||
description: 'Updated organization whitelabel settings',
|
||||
metadata: { changes: Object.keys(incoming) },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update whitelabel settings', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
|
||||
@@ -7,31 +8,45 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
|
||||
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import {
|
||||
BRAND_COOKIE_NAME,
|
||||
type BrandCache,
|
||||
BrandingProvider,
|
||||
} from '@/ee/whitelabeling/components/branding-provider'
|
||||
|
||||
export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies()
|
||||
let initialCache: BrandCache | null = null
|
||||
try {
|
||||
const raw = cookieStore.get(BRAND_COOKIE_NAME)?.value
|
||||
if (raw) initialCache = JSON.parse(decodeURIComponent(raw))
|
||||
} catch {}
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||
<ImpersonationBanner />
|
||||
<WorkspacePermissionsProvider>
|
||||
<WorkspaceScopeSync />
|
||||
<div className='flex min-h-0 flex-1'>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
<BrandingProvider initialCache={initialCache}>
|
||||
<ToastProvider>
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||
<ImpersonationBanner />
|
||||
<WorkspacePermissionsProvider>
|
||||
<WorkspaceScopeSync />
|
||||
<div className='flex min-h-0 flex-1'>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavTour />
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
</ToastProvider>
|
||||
<NavTour />
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
</ToastProvider>
|
||||
</BrandingProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,6 +156,13 @@ const AccessControl = dynamic(
|
||||
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
|
||||
loading: () => <SettingsSectionSkeleton />,
|
||||
})
|
||||
const WhitelabelingSettings = dynamic(
|
||||
() =>
|
||||
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
|
||||
(m) => m.WhitelabelingSettings
|
||||
),
|
||||
{ loading: () => <SettingsSectionSkeleton /> }
|
||||
)
|
||||
|
||||
interface SettingsPageProps {
|
||||
section: SettingsSection
|
||||
@@ -198,6 +205,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
|
||||
{isBillingEnabled && effectiveSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && effectiveSection === 'team' && <TeamManagement />}
|
||||
{effectiveSection === 'sso' && <SSO />}
|
||||
{effectiveSection === 'whitelabeling' && <WhitelabelingSettings />}
|
||||
{effectiveSection === 'byok' && <BYOK />}
|
||||
{effectiveSection === 'copilot' && <Copilot />}
|
||||
{effectiveSection === 'mcp' && <MCP initialServerId={mcpServerId} />}
|
||||
|
||||
@@ -75,52 +75,59 @@ export function useProfilePictureUpload({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
onError?.(validationError)
|
||||
return
|
||||
}
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
onError?.(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setFileName(file.name)
|
||||
setFileName(file.name)
|
||||
|
||||
const newPreviewUrl = URL.createObjectURL(file)
|
||||
const newPreviewUrl = URL.createObjectURL(file)
|
||||
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
|
||||
setPreviewUrl(newPreviewUrl)
|
||||
previewRef.current = newPreviewUrl
|
||||
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
}
|
||||
|
||||
setPreviewUrl(newPreviewUrl)
|
||||
previewRef.current = newPreviewUrl
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const serverUrl = await uploadFileToServer(file)
|
||||
|
||||
URL.revokeObjectURL(newPreviewUrl)
|
||||
previewRef.current = null
|
||||
setPreviewUrl(serverUrl)
|
||||
|
||||
onUpload?.(serverUrl)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to upload profile picture'
|
||||
onError?.(errorMessage)
|
||||
|
||||
URL.revokeObjectURL(newPreviewUrl)
|
||||
previewRef.current = null
|
||||
setPreviewUrl(currentImage || null)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const serverUrl = await uploadFileToServer(file)
|
||||
URL.revokeObjectURL(newPreviewUrl)
|
||||
previewRef.current = null
|
||||
setPreviewUrl(serverUrl)
|
||||
onUpload?.(serverUrl)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to upload profile picture'
|
||||
onError?.(errorMessage)
|
||||
URL.revokeObjectURL(newPreviewUrl)
|
||||
previewRef.current = null
|
||||
setPreviewUrl(currentImage || null)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[onUpload, onError, uploadFileToServer, validateFile, currentImage]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
@@ -148,6 +155,7 @@ export function useProfilePictureUpload({
|
||||
fileInputRef,
|
||||
handleThumbnailClick,
|
||||
handleFileChange,
|
||||
handleFileDrop,
|
||||
handleRemove,
|
||||
isUploading,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Lock,
|
||||
LogIn,
|
||||
Mail,
|
||||
Palette,
|
||||
Send,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -31,6 +32,7 @@ export type SettingsSection =
|
||||
| 'subscription'
|
||||
| 'team'
|
||||
| 'sso'
|
||||
| 'whitelabeling'
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
@@ -162,6 +164,15 @@ export const allNavigationItems: NavigationItem[] = [
|
||||
requiresEnterprise: true,
|
||||
selfHostedOverride: isSSOEnabled,
|
||||
},
|
||||
{
|
||||
id: 'whitelabeling',
|
||||
label: 'Whitelabeling',
|
||||
icon: Palette,
|
||||
section: 'enterprise',
|
||||
requiresHosted: true,
|
||||
requiresEnterprise: true,
|
||||
selfHostedOverride: isBillingEnabled,
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
label: 'Admin',
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface HighlightContext {
|
||||
highlightAll?: boolean
|
||||
}
|
||||
|
||||
const SYSTEM_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
|
||||
const SYSTEM_PREFIXES = new Set(['loop', 'parallel', 'variable'])
|
||||
|
||||
/**
|
||||
* Formats text by highlighting block references (<...>) and environment variables ({{...}})
|
||||
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
useImportWorkflow,
|
||||
useImportWorkspace,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
|
||||
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
|
||||
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
|
||||
import { useTablesList } from '@/hooks/queries/tables'
|
||||
@@ -337,7 +337,7 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item'
|
||||
* @returns Sidebar with workflows panel
|
||||
*/
|
||||
export const Sidebar = memo(function Sidebar() {
|
||||
const brand = getBrandConfig()
|
||||
const brand = useOrgBrandConfig()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string | undefined
|
||||
@@ -1251,7 +1251,16 @@ export const Sidebar = memo(function Sidebar() {
|
||||
tabIndex={isCollapsed ? -1 : undefined}
|
||||
aria-label={brand.name}
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
{brand.wordmarkUrl ? (
|
||||
<Image
|
||||
src={brand.wordmarkUrl}
|
||||
alt={brand.name}
|
||||
height={16}
|
||||
width={80}
|
||||
className='h-[16px] w-auto flex-shrink-0 object-contain object-left'
|
||||
unoptimized
|
||||
/>
|
||||
) : brand.logoUrl ? (
|
||||
<Image
|
||||
src={brand.logoUrl}
|
||||
alt={brand.name}
|
||||
|
||||
@@ -20,7 +20,7 @@ const buttonVariants = cva(
|
||||
'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106',
|
||||
secondary: 'bg-[var(--brand-secondary)] text-[var(--text-primary)]',
|
||||
tertiary:
|
||||
'bg-[var(--brand-accent)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[#2DAC72] dark:bg-[var(--brand-accent)] dark:hover-hover:bg-[#2DAC72] dark:text-[var(--text-inverse)] dark:hover-hover:text-[var(--text-inverse)]',
|
||||
'bg-[var(--brand-accent)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[var(--brand-accent-hover)] dark:bg-[var(--brand-accent)] dark:hover-hover:bg-[var(--brand-accent-hover)] dark:text-[var(--text-inverse)] dark:hover-hover:text-[var(--text-inverse)]',
|
||||
ghost: 'text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)]',
|
||||
subtle:
|
||||
'text-[var(--text-body)] hover-hover:text-[var(--text-body)] hover-hover:bg-[var(--surface-4)]',
|
||||
|
||||
@@ -26,12 +26,14 @@ export const getBrandConfig = (): BrandConfig => {
|
||||
const hasCustomBrand = Boolean(
|
||||
getEnv('NEXT_PUBLIC_BRAND_NAME') ||
|
||||
getEnv('NEXT_PUBLIC_BRAND_LOGO_URL') ||
|
||||
getEnv('NEXT_PUBLIC_BRAND_WORDMARK_URL') ||
|
||||
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_COLOR')
|
||||
)
|
||||
|
||||
return {
|
||||
name: getEnv('NEXT_PUBLIC_BRAND_NAME') || defaultBrandConfig.name,
|
||||
logoUrl: getEnv('NEXT_PUBLIC_BRAND_LOGO_URL') || defaultBrandConfig.logoUrl,
|
||||
wordmarkUrl: getEnv('NEXT_PUBLIC_BRAND_WORDMARK_URL') || defaultBrandConfig.wordmarkUrl,
|
||||
faviconUrl: getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL') || defaultBrandConfig.faviconUrl,
|
||||
customCssUrl: getEnv('NEXT_PUBLIC_CUSTOM_CSS_URL') || defaultBrandConfig.customCssUrl,
|
||||
supportEmail: getEnv('NEXT_PUBLIC_SUPPORT_EMAIL') || defaultBrandConfig.supportEmail,
|
||||
|
||||
124
apps/sim/ee/whitelabeling/components/branding-provider.tsx
Normal file
124
apps/sim/ee/whitelabeling/components/branding-provider.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { BrandConfig } from '@/lib/branding/types'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling/branding'
|
||||
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
|
||||
import { generateOrgThemeCSS, mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
|
||||
export const BRAND_COOKIE_NAME = 'sim-wl'
|
||||
const BRAND_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
|
||||
|
||||
/**
|
||||
* Brand assets and theme CSS cached in a cookie between page loads.
|
||||
* Written client-side after org settings resolve; read server-side in the
|
||||
* workspace layout so the correct branding is baked into the initial HTML.
|
||||
*/
|
||||
export interface BrandCache {
|
||||
logoUrl?: string
|
||||
wordmarkUrl?: string
|
||||
/** Pre-generated `:root { ... }` CSS from the last resolved org settings. */
|
||||
themeCSS?: string
|
||||
}
|
||||
|
||||
function writeBrandCookie(cache: BrandCache | null): void {
|
||||
try {
|
||||
if (cache && Object.keys(cache).length > 0) {
|
||||
document.cookie = `${BRAND_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(cache))}; path=/; max-age=${BRAND_COOKIE_MAX_AGE}; SameSite=Lax`
|
||||
} else {
|
||||
document.cookie = `${BRAND_COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax`
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
interface BrandingContextValue {
|
||||
config: BrandConfig
|
||||
}
|
||||
|
||||
const BrandingContext = createContext<BrandingContextValue>({
|
||||
config: getBrandConfig(),
|
||||
})
|
||||
|
||||
interface BrandingProviderProps {
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Brand cache read server-side from the `sim-wl` cookie by the workspace
|
||||
* layout. When present, the server renders the correct org branding from the
|
||||
* first byte — no flash of any kind on page load or hard refresh.
|
||||
*/
|
||||
initialCache?: BrandCache | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides merged branding (instance env vars + org DB settings) to the workspace.
|
||||
* Injects a `<style>` tag with CSS variable overrides when org colors are configured.
|
||||
*
|
||||
* Flow:
|
||||
* - First visit: org logo loads after the API call resolves (one-time flash).
|
||||
* - All subsequent visits: the workspace layout reads the `sim-wl` cookie
|
||||
* server-side and passes it as `initialCache`. The server renders the correct
|
||||
* brand in the initial HTML — no flash of any kind.
|
||||
*/
|
||||
export function BrandingProvider({ children, initialCache }: BrandingProviderProps) {
|
||||
const [cache, setCache] = useState<BrandCache | null>(initialCache ?? null)
|
||||
|
||||
const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
|
||||
const orgId = orgsData?.activeOrganization?.id
|
||||
const { data: orgSettings, isLoading: settingsLoading } = useWhitelabelSettings(orgId)
|
||||
|
||||
useEffect(() => {
|
||||
if (orgsLoading) return
|
||||
|
||||
if (!orgId) {
|
||||
writeBrandCookie(null)
|
||||
setCache(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (settingsLoading) return
|
||||
|
||||
const themeCSS = orgSettings ? generateOrgThemeCSS(orgSettings) : null
|
||||
const next: BrandCache = {}
|
||||
if (orgSettings?.logoUrl) next.logoUrl = orgSettings.logoUrl
|
||||
if (orgSettings?.wordmarkUrl) next.wordmarkUrl = orgSettings.wordmarkUrl
|
||||
if (themeCSS) next.themeCSS = themeCSS
|
||||
|
||||
const newCache = Object.keys(next).length > 0 ? next : null
|
||||
writeBrandCookie(newCache)
|
||||
setCache(newCache)
|
||||
}, [orgsLoading, orgId, settingsLoading, orgSettings])
|
||||
|
||||
const brandConfig = useMemo(() => {
|
||||
const base = mergeOrgBrandConfig(orgSettings ?? null, getBrandConfig())
|
||||
if (!orgSettings && cache) {
|
||||
return {
|
||||
...base,
|
||||
...(cache.logoUrl && { logoUrl: cache.logoUrl }),
|
||||
...(cache.wordmarkUrl && { wordmarkUrl: cache.wordmarkUrl }),
|
||||
}
|
||||
}
|
||||
return base
|
||||
}, [orgSettings, cache])
|
||||
|
||||
const themeCSS = useMemo(() => {
|
||||
if (orgSettings) return generateOrgThemeCSS(orgSettings)
|
||||
if (cache?.themeCSS) return cache.themeCSS
|
||||
return ''
|
||||
}, [orgSettings, cache])
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={{ config: brandConfig }}>
|
||||
{themeCSS && <style>{themeCSS}</style>}
|
||||
{children}
|
||||
</BrandingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the merged brand config (org settings overlaid on instance defaults).
|
||||
* Use this inside the workspace instead of `getBrandConfig()`.
|
||||
*/
|
||||
export function useOrgBrandConfig(): BrandConfig {
|
||||
return useContext(BrandingContext).config
|
||||
}
|
||||
536
apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
Normal file
536
apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Button, Input, Label, Switch } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
|
||||
import { HEX_COLOR_REGEX } from '@/lib/branding'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getUserRole } from '@/lib/workspaces/organization/utils'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
|
||||
import {
|
||||
useUpdateWhitelabelSettings,
|
||||
useWhitelabelSettings,
|
||||
type WhitelabelSettingsPayload,
|
||||
} from '@/ee/whitelabeling/hooks/whitelabel'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('WhitelabelingSettings')
|
||||
|
||||
interface DropZoneProps {
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DropZone({ onDrop, children, className }: DropZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
setIsDragging(false)
|
||||
onDrop(e)
|
||||
},
|
||||
[onDrop]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{children}
|
||||
{isDragging && (
|
||||
<div className='pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg border-[1.5px] border-[var(--brand)] border-dashed bg-[color-mix(in_srgb,var(--brand)_8%,transparent)]'>
|
||||
<span className='font-medium text-[12px] text-[var(--brand)]'>Drop image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ColorInputProps {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function ColorInput({ label, value, onChange, placeholder = '#000000' }: ColorInputProps) {
|
||||
const isValidHex = !value || HEX_COLOR_REGEX.test(value)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>{label}</Label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative flex h-[36px] w-[36px] shrink-0 items-center justify-center overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)]'>
|
||||
{value && isValidHex ? (
|
||||
<div className='h-full w-full rounded-md' style={{ backgroundColor: value }} />
|
||||
) : (
|
||||
<div className='h-full w-full rounded-md bg-[var(--surface-3)]' />
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-[36px] font-mono text-[13px]',
|
||||
!isValidHex && 'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
{!isValidHex && (
|
||||
<p className='text-[12px] text-red-500'>Must be a valid hex color (e.g. #701ffc)</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingRowProps {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function SettingRow({ label, description, children }: SettingRowProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>{label}</Label>
|
||||
{description && <p className='text-[12px] text-[var(--text-muted)]'>{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h3 className='mb-4 font-medium text-[15px] text-[var(--text-primary)]'>{children}</h3>
|
||||
}
|
||||
|
||||
export function WhitelabelingSettings() {
|
||||
const { data: session } = useSession()
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
const orgId = activeOrganization?.id
|
||||
|
||||
const { data: savedSettings, isLoading } = useWhitelabelSettings(orgId)
|
||||
const updateSettings = useUpdateWhitelabelSettings()
|
||||
|
||||
const userEmail = session?.user?.email
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
const canManage = userRole === 'owner' || userRole === 'admin'
|
||||
const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data)
|
||||
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
|
||||
|
||||
const [brandName, setBrandName] = useState('')
|
||||
const [primaryColor, setPrimaryColor] = useState('')
|
||||
const [primaryHoverColor, setPrimaryHoverColor] = useState('')
|
||||
const [accentColor, setAccentColor] = useState('')
|
||||
const [accentHoverColor, setAccentHoverColor] = useState('')
|
||||
const [supportEmail, setSupportEmail] = useState('')
|
||||
const [documentationUrl, setDocumentationUrl] = useState('')
|
||||
const [termsUrl, setTermsUrl] = useState('')
|
||||
const [privacyUrl, setPrivacyUrl] = useState('')
|
||||
const [hidePoweredBySim, setHidePoweredBySim] = useState(false)
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||
const [wordmarkUrl, setWordmarkUrl] = useState<string | null>(null)
|
||||
const [formInitialized, setFormInitialized] = useState(false)
|
||||
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
|
||||
if (savedSettings && !formInitialized) {
|
||||
setBrandName(savedSettings.brandName ?? '')
|
||||
setPrimaryColor(savedSettings.primaryColor ?? '')
|
||||
setPrimaryHoverColor(savedSettings.primaryHoverColor ?? '')
|
||||
setAccentColor(savedSettings.accentColor ?? '')
|
||||
setAccentHoverColor(savedSettings.accentHoverColor ?? '')
|
||||
setSupportEmail(savedSettings.supportEmail ?? '')
|
||||
setDocumentationUrl(savedSettings.documentationUrl ?? '')
|
||||
setTermsUrl(savedSettings.termsUrl ?? '')
|
||||
setPrivacyUrl(savedSettings.privacyUrl ?? '')
|
||||
setHidePoweredBySim(savedSettings.hidePoweredBySim ?? false)
|
||||
setLogoUrl(savedSettings.logoUrl ?? null)
|
||||
setWordmarkUrl(savedSettings.wordmarkUrl ?? null)
|
||||
setFormInitialized(true)
|
||||
}
|
||||
|
||||
const logoUpload = useProfilePictureUpload({
|
||||
currentImage: logoUrl,
|
||||
onUpload: (url) => setLogoUrl(url),
|
||||
onError: (error) => setSaveError(error),
|
||||
})
|
||||
|
||||
const wordmarkUpload = useProfilePictureUpload({
|
||||
currentImage: wordmarkUrl,
|
||||
onUpload: (url) => setWordmarkUrl(url),
|
||||
onError: (error) => setSaveError(error),
|
||||
})
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!orgId) return
|
||||
|
||||
setSaveError(null)
|
||||
setSaveSuccess(false)
|
||||
|
||||
const colorFields: Array<[string, string]> = [
|
||||
['Primary color', primaryColor],
|
||||
['Primary hover color', primaryHoverColor],
|
||||
['Accent color', accentColor],
|
||||
['Accent hover color', accentHoverColor],
|
||||
]
|
||||
|
||||
for (const [fieldName, value] of colorFields) {
|
||||
if (value && !HEX_COLOR_REGEX.test(value)) {
|
||||
setSaveError(`${fieldName} must be a valid hex color (e.g. #701ffc)`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const settings: WhitelabelSettingsPayload = {
|
||||
brandName: brandName || null,
|
||||
logoUrl: logoUpload.previewUrl || null,
|
||||
wordmarkUrl: wordmarkUpload.previewUrl || null,
|
||||
primaryColor: primaryColor || null,
|
||||
primaryHoverColor: primaryHoverColor || null,
|
||||
accentColor: accentColor || null,
|
||||
accentHoverColor: accentHoverColor || null,
|
||||
supportEmail: supportEmail || null,
|
||||
documentationUrl: documentationUrl || null,
|
||||
termsUrl: termsUrl || null,
|
||||
privacyUrl: privacyUrl || null,
|
||||
hidePoweredBySim,
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSettings.mutateAsync({ orgId, settings })
|
||||
setSaveSuccess(true)
|
||||
setTimeout(() => setSaveSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save whitelabel settings', { error })
|
||||
setSaveError(error instanceof Error ? error.message : 'Failed to save settings')
|
||||
}
|
||||
}, [
|
||||
orgId,
|
||||
brandName,
|
||||
logoUpload.previewUrl,
|
||||
wordmarkUpload.previewUrl,
|
||||
primaryColor,
|
||||
primaryHoverColor,
|
||||
accentColor,
|
||||
accentHoverColor,
|
||||
supportEmail,
|
||||
documentationUrl,
|
||||
termsUrl,
|
||||
privacyUrl,
|
||||
hidePoweredBySim,
|
||||
])
|
||||
|
||||
if (isBillingEnabled) {
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
|
||||
You must be part of an organization to configure whitelabeling.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasEnterprisePlan) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
|
||||
Whitelabeling is available on Enterprise plans only.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
|
||||
Only organization owners and admins can configure whitelabeling settings.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-8'>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className='flex flex-col gap-3'>
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-[var(--surface-3)]' />
|
||||
<div className='h-9 w-full animate-pulse rounded-lg bg-[var(--surface-3)]' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isUploading = logoUpload.isUploading || wordmarkUpload.isUploading
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-8'>
|
||||
<section>
|
||||
<SectionTitle>Brand Identity</SectionTitle>
|
||||
<div className='flex flex-col gap-5'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SettingRow
|
||||
label='Logo'
|
||||
description='Shown in the collapsed sidebar. Square image recommended (PNG or JPEG, max 5MB).'
|
||||
>
|
||||
<DropZone onDrop={logoUpload.handleFileDrop} className='flex items-center gap-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={logoUpload.handleThumbnailClick}
|
||||
disabled={logoUpload.isUploading}
|
||||
className='group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
|
||||
>
|
||||
{logoUpload.isUploading ? (
|
||||
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
|
||||
) : logoUpload.previewUrl ? (
|
||||
<Image
|
||||
src={logoUpload.previewUrl}
|
||||
alt='Logo'
|
||||
fill
|
||||
className='object-contain p-1'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>Logo</span>
|
||||
)}
|
||||
</button>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={logoUpload.handleThumbnailClick}
|
||||
disabled={logoUpload.isUploading}
|
||||
className='text-[13px]'
|
||||
>
|
||||
{logoUpload.previewUrl ? 'Change' : 'Upload'}
|
||||
</Button>
|
||||
{logoUpload.previewUrl && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={logoUpload.handleRemove}
|
||||
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={logoUpload.fileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg'
|
||||
onChange={logoUpload.handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
</DropZone>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label='Wordmark'
|
||||
description='Shown in the expanded sidebar. Wide image recommended (PNG or JPEG, max 5MB).'
|
||||
>
|
||||
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='flex items-center gap-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={wordmarkUpload.handleThumbnailClick}
|
||||
disabled={wordmarkUpload.isUploading}
|
||||
className='group relative flex h-16 w-40 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
|
||||
>
|
||||
{wordmarkUpload.isUploading ? (
|
||||
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
|
||||
) : wordmarkUpload.previewUrl ? (
|
||||
<Image
|
||||
src={wordmarkUpload.previewUrl}
|
||||
alt='Wordmark'
|
||||
fill
|
||||
className='object-contain p-2'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>Wordmark</span>
|
||||
)}
|
||||
</button>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={wordmarkUpload.handleThumbnailClick}
|
||||
disabled={wordmarkUpload.isUploading}
|
||||
className='text-[13px]'
|
||||
>
|
||||
{wordmarkUpload.previewUrl ? 'Change' : 'Upload'}
|
||||
</Button>
|
||||
{wordmarkUpload.previewUrl && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={wordmarkUpload.handleRemove}
|
||||
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={wordmarkUpload.fileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg'
|
||||
onChange={wordmarkUpload.handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
</DropZone>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
label='Brand name'
|
||||
description='Replaces "Sim" in the sidebar and select UI elements.'
|
||||
>
|
||||
<Input
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
placeholder='Your Company'
|
||||
className='h-[36px] max-w-[320px] text-[13px]'
|
||||
maxLength={64}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Colors</SectionTitle>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<ColorInput
|
||||
label='Primary color'
|
||||
value={primaryColor}
|
||||
onChange={setPrimaryColor}
|
||||
placeholder='#701ffc'
|
||||
/>
|
||||
<ColorInput
|
||||
label='Primary hover color'
|
||||
value={primaryHoverColor}
|
||||
onChange={setPrimaryHoverColor}
|
||||
placeholder='#802fff'
|
||||
/>
|
||||
<ColorInput
|
||||
label='Accent color'
|
||||
value={accentColor}
|
||||
onChange={setAccentColor}
|
||||
placeholder='#9d54ff'
|
||||
/>
|
||||
<ColorInput
|
||||
label='Accent hover color'
|
||||
value={accentHoverColor}
|
||||
onChange={setAccentHoverColor}
|
||||
placeholder='#a66fff'
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Links</SectionTitle>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<SettingRow label='Support email'>
|
||||
<Input
|
||||
type='email'
|
||||
value={supportEmail}
|
||||
onChange={(e) => setSupportEmail(e.target.value)}
|
||||
placeholder='support@yourcompany.com'
|
||||
className='h-[36px] text-[13px]'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Documentation URL'>
|
||||
<Input
|
||||
type='url'
|
||||
value={documentationUrl}
|
||||
onChange={(e) => setDocumentationUrl(e.target.value)}
|
||||
placeholder='https://docs.yourcompany.com'
|
||||
className='h-[36px] text-[13px]'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Terms of service URL'>
|
||||
<Input
|
||||
type='url'
|
||||
value={termsUrl}
|
||||
onChange={(e) => setTermsUrl(e.target.value)}
|
||||
placeholder='https://yourcompany.com/terms'
|
||||
className='h-[36px] text-[13px]'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Privacy policy URL'>
|
||||
<Input
|
||||
type='url'
|
||||
value={privacyUrl}
|
||||
onChange={(e) => setPrivacyUrl(e.target.value)}
|
||||
placeholder='https://yourcompany.com/privacy'
|
||||
className='h-[36px] text-[13px]'
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Advanced</SectionTitle>
|
||||
<div className='flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>
|
||||
Hide "Powered by Sim" branding
|
||||
</span>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
Removes the Sim logo from deployed chats and forms.
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={hidePoweredBySim} onCheckedChange={setHidePoweredBySim} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateSettings.isPending || isUploading}
|
||||
className='text-[13px]'
|
||||
>
|
||||
{updateSettings.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-3.5 w-3.5 animate-spin' />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
'Save changes'
|
||||
)}
|
||||
</Button>
|
||||
{saveSuccess && (
|
||||
<span className='text-[13px] text-green-500'>Settings saved successfully.</span>
|
||||
)}
|
||||
{saveError && <span className='text-[13px] text-red-500'>{saveError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
apps/sim/ee/whitelabeling/hooks/whitelabel.ts
Normal file
83
apps/sim/ee/whitelabeling/hooks/whitelabel.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
/** PUT payload — string fields accept null to clear a previously-set value. */
|
||||
export type WhitelabelSettingsPayload = {
|
||||
[K in keyof OrganizationWhitelabelSettings]: OrganizationWhitelabelSettings[K] extends
|
||||
| string
|
||||
| undefined
|
||||
? string | null
|
||||
: OrganizationWhitelabelSettings[K]
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key factories for whitelabel-related queries
|
||||
*/
|
||||
export const whitelabelKeys = {
|
||||
all: ['whitelabel'] as const,
|
||||
settings: (orgId: string) => [...whitelabelKeys.all, 'settings', orgId] as const,
|
||||
}
|
||||
|
||||
async function fetchWhitelabelSettings(
|
||||
orgId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OrganizationWhitelabelSettings> {
|
||||
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, { signal })
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error ?? 'Failed to fetch whitelabel settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
return data as OrganizationWhitelabelSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch whitelabel settings for an organization.
|
||||
*/
|
||||
export function useWhitelabelSettings(orgId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: whitelabelKeys.settings(orgId ?? ''),
|
||||
queryFn: ({ signal }) => fetchWhitelabelSettings(orgId as string, signal),
|
||||
enabled: Boolean(orgId),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateWhitelabelVariables {
|
||||
orgId: string
|
||||
settings: WhitelabelSettingsPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update whitelabel settings for an organization.
|
||||
*/
|
||||
export function useUpdateWhitelabelSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, settings }: UpdateWhitelabelVariables) => {
|
||||
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error ?? 'Failed to update whitelabel settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
return data as OrganizationWhitelabelSettings
|
||||
},
|
||||
onSettled: (_data, _error, { orgId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: whitelabelKeys.settings(orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(orgId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
export type { BrandConfig, ThemeColors } from './branding'
|
||||
export { getBrandConfig, useBrandConfig } from './branding'
|
||||
export { generateThemeCSS } from './inject-theme'
|
||||
export { generateBrandedMetadata, generateStructuredData } from './metadata'
|
||||
export { generateOrgThemeCSS, mergeOrgBrandConfig } from './org-branding-utils'
|
||||
|
||||
@@ -31,6 +31,7 @@ export function generateThemeCSS(): string {
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR) {
|
||||
cssVars.push(`--brand-hover: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`)
|
||||
cssVars.push(`--brand-accent-hover: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`)
|
||||
cssVars.push(
|
||||
`--auth-primary-btn-hover-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`
|
||||
)
|
||||
|
||||
96
apps/sim/ee/whitelabeling/org-branding-utils.ts
Normal file
96
apps/sim/ee/whitelabeling/org-branding-utils.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
|
||||
/**
|
||||
* Merge org-level whitelabel settings over the instance-level brand config.
|
||||
* Org settings take priority for any field they define.
|
||||
*/
|
||||
export function mergeOrgBrandConfig(
|
||||
orgSettings: OrganizationWhitelabelSettings | null,
|
||||
instanceConfig: BrandConfig
|
||||
): BrandConfig {
|
||||
if (!orgSettings) {
|
||||
return instanceConfig
|
||||
}
|
||||
|
||||
const hasOrgBrand = Boolean(
|
||||
orgSettings.brandName ||
|
||||
orgSettings.logoUrl ||
|
||||
orgSettings.wordmarkUrl ||
|
||||
orgSettings.primaryColor
|
||||
)
|
||||
|
||||
return {
|
||||
...instanceConfig,
|
||||
name: orgSettings.brandName || instanceConfig.name,
|
||||
logoUrl: orgSettings.logoUrl || instanceConfig.logoUrl,
|
||||
wordmarkUrl: orgSettings.wordmarkUrl || instanceConfig.wordmarkUrl,
|
||||
supportEmail: orgSettings.supportEmail || instanceConfig.supportEmail,
|
||||
documentationUrl: orgSettings.documentationUrl || instanceConfig.documentationUrl,
|
||||
termsUrl: orgSettings.termsUrl || instanceConfig.termsUrl,
|
||||
privacyUrl: orgSettings.privacyUrl || instanceConfig.privacyUrl,
|
||||
theme: {
|
||||
...instanceConfig.theme,
|
||||
...(orgSettings.primaryColor && { primaryColor: orgSettings.primaryColor }),
|
||||
...(orgSettings.primaryHoverColor && { primaryHoverColor: orgSettings.primaryHoverColor }),
|
||||
...(orgSettings.accentColor && { accentColor: orgSettings.accentColor }),
|
||||
...(orgSettings.accentHoverColor && { accentHoverColor: orgSettings.accentHoverColor }),
|
||||
},
|
||||
isWhitelabeled: instanceConfig.isWhitelabeled || hasOrgBrand,
|
||||
}
|
||||
}
|
||||
|
||||
function isDarkBackground(hex: string): boolean {
|
||||
let clean = hex.replace('#', '')
|
||||
if (clean.length === 3) {
|
||||
clean = clean
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
}
|
||||
const r = Number.parseInt(clean.slice(0, 2), 16)
|
||||
const g = Number.parseInt(clean.slice(2, 4), 16)
|
||||
const b = Number.parseInt(clean.slice(4, 6), 16)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
|
||||
}
|
||||
|
||||
function getContrastTextColor(hex: string): string {
|
||||
return isDarkBackground(hex) ? '#ffffff' : '#000000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSS variable overrides from org whitelabel settings.
|
||||
* Returns an empty string when no color overrides are set.
|
||||
*/
|
||||
export function generateOrgThemeCSS(settings: OrganizationWhitelabelSettings): string {
|
||||
const vars: string[] = []
|
||||
|
||||
if (settings.primaryColor) {
|
||||
vars.push(`--brand: ${settings.primaryColor};`)
|
||||
vars.push(`--brand-accent: ${settings.primaryColor};`)
|
||||
vars.push(`--auth-primary-btn-bg: ${settings.primaryColor};`)
|
||||
vars.push(`--auth-primary-btn-border: ${settings.primaryColor};`)
|
||||
vars.push(`--auth-primary-btn-hover-bg: ${settings.primaryColor};`)
|
||||
vars.push(`--auth-primary-btn-hover-border: ${settings.primaryColor};`)
|
||||
const textColor = getContrastTextColor(settings.primaryColor)
|
||||
vars.push(`--auth-primary-btn-text: ${textColor};`)
|
||||
vars.push(`--auth-primary-btn-hover-text: ${textColor};`)
|
||||
}
|
||||
|
||||
if (settings.primaryHoverColor) {
|
||||
vars.push(`--brand-hover: ${settings.primaryHoverColor};`)
|
||||
vars.push(`--brand-accent-hover: ${settings.primaryHoverColor};`)
|
||||
vars.push(`--auth-primary-btn-hover-bg: ${settings.primaryHoverColor};`)
|
||||
vars.push(`--auth-primary-btn-hover-border: ${settings.primaryHoverColor};`)
|
||||
vars.push(`--auth-primary-btn-hover-text: ${getContrastTextColor(settings.primaryHoverColor)};`)
|
||||
}
|
||||
|
||||
if (settings.accentColor) {
|
||||
vars.push(`--brand-link: ${settings.accentColor};`)
|
||||
}
|
||||
|
||||
if (settings.accentHoverColor) {
|
||||
vars.push(`--brand-link-hover: ${settings.accentHoverColor};`)
|
||||
}
|
||||
|
||||
return vars.length > 0 ? `:root { ${vars.join(' ')} }` : ''
|
||||
}
|
||||
37
apps/sim/ee/whitelabeling/org-branding.ts
Normal file
37
apps/sim/ee/whitelabeling/org-branding.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling/branding'
|
||||
import { mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
|
||||
|
||||
const logger = createLogger('OrgBranding')
|
||||
|
||||
/**
|
||||
* Fetch whitelabel settings for an organization from the database.
|
||||
*/
|
||||
export async function getOrgWhitelabelSettings(
|
||||
orgId: string
|
||||
): Promise<OrganizationWhitelabelSettings | null> {
|
||||
try {
|
||||
const [org] = await db
|
||||
.select({ whitelabelSettings: organization.whitelabelSettings })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, orgId))
|
||||
.limit(1)
|
||||
|
||||
return org?.whitelabelSettings ?? null
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch org whitelabel settings', { error, orgId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the merged brand config for an org, combining instance env vars with org DB settings.
|
||||
*/
|
||||
export async function getOrgBrandConfig(orgId: string): Promise<BrandConfig> {
|
||||
const orgSettings = await getOrgWhitelabelSettings(orgId)
|
||||
return mergeOrgBrandConfig(orgSettings, getBrandConfig())
|
||||
}
|
||||
@@ -22,8 +22,6 @@ export interface ParallelScope {
|
||||
parallelId: string
|
||||
totalBranches: number
|
||||
branchOutputs: Map<number, NormalizedBlockOutput[]>
|
||||
completedCount: number
|
||||
totalExpectedNodes: number
|
||||
items?: any[]
|
||||
/** Error message if parallel validation failed (e.g., exceeded max branches) */
|
||||
validationError?: string
|
||||
|
||||
@@ -58,9 +58,7 @@ export class NodeExecutionOrchestrator {
|
||||
|
||||
const parallelId = node.metadata.parallelId
|
||||
if (parallelId && !this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
const nodesInParallel = parallelConfig?.nodes?.length || 1
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel)
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
|
||||
}
|
||||
|
||||
if (node.metadata.isSentinel) {
|
||||
@@ -157,8 +155,7 @@ export class NodeExecutionOrchestrator {
|
||||
if (!this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
if (parallelConfig) {
|
||||
const nodesInParallel = parallelConfig.nodes?.length || 1
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel)
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,20 +234,9 @@ export class NodeExecutionOrchestrator {
|
||||
): Promise<void> {
|
||||
const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId)
|
||||
if (!scope) {
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
const nodesInParallel = parallelConfig?.nodes?.length || 1
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel)
|
||||
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
|
||||
}
|
||||
const allComplete = this.parallelOrchestrator.handleParallelBranchCompletion(
|
||||
ctx,
|
||||
parallelId,
|
||||
node.id,
|
||||
output
|
||||
)
|
||||
if (allComplete) {
|
||||
await this.parallelOrchestrator.aggregateParallelResults(ctx, parallelId)
|
||||
}
|
||||
|
||||
this.parallelOrchestrator.handleParallelBranchCompletion(ctx, parallelId, node.id, output)
|
||||
this.state.setBlockOutput(node.id, output)
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('ParallelOrchestrator', () => {
|
||||
)
|
||||
const ctx = createContext()
|
||||
|
||||
const initializePromise = orchestrator.initializeParallelScope(ctx, 'parallel-1', 1)
|
||||
const initializePromise = orchestrator.initializeParallelScope(ctx, 'parallel-1')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onBlockStart).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -47,17 +47,13 @@ export class ParallelOrchestrator {
|
||||
private contextExtensions: ContextExtensions | null = null
|
||||
) {}
|
||||
|
||||
async initializeParallelScope(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string,
|
||||
terminalNodesCount = 1
|
||||
): Promise<ParallelScope> {
|
||||
async initializeParallelScope(ctx: ExecutionContext, parallelId: string): Promise<ParallelScope> {
|
||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||
if (!parallelConfig) {
|
||||
throw new Error(`Parallel config not found: ${parallelId}`)
|
||||
}
|
||||
|
||||
if (terminalNodesCount === 0 || parallelConfig.nodes.length === 0) {
|
||||
if (parallelConfig.nodes.length === 0) {
|
||||
const errorMessage =
|
||||
'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.'
|
||||
logger.error(errorMessage, { parallelId })
|
||||
@@ -108,8 +104,6 @@ export class ParallelOrchestrator {
|
||||
parallelId,
|
||||
totalBranches: 0,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 0,
|
||||
items: [],
|
||||
isEmpty: true,
|
||||
}
|
||||
@@ -186,8 +180,6 @@ export class ParallelOrchestrator {
|
||||
parallelId,
|
||||
totalBranches: branchCount,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: branchCount * terminalNodesCount,
|
||||
items,
|
||||
}
|
||||
|
||||
@@ -253,8 +245,6 @@ export class ParallelOrchestrator {
|
||||
parallelId,
|
||||
totalBranches: 0,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 0,
|
||||
items: [],
|
||||
validationError: errorMessage,
|
||||
}
|
||||
@@ -277,32 +267,34 @@ export class ParallelOrchestrator {
|
||||
return resolveArrayInput(ctx, config.distribution, this.resolver)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a node's output in the branch outputs for later aggregation.
|
||||
* Aggregation is triggered by the sentinel-end node via the edge mechanism,
|
||||
* not by counting individual node completions. This avoids incorrect completion
|
||||
* detection when branches have conditional paths (error edges, conditions).
|
||||
*/
|
||||
handleParallelBranchCompletion(
|
||||
ctx: ExecutionContext,
|
||||
parallelId: string,
|
||||
nodeId: string,
|
||||
output: NormalizedBlockOutput
|
||||
): boolean {
|
||||
): void {
|
||||
const scope = ctx.parallelExecutions?.get(parallelId)
|
||||
if (!scope) {
|
||||
logger.warn('Parallel scope not found for branch completion', { parallelId, nodeId })
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
const branchIndex = extractBranchIndex(nodeId)
|
||||
if (branchIndex === null) {
|
||||
logger.warn('Could not extract branch index from node ID', { nodeId })
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
if (!scope.branchOutputs.has(branchIndex)) {
|
||||
scope.branchOutputs.set(branchIndex, [])
|
||||
}
|
||||
scope.branchOutputs.get(branchIndex)!.push(output)
|
||||
scope.completedCount++
|
||||
|
||||
const allComplete = scope.completedCount >= scope.totalExpectedNodes
|
||||
return allComplete
|
||||
}
|
||||
|
||||
async aggregateParallelResults(
|
||||
|
||||
@@ -228,8 +228,6 @@ export interface ExecutionContext {
|
||||
parallelId: string
|
||||
totalBranches: number
|
||||
branchOutputs: Map<number, any[]>
|
||||
completedCount: number
|
||||
totalExpectedNodes: number
|
||||
parallelType?: 'count' | 'collection'
|
||||
items?: any[]
|
||||
}
|
||||
|
||||
@@ -43,8 +43,6 @@ describe('getIterationContext', () => {
|
||||
parallelId: 'p1',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 3,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -135,8 +133,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'outer-p',
|
||||
totalBranches: 4,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 4,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -164,8 +160,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 5,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -232,8 +226,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'outer-p',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 3,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -275,8 +267,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'P1',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -285,8 +275,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'P2',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -295,8 +283,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'P2__obranch-1',
|
||||
totalBranches: 2,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 2,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -363,8 +349,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 5,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -423,8 +407,6 @@ describe('buildUnifiedParentIterations', () => {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 3,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -478,8 +460,6 @@ describe('buildContainerIterationContext', () => {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 5,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -541,8 +521,6 @@ describe('buildContainerIterationContext', () => {
|
||||
parallelId: 'P2__obranch-1',
|
||||
totalBranches: 5,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 5,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -568,8 +546,6 @@ describe('buildContainerIterationContext', () => {
|
||||
parallelId: 'outer-parallel',
|
||||
totalBranches: 3,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 3,
|
||||
},
|
||||
],
|
||||
]),
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
interface BlockDef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal workflow for testing.
|
||||
*/
|
||||
@@ -18,7 +23,8 @@ function createTestWorkflow(
|
||||
distribution?: any
|
||||
parallelType?: 'count' | 'collection'
|
||||
}
|
||||
> = {}
|
||||
> = {},
|
||||
blockDefs: BlockDef[] = []
|
||||
) {
|
||||
const normalizedParallels: Record<
|
||||
string,
|
||||
@@ -37,9 +43,18 @@ function createTestWorkflow(
|
||||
parallelType: parallel.parallelType,
|
||||
}
|
||||
}
|
||||
const blocks = blockDefs.map((b) => ({
|
||||
id: b.id,
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'test', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: b.name },
|
||||
enabled: true,
|
||||
}))
|
||||
return {
|
||||
version: '1.0',
|
||||
blocks: [],
|
||||
blocks,
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: normalizedParallels,
|
||||
@@ -54,8 +69,6 @@ function createParallelScope(items: any[]) {
|
||||
parallelId: 'parallel-1',
|
||||
totalBranches: items.length,
|
||||
branchOutputs: new Map(),
|
||||
completedCount: 0,
|
||||
totalExpectedNodes: 1,
|
||||
items,
|
||||
}
|
||||
}
|
||||
@@ -65,13 +78,16 @@ function createParallelScope(items: any[]) {
|
||||
*/
|
||||
function createTestContext(
|
||||
currentNodeId: string,
|
||||
parallelExecutions?: Map<string, any>
|
||||
parallelExecutions?: Map<string, any>,
|
||||
blockOutputs?: Record<string, any>
|
||||
): ResolutionContext {
|
||||
return {
|
||||
executionContext: {
|
||||
parallelExecutions: parallelExecutions ?? new Map(),
|
||||
},
|
||||
executionState: {},
|
||||
executionState: {
|
||||
getBlockOutput: (id: string) => blockOutputs?.[id],
|
||||
},
|
||||
currentNodeId,
|
||||
} as ResolutionContext
|
||||
}
|
||||
@@ -385,4 +401,119 @@ describe('ParallelResolver', () => {
|
||||
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-3₍1₎'))).toBe('p4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('named parallel references', () => {
|
||||
it.concurrent('should resolve result from anywhere after parallel completes', () => {
|
||||
const workflow = createTestWorkflow(
|
||||
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
|
||||
[{ id: 'parallel-1', name: 'Parallel 1' }]
|
||||
)
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const results = [[{ response: 'a' }], [{ response: 'b' }]]
|
||||
const ctx = createTestContext('block-outside', new Map(), {
|
||||
'parallel-1': { results },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<parallel1.result>', ctx)).toEqual(results)
|
||||
expect(resolver.resolve('<parallel1.results>', ctx)).toEqual(results)
|
||||
})
|
||||
|
||||
it.concurrent('should resolve result with nested path', () => {
|
||||
const workflow = createTestWorkflow(
|
||||
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
|
||||
[{ id: 'parallel-1', name: 'Parallel 1' }]
|
||||
)
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const results = [[{ response: 'a' }], [{ response: 'b' }]]
|
||||
const ctx = createTestContext('block-outside', new Map(), {
|
||||
'parallel-1': { results },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<parallel1.result.0>', ctx)).toEqual([{ response: 'a' }])
|
||||
expect(resolver.resolve('<parallel1.result.1.0.response>', ctx)).toBe('b')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve result with empty currentNodeId', () => {
|
||||
const workflow = createTestWorkflow(
|
||||
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
|
||||
[{ id: 'parallel-1', name: 'Parallel 1' }]
|
||||
)
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const results = [[{ output: 'x' }], [{ output: 'y' }]]
|
||||
const ctx = createTestContext('', new Map(), {
|
||||
'parallel-1': { results },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<parallel1.results>', ctx)).toEqual(results)
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined when no output stored yet', () => {
|
||||
const workflow = createTestWorkflow(
|
||||
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a'] } },
|
||||
[{ id: 'parallel-1', name: 'Parallel 1' }]
|
||||
)
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-outside', new Map())
|
||||
|
||||
expect(resolver.resolve('<parallel1.results>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should resolve iteration properties via named reference', () => {
|
||||
const workflow = createTestWorkflow(
|
||||
{
|
||||
'parallel-1': {
|
||||
nodes: ['block-1'],
|
||||
distribution: ['x', 'y', 'z'],
|
||||
parallelType: 'collection',
|
||||
},
|
||||
},
|
||||
[{ id: 'parallel-1', name: 'Parallel 1' }]
|
||||
)
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍1₎')
|
||||
|
||||
expect(resolver.resolve('<parallel1.index>', ctx)).toBe(1)
|
||||
expect(resolver.resolve('<parallel1.currentItem>', ctx)).toBe('y')
|
||||
expect(resolver.resolve('<parallel1.items>', ctx)).toEqual(['x', 'y', 'z'])
|
||||
})
|
||||
|
||||
it.concurrent('should throw InvalidFieldError for unknown property on named ref', () => {
|
||||
const workflow = createTestWorkflow(
|
||||
{
|
||||
'parallel-1': {
|
||||
nodes: ['block-1'],
|
||||
distribution: ['a'],
|
||||
parallelType: 'collection',
|
||||
},
|
||||
},
|
||||
[{ id: 'parallel-1', name: 'Parallel 1' }]
|
||||
)
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const ctx = createTestContext('block-1₍0₎')
|
||||
|
||||
expect(() => resolver.resolve('<parallel1.unknownProp>', ctx)).toThrow(InvalidFieldError)
|
||||
})
|
||||
|
||||
it.concurrent('should not resolve named ref when no matching block exists', () => {
|
||||
const workflow = createTestWorkflow({ 'parallel-1': { nodes: ['block-1'] } }, [
|
||||
{ id: 'parallel-1', name: 'Parallel 1' },
|
||||
])
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
expect(resolver.canResolve('<parallel99.index>')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should resolve generic parallel results from inside a branch', () => {
|
||||
const workflow = createTestWorkflow({
|
||||
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] },
|
||||
})
|
||||
const resolver = new ParallelResolver(workflow)
|
||||
const results = [[{ response: 'a' }], [{ response: 'b' }]]
|
||||
const ctx = createTestContext('block-1₍0₎', new Map(), {
|
||||
'parallel-1': { results },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<parallel.results>', ctx)).toEqual(results)
|
||||
expect(resolver.resolve('<parallel.result>', ctx)).toEqual(results)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ export class ParallelResolver implements Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
private static OUTPUT_PROPERTIES = new Set(['result', 'results'])
|
||||
private static KNOWN_PROPERTIES = new Set(['index', 'currentItem', 'items'])
|
||||
|
||||
canResolve(reference: string): boolean {
|
||||
@@ -73,6 +74,10 @@ export class ParallelResolver implements Resolver {
|
||||
)
|
||||
}
|
||||
|
||||
if (rest.length > 0 && ParallelResolver.OUTPUT_PROPERTIES.has(rest[0])) {
|
||||
return this.resolveOutput(targetParallelId, rest.slice(1), context)
|
||||
}
|
||||
|
||||
// Look up config using the original (non-cloned) ID
|
||||
const originalParallelId = stripOuterBranchSuffix(targetParallelId)
|
||||
const parallelConfig = this.workflow.parallels?.[originalParallelId]
|
||||
@@ -116,7 +121,9 @@ export class ParallelResolver implements Resolver {
|
||||
|
||||
if (!ParallelResolver.KNOWN_PROPERTIES.has(property)) {
|
||||
const isCollection = parallelConfig.parallelType === 'collection'
|
||||
const availableFields = isCollection ? ['index', 'currentItem', 'items'] : ['index']
|
||||
const availableFields = isCollection
|
||||
? ['index', 'currentItem', 'items', 'result']
|
||||
: ['index', 'result']
|
||||
throw new InvalidFieldError(firstPart, property, availableFields)
|
||||
}
|
||||
|
||||
@@ -216,6 +223,22 @@ export class ParallelResolver implements Resolver {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private resolveOutput(
|
||||
parallelId: string,
|
||||
pathParts: string[],
|
||||
context: ResolutionContext
|
||||
): unknown {
|
||||
const output = context.executionState.getBlockOutput(parallelId)
|
||||
if (!output || typeof output !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const value = (output as Record<string, unknown>).results
|
||||
if (pathParts.length > 0) {
|
||||
return navigatePath(value, pathParts)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private getDistributionItems(parallelConfig: SerializedParallel): unknown[] {
|
||||
const rawItems = parallelConfig.distribution ?? []
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ import {
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import {
|
||||
isAccessControlEnabled,
|
||||
isBillingEnabled,
|
||||
isCredentialSetsEnabled,
|
||||
isHosted,
|
||||
isInboxEnabled,
|
||||
isProd,
|
||||
isSsoEnabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -98,7 +98,7 @@ export async function hasPaidSubscription(referenceId: string): Promise<boolean>
|
||||
*/
|
||||
export async function isProPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function isProPlan(userId: string): Promise<boolean> {
|
||||
*/
|
||||
export async function isTeamPlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function isTeamPlan(userId: string): Promise<boolean> {
|
||||
*/
|
||||
export async function isEnterprisePlan(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export async function isEnterprisePlan(userId: string): Promise<boolean> {
|
||||
*/
|
||||
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole
|
||||
*/
|
||||
export async function isTeamOrgAdminOrOwner(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
|
||||
organizationId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
|
||||
*/
|
||||
export async function isOrganizationOnEnterprisePlan(organizationId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ export async function hasInboxAccess(userId: string): Promise<boolean> {
|
||||
if (isInboxEnabled) {
|
||||
return true
|
||||
}
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return true
|
||||
}
|
||||
const [sub, billingStatus] = await Promise.all([
|
||||
@@ -490,7 +490,7 @@ export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
|
||||
*/
|
||||
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
if (!isBillingEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -560,7 +560,7 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
|
||||
// Determine plan types based on subscription (avoid redundant DB calls)
|
||||
const isPro =
|
||||
!isProd ||
|
||||
!isBillingEnabled ||
|
||||
!!(
|
||||
subscription &&
|
||||
(checkProPlan(subscription) ||
|
||||
@@ -568,9 +568,9 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
checkEnterprisePlan(subscription))
|
||||
)
|
||||
const isTeam =
|
||||
!isProd ||
|
||||
!isBillingEnabled ||
|
||||
!!(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
|
||||
const isEnterprise = !isProd || !!(subscription && checkEnterprisePlan(subscription))
|
||||
const isEnterprise = !isBillingEnabled || !!(subscription && checkEnterprisePlan(subscription))
|
||||
const isFree = !isPro && !isTeam && !isEnterprise
|
||||
|
||||
// Determine plan name
|
||||
@@ -581,7 +581,7 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
|
||||
// Check cost limit using already-fetched user stats
|
||||
let hasExceededLimit = false
|
||||
if (isProd && statsRecords.length > 0) {
|
||||
if (isBillingEnabled && statsRecords.length > 0) {
|
||||
let limit = getFreeTierLimit() // Default free tier limit
|
||||
if (subscription) {
|
||||
// Team/Enterprise: Use organization limit
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BrandConfig } from './types'
|
||||
export const defaultBrandConfig: BrandConfig = {
|
||||
name: 'Sim',
|
||||
logoUrl: undefined,
|
||||
wordmarkUrl: undefined,
|
||||
faviconUrl: undefined,
|
||||
customCssUrl: undefined,
|
||||
supportEmail: 'help@sim.ai',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { defaultBrandConfig } from './defaults'
|
||||
export type { BrandConfig, ThemeColors } from './types'
|
||||
export type { BrandConfig, OrganizationWhitelabelSettings, ThemeColors } from './types'
|
||||
export { HEX_COLOR_REGEX } from './types'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/** Matches 3- or 6-digit hex colors, e.g. `#abc` or `#701ffc`. */
|
||||
export const HEX_COLOR_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
|
||||
|
||||
export interface ThemeColors {
|
||||
primaryColor?: string
|
||||
primaryHoverColor?: string
|
||||
@@ -9,6 +12,7 @@ export interface ThemeColors {
|
||||
export interface BrandConfig {
|
||||
name: string
|
||||
logoUrl?: string
|
||||
wordmarkUrl?: string
|
||||
faviconUrl?: string
|
||||
customCssUrl?: string
|
||||
supportEmail?: string
|
||||
@@ -19,3 +23,22 @@ export interface BrandConfig {
|
||||
/** Whether this instance has custom branding applied (any brand env var is set) */
|
||||
isWhitelabeled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-organization whitelabel settings stored in the database.
|
||||
* Only available for enterprise organizations on the hosted platform.
|
||||
*/
|
||||
export interface OrganizationWhitelabelSettings {
|
||||
brandName?: string
|
||||
logoUrl?: string
|
||||
wordmarkUrl?: string
|
||||
primaryColor?: string
|
||||
primaryHoverColor?: string
|
||||
accentColor?: string
|
||||
accentHoverColor?: string
|
||||
supportEmail?: string
|
||||
documentationUrl?: string
|
||||
termsUrl?: string
|
||||
privacyUrl?: string
|
||||
hidePoweredBySim?: boolean
|
||||
}
|
||||
|
||||
@@ -725,7 +725,15 @@ export async function processPolledWebhookEvent(
|
||||
try {
|
||||
const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
|
||||
if (preprocessResult.error) {
|
||||
return { success: false, error: 'Preprocessing failed', statusCode: 500 }
|
||||
const errorResponse = preprocessResult.error
|
||||
const statusCode = errorResponse.status
|
||||
const errorBody = await errorResponse.json().catch(() => ({}))
|
||||
const errorMessage = errorBody.error ?? 'Preprocessing failed'
|
||||
logger.warn(`[${requestId}] Polled webhook preprocessing failed`, {
|
||||
statusCode,
|
||||
error: errorMessage,
|
||||
})
|
||||
return { success: false, error: errorMessage, statusCode }
|
||||
}
|
||||
|
||||
if (foundWebhook.blockId) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { normalizeName, REFERENCE } from '@/executor/constants'
|
||||
|
||||
export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
|
||||
export const SYSTEM_REFERENCE_PREFIXES = new Set(['loop', 'parallel', 'variable'])
|
||||
|
||||
const INVALID_REFERENCE_CHARS = /[+*/=<>!]/
|
||||
|
||||
|
||||
@@ -21,7 +21,14 @@ export default defineConfig({
|
||||
files: ['./lib/execution/isolated-vm-worker.cjs', './lib/execution/pptx-worker.cjs'],
|
||||
}),
|
||||
additionalPackages({
|
||||
packages: ['unpdf', 'pdf-lib', 'isolated-vm', 'pptxgenjs'],
|
||||
packages: [
|
||||
'unpdf',
|
||||
'pdf-lib',
|
||||
'isolated-vm',
|
||||
'pptxgenjs',
|
||||
'react-dom',
|
||||
'@react-email/render',
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -181,7 +181,8 @@ app:
|
||||
|
||||
# UI Branding & Whitelabeling Configuration
|
||||
NEXT_PUBLIC_BRAND_NAME: "Sim" # Custom brand name
|
||||
NEXT_PUBLIC_BRAND_LOGO_URL: "" # Custom logo URL (leave empty for default)
|
||||
NEXT_PUBLIC_BRAND_LOGO_URL: "" # Custom logo URL — square icon, shown in collapsed sidebar
|
||||
NEXT_PUBLIC_BRAND_WORDMARK_URL: "" # Custom wordmark URL — wide image, shown in expanded sidebar
|
||||
NEXT_PUBLIC_BRAND_FAVICON_URL: "" # Custom favicon URL (leave empty for default)
|
||||
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: "" # Primary brand color (hex, e.g., "#701a75")
|
||||
NEXT_PUBLIC_BRAND_ACCENT_COLOR: "" # Accent color (hex, e.g., "#9333ea")
|
||||
|
||||
1
packages/db/migrations/0188_short_luke_cage.sql
Normal file
1
packages/db/migrations/0188_short_luke_cage.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "organization" ADD COLUMN "whitelabel_settings" json;
|
||||
14646
packages/db/migrations/meta/0188_snapshot.json
Normal file
14646
packages/db/migrations/meta/0188_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1310,6 +1310,13 @@
|
||||
"when": 1775630332078,
|
||||
"tag": "0187_fuzzy_wendell_vaughn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 188,
|
||||
"version": "7",
|
||||
"when": 1775666653692,
|
||||
"tag": "0188_short_luke_cage",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -947,6 +947,19 @@ export const organization = pgTable('organization', {
|
||||
slug: text('slug').notNull(),
|
||||
logo: text('logo'),
|
||||
metadata: json('metadata'),
|
||||
whitelabelSettings: json('whitelabel_settings').$type<{
|
||||
brandName?: string
|
||||
logoUrl?: string
|
||||
primaryColor?: string
|
||||
primaryHoverColor?: string
|
||||
accentColor?: string
|
||||
accentHoverColor?: string
|
||||
supportEmail?: string
|
||||
documentationUrl?: string
|
||||
termsUrl?: string
|
||||
privacyUrl?: string
|
||||
hidePoweredBySim?: boolean
|
||||
}>(),
|
||||
orgUsageLimit: decimal('org_usage_limit'),
|
||||
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
|
||||
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
|
||||
|
||||
12
packages/testing/src/factories/table.factory.test.ts
Normal file
12
packages/testing/src/factories/table.factory.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTableColumn } from './table.factory'
|
||||
|
||||
describe('table factory', () => {
|
||||
it('generates default column names that match table naming rules', () => {
|
||||
const generatedNames = Array.from({ length: 100 }, () => createTableColumn().name)
|
||||
|
||||
for (const name of generatedNames) {
|
||||
expect(name).toMatch(/^[a-z_][a-z0-9_]*$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
62
packages/testing/src/factories/table.factory.ts
Normal file
62
packages/testing/src/factories/table.factory.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { customAlphabet, nanoid } from 'nanoid'
|
||||
|
||||
export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
|
||||
|
||||
export interface TableColumnFixture {
|
||||
name: string
|
||||
type: TableColumnType
|
||||
required?: boolean
|
||||
unique?: boolean
|
||||
}
|
||||
|
||||
export interface TableRowFixture {
|
||||
id: string
|
||||
data: Record<string, unknown>
|
||||
position: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TableColumnFactoryOptions {
|
||||
name?: string
|
||||
type?: TableColumnType
|
||||
required?: boolean
|
||||
unique?: boolean
|
||||
}
|
||||
|
||||
export interface TableRowFactoryOptions {
|
||||
id?: string
|
||||
data?: Record<string, unknown>
|
||||
position?: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const createTableColumnSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789_', 6)
|
||||
|
||||
/**
|
||||
* Creates a table column fixture with sensible defaults.
|
||||
*/
|
||||
export function createTableColumn(options: TableColumnFactoryOptions = {}): TableColumnFixture {
|
||||
return {
|
||||
name: options.name ?? `column_${createTableColumnSuffix()}`,
|
||||
type: options.type ?? 'string',
|
||||
required: options.required,
|
||||
unique: options.unique,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a table row fixture with sensible defaults.
|
||||
*/
|
||||
export function createTableRow(options: TableRowFactoryOptions = {}): TableRowFixture {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
return {
|
||||
id: options.id ?? `row_${nanoid(8)}`,
|
||||
data: options.data ?? {},
|
||||
position: options.position ?? 0,
|
||||
createdAt: options.createdAt ?? timestamp,
|
||||
updatedAt: options.updatedAt ?? timestamp,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://v2-9-4.turborepo.dev/schema.json",
|
||||
"$schema": "https://v2-9-5.turborepo.dev/schema.json",
|
||||
"envMode": "loose",
|
||||
"tasks": {
|
||||
"transit": {
|
||||
|
||||
Reference in New Issue
Block a user