Skip to content

Branding & White-Labeling ​

Overview ​

  1. Three branding composables — useBrand, useBrandingHead, and useBrandingTheme — handle brand resolution, document head injection, and Vuetify theme application respectively.
  2. useBrand reads damStore.brandingDetails and produces a render-ready Brand computed that components and layouts consume. It distinguishes Collage-domain requests from white-label host requests.
  3. SSR plugin (branding.server.ts) resolves branding before the initial HTML render via three sequential strategies: share page API, workspace ID API, and host-based check-branding.
  4. Client plugin (branding.client.ts) fires a fallback check-branding call for non-workspace, non-share routes where the SSR plugin could not resolve.
  5. usePortalBrandingTab composable drives the portal branding editor UI, handling logo/favicon upload, color pickers, domain configuration, and portal visibility.
  6. PortalBrandingTab.vue renders the two-column branding settings form inside the portal detail page; it is the primary UI for customizing a portal's visual identity.

Architecture ​

Branding resolves at two moments. On the server, branding.server.ts runs before rendering and tries three strategies in order, calling damStore.setBrandingDetails on the first successful response. This seeds brandingDetails in the Pinia store so useBrand produces the correct Brand object during SSR, preventing a favicon or title flash on initial page load. On the client, useBrandingTheme watches the resolved brand and writes hex color values directly into Vuetify's live light theme, and useBrandingHead drives useHead with title, OG meta, and favicon derived from the brand.

Portal-level branding is separate from the host/workspace branding. It lives in PortalBrandingTab.vue and usePortalBrandingTab.ts and controls the specific visual settings of a brand portal instance (logo, favicon, colors, domain, visibility).

File Structure ​

Branding Composables ​

  • app/composables/branding/useBrand.ts — Resolves damStore.brandingDetails into a Brand computed; handles three cases: unknown white-label host (blank shell), Collage domain (defaults), and white-label enabled (mapped fields)
  • app/composables/branding/useBrandingHead.ts — Drives useHead with tagPriority: 'high'; sets title, OG/Twitter meta, and favicon from the resolved brand; suppresses host-brand assets on /shared-assets routes unless the share is branded
  • app/composables/branding/useBrandingTheme.ts — Watches brand.is_branded and damStore.brandingDetails?.branding; writes validated hex primary_color / secondary_color into Vuetify's live light theme colors

Portal Branding Composable ​

  • app/composables/core/components/usePortalBrandingTab.ts — Full portal branding composable; manages form state (brand_name, description, brandUrl, domainUrl, primary_color, secondary_color, is_domain, is_pointed, is_public_portal), logo/favicon upload, color updates, portal visibility, and trending/recent upload toggles

Vue Component Files ​

  • app/components/dam/portals/PortalBrandingTab.vue — Two-column branding editor rendered on the portal detail page; left column: portal metadata and URL/domain config; right column: logo, favicon, and brand color pickers
  • app/pages/[workspace_id]/dam/portals/[id].vue — Portal detail page; hosts the Customize tab (which renders PortalBrandingTab), Users tab, and Content Blocks tab

Plugin Files ​

  • app/plugins/branding.server.ts — SSR-only; resolves branding via three strategies before initial HTML render
  • app/plugins/branding.client.ts — Client-only fallback; calls damStore.fetchCheckBranding() for non-workspace, non-share routes where SSR did not resolve

Store ​

  • app/stores/dam.ts — Holds brandingDetails: BrandingDetails | null; exposes setBrandingDetails, fetchWorkspaceBranding, and fetchCheckBranding actions

Type Files ​

  • app/types/store.ts — Brand, BrandingDetails, BrandingColors interfaces
  • app/types/portal.ts — PortalBranding, PortalInstance, BrandForm, BrandingUpdatePayload, SubscriptionFeatures (includes custom_brand_url and dam_branding feature flags)

useBrand ​

File: app/composables/branding/useBrand.ts

Resolves damStore.brandingDetails into a render-ready Brand computed. Returns a brandingReady flag that is true once brandingDetails is non-null.

