Removing paywall from Company logo + white-label basics, User roles, Email reminders, Conditional fields + formulas and API/Embedding

pull/637/head
Eros Stein 1 week ago
parent eea44bda34
commit aa764cfdcf

@ -2,26 +2,34 @@ name: Build Docker Images
on: on:
push: push:
branches:
- main
- master
tags: tags:
- "*.*.*" - "*.*.*"
workflow_dispatch:
jobs: jobs:
build: build:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v5
with: with:
images: docuseal/docuseal images: ${{ secrets.DOCKERHUB_USERNAME }}/docuseal-wl
tags: type=semver,pattern={{version}} tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
type=sha,prefix=sha-
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3

@ -1,38 +1,53 @@
# frozen_string_literal: true # frozen_string_literal: true
class EmbedScriptsController < ActionController::Metal class EmbedScriptsController < ActionController::Metal
DUMMY_SCRIPT = <<~JAVASCRIPT.freeze EMBED_SCRIPT = <<~JAVASCRIPT.freeze
const DummyBuilder = class extends HTMLElement { const buildIframe = (element) => {
const iframe = document.createElement('iframe');
const src = element.dataset.src || element.getAttribute('src');
if (!src) return;
const url = new URL(src, window.location.href);
['email', 'name', 'phone', 'role', 'token', 'preview', 'externalId', 'completedRedirectUrl'].forEach((key) => {
const value = element.dataset[key];
if (value) url.searchParams.set(key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), value);
});
iframe.src = url.toString();
iframe.style.width = element.dataset.width || '100%';
iframe.style.height = element.dataset.height || '700px';
iframe.style.border = element.dataset.border || '0';
iframe.allow = element.dataset.allow || 'clipboard-write; fullscreen';
iframe.title = element.dataset.title || 'DocuSeal';
element.innerHTML = '';
element.appendChild(iframe);
};
const EmbeddedBuilder = class extends HTMLElement {
connectedCallback() { connectedCallback() {
this.innerHTML = ` buildIframe(this);
<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>
`;
} }
}; };
const DummyForm = class extends DummyBuilder {}; const EmbeddedForm = class extends EmbeddedBuilder {};
if (!window.customElements.get('docuseal-builder')) { if (!window.customElements.get('docuseal-builder')) {
window.customElements.define('docuseal-builder', DummyBuilder); window.customElements.define('docuseal-builder', EmbeddedBuilder);
} }
if (!window.customElements.get('docuseal-form')) { if (!window.customElements.get('docuseal-form')) {
window.customElements.define('docuseal-form', DummyForm); window.customElements.define('docuseal-form', EmbeddedForm);
} }
JAVASCRIPT JAVASCRIPT
def show def show
headers['Content-Type'] = 'application/javascript' headers['Content-Type'] = 'application/javascript'
self.response_body = DUMMY_SCRIPT self.response_body = EMBED_SCRIPT
self.status = 200 self.status = 200
end end

@ -13,13 +13,23 @@ class PersonalizationSettingsController < ApplicationController
InvalidKey = Class.new(StandardError) InvalidKey = Class.new(StandardError)
before_action :load_and_authorize_account_config, only: :create before_action :load_and_authorize_account_config, only: :create, if: -> { params[:account_config].present? }
def show def show
authorize!(:read, AccountConfig) authorize!(:read, AccountConfig)
end end
def create def create
if params[:account].present?
authorize!(:update, current_account)
current_account.logo.purge if account_params[:remove_logo] == '1'
current_account.logo.attach(account_params[:logo]) if account_params[:logo].present?
return redirect_back(fallback_location: settings_personalization_path,
notice: I18n.t('settings_have_been_saved'))
end
if @account_config.value.is_a?(Hash) if @account_config.value.is_a?(Hash)
@account_config.value = @account_config.value.reject do |_, v| @account_config.value = @account_config.value.reject do |_, v|
v.blank? && v != false v.blank? && v != false
@ -65,4 +75,8 @@ class PersonalizationSettingsController < ApplicationController
attrs attrs
end end
def account_params
params.require(:account).permit(:logo, :remove_logo)
end
end end

@ -96,17 +96,6 @@
</span> </span>
</a> </a>
</div> </div>
<div
v-if="attribution"
class="text-center mt-4"
>
{{ t('powered_by') }}
<a
href="https://www.docuseal.com/start"
target="_blank"
class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }}
</div>
</div> </div>
</template> </template>

@ -0,0 +1,32 @@
# frozen_string_literal: true
class SendSubmitterReminderEmailJob
include Sidekiq::Job
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
return if submitter.completed_at?
return if submitter.declined_at?
return if submitter.submission.archived_at?
return if submitter.template&.archived_at?
return unless submitter.sent_at?
return unless Accounts.can_send_invitation_emails?(submitter.account)
reminder_index = params['reminder_index'].to_i
return if reminder_index.positive? &&
submitter.submission_events.exists?(event_type: 'send_reminder_email',
data: { 'reminder_index' => reminder_index })
mail = SubmitterMailer.invitation_email(submitter)
Submitters::ValidateSending.call(submitter, mail)
mail.deliver_now!
SubmissionEvent.create!(submitter: submitter,
event_type: 'send_reminder_email',
data: { reminder_index: reminder_index })
end
end

