diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2eb09ddc..82d4202f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 diff --git a/app/controllers/embed_scripts_controller.rb b/app/controllers/embed_scripts_controller.rb index a40ed79c..c909040d 100644 --- a/app/controllers/embed_scripts_controller.rb +++ b/app/controllers/embed_scripts_controller.rb @@ -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 = ` -
-

Upgrade to Pro

-

Unlock embedded components by upgrading to Pro

-
- - Learn More - -
-
- `; + 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 diff --git a/app/controllers/personalization_settings_controller.rb b/app/controllers/personalization_settings_controller.rb index d9d33490..47fc0204 100644 --- a/app/controllers/personalization_settings_controller.rb +++ b/app/controllers/personalization_settings_controller.rb @@ -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 diff --git a/app/javascript/submission_form/completed.vue b/app/javascript/submission_form/completed.vue index 6c07a09c..ed8a6bd7 100644 --- a/app/javascript/submission_form/completed.vue +++ b/app/javascript/submission_form/completed.vue @@ -96,17 +96,6 @@ -
- {{ t('powered_by') }} - DocuSeal - {{ t('open_source_documents_software') }} -
diff --git a/app/jobs/send_submitter_reminder_email_job.rb b/app/jobs/send_submitter_reminder_email_job.rb new file mode 100644 index 00000000..8ce48f41 --- /dev/null +++ b/app/jobs/send_submitter_reminder_email_job.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index bc6471bf..d15d1c3a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index b80ae769..8b706ac0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/notifications_settings/_reminder_banner.html.erb b/app/views/notifications_settings/_reminder_banner.html.erb index 926e952d..e69de29b 100644 --- a/app/views/notifications_settings/_reminder_banner.html.erb +++ b/app/views/notifications_settings/_reminder_banner.html.erb @@ -1 +0,0 @@ -<%= render 'reminder_placeholder' %> diff --git a/app/views/personalization_settings/_logo_form.html.erb b/app/views/personalization_settings/_logo_form.html.erb index fc6f3ac7..df80811b 100644 --- a/app/views/personalization_settings/_logo_form.html.erb +++ b/app/views/personalization_settings/_logo_form.html.erb @@ -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? %> +
+ <%= image_tag current_account.logo, class: 'max-h-16 max-w-48 rounded bg-base-200 object-contain p-2' %> + +
+ <% end %> +
+ <%= 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' %> +
+
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %> +
+<% end %> diff --git a/app/views/shared/_attribution.html.erb b/app/views/shared/_attribution.html.erb index bc464bba..e69de29b 100644 --- a/app/views/shared/_attribution.html.erb +++ b/app/views/shared/_attribution.html.erb @@ -1 +0,0 @@ -<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter], link_path: local_assigns[:link_path] %> diff --git a/app/views/shared/_mailer_attribution.html.erb b/app/views/shared/_mailer_attribution.html.erb index b9fa9276..e69de29b 100644 --- a/app/views/shared/_mailer_attribution.html.erb +++ b/app/views/shared/_mailer_attribution.html.erb @@ -1 +0,0 @@ -<%= render 'shared/email_attribution' %> diff --git a/app/views/start_form/_docuseal_logo.html.erb b/app/views/start_form/_docuseal_logo.html.erb index 735c607c..2b238d99 100644 --- a/app/views/start_form/_docuseal_logo.html.erb +++ b/app/views/start_form/_docuseal_logo.html.erb @@ -1,6 +1,11 @@ +<% account = @template&.account || @submitter&.account || current_account %> - - <%= render 'shared/logo', width: '50px', height: '50px' %> - -

DocuSeal

+ <% if account&.logo&.attached? %> + <%= image_tag account.logo, class: 'mr-3 max-w-14 max-h-14 object-contain' %> + <% else %> + + <%= render 'shared/logo', width: '50px', height: '50px' %> + + <% end %> +

<%= account&.name || Docuseal.product_name %>

diff --git a/app/views/submit_form/_docuseal_logo.html.erb b/app/views/submit_form/_docuseal_logo.html.erb index 645f7fc5..9a729da1 100644 --- a/app/views/submit_form/_docuseal_logo.html.erb +++ b/app/views/submit_form/_docuseal_logo.html.erb @@ -1,4 +1,9 @@ +<% account = @submitter&.account || current_account %> - <%= render 'shared/logo', class: 'w-9 h-9 md:w-12 md:h-12' %> - <%= Docuseal.product_name %> + <% 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 %> + <%= account&.name || Docuseal.product_name %> diff --git a/app/views/templates/edit.html.erb b/app/views/templates/edit.html.erb index 18684bb5..539f9ee2 100644 --- a/app/views/templates/edit.html.erb +++ b/app/views/templates/edit.html.erb @@ -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 %> - + diff --git a/app/views/templates_code_modal/show.html.erb b/app/views/templates_code_modal/show.html.erb index 46e9c893..0b955db2 100644 --- a/app/views/templates_code_modal/show.html.erb +++ b/app/views/templates_code_modal/show.html.erb @@ -34,5 +34,4 @@ <% end %> <%= render 'templates_code_modal/preferences' %> - <%= render 'templates_code_modal/placeholder' %> <% end %> diff --git a/app/views/templates_preferences/show.html.erb b/app/views/templates_preferences/show.html.erb index ddb52bd8..d2184a66 100644 --- a/app/views/templates_preferences/show.html.erb +++ b/app/views/templates_preferences/show.html.erb @@ -168,7 +168,6 @@ <% end %> - <%= 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| %> diff --git a/app/views/users/_role_select.html.erb b/app/views/users/_role_select.html.erb index d14b2778..913135a4 100644 --- a/app/views/users/_role_select.html.erb +++ b/app/views/users/_role_select.html.erb @@ -1,20 +1,4 @@
<%= f.label :role, class: 'label' %> - <%= f.select :role, nil, {}, class: 'base-select' do %> - - - - <% end %> - <% if Docuseal.multitenant? %> - - <% end %> - "> - <%= svg_icon('info_circle', class: 'w-4 h-4 inline align-text-bottom') %> - <%= t('unlock_more_user_roles_with_docuseal_pro') %> - <%= t('learn_more') %> - + <%= f.select :role, User::ROLES.map { |role| [t(role), role] }, {}, class: 'base-select' %>
diff --git a/lib/ability.rb b/lib/ability.rb index da472f58..d66972b6 100644 --- a/lib/ability.rb +++ b/lib/ability.rb @@ -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 diff --git a/lib/account_configs.rb b/lib/account_configs.rb index 06fec78f..832aae8c 100644 --- a/lib/account_configs.rb +++ b/lib/account_configs.rb @@ -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 diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index 82d79dd2..4f48c409 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -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, diff --git a/lib/submitters.rb b/lib/submitters.rb index ec8330b1..11ace9cb 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -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