@ -0,0 +1 @@
|
||||
HOST=hostname
|
||||
@ -1 +1,2 @@
|
||||
*.html linguist-detectable=false
|
||||
bin/* text eol=lf
|
||||
|
||||
@ -1,39 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EmbedScriptsController < ActionController::Metal
|
||||
DUMMY_SCRIPT = <<~JAVASCRIPT.freeze
|
||||
const DummyBuilder = class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<h2>Upgrade to Pro</h2>
|
||||
<p>Unlock embedded components by upgrading to Pro</p>
|
||||
<div style="margin-top: 40px;">
|
||||
<a href="#{Docuseal::CONSOLE_URL}/on_premises" target="_blank" style="padding: 15px 25px; background-color: #222; color: white; text-decoration: none; border-radius: 5px; font-size: 16px; cursor: pointer;">
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
def show
|
||||
headers['Content-Type'] = 'application/javascript'
|
||||
|
||||
const DummyForm = class extends DummyBuilder {};
|
||||
self.response_body = dummy_script
|
||||
|
||||
if (!window.customElements.get('docuseal-builder')) {
|
||||
window.customElements.define('docuseal-builder', DummyBuilder);
|
||||
}
|
||||
self.status = 200
|
||||
end
|
||||
|
||||
if (!window.customElements.get('docuseal-form')) {
|
||||
window.customElements.define('docuseal-form', DummyForm);
|
||||
}
|
||||
JAVASCRIPT
|
||||
private
|
||||
|
||||
def show
|
||||
headers['Content-Type'] = 'application/javascript'
|
||||
def dummy_script
|
||||
<<~JAVASCRIPT
|
||||
const DummyBuilder = class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<div style="text-align: center; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<h2>Upgrade to Pro</h2>
|
||||
<p>Unlock embedded components by upgrading to Pro</p>
|
||||
<div style="margin-top: 40px;">
|
||||
<a href="#{Docuseal::CONSOLE_URL}/on_premises" target="_blank" style="padding: 15px 25px; background-color: #222; color: white; text-decoration: none; border-radius: 5px; font-size: 16px; cursor: pointer;">
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
self.response_body = DUMMY_SCRIPT
|
||||
const DummyForm = class extends DummyBuilder {};
|
||||
|
||||
self.status = 200
|
||||
if (!window.customElements.get('docuseal-builder')) {
|
||||
window.customElements.define('docuseal-builder', DummyBuilder);
|
||||
}
|
||||
|
||||
if (!window.customElements.get('docuseal-form')) {
|
||||
window.customElements.define('docuseal-form', DummyForm);
|
||||
}
|
||||
JAVASCRIPT
|
||||
end
|
||||
end
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# =============================================================================
|
||||
# WhitelabelHelper — makes Whitelabel config available in all views
|
||||
# =============================================================================
|
||||
# Include this in ApplicationController to use `wl` in all ERB templates.
|
||||
#
|
||||
# Usage in views:
|
||||
# <%= wl.brand_name %>
|
||||
# <%= wl.logo_path %>
|
||||
# <%= wl.support_email %>
|
||||
# =============================================================================
|
||||
|
||||
module WhitelabelHelper
|
||||
def wl
|
||||
Whitelabel
|
||||
end
|
||||
end
|
||||
@ -1,4 +1,6 @@
|
||||
<title>
|
||||
<%= content_for(:html_title) || (signed_in? ? 'DocuSeal' : 'DocuSeal | Open Source Document Signing') %>
|
||||
<%= content_for(:html_title) || (signed_in? ? wl.page_title(signed_in: true) : wl.page_title(signed_in: false)) %>
|
||||
</title>
|
||||
<%= render 'shared/meta' %>
|
||||
<link rel="stylesheet" href="/intebec.css">
|
||||
<style id="whitelabel-theme-vars"><%= wl.inline_css_variables %></style>
|
||||
|
||||
@ -1,79 +1,2 @@
|
||||
<section class="flex items-center justify-center">
|
||||
<div class="py-10 mx-auto max-w-7xl">
|
||||
<div class="max-w-xl mx-auto">
|
||||
<div class="text-center">
|
||||
<div class="relative flex flex-col items-center">
|
||||
<%= render 'shared/logo', width: '100', height: '100' %>
|
||||
<h1 class="text-6xl font-bold mt-4 mb-4">
|
||||
DocuSeal
|
||||
</h1>
|
||||
<% if Docuseal.version.present? %>
|
||||
<a href="https://github.com/docusealco/docuseal/releases" target="_blank" class="badge badge-outline badge-lg block mx-auto">
|
||||
v<%= Docuseal.version %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<h2 class="mt-4 mb-16 text-lg text-center text-gray-600">
|
||||
A self-hosted and open-source web platform that provides secure and efficient digital document signing and processing.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-12 md:gap-10 md:grid-cols-2">
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<div class="text-center transition-all">
|
||||
<div class="inline-block p-4 mb-4 -mt-16 bg-base-content rounded-full">
|
||||
<%= svg_icon('brand_docker', class: 'w-10 h-10 text-base-100 stroke-1') %>
|
||||
</div>
|
||||
<h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3>
|
||||
<p class="text-base text-gray-500">
|
||||
Run on your own host using Docker container, or deploy on your favorite managed PaaS with a single <a href="https://www.docuseal.com/install" class="link link-neutral font-bold">click</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<div class="text-center transition-all">
|
||||
<div class="inline-block p-4 mb-4 -mt-16 bg-base-content rounded-full">
|
||||
<%= svg_icon('devices', class: 'w-10 h-10 text-base-100') %>
|
||||
</div>
|
||||
<h3 class="mb-4 text-2xl font-semibold">Mobile Optimized</h3>
|
||||
<p class="text-base text-gray-500">
|
||||
Review and sign digital documents online from any device.
|
||||
Docuseal document forms are optimized for screens of all sizes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<div class="text-center transition-all">
|
||||
<div class="inline-block p-4 mb-4 -mt-16 bg-base-content rounded-full">
|
||||
<%= svg_icon('shield_check', class: 'w-10 h-10 text-base-100') %>
|
||||
</div>
|
||||
<h3 class="mb-4 text-2xl font-semibold">Secure</h3>
|
||||
<p class="text-base text-gray-500">
|
||||
Host it on your hardware under a VPN to ensure that important documents can be accesses only within your organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<div class="text-center transition-all">
|
||||
<div class="inline-block p-4 mb-4 -mt-16 bg-base-content rounded-full">
|
||||
<%= svg_icon('brand_github', class: 'w-10 h-10 text-base-100') %>
|
||||
</div>
|
||||
<h3 class="mb-4 text-2xl font-semibold">Open Source</h3>
|
||||
<p class="text-base text-gray-500">
|
||||
Source code is available under <a href="<%= Docuseal::GITHUB_URL %>" class="link link-neutral font-bold" target="_blank">github.com/docusealco</a>.<br>
|
||||
Open-source contributors are always ready to help!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<%= render 'shared/attribution', with_counter: true %>
|
||||
<% response.headers['Location'] = new_user_session_path %>
|
||||
<% response.status = 302 %>
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
|
||||
<span class="flex items-center justify-between space-x-0.5 font-medium">
|
||||
<%= svg_icon('start', class: 'h-3 w-3') %>
|
||||
<span>11k</span>
|
||||
</span>
|
||||
</a>
|
||||
@ -1,10 +1,12 @@
|
||||
<a href="https://github.com/docusealco/docuseal" class="btn btn-neutral btn-sm btn-outline inline-flex items-center justify-center" target="_blank" alt="Star on GitHub" style="height: 37px">
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex">
|
||||
<span class="hidden lg:block">Star on </span>GitHub
|
||||
</span>
|
||||
</a>
|
||||
<% if Whitelabel.show_github_button? && Whitelabel.github_url.present? %>
|
||||
<a href="<%= Whitelabel.github_url %>" class="btn btn-neutral btn-sm btn-outline inline-flex items-center justify-center" target="_blank" alt="Star on GitHub" style="height: 37px">
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex">
|
||||
<span class="hidden lg:block">Star on </span>GitHub
|
||||
</span>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 225 B |
@ -1,8 +1,3 @@
|
||||
<% if signed_in? && current_user != true_user %>
|
||||
<%= render 'shared/test_alert' %>
|
||||
<% elsif request.path.starts_with?('/settings') %>
|
||||
<%= link_to "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
|
||||
<%= t('upgrade') %>
|
||||
<% end %>
|
||||
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
|
||||
<% end %>
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
<div class="text-center px-2">
|
||||
<% if local_assigns[:with_counter] %>
|
||||
<% count = CompletedSubmitter.distinct.count(:submission_id) %>
|
||||
<% if count > 1 %>
|
||||
<%= t('count_documents_signed_with_html', count:) %>
|
||||
<% if Whitelabel.show_powered_by? %>
|
||||
<div class="text-center px-2">
|
||||
<% if local_assigns[:with_counter] %>
|
||||
<% count = CompletedSubmitter.distinct.count(:submission_id) %>
|
||||
<% if count > 1 %>
|
||||
<%= t('count_documents_signed_with_html', count:) %>
|
||||
<% else %>
|
||||
<%= t('powered_by') %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= t('powered_by') %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= t('powered_by') %>
|
||||
<% end %>
|
||||
<a href="<%= Docuseal::PRODUCT_URL %><%= local_assigns[:link_path] %>" class="underline"><%= Docuseal.product_name %></a> - <%= t('open_source_documents_software') %>
|
||||
</div>
|
||||
<a href="<%= Whitelabel.website_url %><%= local_assigns[:link_path] %>" class="underline"><%= Whitelabel.powered_by_text %></a> - <%= t('open_source_documents_software') %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
<%= render 'shared/logo' %>
|
||||
<span>DocuSeal</span>
|
||||
<span><%= wl.brand_name %></span>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<a href="<%= root_path %>" class="mx-auto text-2xl md:text-3xl font-bold items-center flex space-x-3">
|
||||
<%= render 'shared/logo', class: 'w-9 h-9 md:w-12 md:h-12' %>
|
||||
<span><%= Docuseal.product_name %></span>
|
||||
<span><%= Whitelabel.brand_name %></span>
|
||||
</a>
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# =============================================================================
|
||||
# Whitelabel initializer
|
||||
# =============================================================================
|
||||
# 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'
|
||||
|
||||
# Ensure lib/docuseal.rb is fully loaded before we reopen and patch the module.
|
||||
# Without this, Zeitwerk sees `module Docuseal` below and marks the constant as
|
||||
# already defined, so it never loads lib/docuseal.rb — leaving multitenant? and
|
||||
# other module_function methods undefined during eager loading.
|
||||
require Rails.root.join('lib/docuseal')
|
||||
|
||||
# Patch Docuseal module to delegate brand-related values to Whitelabel
|
||||
module Docuseal
|
||||
# Override the product_name method to use Whitelabel config
|
||||
def self.product_name
|
||||
Whitelabel.brand_name
|
||||
end
|
||||
|
||||
# Override constants that are used in views/mailers — we make them
|
||||
# methods instead so they pick up the Whitelabel config dynamically.
|
||||
# The constants still exist for backward compat but the methods take
|
||||
# precedence when called as Docuseal.xxx.
|
||||
|
||||
def self.product_url
|
||||
Whitelabel.website_url
|
||||
end
|
||||
|
||||
def self.support_email_address
|
||||
Whitelabel.support_email
|
||||
end
|
||||
|
||||
def self.github_url_value
|
||||
Whitelabel.github_url || ''
|
||||
end
|
||||
|
||||
def self.twitter_url_value
|
||||
Whitelabel.twitter_url || ''
|
||||
end
|
||||
|
||||
def self.twitter_handle_value
|
||||
Whitelabel.twitter_handle || ''
|
||||
end
|
||||
|
||||
def self.discord_url_value
|
||||
Whitelabel.discord_url || ''
|
||||
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}"
|
||||
@ -0,0 +1,128 @@
|
||||
# =============================================================================
|
||||
# White-Label Locale Overrides
|
||||
# =============================================================================
|
||||
# This file overrides i18n keys that contain brand-specific text (DocuSeal).
|
||||
# Rails automatically merges locale files, so keys here take precedence.
|
||||
#
|
||||
# When upstream DocuSeal updates add new branded keys to i18n.yml,
|
||||
# add the override here — keeping the main file untouched for easy merging.
|
||||
#
|
||||
# Languages: English (en) + French (fr)
|
||||
# =============================================================================
|
||||
|
||||
en: &en_wl
|
||||
docuseal_trusted_signature: "Intébec Trusted Signature"
|
||||
sent_with_docuseal_pro_html: 'Sent with <a href="%{product_url}">Intébec Pro</a>'
|
||||
show_send_with_docuseal_pro_attribution_in_emails_html: 'Show "Sent with <span class="link">Intébec Pro</span>" attribution in emails'
|
||||
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: "Sign documents with trusted certificate provided by Intébec. Your documents and data are never shared with Intébec. PDF checksum is provided to generate a trusted signature."
|
||||
by_creating_an_account_you_agree_to_our_html: 'By creating an account, you agree to our <a target="_blank" href="https://intebec.ca/privacy">Privacy Policy</a> and <a target="_blank" href="https://intebec.ca/terms">Terms of Service</a>.'
|
||||
connect_salesforce_account_to_integrate_with_docuseal: "Connect Salesforce account to integrate with Intébec"
|
||||
sign_up_in_docuseal_console_to_upgrade_on_premises_app_is_completely_standalone_console_is_used_only_to_manage_your_license: "Sign up in Intébec Console to upgrade. On-premises app is completely standalone, Console is used only to manage your license."
|
||||
unlock_with_docuseal_pro: "Unlock with Intébec Pro"
|
||||
activate_with_docuseal_pro: "Activate with Intébec Pro"
|
||||
send_signature_request_emails_without_limits_with_docuseal_pro: "Send signature request emails without limits with Intébec Pro"
|
||||
unlock_more_user_roles_with_docuseal_pro: "Unlock more user roles with Intébec Pro."
|
||||
docuseal_support: "Intébec Support"
|
||||
click_here_to_learn_more_about_user_roles_and_permissions_html: '<a href="https://intebec.ca/resources/manage-users-and-roles" class="link" rel="noopener noreferrer nofollow" target="_blank">Click here</a> to learn more about user roles and permissions.'
|
||||
on_a_scale_of_1_to_10_how_satisfied_are_you_with_the_docuseal_product_: "On a scale of 1 to 10, how satisfied are you with the Intébec product?"
|
||||
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: "Your Pro Plan has been suspended due to unpaid invoices. You can update your payment details to settle the invoice and continue using Intébec or cancel your subscription."
|
||||
this_submission_has_multiple_signers_which_prevents_the_use_of_a_sharing_link_html: 'This submission has multiple signers, which prevents the use of a sharing link as it''s unclear which signer is responsible for specific fields. To resolve this, follow this <a href="https://intebec.ca/resources/pre-filling-recipients" class="link font-bold" rel="noopener noreferrer nofollow" target="_blank">guide</a> to define the default signer details.'
|
||||
welcome_to_docuseal: "Welcome to Intébec"
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: "Start a quick tour to learn how to create and send your first document with Intébec."
|
||||
your_email_could_not_be_reached_this_may_happen_if_there_was_a_typo_in_your_address_or_if_your_mailbox_is_not_available_please_contact_support_email_to_log_in: "Your email could not be reached. This may happen if there was a typo in your address or if your mailbox is not available. Please contact support@intebec.ca to log in."
|
||||
add_a_unique_signature_id_and_timestamp_to_each_signature_for_audit_and_traceability_purposes_along_with_the_timestamp_part_of_docuseals_21_cfr_part_11_compliance_settings: "Add a unique Signature ID and timestamp to each signature for audit and traceability purposes along with the timestamp. Part of Intébec's 21 CFR Part 11 compliance settings."
|
||||
require_signer_to_provide_a_reason_for_signing_before_completing_their_signature_e_g_approvals_certifications_part_of_docuseals_21_cfr_part_11_compliance_settings: "Require signers to provide a reason for signing before completing their signature (e.g., approvals, certifications). Part of Intébec's 21 CFR Part 11 compliance settings."
|
||||
role_admin: "Administrator"
|
||||
role_gestionnaire: "Manager"
|
||||
role_user: "User"
|
||||
unauthorized:
|
||||
default: "You are not authorized to access this page."
|
||||
manage:
|
||||
all: "You are not authorized to perform this action."
|
||||
onboarding:
|
||||
support_description: "You can use our self-service AI assistant or email us at support@intebec.ca if you have any questions."
|
||||
|
||||
fr: &fr_wl
|
||||
docuseal_trusted_signature: "Signature de confiance Intébec"
|
||||
sent_with_docuseal_pro_html: 'Envoyé avec <a href="%{product_url}">Intébec Pro</a>'
|
||||
show_send_with_docuseal_pro_attribution_in_emails_html: 'Afficher l''attribution « Envoyé avec <span class="link">Intébec Pro</span> » dans les courriels'
|
||||
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: "Signez des documents avec un certificat de confiance fourni par Intébec. Vos documents et données ne sont jamais partagés avec Intébec. Une somme de contrôle PDF est fournie pour générer une signature de confiance."
|
||||
by_creating_an_account_you_agree_to_our_html: 'En créant un compte, vous acceptez notre <a target="_blank" href="https://intebec.ca/privacy">Politique de confidentialité</a> et nos <a target="_blank" href="https://intebec.ca/terms">Conditions d''utilisation</a>.'
|
||||
connect_salesforce_account_to_integrate_with_docuseal: "Connectez votre compte Salesforce pour l'intégrer à Intébec"
|
||||
sign_up_in_docuseal_console_to_upgrade_on_premises_app_is_completely_standalone_console_is_used_only_to_manage_your_license: "Inscrivez-vous dans la console Intébec pour effectuer la mise à niveau. L'application sur site est entièrement autonome, la console est utilisée uniquement pour gérer votre licence."
|
||||
unlock_with_docuseal_pro: "Débloquer avec Intébec Pro"
|
||||
activate_with_docuseal_pro: "Activer avec Intébec Pro"
|
||||
send_signature_request_emails_without_limits_with_docuseal_pro: "Envoyez des courriels de demande de signature sans limites avec Intébec Pro"
|
||||
unlock_more_user_roles_with_docuseal_pro: "Débloquez plus de rôles utilisateurs avec Intébec Pro."
|
||||
docuseal_support: "Soutien Intébec"
|
||||
click_here_to_learn_more_about_user_roles_and_permissions_html: '<a href="https://intebec.ca/resources/manage-users-and-roles" class="link" rel="noopener noreferrer nofollow" target="_blank">Cliquez ici</a> pour en savoir plus sur les rôles et les permissions des utilisateurs.'
|
||||
on_a_scale_of_1_to_10_how_satisfied_are_you_with_the_docuseal_product_: "Sur une échelle de 1 à 10, à quel point êtes-vous satisfait du produit Intébec?"
|
||||
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: "Votre plan Pro a été suspendu en raison de factures impayées. Vous pouvez mettre à jour vos informations de paiement pour régler la facture et continuer à utiliser Intébec ou annuler votre abonnement."
|
||||
this_submission_has_multiple_signers_which_prevents_the_use_of_a_sharing_link_html: 'Cette soumission comporte plusieurs signataires, ce qui empêche l''utilisation d''un lien de partage car on ne sait pas quel signataire est responsable de quels champs. Pour résoudre ce problème, suivez ce <a href="https://intebec.ca/resources/pre-filling-recipients" class="link font-bold" rel="noopener noreferrer nofollow" target="_blank">guide</a> pour définir les détails du signataire par défaut.'
|
||||
welcome_to_docuseal: "Bienvenue sur Intébec"
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: "Lancez une visite rapide pour apprendre à créer et envoyer votre premier document avec Intébec."
|
||||
your_email_could_not_be_reached_this_may_happen_if_there_was_a_typo_in_your_address_or_if_your_mailbox_is_not_available_please_contact_support_email_to_log_in: "Votre courriel n'a pas pu être atteint. Cela peut arriver s'il y a une faute de frappe dans votre adresse ou si votre boîte de réception n'est pas disponible. Veuillez contacter support@intebec.ca pour vous connecter."
|
||||
add_a_unique_signature_id_and_timestamp_to_each_signature_for_audit_and_traceability_purposes_along_with_the_timestamp_part_of_docuseals_21_cfr_part_11_compliance_settings: "Ajoutez un identifiant de signature unique et un horodatage à chaque signature à des fins d'audit et de traçabilité, ainsi que l'horodatage. Fait partie des paramètres de conformité 21 CFR Part 11 d'Intébec."
|
||||
require_signer_to_provide_a_reason_for_signing_before_completing_their_signature_e_g_approvals_certifications_part_of_docuseals_21_cfr_part_11_compliance_settings: "Exiger que le signataire fournisse une raison de signature avant de terminer sa signature (ex. : approbations, certifications). Fait partie des paramètres de conformité 21 CFR Part 11 d'Intébec."
|
||||
role_admin: "Administrateur"
|
||||
role_gestionnaire: "Gestionnaire"
|
||||
role_user: "Utilisateur"
|
||||
onboarding:
|
||||
support_description: "Vous pouvez utiliser notre assistant IA en libre-service ou nous écrire à support@intebec.ca si vous avez des questions."
|
||||
# -------------------------------------------------------------------------
|
||||
# Common French overrides for base UI strings
|
||||
# -------------------------------------------------------------------------
|
||||
sign_in: "Connexion"
|
||||
sign_out: "Déconnexion"
|
||||
sign_up: "Inscription"
|
||||
settings: "Paramètres"
|
||||
profile: "Profil"
|
||||
password: "Mot de passe"
|
||||
email: "Courriel"
|
||||
submit: "Soumettre"
|
||||
save: "Enregistrer"
|
||||
cancel: "Annuler"
|
||||
delete: "Supprimer"
|
||||
search: "Rechercher"
|
||||
download: "Télécharger"
|
||||
powered_by: "Propulsé par"
|
||||
open_source_documents_software: "logiciel de signature de documents"
|
||||
unauthorized:
|
||||
default: "Vous n'êtes pas autorisé à accéder à cette page."
|
||||
manage:
|
||||
all: "Vous n'êtes pas autorisé à effectuer cette action."
|
||||
devise:
|
||||
sessions:
|
||||
signed_in: "Connexion réussie."
|
||||
signed_out: "Déconnexion réussie."
|
||||
already_signed_in: "Vous êtes déjà connecté."
|
||||
failure:
|
||||
already_authenticated: "Vous êtes déjà connecté."
|
||||
unauthenticated: "Vous devez vous connecter pour accéder à cette page."
|
||||
locked: "Votre compte est verrouillé."
|
||||
invalid: "Adresse courriel ou mot de passe invalide."
|
||||
last_attempt: "Il vous reste une tentative avant que votre compte soit verrouillé."
|
||||
not_found_in_database: "Adresse courriel ou mot de passe invalide."
|
||||
timeout: "Votre session a expiré. Veuillez vous reconnecter."
|
||||
inactive: "Votre compte n'est pas encore activé."
|
||||
passwords:
|
||||
send_instructions: "Vous recevrez un courriel avec les instructions pour réinitialiser votre mot de passe."
|
||||
send_paranoid_instructions: "Si votre courriel est dans notre base de données, vous recevrez un lien pour réinitialiser votre mot de passe."
|
||||
updated: "Votre mot de passe a été modifié. Vous êtes maintenant connecté."
|
||||
updated_not_active: "Votre mot de passe a été modifié avec succès."
|
||||
mailer:
|
||||
reset_password_instructions:
|
||||
subject: "Réinitialisation de votre mot de passe"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regional locale aliases — inherit all overrides from the base locales above
|
||||
# (mirrors the YAML anchor pattern used in i18n.yml)
|
||||
# ---------------------------------------------------------------------------
|
||||
en-US:
|
||||
<<: *en_wl
|
||||
|
||||
en-GB:
|
||||
<<: *en_wl
|
||||
|
||||
fr-FR:
|
||||
<<: *fr_wl
|
||||
@ -1,39 +1,16 @@
|
||||
services:
|
||||
app:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
image: docuseal/docuseal:latest
|
||||
build: .
|
||||
image: intebec/docuseal:latest
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ./docuseal:/data/docuseal
|
||||
- ${INTEBEC_CONFIG_FILE:-./config/config.yml}:/run/secrets/config.yml:ro
|
||||
environment:
|
||||
- FORCE_SSL=${HOST}
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal
|
||||
|
||||
postgres:
|
||||
image: postgres:18
|
||||
volumes:
|
||||
- './pg_data:/var/lib/postgresql/18/docker'
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: docuseal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
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}
|
||||
- FORCE_SSL=${FORCE_SSL:-false}
|
||||
# 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:-}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
# White-Label Developer Reference (Internal)
|
||||
|
||||
> **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.
|
||||
|
||||
### In ERB views
|
||||
|
||||
```erb
|
||||
<%= wl.brand_name %>
|
||||
<%= wl.logo_path %>
|
||||
<%= wl.support_email %>
|
||||
<%= wl.page_title(signed_in: true) %>
|
||||
```
|
||||
|
||||
### In Ruby (controllers, mailers, lib)
|
||||
|
||||
```ruby
|
||||
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): `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)
|
||||
3. **Untouched internal identifiers**: `data-theme="docuseal"`, `Docuseal` module name, `#docuseal_modal_container`, `docuseal_clipboard` localStorage keys — all kept as-is for compatibility
|
||||
|
||||
### When Upstream Adds New Branded Content
|
||||
|
||||
1. Check if new views/lib files have hardcoded "DocuSeal" text
|
||||
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`
|
||||
|
||||
## File Reference
|
||||
|
||||
| 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 |
|
||||
@ -0,0 +1,737 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# =============================================================================
|
||||
# Whitelabel — Centralised brand config + licence enforcement
|
||||
# =============================================================================
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# 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
|
||||
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
|
||||
# =====================================================================
|
||||
|
||||
def config
|
||||
@config || load_config!
|
||||
end
|
||||
|
||||
def reload!
|
||||
@mutex.synchronize { @config = nil }
|
||||
load_config!
|
||||
end
|
||||
|
||||
def config_source
|
||||
return :api if @api_sourced
|
||||
return :test if @config && !CONFIG_PATH.file?
|
||||
:file
|
||||
end
|
||||
|
||||
# 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'
|
||||
end
|
||||
|
||||
def brand_short_name
|
||||
config.dig('brand', 'short_name') || brand_name
|
||||
end
|
||||
|
||||
def tagline
|
||||
config.dig('brand', 'tagline') || ''
|
||||
end
|
||||
|
||||
def description
|
||||
config.dig('brand', 'description') || ''
|
||||
end
|
||||
|
||||
def page_title(signed_in: false)
|
||||
key = signed_in ? 'page_title_signed_in' : 'page_title_signed_out'
|
||||
config.dig('brand', key) || brand_name
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# URLs
|
||||
# =====================================================================
|
||||
|
||||
def website_url
|
||||
config.dig('urls', 'website') || 'https://www.docuseal.com'
|
||||
end
|
||||
|
||||
def support_email
|
||||
config.dig('urls', 'support_email') || 'support@docuseal.com'
|
||||
end
|
||||
|
||||
def privacy_policy_url
|
||||
config.dig('urls', 'privacy_policy')
|
||||
end
|
||||
|
||||
def terms_url
|
||||
config.dig('urls', 'terms_of_service')
|
||||
end
|
||||
|
||||
def twitter_url
|
||||
config.dig('urls', 'twitter_url')
|
||||
end
|
||||
|
||||
def twitter_handle
|
||||
config.dig('urls', 'twitter_handle')
|
||||
end
|
||||
|
||||
def github_url
|
||||
config.dig('urls', 'github_url')
|
||||
end
|
||||
|
||||
def discord_url
|
||||
config.dig('urls', 'discord_url')
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# Email
|
||||
# =====================================================================
|
||||
|
||||
def email_from
|
||||
name = config.dig('email', 'from_name') || brand_name
|
||||
addr = config.dig('email', 'from_address') || support_email
|
||||
"#{name} <#{addr}>"
|
||||
end
|
||||
|
||||
def 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'
|
||||
end
|
||||
|
||||
def logo_width
|
||||
config.dig('assets', 'logo_width') || 37
|
||||
end
|
||||
|
||||
def logo_height
|
||||
config.dig('assets', 'logo_height') || 37
|
||||
end
|
||||
|
||||
def favicon_svg
|
||||
config.dig('assets', 'favicon_svg') || '/favicon.svg'
|
||||
end
|
||||
|
||||
def favicon_ico
|
||||
config.dig('assets', 'favicon_ico') || '/favicon.ico'
|
||||
end
|
||||
|
||||
def favicon_16
|
||||
config.dig('assets', 'favicon_16') || '/favicon-16x16.png'
|
||||
end
|
||||
|
||||
def favicon_32
|
||||
config.dig('assets', 'favicon_32') || '/favicon-32x32.png'
|
||||
end
|
||||
|
||||
def favicon_96
|
||||
config.dig('assets', 'favicon_96') || '/favicon-96x96.png'
|
||||
end
|
||||
|
||||
def apple_touch_icon
|
||||
config.dig('assets', 'apple_touch_icon') || '/apple-icon-180x180.png'
|
||||
end
|
||||
|
||||
def preview_image
|
||||
config.dig('assets', 'preview_image') || '/preview.png'
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# Theme — HSL triplets for DaisyUI / CSS custom properties
|
||||
# =====================================================================
|
||||
|
||||
def theme(key)
|
||||
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}'
|
||||
template.gsub('%{name}', name.to_s)
|
||||
end
|
||||
|
||||
def audit_trail_footer
|
||||
config.dig('pdf', 'audit_trail_footer') || "Signed with #{brand_name}"
|
||||
end
|
||||
|
||||
def pdf_creator
|
||||
creator = config.dig('pdf', 'creator') || brand_name
|
||||
"#{creator} (#{website_url})"
|
||||
end
|
||||
|
||||
def cert_name
|
||||
config.dig('pdf', 'cert_name') || 'docuseal_aatl'
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# PWA
|
||||
# =====================================================================
|
||||
|
||||
def pwa_description
|
||||
config.dig('pwa', 'description') || description
|
||||
end
|
||||
|
||||
def pwa_theme_color
|
||||
config.dig('pwa', 'theme_color') || '#FFFFFF'
|
||||
end
|
||||
|
||||
def pwa_background_color
|
||||
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?
|
||||
dig_bool('features', 'show_github_button', false)
|
||||
end
|
||||
|
||||
def show_powered_by?
|
||||
dig_bool('features', 'show_powered_by', false)
|
||||
end
|
||||
|
||||
def powered_by_text
|
||||
config.dig('features', 'powered_by_text') || brand_name
|
||||
end
|
||||
|
||||
def show_ai_link?
|
||||
dig_bool('features', 'show_ai_link', false)
|
||||
end
|
||||
|
||||
def show_discord_link?
|
||||
dig_bool('features', 'show_discord_link', false)
|
||||
end
|
||||
|
||||
def show_pro_upsells?
|
||||
dig_bool('features', 'show_pro_upsells', false)
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# Roles & Permissions (config-driven)
|
||||
# =====================================================================
|
||||
#
|
||||
# Config format:
|
||||
# roles:
|
||||
# admin:
|
||||
# permissions:
|
||||
# templates: [read, create, update, delete]
|
||||
# submissions: [read, create, update, delete]
|
||||
# users: [read, create, update, delete]
|
||||
# settings: [read, create, update, delete]
|
||||
# gestionnaire:
|
||||
# permissions:
|
||||
# templates: [read, create, update, delete]
|
||||
# submissions: [read, create, update, delete]
|
||||
# users: [read]
|
||||
# settings: [read]
|
||||
# user:
|
||||
# permissions:
|
||||
# templates: [read]
|
||||
# submissions: [read]
|
||||
#
|
||||
|
||||
# Default permission matrix — used when no roles section in config.
|
||||
DEFAULT_ROLES = {
|
||||
'admin' => {
|
||||
'permissions' => {
|
||||
'templates' => %w[read create update delete],
|
||||
'submissions' => %w[read create update delete],
|
||||
'users' => %w[read create update delete],
|
||||
'settings' => %w[read create update delete]
|
||||
}
|
||||
},
|
||||
'gestionnaire' => {
|
||||
'permissions' => {
|
||||
'templates' => %w[read create update delete],
|
||||
'submissions' => %w[read create update delete],
|
||||
'users' => %w[read],
|
||||
'settings' => %w[read]
|
||||
}
|
||||
},
|
||||
'user' => {
|
||||
'permissions' => {
|
||||
'templates' => %w[read],
|
||||
'submissions' => %w[read],
|
||||
'users' => [],
|
||||
'settings' => []
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# All available roles (keys). Order matters — first is the default.
|
||||
def roles
|
||||
(config.dig('roles') || DEFAULT_ROLES).keys
|
||||
end
|
||||
|
||||
# The default role assigned to new users.
|
||||
def default_role
|
||||
roles.first
|
||||
end
|
||||
|
||||
# Full role definition hash for a given role slug.
|
||||
def role_definition(role_slug)
|
||||
all = config.dig('roles') || DEFAULT_ROLES
|
||||
all[role_slug.to_s] || {}
|
||||
end
|
||||
|
||||
# Permission list for a role + resource.
|
||||
# Returns e.g. ["read", "create", "update"] or [].
|
||||
def role_permissions(role_slug, resource)
|
||||
perms = role_definition(role_slug).dig('permissions', resource.to_s)
|
||||
perms.is_a?(Array) ? perms : []
|
||||
end
|
||||
|
||||
# Check if a role has a specific action on a resource.
|
||||
def role_can?(role_slug, resource, action)
|
||||
role_permissions(role_slug, resource).include?(action.to_s)
|
||||
end
|
||||
|
||||
# Check if a role is an admin (first role in the list is always the admin).
|
||||
def admin_role?(role_slug)
|
||||
role_slug.to_s == roles.first
|
||||
end
|
||||
|
||||
# Validate that a role slug exists in config.
|
||||
def role_valid?(role_slug)
|
||||
roles.include?(role_slug.to_s)
|
||||
end
|
||||
|
||||
# Returns the rank index of a role (0 = highest privilege = admin).
|
||||
# Unknown roles return roles.size (treated as lowest).
|
||||
def role_rank(role_slug)
|
||||
roles.index(role_slug.to_s) || roles.size
|
||||
end
|
||||
|
||||
# Returns only roles that the given actor_role can assign/manage.
|
||||
# An actor can only work with roles at their own rank or lower (higher index).
|
||||
def manageable_roles(actor_role)
|
||||
rank = role_rank(actor_role.to_s)
|
||||
roles[rank..]
|
||||
end
|
||||
|
||||
# All known settings sections in display order.
|
||||
ALL_SETTINGS_SECTIONS = %w[account email storage notifications esign personalization users api webhooks].freeze
|
||||
|
||||
# Returns true if the role is allowed to see the given settings section.
|
||||
# Falls back to ALL_SETTINGS_SECTIONS for roles that have settings read
|
||||
# permission but no explicit sections list (backward-compatible).
|
||||
def setting_section_visible?(role_slug, section)
|
||||
defn = role_definition(role_slug)
|
||||
sections = defn['settings_sections']
|
||||
|
||||
if sections.is_a?(Array)
|
||||
sections.map(&:to_s).include?(section.to_s)
|
||||
else
|
||||
# No explicit list → grant all sections to roles that can read settings.
|
||||
role_permissions(role_slug, 'settings').include?('read')
|
||||
end
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# Internal
|
||||
# =====================================================================
|
||||
|
||||
def temp_email_domain
|
||||
config.dig('internal', 'temp_email_domain') || 'docuseal.com'
|
||||
end
|
||||
|
||||
# =====================================================================
|
||||
# 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 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
|
||||
|
||||
# =====================================================================
|
||||
# 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
|
||||
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
|
||||
|
Before Width: | Height: | Size: 807 B After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 1.8 KiB |