@ -20,6 +20,8 @@
class Account < ApplicationRecord class Account < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
has_one_attached :logo
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy has_many :account_configs, dependent: :destroy

@ -48,7 +48,9 @@
# #
class User < ApplicationRecord class User < ApplicationRecord
ROLES = [ ROLES = [
ADMIN_ROLE = 'admin' ADMIN_ROLE = 'admin',
EDITOR_ROLE = 'editor',
VIEWER_ROLE = 'viewer'
].freeze ].freeze
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
@ -78,6 +80,18 @@ class User < ApplicationRecord
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :admins, -> { where(role: ADMIN_ROLE) } scope :admins, -> { where(role: ADMIN_ROLE) }
def admin?
role == ADMIN_ROLE
end
def editor?
role == EDITOR_ROLE
end
def viewer?
role == VIEWER_ROLE
end
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ } validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
def access_token def access_token

@ -1 +0,0 @@
<%= render 'reminder_placeholder' %>

@ -1 +1,18 @@
<%= render 'logo_placeholder' %> <%= form_for current_account, url: settings_personalization_path, method: :post, html: { multipart: true, autocomplete: 'off', class: 'space-y-4' } do |f| %>
<% if current_account.logo.attached? %>
<div class="flex items-center gap-4">
<%= image_tag current_account.logo, class: 'max-h-16 max-w-48 rounded bg-base-200 object-contain p-2' %>
<label class="label cursor-pointer gap-2">
<%= f.check_box :remove_logo, class: 'base-checkbox' %>
<span><%= t('remove') %></span>
</label>
</div>
<% end %>
<div class="form-control">
<%= f.label :logo, t('company_logo'), class: 'label' %>
<%= f.file_field :logo, accept: 'image/png,image/jpeg,image/webp,image/svg+xml', class: 'file-input file-input-bordered w-full' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div>
<% end %>

@ -1 +0,0 @@
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter], link_path: local_assigns[:link_path] %>

@ -1 +0,0 @@
<%= render 'shared/email_attribution' %>

@ -1,6 +1,11 @@
<% account = @template&.account || @submitter&.account || current_account %>
<a href="/" class="flex justify-center items-center"> <a href="/" class="flex justify-center items-center">
<span class="mr-3"> <% if account&.logo&.attached? %>
<%= render 'shared/logo', width: '50px', height: '50px' %> <%= image_tag account.logo, class: 'mr-3 max-w-14 max-h-14 object-contain' %>
</span> <% else %>
<h1 class="text-5xl font-bold text-center">DocuSeal</h1> <span class="mr-3">
<%= render 'shared/logo', width: '50px', height: '50px' %>
</span>
<% end %>
<h1 class="text-5xl font-bold text-center"><%= account&.name || Docuseal.product_name %></h1>
</a> </a>

@ -1,4 +1,9 @@
<% account = @submitter&.account || current_account %>
<a href="<%= root_path %>" class="mx-auto text-2xl md:text-3xl font-bold items-center flex space-x-3"> <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' %> <% if account&.logo&.attached? %>
<span><%= Docuseal.product_name %></span> <%= image_tag account.logo, class: 'max-w-12 max-h-12 object-contain' %>
<% else %>
<%= render 'shared/logo', class: 'w-9 h-9 md:w-12 md:h-12' %>
<% end %>
<span><%= account&.name || Docuseal.product_name %></span>
</a> </a>

@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %> <%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %> <% end %>
<% end %> <% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder> <template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-conditions="true" data-with-formula="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>

@ -34,5 +34,4 @@
</div> </div>
<% end %> <% end %>
<%= render 'templates_code_modal/preferences' %> <%= render 'templates_code_modal/preferences' %>
<%= render 'templates_code_modal/placeholder' %>
<% end %> <% end %>

@ -168,7 +168,6 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<%= render 'templates_code_modal/placeholder' %>
<%= render 'templates/embedding', template: @template %> <%= render 'templates/embedding', template: @template %>
<% if can?(:manage, TemplateSharing.new(template: @template)) %> <% if can?(:manage, TemplateSharing.new(template: @template)) %>
<%= form_for '', url: template_sharings_testing_index_path, method: :post, html: { class: 'mt-1' }, data: { close_on_submit: false } do |f| %> <%= form_for '', url: template_sharings_testing_index_path, method: :post, html: { class: 'mt-1' }, data: { close_on_submit: false } do |f| %>

