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:
push:
branches:
- main
- master
tags:
- "*.*.*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
submodules: recursive
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: docuseal/docuseal
tags: type=semver,pattern={{version}}
images: ${{ secrets.DOCKERHUB_USERNAME }}/docuseal-wl
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
uses: docker/setup-qemu-action@v3

@ -1,38 +1,53 @@
# frozen_string_literal: true
class EmbedScriptsController < ActionController::Metal
DUMMY_SCRIPT = <<~JAVASCRIPT.freeze
const DummyBuilder = class extends HTMLElement {
EMBED_SCRIPT = <<~JAVASCRIPT.freeze
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() {
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>
`;
buildIframe(this);
}
};
const DummyForm = class extends DummyBuilder {};
const EmbeddedForm = class extends EmbeddedBuilder {};
if (!window.customElements.get('docuseal-builder')) {
window.customElements.define('docuseal-builder', DummyBuilder);
window.customElements.define('docuseal-builder', EmbeddedBuilder);
}
if (!window.customElements.get('docuseal-form')) {
window.customElements.define('docuseal-form', DummyForm);
window.customElements.define('docuseal-form', EmbeddedForm);
}
JAVASCRIPT
def show
headers['Content-Type'] = 'application/javascript'
self.response_body = DUMMY_SCRIPT
self.response_body = EMBED_SCRIPT
self.status = 200
end

@ -13,13 +13,23 @@ class PersonalizationSettingsController < ApplicationController
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
authorize!(:read, AccountConfig)
end
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)
@account_config.value = @account_config.value.reject do |_, v|
v.blank? && v != false
@ -65,4 +75,8 @@ class PersonalizationSettingsController < ApplicationController
attrs
end
def account_params
params.require(:account).permit(:logo, :remove_logo)
end
end

@ -96,17 +96,6 @@
</span>
</a>
</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>
</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
attribute :uuid, :string, default: -> { SecureRandom.uuid }
has_one_attached :logo
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy

@ -48,7 +48,9 @@
#
class User < ApplicationRecord
ROLES = [
ADMIN_ROLE = 'admin'
ADMIN_ROLE = 'admin',
EDITOR_ROLE = 'editor',
VIEWER_ROLE = 'viewer'
].freeze
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
@ -78,6 +80,18 @@ class User < ApplicationRecord
scope :archived, -> { where.not(archived_at: nil) }
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/ }
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">
<span class="mr-3">
<%= render 'shared/logo', width: '50px', height: '50px' %>
</span>
<h1 class="text-5xl font-bold text-center">DocuSeal</h1>
<% if account&.logo&.attached? %>
<%= image_tag account.logo, class: 'mr-3 max-w-14 max-h-14 object-contain' %>
<% else %>
<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>

@ -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">
<%= render 'shared/logo', class: 'w-9 h-9 md:w-12 md:h-12' %>
<span><%= Docuseal.product_name %></span>
<% if account&.logo&.attached? %>
<%= 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>

@ -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' %>
<% 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>
<% end %>
<%= render 'templates_code_modal/preferences' %>
<%= render 'templates_code_modal/placeholder' %>
<% end %>

@ -168,7 +168,6 @@
<% end %>
</div>
</div>
<%= render 'templates_code_modal/placeholder' %>
<%= render 'templates/embedding', 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| %>

@ -1,20 +1,4 @@
<div class="form-control">
<%= f.label :role, class: 'label' %>
<%= f.select :role, nil, {}, class: 'base-select' do %>
<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>
<%= f.select :role, User::ROLES.map { |role| [t(role), role] }, {}, class: 'base-select' %>
</div>

@ -4,25 +4,43 @@ class Ability
include CanCan::Ability
def initialize(user)
can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template|
Abilities::TemplateConditions.entity(template, user:, ability: 'manage')
end
template_scope = Abilities::TemplateConditions.collection(user)
template_check = ->(template) { Abilities::TemplateConditions.entity(template, user: user, ability: 'manage') }
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 :manage, TemplateFolder, account_id: user.account_id
can :manage, TemplateSharing, template: { account_id: user.account_id }
can :manage, Submission, 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, EncryptedConfig, account_id: user.account_id
can :manage, EncryptedUserConfig, user_id: user.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, AccessToken, user_id: user.id
can :manage, McpToken, user_id: user.id
can :manage, WebhookUrl, account_id: user.account_id
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

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

@ -529,11 +529,21 @@ module Submissions
!submission.source.in?(%w[embed api])
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)
ensure
column.formatted_text([{ text: 'DocuSeal',
link: Docuseal::PRODUCT_EMAIL_URL }],
column.formatted_text([{ text: account&.name || Docuseal.product_name }],
font_size: 20,
font: [FONT_NAME, { variant: :bold }],
width: 100,

@ -180,6 +180,23 @@ module Submitters
else
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
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

Loading…
Cancel
Save