chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { type BrandConfig, defaultBrandConfig, type ThemeColors } from '@/lib/branding'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
|
||||
export type { BrandConfig, ThemeColors }
|
||||
|
||||
const getThemeColors = (): ThemeColors => {
|
||||
return {
|
||||
primaryColor:
|
||||
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_COLOR') || defaultBrandConfig.theme?.primaryColor,
|
||||
primaryHoverColor:
|
||||
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR') ||
|
||||
defaultBrandConfig.theme?.primaryHoverColor,
|
||||
accentColor: getEnv('NEXT_PUBLIC_BRAND_ACCENT_COLOR') || defaultBrandConfig.theme?.accentColor,
|
||||
accentHoverColor:
|
||||
getEnv('NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR') || defaultBrandConfig.theme?.accentHoverColor,
|
||||
backgroundColor:
|
||||
getEnv('NEXT_PUBLIC_BRAND_BACKGROUND_COLOR') || defaultBrandConfig.theme?.backgroundColor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get branding configuration from environment variables
|
||||
* Supports runtime configuration via Docker/Kubernetes
|
||||
*/
|
||||
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,
|
||||
documentationUrl:
|
||||
getEnv('NEXT_PUBLIC_DOCUMENTATION_URL') || defaultBrandConfig.documentationUrl,
|
||||
termsUrl: getEnv('NEXT_PUBLIC_TERMS_URL') || defaultBrandConfig.termsUrl,
|
||||
privacyUrl: getEnv('NEXT_PUBLIC_PRIVACY_URL') || defaultBrandConfig.privacyUrl,
|
||||
theme: getThemeColors(),
|
||||
isWhitelabeled: hasCustomBrand,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use brand configuration in React components
|
||||
*/
|
||||
export const useBrandConfig = () => {
|
||||
return getBrandConfig()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import type { BrandConfig, OrganizationWhitelabelSettings } 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'
|
||||
|
||||
interface BrandingContextValue {
|
||||
config: BrandConfig
|
||||
}
|
||||
|
||||
const BrandingContext = createContext<BrandingContextValue>({
|
||||
config: getBrandConfig(),
|
||||
})
|
||||
|
||||
interface BrandingProviderProps {
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Org whitelabel settings fetched server-side from the DB by the workspace layout.
|
||||
* Used as the source of truth until the React Query result becomes available,
|
||||
* ensuring the correct org logo appears in the initial server HTML — no flash.
|
||||
*/
|
||||
initialOrgSettings?: OrganizationWhitelabelSettings | 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.
|
||||
*/
|
||||
export function BrandingProvider({ children, initialOrgSettings }: BrandingProviderProps) {
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const orgId = orgsData?.activeOrganization?.id
|
||||
const { data: orgSettings } = useWhitelabelSettings(orgId)
|
||||
|
||||
const effectiveOrgSettings =
|
||||
orgSettings !== undefined ? orgSettings : (initialOrgSettings ?? null)
|
||||
|
||||
const brandConfig = useMemo(
|
||||
() => mergeOrgBrandConfig(effectiveOrgSettings, getBrandConfig()),
|
||||
[effectiveOrgSettings]
|
||||
)
|
||||
|
||||
const themeCSS = useMemo(
|
||||
() => (effectiveOrgSettings ? generateOrgThemeCSS(effectiveOrgSettings) : ''),
|
||||
[effectiveOrgSettings]
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, ChipInput, cn, Label, Loader, toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { Image as ImageIcon, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams } from 'next/navigation'
|
||||
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/env-flags'
|
||||
import { getUserRole } from '@/lib/workspaces/organization/utils'
|
||||
import {
|
||||
CHIP_FIELD_INPUT,
|
||||
CHIP_FIELD_SHELL,
|
||||
} from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
import { SettingRow } from '@/ee/components/setting-row'
|
||||
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)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
setIsDragging(false)
|
||||
onDrop(e)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{isDragging && (
|
||||
<div className='pointer-events-none absolute inset-0 z-10 rounded-lg border-[1.5px] border-[var(--brand-accent)] border-dashed bg-[color-mix(in_srgb,var(--brand-accent)_8%,transparent)]' />
|
||||
)}
|
||||
</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)
|
||||
const showColor = Boolean(value) && isValidHex
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label className='text-[var(--text-primary)] text-small'>{label}</Label>
|
||||
<div className={cn(CHIP_FIELD_SHELL, !isValidHex && 'border-[var(--text-error)]')}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-[16px] flex-shrink-0 rounded-sm border border-[var(--border-1)]',
|
||||
!showColor && 'bg-[var(--surface-3)]'
|
||||
)}
|
||||
style={showColor ? { backgroundColor: value } : undefined}
|
||||
/>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
let v = e.target.value.trim()
|
||||
if (v && !v.startsWith('#')) {
|
||||
v = `#${v}`
|
||||
}
|
||||
v = v.slice(0, 1) + v.slice(1).replace(/[^0-9a-fA-F]/g, '')
|
||||
onChange(v.slice(0, 7))
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder={placeholder}
|
||||
maxLength={7}
|
||||
className={cn(CHIP_FIELD_INPUT, 'font-mono')}
|
||||
/>
|
||||
</div>
|
||||
{!isValidHex && (
|
||||
<p className='text-[var(--text-error)] text-caption'>
|
||||
Must be a valid hex color (e.g. #33c482)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WhitelabelingSettings() {
|
||||
const params = useParams<{ workspaceId: string }>()
|
||||
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 = isOrgAdminRole(userRole)
|
||||
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 [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||
const [wordmarkUrl, setWordmarkUrl] = useState<string | null>(null)
|
||||
const formInitializedRef = useRef(false)
|
||||
const [savedBrandName, setSavedBrandName] = useState('')
|
||||
const [savedPrimaryColor, setSavedPrimaryColor] = useState('')
|
||||
const [savedPrimaryHoverColor, setSavedPrimaryHoverColor] = useState('')
|
||||
const [savedAccentColor, setSavedAccentColor] = useState('')
|
||||
const [savedAccentHoverColor, setSavedAccentHoverColor] = useState('')
|
||||
const [savedSupportEmail, setSavedSupportEmail] = useState('')
|
||||
const [savedDocumentationUrl, setSavedDocumentationUrl] = useState('')
|
||||
const [savedTermsUrl, setSavedTermsUrl] = useState('')
|
||||
const [savedPrivacyUrl, setSavedPrivacyUrl] = useState('')
|
||||
const [savedLogoUrl, setSavedLogoUrl] = useState<string | null>(null)
|
||||
const [savedWordmarkUrl, setSavedWordmarkUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedSettings || formInitializedRef.current) return
|
||||
const brand = savedSettings.brandName ?? ''
|
||||
const primary = savedSettings.primaryColor ?? ''
|
||||
const primaryHover = savedSettings.primaryHoverColor ?? ''
|
||||
const accent = savedSettings.accentColor ?? ''
|
||||
const accentHover = savedSettings.accentHoverColor ?? ''
|
||||
const support = savedSettings.supportEmail ?? ''
|
||||
const docs = savedSettings.documentationUrl ?? ''
|
||||
const terms = savedSettings.termsUrl ?? ''
|
||||
const privacy = savedSettings.privacyUrl ?? ''
|
||||
const logo = savedSettings.logoUrl ?? null
|
||||
const wordmark = savedSettings.wordmarkUrl ?? null
|
||||
setBrandName(brand)
|
||||
setPrimaryColor(primary)
|
||||
setPrimaryHoverColor(primaryHover)
|
||||
setAccentColor(accent)
|
||||
setAccentHoverColor(accentHover)
|
||||
setSupportEmail(support)
|
||||
setDocumentationUrl(docs)
|
||||
setTermsUrl(terms)
|
||||
setPrivacyUrl(privacy)
|
||||
setLogoUrl(logo)
|
||||
setWordmarkUrl(wordmark)
|
||||
setSavedBrandName(brand)
|
||||
setSavedPrimaryColor(primary)
|
||||
setSavedPrimaryHoverColor(primaryHover)
|
||||
setSavedAccentColor(accent)
|
||||
setSavedAccentHoverColor(accentHover)
|
||||
setSavedSupportEmail(support)
|
||||
setSavedDocumentationUrl(docs)
|
||||
setSavedTermsUrl(terms)
|
||||
setSavedPrivacyUrl(privacy)
|
||||
setSavedLogoUrl(logo)
|
||||
setSavedWordmarkUrl(wordmark)
|
||||
formInitializedRef.current = true
|
||||
}, [savedSettings])
|
||||
|
||||
const logoUpload = useProfilePictureUpload({
|
||||
currentImage: logoUrl,
|
||||
onUpload: (url) => setLogoUrl(url),
|
||||
onError: (error) => toast.error(error),
|
||||
context: 'workspace-logos',
|
||||
workspaceId: params.workspaceId,
|
||||
})
|
||||
|
||||
const wordmarkUpload = useProfilePictureUpload({
|
||||
currentImage: wordmarkUrl,
|
||||
onUpload: (url) => setWordmarkUrl(url),
|
||||
onError: (error) => toast.error(error),
|
||||
context: 'workspace-logos',
|
||||
workspaceId: params.workspaceId,
|
||||
})
|
||||
|
||||
const hasChanges =
|
||||
formInitializedRef.current &&
|
||||
(brandName !== savedBrandName ||
|
||||
primaryColor !== savedPrimaryColor ||
|
||||
primaryHoverColor !== savedPrimaryHoverColor ||
|
||||
accentColor !== savedAccentColor ||
|
||||
accentHoverColor !== savedAccentHoverColor ||
|
||||
supportEmail !== savedSupportEmail ||
|
||||
documentationUrl !== savedDocumentationUrl ||
|
||||
termsUrl !== savedTermsUrl ||
|
||||
privacyUrl !== savedPrivacyUrl ||
|
||||
(logoUpload.previewUrl || null) !== savedLogoUrl ||
|
||||
(wordmarkUpload.previewUrl || null) !== savedWordmarkUrl)
|
||||
|
||||
useSettingsUnsavedGuard({ isDirty: hasChanges })
|
||||
|
||||
async function handleSave() {
|
||||
if (!orgId) return
|
||||
|
||||
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)) {
|
||||
toast.error(`${fieldName} must be a valid hex color (e.g. #33c482)`)
|
||||
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,
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSettings.mutateAsync({ orgId, settings })
|
||||
setSavedBrandName(brandName)
|
||||
setSavedPrimaryColor(primaryColor)
|
||||
setSavedPrimaryHoverColor(primaryHoverColor)
|
||||
setSavedAccentColor(accentColor)
|
||||
setSavedAccentHoverColor(accentHoverColor)
|
||||
setSavedSupportEmail(supportEmail)
|
||||
setSavedDocumentationUrl(documentationUrl)
|
||||
setSavedTermsUrl(termsUrl)
|
||||
setSavedPrivacyUrl(privacyUrl)
|
||||
setSavedLogoUrl(logoUpload.previewUrl || null)
|
||||
setSavedWordmarkUrl(wordmarkUpload.previewUrl || null)
|
||||
toast.success('Whitelabeling settings saved.')
|
||||
} catch (error) {
|
||||
logger.error('Failed to save whitelabel settings', { error })
|
||||
toast.error(toError(error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
setBrandName(savedBrandName)
|
||||
setPrimaryColor(savedPrimaryColor)
|
||||
setPrimaryHoverColor(savedPrimaryHoverColor)
|
||||
setAccentColor(savedAccentColor)
|
||||
setAccentHoverColor(savedAccentHoverColor)
|
||||
setSupportEmail(savedSupportEmail)
|
||||
setDocumentationUrl(savedDocumentationUrl)
|
||||
setTermsUrl(savedTermsUrl)
|
||||
setPrivacyUrl(savedPrivacyUrl)
|
||||
setLogoUrl(savedLogoUrl)
|
||||
setWordmarkUrl(savedWordmarkUrl)
|
||||
}
|
||||
|
||||
if (isBillingEnabled) {
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<SettingsEmptyState>
|
||||
You must be part of an organization to configure whitelabeling.
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasEnterprisePlan) {
|
||||
return (
|
||||
<SettingsEmptyState>
|
||||
Whitelabeling is available on Enterprise plans only.
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<SettingsEmptyState>
|
||||
Only organization owners and admins can configure whitelabeling settings.
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isUploading = logoUpload.isUploading || wordmarkUpload.isUploading
|
||||
|
||||
return (
|
||||
<SettingsPanel
|
||||
actions={saveDiscardActions({
|
||||
dirty: hasChanges,
|
||||
saving: updateSettings.isPending,
|
||||
saveDisabled: isUploading,
|
||||
onSave: handleSave,
|
||||
onDiscard: handleDiscard,
|
||||
})}
|
||||
>
|
||||
<SettingsSection label='Brand Identity'>
|
||||
<div className='flex flex-col gap-5'>
|
||||
<SettingRow
|
||||
label='Brand name'
|
||||
description='Replaces "Sim" in the sidebar and select UI elements.'
|
||||
>
|
||||
<ChipInput
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
placeholder='Your Company'
|
||||
className='max-w-[320px]'
|
||||
maxLength={64}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SettingRow
|
||||
label='Logo'
|
||||
labelTooltip='Shown in the collapsed sidebar. Square image — PNG, JPEG, or SVG, max 5MB.'
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<DropZone onDrop={logoUpload.handleFileDrop}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={logoUpload.handleThumbnailClick}
|
||||
disabled={logoUpload.isUploading}
|
||||
className='group relative flex size-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 ? (
|
||||
<Loader className='size-5 text-[var(--text-muted)]' animate />
|
||||
) : logoUpload.previewUrl ? (
|
||||
<Image
|
||||
src={logoUpload.previewUrl}
|
||||
alt='Logo'
|
||||
fill
|
||||
className='object-contain p-1'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className='size-5 text-[var(--text-muted)]' />
|
||||
)}
|
||||
</button>
|
||||
</DropZone>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={logoUpload.handleThumbnailClick}
|
||||
disabled={logoUpload.isUploading}
|
||||
className='text-small'
|
||||
>
|
||||
{logoUpload.previewUrl ? 'Change' : 'Upload'}
|
||||
</Button>
|
||||
{logoUpload.previewUrl && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={logoUpload.handleRemove}
|
||||
className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='size-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={logoUpload.fileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
|
||||
onChange={logoUpload.handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label='Wordmark'
|
||||
labelTooltip='Shown in the expanded sidebar. Wide image — PNG, JPEG, or SVG, max 5MB.'
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='min-w-0 flex-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={wordmarkUpload.handleThumbnailClick}
|
||||
disabled={wordmarkUpload.isUploading}
|
||||
className='group relative flex h-16 w-full 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 ? (
|
||||
<Loader className='size-5 text-[var(--text-muted)]' animate />
|
||||
) : wordmarkUpload.previewUrl ? (
|
||||
<Image
|
||||
src={wordmarkUpload.previewUrl}
|
||||
alt='Wordmark'
|
||||
fill
|
||||
className='object-contain p-2'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className='size-5 text-[var(--text-muted)]' />
|
||||
)}
|
||||
</button>
|
||||
</DropZone>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={wordmarkUpload.handleThumbnailClick}
|
||||
disabled={wordmarkUpload.isUploading}
|
||||
className='text-small'
|
||||
>
|
||||
{wordmarkUpload.previewUrl ? 'Change' : 'Upload'}
|
||||
</Button>
|
||||
{wordmarkUpload.previewUrl && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={wordmarkUpload.handleRemove}
|
||||
className='text-[var(--text-muted)] text-small hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='size-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={wordmarkUpload.fileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
|
||||
onChange={wordmarkUpload.handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label='Colors'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<ColorInput
|
||||
label='Primary color'
|
||||
value={primaryColor}
|
||||
onChange={setPrimaryColor}
|
||||
placeholder='#33c482'
|
||||
/>
|
||||
<ColorInput
|
||||
label='Primary hover color'
|
||||
value={primaryHoverColor}
|
||||
onChange={setPrimaryHoverColor}
|
||||
placeholder='#2dac72'
|
||||
/>
|
||||
<ColorInput
|
||||
label='Accent color'
|
||||
value={accentColor}
|
||||
onChange={setAccentColor}
|
||||
placeholder='#33b4ff'
|
||||
/>
|
||||
<ColorInput
|
||||
label='Accent hover color'
|
||||
value={accentHoverColor}
|
||||
onChange={setAccentHoverColor}
|
||||
placeholder='#29a0e8'
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label='Links'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<SettingRow label='Support email'>
|
||||
<ChipInput
|
||||
type='email'
|
||||
value={supportEmail}
|
||||
onChange={(e) => setSupportEmail(e.target.value)}
|
||||
placeholder='support@yourcompany.com'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Documentation URL'>
|
||||
<ChipInput
|
||||
type='url'
|
||||
value={documentationUrl}
|
||||
onChange={(e) => setDocumentationUrl(e.target.value)}
|
||||
placeholder='https://docs.yourcompany.com'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Terms of service URL'>
|
||||
<ChipInput
|
||||
type='url'
|
||||
value={termsUrl}
|
||||
onChange={(e) => setTermsUrl(e.target.value)}
|
||||
placeholder='https://yourcompany.com/terms'
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Privacy policy URL'>
|
||||
<ChipInput
|
||||
type='url'
|
||||
value={privacyUrl}
|
||||
onChange={(e) => setPrivacyUrl(e.target.value)}
|
||||
placeholder='https://yourcompany.com/privacy'
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getOrganizationWhitelabelContract,
|
||||
updateOrganizationWhitelabelContract,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
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,
|
||||
settingsList: () => [...whitelabelKeys.all, 'settings'] as const,
|
||||
settings: (orgId: string) => [...whitelabelKeys.settingsList(), orgId] as const,
|
||||
}
|
||||
|
||||
async function fetchWhitelabelSettings(
|
||||
orgId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OrganizationWhitelabelSettings> {
|
||||
const { data } = await requestJson(getOrganizationWhitelabelContract, {
|
||||
params: { id: orgId },
|
||||
signal,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 result = await requestJson(updateOrganizationWhitelabelContract, {
|
||||
params: { id: orgId },
|
||||
body: settings,
|
||||
})
|
||||
return result.data
|
||||
},
|
||||
onSettled: (_data, _error, { orgId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: whitelabelKeys.settings(orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(orgId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +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'
|
||||
@@ -0,0 +1,53 @@
|
||||
import { getContrastTextColor, isDarkColor } from '@/lib/colors'
|
||||
|
||||
export function generateThemeCSS(): string {
|
||||
const cssVars: string[] = []
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR) {
|
||||
cssVars.push(`--brand: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--brand-agent: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--brand-accent: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-hover-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
cssVars.push(`--auth-primary-btn-hover-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR};`)
|
||||
const primaryTextColor = getContrastTextColor(process.env.NEXT_PUBLIC_BRAND_PRIMARY_COLOR)
|
||||
cssVars.push(`--auth-primary-btn-text: ${primaryTextColor};`)
|
||||
cssVars.push(`--auth-primary-btn-hover-text: ${primaryTextColor};`)
|
||||
}
|
||||
|
||||
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};`
|
||||
)
|
||||
cssVars.push(
|
||||
`--auth-primary-btn-hover-border: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`
|
||||
)
|
||||
cssVars.push(
|
||||
`--auth-primary-btn-hover-text: ${getContrastTextColor(process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR)};`
|
||||
)
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR) {
|
||||
cssVars.push(`--brand-accent: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR};`)
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR) {
|
||||
cssVars.push(`--brand-accent-hover: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR};`)
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_CUSTOM_CSS_URL) {
|
||||
cssVars.push('--brand-agent: var(--brand);')
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) {
|
||||
const isDark = isDarkColor(process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR)
|
||||
if (isDark) {
|
||||
cssVars.push(`--brand-is-dark: 1;`)
|
||||
}
|
||||
}
|
||||
|
||||
return cssVars.length > 0 ? `:root { ${cssVars.join(' ')} }` : ''
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getBaseUrl, SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling/branding'
|
||||
|
||||
/**
|
||||
* Generate dynamic metadata based on brand configuration
|
||||
*/
|
||||
export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metadata {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
const defaultTitle = brand.name
|
||||
const summaryFull = `Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work — visually, conversationally, or with code. Trusted by over 100,000 builders — from startups to Fortune 500 companies. SOC2 compliant.`
|
||||
const summaryShort = `Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work.`
|
||||
|
||||
return {
|
||||
title: {
|
||||
template: `%s | ${brand.name}`,
|
||||
default: defaultTitle,
|
||||
},
|
||||
description: summaryShort,
|
||||
applicationName: brand.name,
|
||||
authors: [{ name: brand.name }],
|
||||
generator: 'Next.js',
|
||||
keywords: [
|
||||
'AI workspace',
|
||||
'AI agent builder',
|
||||
'AI agent workflow builder',
|
||||
'build AI agents',
|
||||
'visual workflow builder',
|
||||
'AI agents',
|
||||
'AI agent platform',
|
||||
'open-source AI agents',
|
||||
'agentic workflows',
|
||||
'LLM orchestration',
|
||||
'AI integrations',
|
||||
'knowledge base',
|
||||
'AI automation',
|
||||
'workflow builder',
|
||||
'AI workflow orchestration',
|
||||
'enterprise AI',
|
||||
'AI agent deployment',
|
||||
'intelligent automation',
|
||||
'AI tools',
|
||||
],
|
||||
referrer: 'origin-when-cross-origin',
|
||||
creator: brand.name,
|
||||
publisher: brand.name,
|
||||
metadataBase: new URL(getBaseUrl()),
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
languages: {
|
||||
'en-US': '/',
|
||||
},
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-video-preview': -1,
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: getBaseUrl(),
|
||||
title: defaultTitle,
|
||||
description: summaryFull,
|
||||
siteName: brand.name,
|
||||
images: [
|
||||
{
|
||||
url: brand.logoUrl || '/logo/426-240/reverse/small.png',
|
||||
width: 2130,
|
||||
height: 1200,
|
||||
alt: brand.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: defaultTitle,
|
||||
description: summaryFull,
|
||||
images: [brand.logoUrl || '/logo/426-240/reverse/small.png'],
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
},
|
||||
manifest: '/manifest.webmanifest',
|
||||
icons: {
|
||||
icon: [
|
||||
...(brand.faviconUrl ? [] : [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }]),
|
||||
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{
|
||||
url: '/favicon/android-chrome-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
url: '/favicon/android-chrome-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
...(brand.faviconUrl ? [{ url: brand.faviconUrl, sizes: 'any', type: 'image/png' }] : []),
|
||||
],
|
||||
apple: '/favicon/apple-touch-icon.png',
|
||||
shortcut: brand.faviconUrl || '/icon.svg',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: brand.name,
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
category: 'technology',
|
||||
other: {
|
||||
'apple-mobile-web-app-capable': 'yes',
|
||||
'mobile-web-app-capable': 'yes',
|
||||
'msapplication-TileColor': '#33C482',
|
||||
'msapplication-config': 'none',
|
||||
},
|
||||
...override,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static structured data for SEO
|
||||
*/
|
||||
export function generateStructuredData() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Sim',
|
||||
description:
|
||||
'Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work. Trusted by over 100,000 builders. SOC2 compliant.',
|
||||
url: getBaseUrl(),
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
applicationSubCategory: 'AIWorkspace',
|
||||
areaServed: 'Worldwide',
|
||||
availableLanguage: ['en'],
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
category: 'SaaS',
|
||||
},
|
||||
creator: {
|
||||
'@type': 'Organization',
|
||||
name: 'Sim',
|
||||
url: SITE_URL,
|
||||
},
|
||||
featureList: [
|
||||
'AI Workspace for Teams',
|
||||
'Chat — Natural Language Agent Creation',
|
||||
'Visual Workflow Builder',
|
||||
'1,000+ Integrations',
|
||||
'LLM Orchestration',
|
||||
'Knowledge Base Creation',
|
||||
'Table Creation',
|
||||
'Document Creation',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
import { getContrastTextColor } from '@/lib/colors'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
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 ||
|
||||
Boolean(
|
||||
orgSettings.brandName ||
|
||||
orgSettings.logoUrl ||
|
||||
orgSettings.wordmarkUrl ||
|
||||
orgSettings.primaryColor
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-agent: ${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-accent: ${settings.accentColor};`)
|
||||
}
|
||||
|
||||
if (settings.accentHoverColor) {
|
||||
vars.push(`--brand-accent-hover: ${settings.accentHoverColor};`)
|
||||
}
|
||||
|
||||
return vars.length > 0 ? `:root { ${vars.join(' ')} }` : ''
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user