From 7cba39d076d48f7de4620bae549bdfa7fa93e1b8 Mon Sep 17 00:00:00 2001
From: chapsjust <2238424@carrefour.cegepvicto.ca>
Date: Mon, 2 Mar 2026 21:17:51 -0500
Subject: [PATCH] claude config
---
.gitignore | 1 +
app/controllers/application_controller.rb | 16 +
app/views/layouts/_head_tags.html.erb | 1 +
config/initializers/whitelabel.rb | 48 ++-
config/whitelabel.yml | 188 ---------
docker-compose.yml | 18 +-
docs/WHITELABEL.md | 135 +-----
lib/docuseal.rb | 2 +-
lib/whitelabel.rb | 479 +++++++++++++++++++---
public/intebec.css | 66 +--
10 files changed, 536 insertions(+), 418 deletions(-)
delete mode 100644 config/whitelabel.yml
diff --git a/.gitignore b/.gitignore
index f68165c6..ffe6cdcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@
/public/assets
/config/master.key
+/config/config.yml
/public/packs
/public/packs-test
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5fa236c1..be319fc4 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
check_authorization unless: :devise_controller?
around_action :with_locale
+ before_action :enforce_licence
before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller?
@@ -98,6 +99,21 @@ class ApplicationController < ActionController::Base
sign_in(User.active.order('random()').take) unless signed_in?
end
+ def enforce_licence
+ return if request.path == '/up'
+ return if request.path.start_with?('/assets', '/packs')
+
+ Whitelabel.ensure_valid!
+ rescue Whitelabel::ConfigError => e
+ Rails.logger.error(e.message)
+
+ if request.format.json?
+ render json: { error: 'service_unavailable' }, status: :service_unavailable
+ else
+ render plain: 'Service unavailable.', status: :service_unavailable
+ end
+ end
+
def current_account
current_user&.account
end
diff --git a/app/views/layouts/_head_tags.html.erb b/app/views/layouts/_head_tags.html.erb
index 91abfd86..18eb640c 100644
--- a/app/views/layouts/_head_tags.html.erb
+++ b/app/views/layouts/_head_tags.html.erb
@@ -3,3 +3,4 @@
<%= render 'shared/meta' %>
+
diff --git a/config/initializers/whitelabel.rb b/config/initializers/whitelabel.rb
index 3e9a6a7c..d251dc7a 100644
--- a/config/initializers/whitelabel.rb
+++ b/config/initializers/whitelabel.rb
@@ -3,12 +3,10 @@
# =============================================================================
# Whitelabel initializer
# =============================================================================
-# Loads config/whitelabel.yml and patches the Docuseal module constants so that
-# every existing call to Docuseal.product_name, Docuseal::PRODUCT_URL, etc.
-# automatically returns the white-labelled value.
-#
-# This approach means we do NOT have to change every single call-site — the
-# existing code keeps working, but returns branded values.
+# Triggers config loading (from local file or Dashboard API) and patches the
+# Docuseal module constants so that every existing call to
+# Docuseal.product_name, Docuseal::PRODUCT_URL, etc. automatically returns
+# the white-labelled value.
# =============================================================================
require_relative '../../lib/whitelabel'
@@ -56,4 +54,42 @@ module Docuseal
end
end
+Rails.application.config.i18n.default_locale = Whitelabel.default_locale.to_sym
+Rails.application.config.i18n.available_locales = Whitelabel.available_locales.map(&:to_sym)
+Rails.application.config.i18n.fallbacks = [Whitelabel.fallback_locale.to_sym]
+
+deep_stringify_keys = lambda do |hash|
+ hash.each_with_object({}) do |(key, value), memo|
+ string_key = key.to_s
+ memo[string_key] = value.is_a?(Hash) ? deep_stringify_keys.call(value) : value
+ end
+end
+
+deep_merge_hash = lambda do |left, right|
+ left.merge(right) do |_key, left_value, right_value|
+ if left_value.is_a?(Hash) && right_value.is_a?(Hash)
+ deep_merge_hash.call(left_value, right_value)
+ else
+ right_value
+ end
+ end
+end
+
+undot_keys = lambda do |hash|
+ hash.each_with_object({}) do |(key, value), memo|
+ if key.include?('.')
+ head, *tail = key.split('.')
+ nested = tail.reverse.reduce(value) { |acc, segment| { segment => acc } }
+ memo[head] = memo.key?(head) ? deep_merge_hash.call(memo[head], nested) : nested
+ else
+ memo[key] = value.is_a?(Hash) ? undot_keys.call(value) : value
+ end
+ end
+end
+
+Whitelabel.translation_overrides.each do |locale, raw_values|
+ normalized = undot_keys.call(deep_stringify_keys.call(raw_values))
+ I18n.backend.store_translations(locale.to_sym, normalized)
+end
+
Rails.logger.info "[Whitelabel] Loaded brand: #{Whitelabel.brand_name}"
diff --git a/config/whitelabel.yml b/config/whitelabel.yml
deleted file mode 100644
index b3aa8be5..00000000
--- a/config/whitelabel.yml
+++ /dev/null
@@ -1,188 +0,0 @@
-# =============================================================================
-# White-Label Configuration — Single source of truth for all branding
-# =============================================================================
-#
-# Change the values below to rebrand the entire application for any client.
-# The application reads this file once at boot (and caches it).
-# After editing, restart the app (or call Whitelabel.reload! in a console).
-#
-# IMPORTANT: Keep the YAML keys exactly as they are — only change the values.
-# This file is designed to survive upstream DocuSeal merges since it lives in
-# a file that does not exist in the upstream repo.
-# =============================================================================
-
-# ---------------------------------------------------------------------------
-# Brand identity
-# ---------------------------------------------------------------------------
-brand:
- # The name shown in the navbar, page titles, emails, PDFs, PWA manifest, etc.
- name: "Intébec"
-
- # Shorter variant (used in PWA short_name, compact UI areas)
- short_name: "Intébec"
-
- # Tagline — appears alongside the brand name in page titles, meta tags, etc.
- tagline: "Signature"
-
- # Description for meta tags, PWA manifest, and default OG description
- description: "Outil de signature fait par Intébec. Permet de signer des documents facilement."
-
- # Default page titles (signed_in / signed_out)
- page_title_signed_in: "Intébec Signature"
- page_title_signed_out: "Intébec | Signature"
-
-# ---------------------------------------------------------------------------
-# URLs — your company's own links
-# ---------------------------------------------------------------------------
-urls:
- # Main website (replaces docuseal.com links)
- website: "https://intebec.ca"
-
- # Support / contact email
- support_email: "contact@intebec.ca"
-
- # Privacy policy & terms pages (set to null to hide links)
- privacy_policy: "https://intebec.ca/legal/privacy"
- terms_of_service: "https://intebec.ca/legal/terms"
-
- # Social handles (set to null to hide)
- twitter_url: ~
- twitter_handle: ~
- github_url: ~
- discord_url: ~
-
-# ---------------------------------------------------------------------------
-# Email settings
-# ---------------------------------------------------------------------------
-email:
- # "From" header: "Brand Name
"
- from_name: "Intébec"
- from_address: "info@intebec.ca"
-
- # Attribution line at the bottom of transactional emails
- # Supports basic HTML. Use %{brand} as a placeholder for the brand name.
- # Use %{website} as a placeholder for the website URL.
- attribution_html: 'Envoyé avec %{brand} — signature de documents sécurisée.'
-
-# ---------------------------------------------------------------------------
-# Assets — paths are relative to /public or full URLs
-# ---------------------------------------------------------------------------
-assets:
- # Logo shown in the navbar and form banners
- logo_path: "/logo.svg"
- logo_width: 37
- logo_height: 37
-
- # Favicon files (place your files in /public)
- favicon_svg: "/favicon.svg"
- favicon_ico: "/favicon.ico"
- favicon_16: "/favicon-16x16.png"
- favicon_32: "/favicon-32x32.png"
- favicon_96: "/favicon-96x96.png"
- apple_touch_icon: "/apple-icon-180x180.png"
-
- # Open Graph / social preview image
- preview_image: "/preview.png"
-
-# ---------------------------------------------------------------------------
-# Theme — CSS custom properties injected into the DaisyUI `docuseal` theme
-# Values are HSL triplets "H S% L%" to match DaisyUI conventions.
-# Set a key to null to keep the compiled Tailwind default.
-# ---------------------------------------------------------------------------
-theme:
- # Primary action colour (buttons, links, focus rings)
- primary: "216 77% 52%"
- primary_focus: "216 77% 44%"
- primary_content: "0 0% 100%"
-
- # Secondary colour
- secondary: "220 12% 45%"
- secondary_focus: "220 14% 36%"
- secondary_content: "0 0% 100%"
-
- # Accent colour
- accent: "160 50% 40%"
- accent_focus: "160 50% 34%"
- accent_content: "0 0% 100%"
-
- # Neutral / dark tones
- neutral: "220 16% 12%"
- neutral_focus: "220 16% 8%"
- neutral_content: "0 0% 100%"
-
- # Surfaces / backgrounds
- base_100: "0 0% 100%"
- base_200: "220 14% 96%"
- base_300: "220 12% 93%"
- base_content: "220 14% 10%"
-
- # Functional colours
- info: "205 80% 50%"
- success: "154 55% 38%"
- warning: "38 88% 48%"
- error: "0 72% 50%"
-
- # Border radius & misc
- rounded_btn: "1.9rem"
- tab_border: "2px"
- tab_radius: ".5rem"
-
-# ---------------------------------------------------------------------------
-# PDF / Audit trail branding
-# ---------------------------------------------------------------------------
-pdf:
- # Text embedded in PDF digital signatures (use %{name} for the signer name)
- sign_reason: "Signed by %{name} with Intébec"
-
- # Audit trail footer text
- audit_trail_footer: "Signed with Intébec"
-
- # PDF Creator metadata
- creator: "Intébec"
-
- # E-sign certificate default name
- cert_name: "Intébec Self-Host Autogenerated"
-
-# ---------------------------------------------------------------------------
-# PWA (Progressive Web App) manifest
-# ---------------------------------------------------------------------------
-pwa:
- description: "Intébec is a secure platform for digital document signing and processing."
- theme_color: "#FAF7F4"
- background_color: "#FAF7F4"
-
-# ---------------------------------------------------------------------------
-# Webhook user-agent string
-# ---------------------------------------------------------------------------
-webhooks:
- user_agent: "Intébec Webhook"
-
-# ---------------------------------------------------------------------------
-# Feature flags — toggle upstream DocuSeal features
-# ---------------------------------------------------------------------------
-features:
- # Show the "Star on GitHub" button in the navbar
- show_github_button: false
-
- # Show "Powered by …" attribution on public signing pages
- show_powered_by: true
-
- # Text for the "powered by" line (null = use brand name)
- powered_by_text: ~
-
- # Show the "Ask AI / ChatGPT" link in the user menu
- show_ai_link: false
-
- # Show the Discord link
- show_discord_link: false
-
- # Show "Upgrade to Pro" upsell banners/buttons throughout the app
- # Set to false to hide all Pro upgrade prompts (recommended for self-hosted)
- show_pro_upsells: false
-
-# ---------------------------------------------------------------------------
-# Internal / technical settings
-# ---------------------------------------------------------------------------
-internal:
- # Domain used for auto-generated duplicate/test account emails (never real addresses)
- temp_email_domain: "intebec.ca"
diff --git a/docker-compose.yml b/docker-compose.yml
index 33acc5c3..099c115c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,17 +6,11 @@ services:
- 3000:3000
volumes:
- ./docuseal:/data/docuseal
+ - ${INTEBEC_CONFIG_FILE:-./config/config.yml}:/run/secrets/config.yml:ro
environment:
- FORCE_SSL=${HOST}
-
- caddy:
- image: caddy:latest
- command: caddy reverse-proxy --from $HOST --to app:3000
- ports:
- - 80:80
- - 443:443
- - 443:443/udp
- volumes:
- - ./caddy:/data/caddy
- environment:
- - HOST=${HOST}
+ # File mode: mount a config.yml (see volumes below).
+ # API mode: remove the volume mount and set INTEBEC_LICENCE_KEY + INTEBEC_SECRET_KEY.
+ - INTEBEC_CONFIG_PATH=/run/secrets/config.yml
+ - INTEBEC_LICENCE_KEY=${INTEBEC_LICENCE_KEY:-}
+ - INTEBEC_SECRET_KEY=${INTEBEC_SECRET_KEY:-}
diff --git a/docs/WHITELABEL.md b/docs/WHITELABEL.md
index 3ed66429..5e324f8b 100644
--- a/docs/WHITELABEL.md
+++ b/docs/WHITELABEL.md
@@ -1,80 +1,8 @@
-# White-Label Configuration Guide
+# White-Label Developer Reference (Internal)
-## Overview
-
-This fork of DocuSeal uses a **centralised white-label configuration system** that lets you rebrand the entire application for any client by editing a single YAML file. No need to create a separate repo for each client.
-
-## Architecture
-
-```
-config/whitelabel.yml ← Single source of truth (brand, URLs, theme, PDF, features)
-lib/whitelabel.rb ← Ruby module that loads + exposes the config
-config/initializers/whitelabel.rb ← Patches Docuseal module at boot so existing code uses new values
-app/helpers/whitelabel_helper.rb ← View helper (use `wl.xxx` in any ERB template)
-config/locales/whitelabel.yml ← Locale overrides for branded i18n keys (EN + FR)
-public/intebec.css ← CSS theme overrides (DaisyUI custom properties)
-```
-
-## How to Rebrand for a New Client
-
-### 1. Edit `config/whitelabel.yml`
-
-Change the values under each section:
-
-| Section | What it controls |
-| ---------- | ---------------------------------------------- |
-| `brand` | Name, tagline, description, page titles |
-| `urls` | Website, support email, privacy/terms, socials |
-| `email` | From address, email attribution |
-| `assets` | Logo, favicons, preview image |
-| `theme` | DaisyUI colour palette (HSL values) |
-| `pdf` | Signing reason, audit trail footer, cert name |
-| `pwa` | PWA manifest name, colours |
-| `webhooks` | User-Agent string |
-| `features` | Toggle GitHub button, AI link, powered-by text |
-
-### 2. Replace Asset Files
-
-Put your client's files in `/public`:
-
-- `logo.svg` — navbar + form logo
-- `favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, `favicon-96x96.png`
-- `favicon.svg` — SVG favicon
-- `apple-icon-180x180.png` — iOS home screen icon
-- `preview.png` — Open Graph social preview
-
-### 3. Update Theme Colours
-
-Two options:
-
-**Option A** — Edit `theme:` in `config/whitelabel.yml` (DaisyUI tokens, HSL format):
-
-```yaml
-theme:
- primary: "216 77% 52%"
- secondary: "220 12% 45%"
- accent: "160 50% 40%"
-```
-
-**Option B** — Edit `public/intebec.css` for fine-grained CSS overrides.
-
-### 4. Update Locale Overrides
-
-Edit `config/locales/whitelabel.yml` to change branded text in both English and French. This file **overrides** the base `config/locales/i18n.yml` — Rails merges them automatically.
-
-### 5. Restart
-
-After editing, restart the app:
-
-```bash
-docker compose down && docker compose up -d --build
-```
-
-Or in a Rails console:
-
-```ruby
-Whitelabel.reload!
-```
+> **This file is for Intebec developers only.**
+> Do NOT add config schema details, API contracts, or example YAML here.
+> The private config template is managed in the Intebec Dashboard, not in this repo.
## Usage in Code
@@ -90,27 +18,18 @@ Whitelabel.reload!
### In Ruby (controllers, mailers, lib)
```ruby
-Whitelabel.brand_name # => "Intébec"
-Whitelabel.website_url # => "https://intebec.ca"
-Whitelabel.email_from # => "Intébec "
-Whitelabel.sign_reason("John") # => "Signed by John with Intébec"
-Whitelabel.theme(:primary) # => "216 77% 52%"
-```
-
-### In JavaScript
-
-Brand values are available via `` tags in the page head:
-
-```javascript
-document.querySelector('meta[name="brand-name"]').content; // "Intébec"
-document.querySelector('meta[name="brand-website-url"]').content; // "https://intebec.ca"
+Whitelabel.brand_name
+Whitelabel.website_url
+Whitelabel.email_from
+Whitelabel.sign_reason("John")
+Whitelabel.theme(:primary)
```
## Upstream Merge Strategy
This system is designed to minimise merge conflicts with the upstream DocuSeal repo:
-1. **New files** (no conflicts): `config/whitelabel.yml`, `lib/whitelabel.rb`, `config/initializers/whitelabel.rb`, `app/helpers/whitelabel_helper.rb`, `config/locales/whitelabel.yml`
+1. **New files** (no conflicts): `lib/whitelabel.rb`, `config/initializers/whitelabel.rb`, `app/helpers/whitelabel_helper.rb`, `config/locales/whitelabel.yml`
2. **Patched files** (potential conflicts, but isolated changes):
- `lib/docuseal.rb` — only added a comment block; the `product_name` method is overridden at runtime
- View templates — changes are surgical (replacing one hardcoded string with a `Whitelabel.xxx` call)
@@ -122,31 +41,13 @@ This system is designed to minimise merge conflicts with the upstream DocuSeal r
2. Replace with `Whitelabel.brand_name` or `wl.brand_name`
3. If it's an i18n key, add the override to `config/locales/whitelabel.yml`
-## Feature Flags
-
-Toggle upstream features without removing code:
-
-```yaml
-features:
- show_github_button: false # Hide "Star on GitHub"
- show_powered_by: true # Show/hide "Powered by" on signing pages
- show_ai_link: false # Hide "Ask AI" in user menu
- show_discord_link: false # Hide Discord link
-```
-
## File Reference
-| File | Purpose | Upstream risk |
-| ---------------------------------------- | ------------------ | ----------------------- |
-| `config/whitelabel.yml` | All brand config | New file — zero risk |
-| `lib/whitelabel.rb` | Config loader | New file — zero risk |
-| `config/initializers/whitelabel.rb` | Boot-time patching | New file — zero risk |
-| `app/helpers/whitelabel_helper.rb` | View helper | New file — zero risk |
-| `config/locales/whitelabel.yml` | i18n overrides | New file — zero risk |
-| `public/intebec.css` | Theme CSS | Custom file — zero risk |
-| `lib/docuseal.rb` | Added comment | Low risk — comment only |
-| `app/views/shared/_logo.html.erb` | Dynamic logo path | Low risk |
-| `app/views/shared/_meta.html.erb` | Dynamic meta tags | Low risk |
-| `app/views/shared/_title.html.erb` | Dynamic brand name | Low risk |
-| `app/views/layouts/application.html.erb` | Added meta tags | Low risk |
-| `app/views/layouts/form.html.erb` | Added meta tags | Low risk |
+| File | Purpose | Upstream risk |
+| ----------------------------------- | ---------------------------- | ----------------------- |
+| `lib/whitelabel.rb` | Config loader + licence gate | New file — zero risk |
+| `config/initializers/whitelabel.rb` | Boot-time patching | New file — zero risk |
+| `app/helpers/whitelabel_helper.rb` | View helper | New file — zero risk |
+| `config/locales/whitelabel.yml` | i18n overrides | New file — zero risk |
+| `public/intebec.css` | Theme CSS | Custom file — zero risk |
+| `lib/docuseal.rb` | Added comment | Low risk — comment only |
diff --git a/lib/docuseal.rb b/lib/docuseal.rb
index 84bcbcdd..f0464e9d 100644
--- a/lib/docuseal.rb
+++ b/lib/docuseal.rb
@@ -4,7 +4,7 @@ module Docuseal
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
# NOTE: These constants are kept for backward compatibility with upstream
# DocuSeal code. User-visible values are overridden at runtime by the
- # Whitelabel module (see config/whitelabel.yml + config/initializers/whitelabel.rb).
+ # Whitelabel module (see config/config.example.yml + config/initializers/whitelabel.rb).
PRODUCT_URL = 'https://www.docuseal.com'
PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL)
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
diff --git a/lib/whitelabel.rb b/lib/whitelabel.rb
index 30c45f68..c56c14f5 100644
--- a/lib/whitelabel.rb
+++ b/lib/whitelabel.rb
@@ -1,43 +1,137 @@
# frozen_string_literal: true
# =============================================================================
-# Whitelabel — Centralised brand configuration loader
+# Whitelabel — Centralised brand config + licence enforcement
# =============================================================================
-# Reads config/whitelabel.yml once at boot and exposes every value through
-# simple accessor methods. All view helpers, mailers, PDF generators and
-# other call-sites should use Whitelabel.xxx instead of hard-coding strings.
#
-# Usage examples:
-# Whitelabel.brand_name # => "Intébec"
-# Whitelabel.support_email # => "support@intebec.ca"
-# Whitelabel.theme(:primary) # => "216 77% 52%"
-# Whitelabel.email_from # => "Intébec "
-# Whitelabel.sign_reason("John") # => "Signed by John with Intébec"
+# Config loading priority:
+# 1. Local YAML file → if present, used as-is (dev / custom deploys)
+# 2. Remote API fetch → if no file, calls Intebec Dashboard (production)
+# 3. Empty defaults → test environment only
#
-# After editing config/whitelabel.yml, call Whitelabel.reload! or restart.
+# All accessors use dig() with safe fallbacks so the app never crashes on
+# missing keys. Without a valid config source the fallbacks return plain
+# upstream DocuSeal values — your branding only appears with YOUR config.
+#
+# Env vars:
+# INTEBEC_CONFIG_PATH — override local file path (default: /run/secrets/config.yml)
+# INTEBEC_LICENCE_KEY — licence UUID (required for API mode)
+# INTEBEC_SECRET_KEY — HMAC shared secret (required for API mode)
+# INTEBEC_DASHBOARD_URL — override Dashboard URL (default: https://dashboard.intebec.ca)
# =============================================================================
require 'yaml'
+require 'uri'
+require 'json'
+require 'openssl'
+require 'net/http'
+require 'securerandom'
module Whitelabel
- CONFIG_PATH = Rails.root.join('config', 'whitelabel.yml').freeze
+ class ConfigError < StandardError; end
+ class LicenceRevokedError < ConfigError; end
+
+ CONFIG_PATH = Pathname.new(
+ ENV.fetch('INTEBEC_CONFIG_PATH', '/run/secrets/config.yml')
+ ).freeze
+
+ DASHBOARD_URL = ENV.fetch('INTEBEC_DASHBOARD_URL', 'https://dashboard.intebec.ca').freeze
+ CONFIG_ENDPOINT = '/api/licences/config'
+ API_TIMEOUT = 10
+ API_MAX_RETRIES = 3
+ API_RETRY_DELAY = 2 # seconds, doubles each retry
+ REFRESH_INTERVAL = 24 * 3600 # 24 h
+ REFRESH_ON_ERROR = 5 * 60 # 5 min retry on transient failure
+
+ THEME_DEFAULTS = {
+ 'primary' => '216 77% 52%',
+ 'primary_focus' => '216 77% 44%',
+ 'primary_content' => '0 0% 100%',
+ 'secondary' => '220 12% 45%',
+ 'secondary_focus' => '220 14% 36%',
+ 'secondary_content' => '0 0% 100%',
+ 'accent' => '160 50% 40%',
+ 'accent_focus' => '160 50% 34%',
+ 'accent_content' => '0 0% 100%',
+ 'neutral' => '220 16% 12%',
+ 'neutral_focus' => '220 16% 8%',
+ 'neutral_content' => '0 0% 100%',
+ 'base_100' => '0 0% 100%',
+ 'base_200' => '220 14% 96%',
+ 'base_300' => '220 12% 93%',
+ 'base_content' => '220 14% 10%',
+ 'info' => '205 80% 50%',
+ 'success' => '154 55% 38%',
+ 'warning' => '38 88% 48%',
+ 'error' => '0 72% 50%',
+ 'rounded_btn' => '1.9rem',
+ 'tab_border' => '2px',
+ 'tab_radius' => '.5rem'
+ }.freeze
+
+ DEFAULT_STYLING_VARIABLES = {
+ 'ib-bg' => '220 14% 98%',
+ 'ib-surface' => '0 0% 100%',
+ 'ib-surface-2' => '220 14% 96%',
+ 'ib-border' => '220 10% 88%',
+ 'ib-text' => '220 14% 10%',
+ 'ib-text-secondary' => '220 8% 40%',
+ 'ib-muted' => '220 6% 55%'
+ }.freeze
+
+ # ── Mutable state (thread-safe) ─────────────────────────────────────────
+ @mutex = Mutex.new
+ @config = nil
+ @api_sourced = false
+ @next_refresh = Time.at(0).utc
class << self
- # -----------------------------------------------------------------------
- # Core loader
- # -----------------------------------------------------------------------
+ # =====================================================================
+ # Core
+ # =====================================================================
def config
- @config ||= load_config
+ @config || load_config!
end
def reload!
- @config = load_config
+ @mutex.synchronize { @config = nil }
+ load_config!
+ end
+
+ def config_source
+ return :api if @api_sourced
+ return :test if @config && !CONFIG_PATH.file?
+ :file
end
- # -----------------------------------------------------------------------
- # Brand identity
- # -----------------------------------------------------------------------
+ # Called per-request from ApplicationController.
+ # For API-sourced configs, periodically re-fetches to confirm the
+ # licence is still active and pick up any Dashboard changes.
+ def ensure_valid!
+ return true unless @api_sourced
+ return true unless Time.now.utc >= @next_refresh
+
+ @mutex.synchronize do
+ return true unless Time.now.utc >= @next_refresh
+
+ @config = fetch_remote_config
+ @next_refresh = Time.now.utc + REFRESH_INTERVAL
+ rescue LicenceRevokedError
+ # Licence actively revoked → propagate, controller returns 503
+ @config = {}
+ raise
+ rescue ConfigError => e
+ # Transient error (network, timeout) → keep existing config, retry sooner
+ Rails.logger.error("[Whitelabel] Revalidation failed: #{e.message}")
+ @next_refresh = Time.now.utc + REFRESH_ON_ERROR
+ end
+ true
+ end
+
+ # =====================================================================
+ # Brand
+ # =====================================================================
def brand_name
config.dig('brand', 'name') || 'DocuSeal'
@@ -60,16 +154,16 @@ module Whitelabel
config.dig('brand', key) || brand_name
end
- # -----------------------------------------------------------------------
+ # =====================================================================
# URLs
- # -----------------------------------------------------------------------
+ # =====================================================================
def website_url
- config.dig('urls', 'website') || 'https://intebec.ca'
+ config.dig('urls', 'website') || 'https://www.docuseal.com'
end
def support_email
- config.dig('urls', 'support_email') || 'support@intebec.ca'
+ config.dig('urls', 'support_email') || 'support@docuseal.com'
end
def privacy_policy_url
@@ -96,9 +190,9 @@ module Whitelabel
config.dig('urls', 'discord_url')
end
- # -----------------------------------------------------------------------
+ # =====================================================================
# Email
- # -----------------------------------------------------------------------
+ # =====================================================================
def email_from
name = config.dig('email', 'from_name') || brand_name
@@ -107,13 +201,14 @@ module Whitelabel
end
def email_attribution_html
- raw = config.dig('email', 'attribution_html') || ''
+ raw = config.dig('email', 'attribution_html') ||
+ 'Sent with %{brand}.'
raw.gsub('%{brand}', brand_name).gsub('%{website}', website_url)
end
- # -----------------------------------------------------------------------
+ # =====================================================================
# Assets
- # -----------------------------------------------------------------------
+ # =====================================================================
def logo_path
config.dig('assets', 'logo_path') || '/logo.svg'
@@ -155,20 +250,20 @@ module Whitelabel
config.dig('assets', 'preview_image') || '/preview.png'
end
- # -----------------------------------------------------------------------
- # Theme — returns HSL triplets for DaisyUI / CSS custom properties
- # -----------------------------------------------------------------------
+ # =====================================================================
+ # Theme — HSL triplets for DaisyUI / CSS custom properties
+ # =====================================================================
def theme(key)
- config.dig('theme', key.to_s)
+ config.dig('theme', key.to_s) || THEME_DEFAULTS[key.to_s] || '0 0% 50%'
end
- # -----------------------------------------------------------------------
+ # =====================================================================
# PDF / Audit trail
- # -----------------------------------------------------------------------
+ # =====================================================================
def sign_reason(name)
- template = config.dig('pdf', 'sign_reason') || "Signed by %{name} with #{brand_name}"
+ template = config.dig('pdf', 'sign_reason') || 'Signed by %{name}'
template.gsub('%{name}', name.to_s)
end
@@ -182,43 +277,43 @@ module Whitelabel
end
def cert_name
- config.dig('pdf', 'cert_name') || "#{brand_name} Self-Host Autogenerated"
+ config.dig('pdf', 'cert_name') || 'docuseal_aatl'
end
- # -----------------------------------------------------------------------
+ # =====================================================================
# PWA
- # -----------------------------------------------------------------------
+ # =====================================================================
def pwa_description
- config.dig('pwa', 'description') || "#{brand_name} is a secure platform for digital document signing."
+ config.dig('pwa', 'description') || description
end
def pwa_theme_color
- config.dig('pwa', 'theme_color') || '#FAF7F4'
+ config.dig('pwa', 'theme_color') || '#FFFFFF'
end
def pwa_background_color
- config.dig('pwa', 'background_color') || '#FAF7F4'
+ config.dig('pwa', 'background_color') || '#FFFFFF'
end
- # -----------------------------------------------------------------------
+ # =====================================================================
# Webhooks
- # -----------------------------------------------------------------------
+ # =====================================================================
def webhook_user_agent
config.dig('webhooks', 'user_agent') || "#{brand_name} Webhook"
end
- # -----------------------------------------------------------------------
+ # =====================================================================
# Feature flags
- # -----------------------------------------------------------------------
+ # =====================================================================
def show_github_button?
- config.dig('features', 'show_github_button') == true
+ dig_bool('features', 'show_github_button', false)
end
def show_powered_by?
- config.dig('features', 'show_powered_by') != false
+ dig_bool('features', 'show_powered_by', false)
end
def powered_by_text
@@ -226,34 +321,296 @@ module Whitelabel
end
def show_ai_link?
- config.dig('features', 'show_ai_link') == true
+ dig_bool('features', 'show_ai_link', false)
end
def show_discord_link?
- config.dig('features', 'show_discord_link') == true
+ dig_bool('features', 'show_discord_link', false)
end
def show_pro_upsells?
- config.dig('features', 'show_pro_upsells') == true
+ dig_bool('features', 'show_pro_upsells', false)
end
- # -----------------------------------------------------------------------
- # Internal / technical
- # -----------------------------------------------------------------------
+ # =====================================================================
+ # Internal
+ # =====================================================================
def temp_email_domain
- config.dig('internal', 'temp_email_domain') || 'example.com'
+ config.dig('internal', 'temp_email_domain') || 'docuseal.com'
end
- private
+ # =====================================================================
+ # Locale / Translations
+ # =====================================================================
+
+ def default_locale
+ config.dig('locale', 'default') || 'en'
+ end
+
+ def available_locales
+ config.dig('locale', 'available') || %w[en]
+ end
+
+ def fallback_locale
+ config.dig('locale', 'fallback') || 'en'
+ end
+
+ def translation_overrides
+ config.dig('text', 'translations') || {}
+ end
+
+ # =====================================================================
+ # Styling
+ # =====================================================================
- def load_config
- return {} unless CONFIG_PATH.exist?
+ def styling_variables
+ DEFAULT_STYLING_VARIABLES.merge(config.dig('styling', 'css_variables') || {})
+ end
+
+ def inline_css_variables
+ vars = {
+ 'wl-ib-primary' => theme(:primary),
+ 'wl-ib-primary-strong' => theme(:primary_focus),
+ 'wl-ib-primary-soft' => "#{theme(:primary)} / 0.12",
+ 'wl-ib-neutral' => theme(:neutral),
+ 'wl-ib-neutral-soft' => theme(:base_200),
+ 'wl-p' => theme(:primary),
+ 'wl-pf' => theme(:primary_focus),
+ 'wl-pc' => theme(:primary_content),
+ 'wl-s' => theme(:secondary),
+ 'wl-sf' => theme(:secondary_focus),
+ 'wl-sc' => theme(:secondary_content),
+ 'wl-a' => theme(:accent),
+ 'wl-af' => theme(:accent_focus),
+ 'wl-ac' => theme(:accent_content),
+ 'wl-n' => theme(:neutral),
+ 'wl-nf' => theme(:neutral_focus),
+ 'wl-nc' => theme(:neutral_content),
+ 'wl-b1' => theme(:base_100),
+ 'wl-b2' => theme(:base_200),
+ 'wl-b3' => theme(:base_300),
+ 'wl-bc' => theme(:base_content),
+ 'wl-in' => theme(:info),
+ 'wl-su' => theme(:success),
+ 'wl-wa' => theme(:warning),
+ 'wl-er' => theme(:error),
+ 'wl-rounded-btn' => theme(:rounded_btn)
+ }
+
+ styling_variables.each { |key, value| vars["wl-#{key}"] = value }
+
+ declarations = vars.map { |k, v| "--#{k}: #{v};" }.join(' ')
+ ":root { #{declarations} }"
+ end
+
+ # =====================================================================
+ # Config signature (file-based only)
+ # =====================================================================
+
+ def enforce_config_signature?
+ dig_bool('security', 'enforce_config_signature', false)
+ end
+
+ def config_signature
+ config.dig('security', 'config_signature') || ''
+ end
+
+ def signature_payload
+ canonical_payload(config)
+ end
+
+ def generate_config_signature(secret)
+ raise ConfigError, 'Secret required' if secret.to_s.empty?
+
+ OpenSSL::HMAC.hexdigest('SHA256', secret, signature_payload).downcase
+ end
+
+ private
- YAML.safe_load_file(CONFIG_PATH, permitted_classes: [Symbol]) || {}
+ # =====================================================================
+ # Config loading
+ # =====================================================================
+
+ def load_config!
+ @mutex.synchronize do
+ return @config if @config # another thread beat us
+
+ if CONFIG_PATH.file?
+ load_from_file!
+ elsif Rails.env.test?
+ load_test_defaults!
+ else
+ load_from_api!
+ end
+ end
+ @config
+ end
+
+ def load_from_file!
+ raw = YAML.safe_load_file(
+ CONFIG_PATH,
+ permitted_classes: [], permitted_symbols: [], aliases: false
+ )
+ raise ConfigError, '[Whitelabel] Config must be a YAML mapping' unless raw.is_a?(Hash)
+
+ verify_file_signature!(raw)
+ @config = raw
+ @api_sourced = false
+ Rails.logger.info("[Whitelabel] Loaded config from file: #{CONFIG_PATH}")
rescue Psych::SyntaxError => e
- Rails.logger.error("[Whitelabel] Failed to parse #{CONFIG_PATH}: #{e.message}")
- {}
+ raise ConfigError, "[Whitelabel] YAML parse error in #{CONFIG_PATH}: #{e.message}"
+ rescue Errno::EISDIR
+ raise ConfigError, "[Whitelabel] #{CONFIG_PATH} is a directory, not a file."
+ end
+
+ def load_from_api!
+ licence_key = ENV['INTEBEC_LICENCE_KEY'].to_s
+ secret_key = ENV['INTEBEC_SECRET_KEY'].to_s
+
+ if licence_key.empty? || secret_key.empty?
+ raise ConfigError,
+ '[Whitelabel] No config file found and INTEBEC_LICENCE_KEY / INTEBEC_SECRET_KEY ' \
+ 'env vars are missing. Cannot start without a config source.'
+ end
+
+ @config = fetch_remote_config
+ @api_sourced = true
+ @next_refresh = Time.now.utc + REFRESH_INTERVAL
+ Rails.logger.info('[Whitelabel] Loaded config from Intebec Dashboard API')
+ end
+
+ def load_test_defaults!
+ @config = {}
+ @api_sourced = false
+ Rails.logger.info('[Whitelabel] Test mode — all accessors return safe fallbacks')
+ end
+
+ # =====================================================================
+ # Remote config fetch (with retry)
+ # =====================================================================
+
+ def fetch_remote_config
+ licence_key = ENV.fetch('INTEBEC_LICENCE_KEY')
+ secret_key = ENV.fetch('INTEBEC_SECRET_KEY')
+ last_error = nil
+
+ API_MAX_RETRIES.times do |attempt|
+ uri = URI.join(DASHBOARD_URL, CONFIG_ENDPOINT)
+ timestamp = Time.now.utc.to_i.to_s
+ nonce = SecureRandom.hex(12)
+ instance_id = stable_instance_id
+ payload = [licence_key, timestamp, nonce, instance_id].join('.')
+ signature = OpenSSL::HMAC.hexdigest('SHA256', secret_key, payload)
+
+ uri.query = URI.encode_www_form(licence_key: licence_key, instance_id: instance_id)
+
+ req = Net::HTTP::Get.new(uri)
+ req['Accept'] = 'application/json'
+ req['X-Licence-Key'] = licence_key
+ req['X-Licence-Timestamp'] = timestamp
+ req['X-Licence-Nonce'] = nonce
+ req['X-Licence-Signature'] = signature
+ req['X-Licence-Instance'] = instance_id
+ req['User-Agent'] = 'Intebec-DocuSeal'
+
+ resp = Net::HTTP.start(
+ uri.host, uri.port,
+ use_ssl: uri.scheme == 'https',
+ open_timeout: API_TIMEOUT,
+ read_timeout: API_TIMEOUT
+ ) { |http| http.request(req) }
+
+ unless [200, 201].include?(resp.code.to_i)
+ raise ConfigError, "HTTP #{resp.code}"
+ end
+
+ parsed = JSON.parse(resp.body)
+ status = parsed['status'].to_s
+
+ unless %w[active trial].include?(status)
+ raise LicenceRevokedError, "Licence status: #{status}"
+ end
+
+ remote_cfg = parsed['config']
+ raise ConfigError, 'API returned no config payload' unless remote_cfg.is_a?(Hash)
+
+ return remote_cfg
+
+ rescue LicenceRevokedError
+ raise # don't retry revocations
+
+ rescue StandardError => e
+ last_error = e.message
+ delay = API_RETRY_DELAY * (2**attempt)
+ if attempt < API_MAX_RETRIES - 1
+ Rails.logger.warn(
+ "[Whitelabel] API attempt #{attempt + 1}/#{API_MAX_RETRIES} " \
+ "failed: #{e.message}, retry in #{delay}s"
+ )
+ sleep(delay)
+ end
+ end
+
+ raise ConfigError,
+ "[Whitelabel] Dashboard unreachable after #{API_MAX_RETRIES} attempts: #{last_error}"
+ end
+
+ def stable_instance_id
+ @stable_instance_id ||= begin
+ raw = [ENV.fetch('INTEBEC_LICENCE_KEY', ''), ENV.fetch('HOST', 'localhost')].join(':')
+ OpenSSL::Digest::SHA256.hexdigest(raw)
+ end
+ end
+
+ # =====================================================================
+ # File signature verification (optional, for file-based configs)
+ # =====================================================================
+
+ def verify_file_signature!(raw)
+ return unless raw.dig('security', 'enforce_config_signature') == true
+
+ secret = ENV['INTEBEC_SECRET_KEY'].to_s
+ raise ConfigError, '[Whitelabel] INTEBEC_SECRET_KEY required for config signature verification' if secret.empty?
+
+ expected = raw.dig('security', 'config_signature').to_s.downcase
+ actual = OpenSSL::HMAC.hexdigest('SHA256', secret, canonical_payload(raw)).downcase
+
+ unless expected.length == 64 && secure_compare(actual, expected)
+ raise ConfigError, '[Whitelabel] Config signature mismatch — refusing to boot.'
+ end
+ end
+
+ def canonical_payload(loaded)
+ copy = Marshal.load(Marshal.dump(loaded))
+ copy['security']&.delete('config_signature')
+ JSON.generate(deep_sort_hash(copy))
+ end
+
+ def deep_sort_hash(value)
+ case value
+ when Hash
+ value.keys.sort.each_with_object({}) { |k, h| h[k] = deep_sort_hash(value[k]) }
+ when Array
+ value.map { |v| deep_sort_hash(v) }
+ else
+ value
+ end
+ end
+
+ def secure_compare(a, b)
+ return false unless a.bytesize == b.bytesize
+
+ ActiveSupport::SecurityUtils.secure_compare(a, b)
+ end
+
+ # =====================================================================
+ # Helpers
+ # =====================================================================
+
+ def dig_bool(section, key, default = false)
+ value = config.dig(section, key)
+ value.nil? ? default : value
end
end
end
diff --git a/public/intebec.css b/public/intebec.css
index 1917c7c6..03c6ea04 100644
--- a/public/intebec.css
+++ b/public/intebec.css
@@ -4,52 +4,52 @@
[data-theme="docuseal"] {
/* Brand – used sparingly */
- --ib-primary: 216 77% 52%;
- --ib-primary-strong: 216 77% 44%;
- --ib-primary-soft: 216 60% 95%;
+ --ib-primary: var(--wl-ib-primary, 216 77% 52%);
+ --ib-primary-strong: var(--wl-ib-primary-strong, 216 77% 44%);
+ --ib-primary-soft: var(--wl-ib-primary-soft, 216 60% 95%);
/* Neutrals – the backbone of the UI */
- --ib-neutral: 220 16% 12%;
- --ib-neutral-soft: 220 12% 96%;
+ --ib-neutral: var(--wl-ib-neutral, 220 16% 12%);
+ --ib-neutral-soft: var(--wl-ib-neutral-soft, 220 12% 96%);
- --ib-bg: 220 14% 98%;
- --ib-surface: 0 0% 100%;
- --ib-surface-2: 220 14% 96%;
- --ib-border: 220 10% 88%;
- --ib-text: 220 14% 10%;
- --ib-text-secondary: 220 8% 40%;
- --ib-muted: 220 6% 55%;
+ --ib-bg: var(--wl-ib-bg, 220 14% 98%);
+ --ib-surface: var(--wl-ib-surface, 0 0% 100%);
+ --ib-surface-2: var(--wl-ib-surface-2, 220 14% 96%);
+ --ib-border: var(--wl-ib-border, 220 10% 88%);
+ --ib-text: var(--wl-ib-text, 220 14% 10%);
+ --ib-text-secondary: var(--wl-ib-text-secondary, 220 8% 40%);
+ --ib-muted: var(--wl-ib-muted, 220 6% 55%);
/* DaisyUI theme tokens (H S L space-separated) */
- --p: var(--ib-primary);
- --pf: var(--ib-primary-strong);
- --pc: 0 0% 100%;
+ --p: var(--wl-p, var(--ib-primary));
+ --pf: var(--wl-pf, var(--ib-primary-strong));
+ --pc: var(--wl-pc, 0 0% 100%);
- --s: 220 12% 45%;
- --sf: 220 14% 36%;
- --sc: 0 0% 100%;
+ --s: var(--wl-s, 220 12% 45%);
+ --sf: var(--wl-sf, 220 14% 36%);
+ --sc: var(--wl-sc, 0 0% 100%);
- --a: 160 50% 40%;
- --af: 160 50% 34%;
- --ac: 0 0% 100%;
+ --a: var(--wl-a, 160 50% 40%);
+ --af: var(--wl-af, 160 50% 34%);
+ --ac: var(--wl-ac, 0 0% 100%);
- --n: var(--ib-neutral);
- --nf: 220 16% 8%;
- --nc: 0 0% 100%;
+ --n: var(--wl-n, var(--ib-neutral));
+ --nf: var(--wl-nf, 220 16% 8%);
+ --nc: var(--wl-nc, 0 0% 100%);
- --b1: var(--ib-surface);
- --b2: var(--ib-surface-2);
- --b3: 220 12% 93%;
- --bc: var(--ib-text);
+ --b1: var(--wl-b1, var(--ib-surface));
+ --b2: var(--wl-b2, var(--ib-surface-2));
+ --b3: var(--wl-b3, 220 12% 93%);
+ --bc: var(--wl-bc, var(--ib-text));
- --in: 205 80% 50%;
- --su: 154 55% 38%;
- --wa: 38 88% 48%;
- --er: 0 72% 50%;
+ --in: var(--wl-in, 205 80% 50%);
+ --su: var(--wl-su, 154 55% 38%);
+ --wa: var(--wl-wa, 38 88% 48%);
+ --er: var(--wl-er, 0 72% 50%);
/* Radii + shadow */
--rounded-box: 0.875rem;
- --rounded-btn: 0.625rem;
+ --rounded-btn: var(--wl-rounded-btn, 0.625rem);
--rounded-badge: 9999px;
}