claude config

pull/627/head^2
chapsjust 2 months ago
parent 31297c23a3
commit 7cba39d076

1
.gitignore vendored

@ -21,6 +21,7 @@
/public/assets
/config/master.key
/config/config.yml
/public/packs
/public/packs-test

@ -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

@ -3,3 +3,4 @@
</title>
<%= render 'shared/meta' %>
<link rel="stylesheet" href="/intebec.css">
<style id="whitelabel-theme-vars"><%= wl.inline_css_variables %></style>

@ -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}"

@ -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 <address>"
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 <a href="%{website}">%{brand}</a> — 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"

@ -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:-}

@ -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 <info@intebec.ca>"
Whitelabel.sign_reason("John") # => "Signed by John with Intébec"
Whitelabel.theme(:primary) # => "216 77% 52%"
```
### In JavaScript
Brand values are available via `<meta>` 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 |

@ -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

@ -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 <info@intebec.ca>"
# 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 <a href="%{website}">%{brand}</a>.'
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

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

Loading…
Cancel
Save