From 123c4c58d1e526afcc45ed5698430bffb524c49b Mon Sep 17 00:00:00 2001 From: Ricky Gray Date: Fri, 3 Jul 2026 15:34:10 -0500 Subject: [PATCH] Add logo upload, Editor role, and Viewer role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logo upload: Admins can upload an org logo via Settings > Personalization. Logo is stored via ActiveStorage (has_one_attached :logo on Account), served publicly for signing pages (existing blob proxy exemption for 'logo' reused), and displayed in the navbar replacing the default DocuSeal wordmark when set. - Editor role: New role with full document lifecycle (upload, field editing, send for signature, manage submissions) but cannot delete/archive templates. Cannot manage users, account settings, webhooks, or API tokens. - Viewer role: New read-only role. Can browse templates and submissions, and download completed signed documents. No create/update/destroy on any resource. - Role select UI: Enabled Editor and Viewer options (previously disabled behind a Pro paywall link). Removed upgrade prompt from self-hosted fork. - Admin guard: Added require_admin! helper to ApplicationController, applied to UsersController and PersonalizationSettingsController so non-admins are redirected instead of hitting CanCan authorization errors. - i18n: Added access_denied and remove_logo English locale keys. No DB migration needed — role column is an existing plain string field. --- .../i-have-forked-this-harmonic-porcupine.md | 237 ++++++++++++++++++ app/controllers/application_controller.rb | 4 + app/controllers/logo_settings_controller.rb | 17 ++ .../personalization_settings_controller.rb | 1 + app/controllers/users_controller.rb | 2 + app/models/account.rb | 2 + app/models/user.rb | 8 +- .../_logo_form.html.erb | 18 +- app/views/shared/_title.html.erb | 8 +- app/views/users/_role_select.html.erb | 16 +- config/locales/i18n.yml | 2 + config/routes.rb | 1 + lib/ability.rb | 39 +++ 13 files changed, 337 insertions(+), 18 deletions(-) create mode 100644 Plans/i-have-forked-this-harmonic-porcupine.md create mode 100644 app/controllers/logo_settings_controller.rb diff --git a/Plans/i-have-forked-this-harmonic-porcupine.md b/Plans/i-have-forked-this-harmonic-porcupine.md new file mode 100644 index 00000000..d7645469 --- /dev/null +++ b/Plans/i-have-forked-this-harmonic-porcupine.md @@ -0,0 +1,237 @@ +# Plan: Logo Upload + Editor/Viewer Roles + +## Context + +This is a self-hosted fork of DocuSeal for the City of Rayne. Three features are needed: +1. Admin can upload an org logo displayed site-wide +2. A new **Editor** role: full document lifecycle (upload, field edit, send) except delete +3. A new **Viewer** role: read-only access + download signed documents + +The codebase uses Devise for auth, CanCanCan for authorization (`lib/ability.rb`), and ActiveStorage for file attachments. The Editor/Viewer role options already exist in the UI but are disabled and not in `User::ROLES`. The logo feature is gated behind a Pro paywall placeholder — the ActiveStorage exemption for `logo` attachments is already in place on the blob proxy controllers. + +--- + +## Feature 1: Logo Upload + +### What to change + +**1. `app/models/account.rb`** +Add ActiveStorage attachment: +```ruby +has_one_attached :logo +``` + +**2. New controller: `app/controllers/logo_settings_controller.rb`** +```ruby +class LogoSettingsController < ApplicationController + def update + authorize!(:manage, current_account) + current_account.logo.attach(params[:logo]) if params[:logo].present? + current_account.logo.purge if params[:remove_logo] == '1' + redirect_to settings_personalization_path, notice: t('settings_have_been_saved') + end +end +``` + +**3. `config/routes.rb`** +```ruby +resource :logo_settings, only: [:update], path: 'settings/logo' +``` + +**4. `app/views/personalization_settings/_logo_form.html.erb`** (replace placeholder reference) +Replace the locked placeholder with a real upload form: +```erb +<%= form_with url: logo_settings_path, method: :patch, multipart: true do |f| %> + <% if current_account.logo.attached? %> + <%= image_tag url_for(current_account.logo), class: 'h-16 mb-4' %> + <%= f.hidden_field :remove_logo, value: '0' %> + <%= f.submit t('remove_logo'), name: 'remove_logo', value: '1', class: 'btn btn-sm btn-ghost' %> + <% end %> + <%= f.file_field :logo, accept: 'image/*', class: 'file-input file-input-bordered' %> + <%= f.submit t('save'), class: 'btn btn-primary btn-sm' %> +<% end %> +``` + +**5. `app/views/personalization_settings/show.html.erb`** +Remove the placeholder partial reference; `_logo_form.html.erb` now contains the real form (already referenced there via `_logo_form`). + +**6. Display logo site-wide** +In `app/views/layouts/application.html.erb` (or equivalent nav partial), replace the static logo/name with: +```erb +<% if current_account&.logo&.attached? %> + <%= image_tag url_for(current_account.logo), class: 'h-8' %> +<% else %> + DocuSeal +<% end %> +``` + +The blob proxy already exempts `logo` from auth checks (both proxy controllers check `attachment.name == 'logo'`), so logo images will serve publicly to unauthenticated signers as well. + +--- + +## Feature 2 & 3: Editor and Viewer Roles + +### Step 1 — Register the roles + +**`app/models/user.rb`** +```ruby +ROLES = [ + ADMIN_ROLE = 'admin', + EDITOR_ROLE = 'editor', + VIEWER_ROLE = 'viewer' +].freeze +``` +Add helper predicates: +```ruby +def admin? = role == ADMIN_ROLE +def editor? = role == EDITOR_ROLE +def viewer? = role == VIEWER_ROLE +``` + +### Step 2 — Enable role select UI + +**`app/views/users/_role_select.html.erb`** +- Remove `disabled` from editor and viewer options +- Remove the "unlock with Pro" upgrade link block (it's a self-hosted fork) + +### Step 3 — Rewrite `lib/ability.rb` + +Replace the single flat ability block with role-branched logic: + +```ruby +class Ability + include CanCan::Ability + + def initialize(user) + case user.role + when User::ADMIN_ROLE + admin_abilities(user) + when User::EDITOR_ROLE + editor_abilities(user) + when User::VIEWER_ROLE + viewer_abilities(user) + end + end + + private + + def admin_abilities(user) + # Existing full-access block — unchanged + can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |t| + Abilities::TemplateConditions.entity(t, user:, ability: 'manage') + end + 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, 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 + end + + def editor_abilities(user) + # Full template lifecycle EXCEPT destroy + can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |t| + Abilities::TemplateConditions.entity(t, user:, ability: 'manage') + end + 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 + # Own profile only + can %i[read update], User, id: user.id + can :manage, EncryptedUserConfig, user_id: user.id + can :manage, UserConfig, user_id: user.id + can :manage, AccessToken, user_id: user.id + can :read, Account, id: user.account_id + # No: destroy Template, manage AccountConfig, manage WebhookUrl, manage all Users + end + + def viewer_abilities(user) + can :read, Template, account_id: user.account_id + can :read, Submission, account_id: user.account_id + can :read, Submitter, account_id: user.account_id + can %i[read update], User, id: user.id # own profile (password reset etc.) + can :manage, EncryptedUserConfig, user_id: user.id + can :manage, UserConfig, user_id: user.id + can :read, Account, id: user.account_id + # Download permission: submitters download their own completed docs via public route + # No create/update/destroy on any resource + end +end +``` + +### Step 4 — Guard admin-only UI routes + +**`app/controllers/application_controller.rb`** (or a new `before_action` concern) + +Add a helper: +```ruby +def require_admin! + redirect_to root_path, alert: t('access_denied') unless current_user.admin? +end +``` + +Apply it in: +- `PersonalizationSettingsController` — `before_action :require_admin!` +- `UsersController` — `before_action :require_admin!` (editors/viewers can't manage other users) +- `WebhookUrlsController` — `before_action :require_admin!` +- Any other settings-only controllers + +### Step 5 — Hide delete UI for editors + +In `app/views/templates/_template_actions.html.erb` (or wherever the delete button is rendered), wrap it: +```erb +<% if current_user.admin? %> + <%= link_to t('delete'), template_path(@template), method: :delete, ... %> +<% end %> +``` + +CanCan already blocks the actual destroy action at the authorization layer — this is just UI polish. + +### Step 6 — Migration (no schema change needed) + +The `role` column is already a plain string column. No migration required — new roles are stored as `'editor'` or `'viewer'` strings. The `ROLES` constant update in step 1 makes them valid through `role_valid?` in `UsersController`. + +--- + +## Viewer: Signed Document Download + +Viewers can already `:read` Submission and Submitter records. DocuSeal's existing download route for completed documents goes through `SubmittersController` or a dedicated download route — CanCan's `:read` on Submitter should cover it. Verify after implementation that `authorize! :read, @submitter` is satisfied for viewer-role users; if any download action uses `:manage`, change it to `:read` in the relevant controller. + +--- + +## Verification + +1. **Logo**: Log in as admin → Settings → Personalization → upload a PNG → confirm it appears in the nav. Browse to the logo URL while logged out — should serve without redirect. +2. **Editor role**: Create an editor user → log in → confirm can upload a doc, add fields, send for signature → confirm Delete button is absent → try `DELETE /templates/:id` directly → expect 403. +3. **Viewer role**: Create a viewer user → log in → confirm can browse templates and submissions → confirm no create/edit/delete buttons → confirm can download a completed signed document → try `POST /templates` directly → expect 403. +4. **Admin unchanged**: Confirm admin user retains all existing capabilities. +5. **User management**: Log in as editor → try to visit `/settings/users` → expect redirect with access denied. + +--- + +## Files Modified + +| File | Change | +|------|--------| +| `app/models/user.rb` | Add EDITOR_ROLE, VIEWER_ROLE to ROLES; add predicate methods | +| `app/models/account.rb` | Add `has_one_attached :logo` | +| `lib/ability.rb` | Role-branched ability definitions | +| `app/controllers/logo_settings_controller.rb` | New — handles logo upload/removal | +| `app/controllers/application_controller.rb` | Add `require_admin!` helper | +| `app/controllers/personalization_settings_controller.rb` | Add `before_action :require_admin!` | +| `app/controllers/users_controller.rb` | Add `before_action :require_admin!` | +| `app/views/personalization_settings/_logo_form.html.erb` | Replace Pro placeholder with real upload form | +| `app/views/users/_role_select.html.erb` | Enable editor/viewer options, remove upgrade link | +| `app/views/templates/_template_actions.html.erb` (or equiv) | Wrap delete button in admin-only check | +| `app/views/layouts/application.html.erb` (or nav partial) | Conditionally render logo | +| `config/routes.rb` | Add logo_settings route | diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 592c006d..988a2190 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -121,6 +121,10 @@ class ApplicationController < ActionController::Base Docuseal.default_url_options[:host] end + def require_admin! + redirect_to root_path, alert: t('access_denied') unless current_user&.admin? + end + def maybe_redirect_com return if request.domain != 'docuseal.co' diff --git a/app/controllers/logo_settings_controller.rb b/app/controllers/logo_settings_controller.rb new file mode 100644 index 00000000..14a7db96 --- /dev/null +++ b/app/controllers/logo_settings_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class LogoSettingsController < ApplicationController + before_action :require_admin! + + def update + authorize!(:manage, current_account) + + if params[:remove_logo] == '1' + current_account.logo.purge + elsif params[:logo].present? + current_account.logo.attach(params[:logo]) + end + + redirect_to settings_personalization_path, notice: t('settings_have_been_saved') + end +end diff --git a/app/controllers/personalization_settings_controller.rb b/app/controllers/personalization_settings_controller.rb index d9d33490..50a57fed 100644 --- a/app/controllers/personalization_settings_controller.rb +++ b/app/controllers/personalization_settings_controller.rb @@ -13,6 +13,7 @@ class PersonalizationSettingsController < ApplicationController InvalidKey = Class.new(StandardError) + before_action :require_admin! before_action :load_and_authorize_account_config, only: :create def show diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2d8f818f..6be7567a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class UsersController < ApplicationController + before_action :require_admin! + load_and_authorize_resource :user, only: %i[index edit update destroy] before_action :build_user, only: %i[new create] diff --git a/app/models/account.rb b/app/models/account.rb index d3d53d0c..2891166d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -18,6 +18,8 @@ # index_accounts_on_uuid (uuid) UNIQUE # class Account < ApplicationRecord + has_one_attached :logo + attribute :uuid, :string, default: -> { SecureRandom.uuid } has_many :users, dependent: :destroy diff --git a/app/models/user.rb b/app/models/user.rb index b80ae769..0ff3f230 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]+/ @@ -92,6 +94,10 @@ class User < ApplicationRecord true end + def admin? = role == ADMIN_ROLE + def editor? = role == EDITOR_ROLE + def viewer? = role == VIEWER_ROLE + def sidekiq? return true if Rails.env.development? diff --git a/app/views/personalization_settings/_logo_form.html.erb b/app/views/personalization_settings/_logo_form.html.erb index fc6f3ac7..e64598f8 100644 --- a/app/views/personalization_settings/_logo_form.html.erb +++ b/app/views/personalization_settings/_logo_form.html.erb @@ -1 +1,17 @@ -<%= render 'logo_placeholder' %> +<%= form_with url: logo_settings_path, method: :patch, multipart: true do |f| %> + <% if current_account.logo.attached? %> +
+ <%= image_tag url_for(current_account.logo), class: 'h-16 object-contain' %> +
+ <% end %> +
+ <%= f.file_field :logo, accept: 'image/*', class: 'file-input file-input-bordered w-full max-w-xs' %> +
+ <%= f.submit t('save'), class: 'btn btn-primary btn-sm' %> + <% if current_account.logo.attached? %> + <%= f.submit t('remove_logo'), name: 'remove_logo', value: '1', class: 'btn btn-ghost btn-sm', + data: { turbo_confirm: t('are_you_sure_') } %> + <% end %> +
+
+<% end %> diff --git a/app/views/shared/_title.html.erb b/app/views/shared/_title.html.erb index 9830a7ca..863a211d 100644 --- a/app/views/shared/_title.html.erb +++ b/app/views/shared/_title.html.erb @@ -1,2 +1,6 @@ -<%= render 'shared/logo' %> -DocuSeal +<% if current_account&.logo&.attached? %> + <%= image_tag url_for(current_account.logo), class: 'h-9 object-contain max-w-[180px]', alt: current_account.name %> +<% else %> + <%= render 'shared/logo' %> + DocuSeal +<% end %> diff --git a/app/views/users/_role_select.html.erb b/app/views/users/_role_select.html.erb index d14b2778..72ac3635 100644 --- a/app/views/users/_role_select.html.erb +++ b/app/views/users/_role_select.html.erb @@ -2,19 +2,7 @@ <%= 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') %> - diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index cb4c112b..57cc3783 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -246,6 +246,8 @@ en: &en optional: optional save: Save saving: Saving + access_denied: You are not authorized to access this page. + remove_logo: Remove Logo changes_have_been_saved: Changes have been saved. unlock_with_docuseal_pro: Unlock with DocuSeal Pro use_your_own_certificates_to_sign_and_verify_pdf_files: Use your own certificates to sign and verify PDF files. diff --git a/config/routes.rb b/config/routes.rb index e1100e04..8c21545f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -59,6 +59,7 @@ Rails.application.routes.draw do resources :timestamp_server, only: %i[create] unless Docuseal.multitenant? resources :dashboard, only: %i[index] resources :setup, only: %i[index create] + resource :logo_settings, only: %i[update], path: 'settings/logo' resource :newsletter, only: %i[show update] resources :enquiries, only: %i[create] resources :users, only: %i[new create edit update destroy] do diff --git a/lib/ability.rb b/lib/ability.rb index da472f58..dc8a8dc1 100644 --- a/lib/ability.rb +++ b/lib/ability.rb @@ -4,6 +4,19 @@ class Ability include CanCan::Ability def initialize(user) + case user.role + when User::ADMIN_ROLE + admin_abilities(user) + when User::EDITOR_ROLE + editor_abilities(user) + when User::VIEWER_ROLE + viewer_abilities(user) + end + end + + private + + def admin_abilities(user) can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template| Abilities::TemplateConditions.entity(template, user:, ability: 'manage') end @@ -25,4 +38,30 @@ class Ability can :manage, :mcp end + + def editor_abilities(user) + can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template| + Abilities::TemplateConditions.entity(template, user:, ability: 'manage') + end + + 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 %i[read update], User, id: user.id + can :manage, EncryptedUserConfig, user_id: user.id + can :manage, UserConfig, user_id: user.id + can :manage, AccessToken, user_id: user.id + can :read, Account, id: user.account_id + end + + def viewer_abilities(user) + can :read, Template, account_id: user.account_id + can :read, Submission, account_id: user.account_id + can :read, Submitter, account_id: user.account_id + can %i[read update], User, id: user.id + can :manage, EncryptedUserConfig, user_id: user.id + can :manage, UserConfig, user_id: user.id + can :read, Account, id: user.account_id + end end