Add logo upload, Editor role, and Viewer role

- 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.
pull/699/head
Ricky Gray 1 month ago
parent 673cc1e0df
commit 123c4c58d1

@ -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 <!-- or existing static content -->
<% 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 |

@ -121,6 +121,10 @@ class ApplicationController < ActionController::Base
Docuseal.default_url_options[:host] Docuseal.default_url_options[:host]
end end
def require_admin!
redirect_to root_path, alert: t('access_denied') unless current_user&.admin?
end
def maybe_redirect_com def maybe_redirect_com
return if request.domain != 'docuseal.co' return if request.domain != 'docuseal.co'

@ -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

@ -13,6 +13,7 @@ class PersonalizationSettingsController < ApplicationController
InvalidKey = Class.new(StandardError) InvalidKey = Class.new(StandardError)
before_action :require_admin!
before_action :load_and_authorize_account_config, only: :create before_action :load_and_authorize_account_config, only: :create
def show def show

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class UsersController < ApplicationController class UsersController < ApplicationController
before_action :require_admin!
load_and_authorize_resource :user, only: %i[index edit update destroy] load_and_authorize_resource :user, only: %i[index edit update destroy]
before_action :build_user, only: %i[new create] before_action :build_user, only: %i[new create]

@ -18,6 +18,8 @@
# index_accounts_on_uuid (uuid) UNIQUE # index_accounts_on_uuid (uuid) UNIQUE
# #
class Account < ApplicationRecord class Account < ApplicationRecord
has_one_attached :logo
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
has_many :users, dependent: :destroy has_many :users, 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]+/
@ -92,6 +94,10 @@ class User < ApplicationRecord
true true
end end
def admin? = role == ADMIN_ROLE
def editor? = role == EDITOR_ROLE
def viewer? = role == VIEWER_ROLE
def sidekiq? def sidekiq?
return true if Rails.env.development? return true if Rails.env.development?

@ -1 +1,17 @@
<%= render 'logo_placeholder' %> <%= form_with url: logo_settings_path, method: :patch, multipart: true do |f| %>
<% if current_account.logo.attached? %>
<div class="mb-4">
<%= image_tag url_for(current_account.logo), class: 'h-16 object-contain' %>
</div>
<% end %>
<div class="flex flex-col gap-2">
<%= f.file_field :logo, accept: 'image/*', class: 'file-input file-input-bordered w-full max-w-xs' %>
<div class="flex gap-2 mt-2">
<%= 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 %>
</div>
</div>
<% end %>

@ -1,2 +1,6 @@
<% 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' %> <%= render 'shared/logo' %>
<span>DocuSeal</span> <span>DocuSeal</span>
<% end %>

@ -2,19 +2,7 @@
<%= f.label :role, class: 'label' %> <%= f.label :role, class: 'label' %>
<%= f.select :role, nil, {}, class: 'base-select' do %> <%= f.select :role, nil, {}, class: 'base-select' do %>
<option value="admin"><%= t('admin') %></option> <option value="admin"><%= t('admin') %></option>
<option value="editor" disabled><%= t('editor') %></option> <option value="editor"><%= t('editor') %></option>
<option value="viewer" disabled><%= t('viewer') %></option> <option value="viewer"><%= t('viewer') %></option>
<% end %> <% 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>

@ -246,6 +246,8 @@ en: &en
optional: optional optional: optional
save: Save save: Save
saving: Saving saving: Saving
access_denied: You are not authorized to access this page.
remove_logo: Remove Logo
changes_have_been_saved: Changes have been saved. changes_have_been_saved: Changes have been saved.
unlock_with_docuseal_pro: Unlock with DocuSeal Pro 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. use_your_own_certificates_to_sign_and_verify_pdf_files: Use your own certificates to sign and verify PDF files.

@ -59,6 +59,7 @@ Rails.application.routes.draw do
resources :timestamp_server, only: %i[create] unless Docuseal.multitenant? resources :timestamp_server, only: %i[create] unless Docuseal.multitenant?
resources :dashboard, only: %i[index] resources :dashboard, only: %i[index]
resources :setup, only: %i[index create] resources :setup, only: %i[index create]
resource :logo_settings, only: %i[update], path: 'settings/logo'
resource :newsletter, only: %i[show update] resource :newsletter, only: %i[show update]
resources :enquiries, only: %i[create] resources :enquiries, only: %i[create]
resources :users, only: %i[new create edit update destroy] do resources :users, only: %i[new create edit update destroy] do

@ -4,6 +4,19 @@ class Ability
include CanCan::Ability include CanCan::Ability
def initialize(user) 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| can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template|
Abilities::TemplateConditions.entity(template, user:, ability: 'manage') Abilities::TemplateConditions.entity(template, user:, ability: 'manage')
end end
@ -25,4 +38,30 @@ class Ability
can :manage, :mcp can :manage, :mcp
end 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 end

Loading…
Cancel
Save