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 @@
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);
+ });
+});