Skip to content

Brand as Single Source of Truth

Status: Draft · Owner: Jeff Stolz

Three settings pages edit a vendor's branding. Settings → Brand becomes the only editor for all of it — portal, email, sign-in pages, outbound integrations, admin chrome — and the or_vendors branding columns become a derived read model rather than a second source of truth.

What exists today

Settings → Organization → Details

"Primary logo" (1:1) and "Secondary logo" (16:9).

Storageor_vendors.logo_path, or_vendors.secondary_logo_path — a unique filename under /static/img/logos/, or S3 when offload_profile_images_to_s3
WriterPOST /vendor_profile, multipart vendor_logo_path / vendor_secondary_logo_path (app/api/vendor/routes.py) → Logo.set_logo. Replace-only; no clear path
GateOWNER / ONRAMP_ADMIN, no feature flag

Settings → Portals

Brand color, Background color, Button text color — writing the same vendor_branding_primary_color / portal_background_color / portal_button_text_color columns Brand's palette writes through to. Already hidden behind v-if="vendorProfile && !isPortalStudioEnabled" (Portal.vue:311) — the precedent this proposal follows for the logo rows.

Settings → Brand → Logos & images

"Logo", "Hero image", "Favicon" (BrandPanel.vue).

Storagethe BRAND_DEFAULTS portal-config document at theme.assets.*. logo and favicon are inline base64 data URLs; hero_image is a hosted URL. Caps in THEME_ASSET_MAX_KB
GateOWNER / ONRAMP_ADMIN and the portal-studio feature flag, disabled-by-default per vendor
Reachthe v3 renderer, as a snapshot default baked into portals created from here on (bake_brand_defaults) — deliberately not retroactive. Plus email branding when the flag is on

Where the primary logo actually renders

Verified consumer by consumer. logo_path is an organization identity field, not a portal field:

SurfacePath
Email body logoEmail._set_template_data — CRM account logo → Studio logo → Logo.getVendorLogo → OnRamp default
Auth / join / signup, all three portal versions/api/portal/vendorlogo (populated only from logo_path), rendered in pages/public, pages/v2/public, pages/v3/public
Org-admin user menurelative_vendor_logo, app/api/base/services.py
Email-action landing pageslogo_url=Logo.getVendorLogo(vendor), email_action_controller.py
Embed widget (live)Logo.getVendor(project), embed_widget/routes.py
Outbound CRM / Slack payloadsvendor.logo_url on every project, task, module, note, subtask event — get_integration_data.py
Super-admin org screenslogo_primary, OrganizationMapper

secondary_logo_path reaches only the authenticated portal header (v1/v2 prefer it over primary; v3 prefers studio_logo first), plus super-admin and the assets.logo_dark seed. Not email, not public pages, not integrations.

The overlap, and the defect in it

Already bidirectional:

  1. Legacy → Brand. _vendor_legacy_theme seeds a Studio theme from logo_pathassets.logo and secondary_logo_pathassets.logo_dark.
  2. Brand → Legacy. Publishing the default TEMPLATE portal runs _sync_legacy_vendor_branding, writing theme.assets.logo into or_vendors.logo_path.

assets.logo is a data: URI. Logo.get_logo_path special-cases only http:// and https://; anything else is treated as a filename, os.path.exists fails, and it returns None. So for a portal-studio vendor, setting a logo in Brand and publishing the default portal blanks the logo on every surface in the table above — email, v3's own sign-in pages, outbound CRM payloads included. Email carries a hand-rolled guard for the inverse case; nothing guards this direction.

Established by reading the path; not yet reproduced against a running vendor.

Why the obvious fix does not work

Making the logo slot hosted (as hero_image is) does not fix this. POST /file/upload/otexteditor/image returns static_path — a relative/static/images/… path, not the absolute file_url it also computes (upload_file_controller.py). A relative path fails is_absolute_url exactly as a data URI does. The same endpoint also rejects SVG (jpg/jpeg/png/gif only), so switching would regress SVG logo uploads, which the field's own help text invites.

The fix is to materialize the asset into the legacy logo store at write-through time, keeping logo_path's filename contract and the existing S3 branch intact.