Features ​

  • Host-origin detection via X-Forwarded-Host / X-Forwarded-Proto headers on SSR, window.location.origin on client
  • Three resolution modes: unknown white-label host, Collage domain, white-label enabled
  • A request counts as the Collage host when its host is internal (isInternalHost from #shared/requestHost, e.g. a proxy's loopback name when the deployment does not forward the public Host) or its origin matches runtimeConfig.public.baseUrl. On SSR the host comes from authoritativeHost(host / x-forwarded-host headers), falling back to useRequestURL().host
  • Exports BLANK_FAVICON (transparent 1x1 SVG) used to prevent the Collage favicon from leaking onto a white-label host before branding resolves
  • Exports COLLAGE_DEFAULTS for fallback field values on blank branded shells

Returned Values ​

javascript
// Brand object shape
{
  is_branded: boolean,          // true on white-label hosts
  brand_name: string,           // workspace/portal name
  tagline: string | null,       // shown in OG description meta
  logo: string | null,          // full URL to logo asset
  favicon: string | null,       // full URL to favicon asset
  support_email: string | null, // support email for the brand
  brand_website_url: string,    // request origin on white-label; collage.inc on default
  sso: { enabled: boolean, url_slug: string } | null, // SsoBranding from check-branding
}

sso is null while branding details are missing (Collage defaults), and otherwise copied from brandingDetails.sso, also on the unbranded Collage host. The login page uses the same object from check-branding to decide whether to show the SSO tile on a white-label host and whether it can skip the work-email step (url_slug known). See Single Sign-On.

brandingReady also gates the login forms in pages/index.vue (v-else-if="brandingReady"), and login-layout hides its decorative illustrations until branding resolves, so white-label hosts no longer flash Collage artwork on refresh (05d607b9, 72cc6d0f).

Usage ​

vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>

<template>
  <v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
  <span>{{ brand.brand_name || 'Collage' }}</span>
</template>

useBrandingHead ​

File: app/composables/branding/useBrandingHead.ts

Drives useHead with tagPriority: 'high' so branded title and favicon override any page-level defaults.

Features ​

  • Sets <title> to brand.brand_name (suppressed when not branded)
  • Sets og:site_name, og:title, og:description, og:image, twitter:card
  • og:image is the brand logo when it is an absolute http URL. Otherwise it falls back to the bundled Collage card (~/assets/img/og-card.png, declared as og:image:width 1200 and og:image:height 630), made absolute against the request origin, or runtimeConfig.public.baseUrl when the request host is internal. No fallback card is emitted on share pages or for a resolved branded host
  • Sets favicon and shortcut-icon link elements from brand.favicon || brand.logo
  • On /shared-assets routes: host-brand assets are suppressed unless brand.is_branded to avoid leaking the host workspace logo onto a partner's shared link
  • Falls back to BLANK_FAVICON when assets are suppressed

Usage ​

vue
<!-- Call once from the main layout setup — not in individual pages -->
<script setup lang="ts">
useBrandingHead()
</script>

useBrandingTheme ​

File: app/composables/branding/useBrandingTheme.ts

Applies white-label primary and secondary colors to Vuetify's light theme at runtime.

Features ​

  • Watches [brand.is_branded, damStore.brandingDetails?.branding] as a tuple; only runs on reference change (store replaces brandingDetails wholesale, never mutates in place)
  • Validates hex values with /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/ before writing to theme.themes.value.light.colors
  • Leaves static defaults intact when a color is missing or invalid

Usage ​

vue
<!-- Call once from the main layout setup -->
<script setup lang="ts">
useBrandingTheme()
</script>

usePortalBrandingTab ​

File: app/composables/core/components/usePortalBrandingTab.ts

Full page composable for the PortalBrandingTab component. Manages all reactive state and async operations for portal branding configuration.

Features ​

  • Form model: brand_name, description, brandUrl (slug), domainUrl, is_domain (0|1), is_pointed, is_trading, is_recent_upload, is_public_portal, primary_color, secondary_color
  • isDirty computed compares text fields only (not colors, not files)
  • colorsDirty separate dirty check for color changes
  • Logo validation: max 10MB, jpg/png only; favicon validation: max 2MB, png/ico/svg
  • onLogoPick / onFaviconPick read file to data URL then open a crop dialog
  • uploadAsset('logo' | 'favicon', file) builds multipart FormData and calls updateInstance
  • submit() validates, builds FormData (without logo/favicon), calls updateInstance
  • updateBranding(isBrandingDefault) posts update-workspace-branding with colors + is_branding: 0|1
  • onPortalVisibilityChange posts change-portal-visibility

Amplitude Events ​

javascript
PORTAL_LOGO_UPDATED
PORTAL_FAVICON_UPDATED
PORTAL_SETTINGS_UPDATED
PORTAL_THEME_UPDATED
PORTAL_BRANDING_UPDATED
PORTAL_VISIBILITY_CHANGED

PortalBrandingTab Component ​

File: app/components/dam/portals/PortalBrandingTab.vue

Two-column layout component rendered inside the portal detail page's Customize tab.

Features ​

  • Left column: portal name, meta description, URL mode radio (Collage subdomain vs own domain), slug/domain text field with preview link, Submit/Cancel, display toggles (Trending, Recently Added, Public Portal)
  • Right column: logo upload (pick → crop → immediate upload to S3), favicon upload (same flow), brand color pickers via v-color-picker, Restore To Default checkbox and Update button
  • Default colors: #070E22 primary, #ffffff secondary
  • White-label hosts: when isBranded prop is true, forces is_domain = 1 and hides the Collage subdomain radio

Props ​

javascript
{
  instance: {
    type: Object,        // PortalInstance — portal data
    required: true
  },
  contentLoading: {
    type: Boolean,
    default: false
  },
  customBrandAllowed: {
    type: Boolean,       // subscription gate for custom domain
    default: false
  },
  isBranded: {
    type: Boolean,       // true on white-label hosts
    default: false
  }
}

Events ​

javascript
{
  'updated': (instance: PortalInstance) => {}  // after successful save
}

Usage ​

PortalBrandingTab is rendered inside the Customize tab of the portal detail page. All props come from usePortalDetail() — the portal detail page composable — not from local refs. onInstanceUpdated merges the API echo back via spread (immutable update); if the response shape is unexpected it re-fetches the full instance.

vue
<!-- app/pages/[workspace_id]/dam/portals/[id].vue (Customize tab) -->
<script setup lang="ts">
import type { PortalInstance } from '~/types/portal'

const {
  contentLoading,
  tab,
  instance,
  tabLabels,
  customBrandAllowed,
  whiteLabelEnabled,
  fetchInstance,
} = usePortalDetail()

const onInstanceUpdated = (data: unknown): void => {
  if (data && typeof data === 'object') {
    instance.value = { ...(instance.value || {}), ...(data as PortalInstance) }
  } else {
    fetchInstance()
  }
}
</script>

<template>
  <v-window v-model="tab">
    <v-window-item v-for="(label, index) in tabLabels" :key="index" :value="index">
      <PortalBrandingTab
        v-if="label === 'Customize'"
        :instance="instance"
        :content-loading="contentLoading"
        :custom-brand-allowed="customBrandAllowed"
        :is-branded="whiteLabelEnabled"
        @updated="onInstanceUpdated"
      />
    </v-window-item>
  </v-window>
</template>

Component Integration ​

Branding hooks are wired once at the layout level. Individual pages and components consume useBrand() to read the resolved brand — they never call the Pinia store or the branding API directly.

Layout-level setup ​

useBrandingHead() and useBrandingTheme() are called once from the root app or layout. They have no arguments and return nothing — they install watchers internally.

vue
<!-- app/app.vue or the primary layout -->
<script setup lang="ts">
useBrandingHead()    // sets <title>, og:*, and favicon from the resolved brand
useBrandingTheme()  // writes primary_color / secondary_color into Vuetify's light theme
</script>

Consuming the resolved brand in a component ​

vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>

<template>
  <v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
  <span>{{ brand.brand_name }}</span>
</template>

brandingReady is true once damStore.brandingDetails is non-null. On SSR it is true immediately if the server plugin resolved branding before rendering. On the client it becomes true after the store hydrates from the SSR payload.

Portal branding editor ​

See PortalBrandingTab — Usage above for the real portal-page integration pattern. The key point: props come from usePortalDetail(), not from local refs constructed in the page.

Workflows ​

SSR Branding Resolution Flow ​

Client Fallback Branding Flow ​

Portal Branding Update Flow ​

API Integration ​

Branding Endpoints ​

MethodEndpointDescription
GETcheck-brandingHost-based brand lookup (Origin header required)
POSTget-brandingWorkspace brand lookup by workspace_id
POSTshow-share-assetsShare page brand lookup
POSTupdate-workspace-brandingUpdate portal primary/secondary colors
PUTupdate-instanceUpdate portal metadata and domain settings
json
// POST get-branding
{
  "workspace_id": 42
}

// Response
{
  "data": {
    "id": 7,
    "brand_name": "Acme Brand Assets",
    "logo": "https://cdn.collage.inc/logos/acme.png",
    "favicon": "https://cdn.collage.inc/favicons/acme.ico",
    "tagline": "Official brand resources for Acme partners.",
    "support_email": "[email protected]",
    "white_label_enabled": true,
    "branding": {
      "primary_color": "#0A1F44",
      "secondary_color": "#F4A300"
    }
  }
}
json
// POST update-workspace-branding
{
  "workspace_id": 42,
  "instance_id": 7,
  "primary_color": "#0A1F44",
  "secondary_color": "#F4A300",
  "is_branding": 1
}
  • Portals — Brand portal creation and management
  • Workspace — Account settings including logo and favicon upload
  • Permissions — Brand portal settings access control
  • Subscription — Plan gates for custom_brand_url and dam_branding
  • Real-time — Pusher channels for live branding updates