From 1b6aa567573b2cf3996514252ad3d4643371f724 Mon Sep 17 00:00:00 2001 From: Bob Develop Date: Tue, 21 Apr 2026 16:11:02 -0400 Subject: [PATCH] feat(roles): add editor + viewer roles with ability dispatch --- app/models/user.rb | 8 ++-- app/views/users/_role_select.html.erb | 24 +++------- config/locales/i18n.yml | 6 +++ lib/ability.rb | 52 ++++++++++++++++++++-- playwright/tests/v0.5.0-user-roles.spec.ts | 35 +++++++++++++++ 5 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 playwright/tests/v0.5.0-user-roles.spec.ts diff --git a/app/models/user.rb b/app/models/user.rb index b80ae769..120c5e7f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -47,9 +47,11 @@ # fk_rails_... (account_id => accounts.id) # class User < ApplicationRecord - ROLES = [ - ADMIN_ROLE = 'admin' - ].freeze + ADMIN_ROLE = 'admin' + EDITOR_ROLE = 'editor' + VIEWER_ROLE = 'viewer' + + ROLES = [ADMIN_ROLE, EDITOR_ROLE, VIEWER_ROLE].freeze EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ diff --git a/app/views/users/_role_select.html.erb b/app/views/users/_role_select.html.erb index d14b2778..d62981a3 100644 --- a/app/views/users/_role_select.html.erb +++ b/app/views/users/_role_select.html.erb @@ -1,20 +1,10 @@
<%= 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 { |r| [t(r, default: r.titleize), r] }, + {}, + class: 'base-select' %> +
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index f5417a07..e8b3cab2 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -3146,6 +3146,9 @@ it: &it detailed: "%d %B %Y %H:%M:%S" fr: &fr + user_role_help: "Les administrateurs ont un accès complet. Les éditeurs peuvent créer et modifier des modèles. Les lecteurs peuvent uniquement consulter les modèles auxquels ils ont accès." + editor: Éditeur + viewer: Lecteur knowledge_based_authentication: Authentification basée sur la connaissance kba_field: KBA passed: Réussi @@ -8041,3 +8044,6 @@ it-IT: nl-NL: <<: *nl + +nl-NL: + <<: *nl diff --git a/lib/ability.rb b/lib/ability.rb index da472f58..c485c091 100644 --- a/lib/ability.rb +++ b/lib/ability.rb @@ -4,6 +4,24 @@ class Ability include CanCan::Ability def initialize(user) + # Personal config is always accessible regardless of role. + can :manage, EncryptedUserConfig, user_id: user.id + can :manage, UserConfig, user_id: user.id + can :manage, AccessToken, user_id: user.id + + case user.role + when User::VIEWER_ROLE + viewer_abilities(user) + when User::EDITOR_ROLE + editor_abilities(user) + else + admin_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 @@ -15,14 +33,42 @@ class Ability 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) + 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 :create, Submission, account_id: user.account_id + can %i[read update], Submission, account_id: user.account_id, created_by_user_id: user.id + can %i[read update], Submitter, submission: { account_id: user.account_id, created_by_user_id: user.id } + + can :read, User, account_id: user.account_id + can :read, Account, id: user.account_id + can :read, AccountConfig, account_id: user.account_id + end + + def viewer_abilities(user) + can :read, Template, Abilities::TemplateConditions.collection(user) do |template| + Abilities::TemplateConditions.entity(template, user:, ability: 'read') + end + + can :read, TemplateFolder, account_id: user.account_id + can :read, Submission, account_id: user.account_id, created_by_user_id: user.id + can :read, Submitter, submission: { account_id: user.account_id, created_by_user_id: user.id } + + can :read, User, id: user.id + can :read, Account, id: user.account_id + can :read, AccountConfig, account_id: user.account_id + end end diff --git a/playwright/tests/v0.5.0-user-roles.spec.ts b/playwright/tests/v0.5.0-user-roles.spec.ts new file mode 100644 index 00000000..7f51a163 --- /dev/null +++ b/playwright/tests/v0.5.0-user-roles.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import { loginAs, loginAsAdmin, adminEmail, adminPassword } from './helpers/auth'; + +// Phase 1.1 — User roles (admin / editor / viewer) +// Pre-seeded users required in target env: +// - editor@example.com / password (role: editor) +// - viewer@example.com / password (role: viewer) + +const editorEmail = process.env.DOCUSEAL_EDITOR_EMAIL || 'editor@example.com'; +const viewerEmail = process.env.DOCUSEAL_VIEWER_EMAIL || 'viewer@example.com'; +const defaultPassword = process.env.DOCUSEAL_DEFAULT_PASSWORD || 'password'; + +test.describe('User roles', () => { + test('admin sees New Template button', async ({ page }) => { + await loginAs(page, adminEmail, adminPassword); + await page.goto('/'); + await expect(page.getByRole('link', { name: /new template|create/i })).toBeVisible(); + }); + + test('editor can access templates but not account settings', async ({ page }) => { + await loginAs(page, editorEmail, defaultPassword); + await page.goto('/templates'); + await expect(page).toHaveURL(/templates|^\//); + + await page.goto('/settings/account'); + // Editor is denied write access; expect redirect or forbidden copy. + await expect(page).not.toHaveURL(/\/settings\/account(?:$|\?)/); + }); + + test('viewer cannot see New Template / create controls', async ({ page }) => { + await loginAs(page, viewerEmail, defaultPassword); + await page.goto('/templates'); + await expect(page.getByRole('link', { name: /new template/i })).toHaveCount(0); + }); +});