@ -1,20 +1,4 @@
<div class="form-control"> <div class="form-control">
<%= f.label :role, class: 'label' %> <%= f.label :role, class: 'label' %>
<%= f.select :role, nil, {}, class: 'base-select' do %> <%= f.select :role, User::ROLES.map { |role| [t(role), role] }, {}, class: 'base-select' %>
<option value="admin"><%= t('admin') %></option>
<option value="editor" disabled><%= t('editor') %></option>
<option value="viewer" disabled><%= t('viewer') %></option>
<% end %>
<% if Docuseal.multitenant? %>
<label class="label">
<span class="label-text-alt">
<%= t('click_here_to_learn_more_about_user_roles_and_permissions_html') %>
</span>
</label>
<% end %>
<a class="text-sm mt-3 px-4 py-2 bg-base-300 rounded-full block" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}" %>">
<%= svg_icon('info_circle', class: 'w-4 h-4 inline align-text-bottom') %>
<%= t('unlock_more_user_roles_with_docuseal_pro') %>
<span class="link font-medium"><%= t('learn_more') %></span>
</a>
</div> </div>

@ -4,25 +4,43 @@ class Ability
include CanCan::Ability include CanCan::Ability
def initialize(user) def initialize(user)
can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template| template_scope = Abilities::TemplateConditions.collection(user)
Abilities::TemplateConditions.entity(template, user:, ability: 'manage') template_check = ->(template) { Abilities::TemplateConditions.entity(template, user: user, ability: 'manage') }
end
can :read, Template, template_scope, &template_check
can :read, TemplateFolder, account_id: user.account_id
can :read, Submission, account_id: user.account_id
can :read, Submitter, account_id: user.account_id
can :manage, UserConfig, user_id: user.id
can :manage, EncryptedUserConfig, user_id: user.id
can :read, Account, id: user.account_id
return if user.viewer?
can %i[create update], Template, template_scope, &template_check
can :destroy, Template, account_id: user.account_id can :destroy, Template, account_id: user.account_id
can :manage, TemplateFolder, account_id: user.account_id can :manage, TemplateFolder, account_id: user.account_id
can :manage, TemplateSharing, template: { account_id: user.account_id } can :manage, TemplateSharing, template: { account_id: user.account_id }
can :manage, Submission, account_id: user.account_id can :manage, Submission, account_id: user.account_id
can :manage, Submitter, account_id: user.account_id can :manage, Submitter, account_id: user.account_id
can :manage, AccessToken, user_id: user.id
return unless user.admin?
can :manage, User, account_id: user.account_id can :manage, User, account_id: user.account_id
can :manage, EncryptedConfig, account_id: user.account_id can :manage, EncryptedConfig, account_id: user.account_id
can :manage, EncryptedUserConfig, user_id: user.id
can :manage, AccountConfig, account_id: user.account_id can :manage, AccountConfig, account_id: user.account_id
can :manage, UserConfig, user_id: user.id
can :manage, Account, id: user.account_id can :manage, Account, id: user.account_id
can :manage, AccessToken, user_id: user.id
can :manage, McpToken, user_id: user.id can :manage, McpToken, user_id: user.id
can :manage, WebhookUrl, account_id: user.account_id can :manage, WebhookUrl, account_id: user.account_id
can :manage, :mcp can :manage, :mcp
can :manage, :personalization_advanced
can :manage, :reply_to
can :manage, :download_users
can :manage, :bulk_send
can :manage, :disable_decline
can :manage, :delegate_form
can :manage, :countless
end end
end end

@ -34,4 +34,10 @@ module AccountConfigs
configs configs
end end
def duration_for(key)
amount, unit = REMINDER_DURATIONS.fetch(key).split
amount.to_i.public_send(unit)
end
end end

@ -529,11 +529,21 @@ module Submissions
!submission.source.in?(%w[embed api]) !submission.source.in?(%w[embed api])
end end
def add_logo(column, _submission = nil) def add_logo(column, submission = nil)
account = submission&.account
logo_io =
if account&.logo&.attached?
StringIO.new(account.logo.download)
else
PdfIcons.logo_io
end
column.image(logo_io, width: 40, height: 40, position: :float)
rescue StandardError
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
ensure
column.formatted_text([{ text: 'DocuSeal', column.formatted_text([{ text: account&.name || Docuseal.product_name }],
link: Docuseal::PRODUCT_EMAIL_URL }],
font_size: 20, font_size: 20,
font: [FONT_NAME, { variant: :bold }], font: [FONT_NAME, { variant: :bold }],
width: 100, width: 100,

@ -180,6 +180,23 @@ module Submitters
else else
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id) SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
end end
schedule_reminder_emails(submitter, delay_seconds: delay_seconds.to_i + index)
end
end
def schedule_reminder_emails(submitter, delay_seconds: 0)
config = AccountConfigs.find_for_account(submitter.account, AccountConfig::SUBMITTER_REMINDERS)
durations = config&.value.to_h.values_at('first_duration', 'second_duration', 'third_duration').compact_blank
durations.each_with_index do |duration_key, index|
next unless AccountConfigs::REMINDER_DURATIONS.key?(duration_key)
SendSubmitterReminderEmailJob.perform_in(
delay_seconds.seconds + AccountConfigs.duration_for(duration_key),
'submitter_id' => submitter.id,
'reminder_index' => index + 1
)
end end
end end

Loading…
Cancel
Save