Decisions

  • Brand is the only branding editor, app-wide. The or_vendors branding columns stay as the derived read model every non-portal consumer already reads. Storage does not move.
  • Feature gate — conditional and self-retiring. Organization's logo rows render only while portal-studio is off, mirroring what Portal.vue already does with the color pickers. Roles match on both sides (OWNER / ONRAMP_ADMIN), so the flag is the only gap, and the duplication deletes itself as the flag rolls out.
  • Secondary logo — frozen, no editor. The column keeps its legacy value; Studio vendors are no longer offered an editor. Nearly inert for them already. A first-class wide mark would need its own logo_wide slot, not logo_dark.
  • Language must state per-asset reach. "Brand" cannot describe itself as portal defaults once it owns email and CRM branding, and it cannot claim app-wide reach for assets that are portal-only. Each field says where it lands.

Phases

Three PRs, in order. Phase 0 stands alone and is worth shipping on its own.

Phase 0 — make the write-through store a servable value

Backend only. app/api/utils/Logo.py, app/api/portal_studio/services/portal_config_service.py.

  • Add Logo.set_logo_from_data_uri(obj, data_uri, property_name): decode, wrap in a werkzeug.datastructures.FileStorage, delegate to the existing set_logo so the S3-vs-local branch and the filename contract are reused rather than reimplemented.
  • Rework _sync_legacy_vendor_branding to classify before writing:
    • data: URI → materialize, store the resulting filename
    • absolute http(s) URL → store as-is (already supported by get_logo_path)
    • anything else (relative path, unreadable value) → skip and log, never write a value the resolver cannot serve
  • Idempotence without a schema change: derive the filename from a content hash (brand-<sha256[:16]>.<ext>) and skip when the column already holds it, so a republish neither rewrites the column nor orphans a new file each time.
  • Tests: data URI → servable filename; https passthrough; relative skipped + logged; republish is a no-op; S3-offload branch still reached.

Phase 1 — Brand publish drives the write-through

Backend only. portal_config_service.publish.

Today the sync fires only inside the TEMPLATE branch, gated on scope.is_default. Saving Brand itself therefore updates nothing outside the portal. Call it on BRAND_DEFAULTS publish too.

Open call to settle in review: two scopes then write one derived column. Proposed rule — a BRAND_DEFAULTS publish always applies, and the default-TEMPLATE sync stays as the seed/back-compat path but no longer clobbers a slot brand defaults set. In practice they usually agree, because a template's theme is baked from brand defaults at create/publish.

Phase 2 — one editor, honest language

Frontend + copy.

  • OrganizationDetails.vue: wrap both logo rows in v-if="!isPortalStudioEnabled"; when on, replace with a single row pointing at Settings → Brand.

  • Settings → Portals: nothing to do, colors are already gated.

  • Rewrite the copy that currently frames Brand as portal-scoped, stating reach per asset rather than once for the page:

    FieldReach to state
    Paletteportal and email
    Logoportal header and hero, plus email, sign-in and join pages, CRM notifications, admin chrome
    Hero imageportal only
    Faviconportal browser tab only

    Sites: BrandSettings.vue page title/description, brand/router.js meta + aliases (these feed settings search), the BrandDefaultsEditor preview footer ("saving won't change portals that already exist" stays true and stays visible), and each BrandAssetField / color field help.

  • Tests: BrandSettings.test.js for copy anchors; an OrganizationDetails test covering both flag states.

Phase 3 — delete, at full rollout

Logo rows and cropper wiring out of OrganizationDetails.vue; the !isPortalStudioEnabled color form out of Portal.vue; the orphaned OrganizationBilling.vue; the dead vendor_logo_path form-data in Portal.vue built from a vendorPic that component never assigns.

Out of scope, worth filing

  • embed_widget/routes.py builds the preview logo URL by raw interpolation of org.logo_path, bypassing Logo.get_logo_path. Already wrong for S3-offloaded vendors, independent of this work.
  • Logo.save_logo_images reads offload_profile_images_to_s3 with no vendor_id while get_logo_path passes one — a read/write asymmetry that predates this.
  • Favicon and hero image have no legacy column, so nothing to reconcile; they stay portal-only until a non-portal surface wants them.

Internal documentation — gated behind Cloudflare Access.