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; }