Add Teams and Editor Role with team-scoped access control (#1)

* feat(teams): add Team model and migrations

Create teams table with name, account_id, uuid, archived_at.
Add team_id foreign key to users, templates, submissions,
submitters, and template_folders. Backfill migration creates
a Default team per account and assigns all existing records.

* feat(teams): add team associations to models

User belongs_to team (required). Template, Submission, Submitter,
and TemplateFolder belong_to team (optional for backwards compat).
Account has_many teams with default_team helper. Add EDITOR_ROLE
to User::ROLES and editors scope.

* feat(auth): rewrite abilities with role + team scoping

Admin retains full account-wide access. Editor gets team-scoped
access to templates, submissions, submitters, folders, plus
account-wide access to AccountConfig and WebhookUrl.

ProfileController uses authorize!(:update) instead of :manage
so editors can access their own profile page.

* feat(teams): add team CRUD controller and views

Admin-only team management at /settings/teams. Create, edit,
and archive teams. Index view uses eager-loaded user counts
to avoid N+1 queries. Routes added in settings scope.

* feat(teams): add role and team selection to user management

Enable editor role in role select (remove Pro upsell gate).
Add team dropdown to user form visible to admins only.
Validate team_id belongs to current account on create.

* feat(teams): assign team_id on resource creation

Set team_id from current_user.team_id when creating templates,
submissions, submitters, and folders. Setup controller creates
Default team alongside first account for greenfield installs.

* feat(teams): add move-folder-to-team and editor settings access

Admins can move folders between teams via the folder edit modal.
Moving cascades team_id to all templates, submissions, and
submitters in a transaction. Editors can now access
personalization, API, and webhook settings.

* docs: document teams and roles feature in README

---------

Co-authored-by: Sebastian Noe <sebastian.schneider@boxine.de>
pull/681/head
Sebastian Noe 1 month ago committed by GitHub
parent 812c162ea2
commit 3f80171163
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -21,9 +21,27 @@ Support companies that are providing open-source solutions!
| Automated reminders | Done | Configure reminder intervals per-account. Pending signers receive scheduled follow-up emails. | | Automated reminders | Done | Configure reminder intervals per-account. Pending signers receive scheduled follow-up emails. |
| Template creation via API | Done | `POST /api/templates/pdf` and `PUT /api/templates/:id/documents` — create and manage templates programmatically with field coordinates or embedded text tags. | | Template creation via API | Done | `POST /api/templates/pdf` and `PUT /api/templates/:id/documents` — create and manage templates programmatically with field coordinates or embedded text tags. |
| Professional email design | Done | Table-based responsive email layout with company branding, styled CTA buttons, and proper footer. | | Professional email design | Done | Table-based responsive email layout with company branding, styled CTA buttons, and proper footer. |
| Teams & user roles | Done | Multi-team support with admin/editor roles. Editors see only their team's documents. Admins can move folders between teams. |
See [`docs/API.md`](docs/API.md) for full API reference on the new endpoints. See [`docs/API.md`](docs/API.md) for full API reference on the new endpoints.
## Teams & Roles
This fork implements team-based access control with two roles:
| Role | Access |
|------|--------|
| **Admin** | Full access to all teams, users, settings, and resources in the account |
| **Editor** | Full access to templates, submissions, and documents within their team only. Can manage personalization, API keys, and webhooks. Cannot manage users, teams, or account settings. |
**Key features:**
- Create multiple teams per account (Settings > Teams)
- Assign users to teams with role selection
- Editors are scoped to their team — they only see templates, submissions, and folders belonging to their team
- Admins can move entire folders (with all templates and submissions) to another team via the folder edit modal
- API tokens respect the user's role and team membership
- Migrations handle both greenfield installs and existing deployments (auto-creates a "Default" team and backfills)
## What's NOT included ## What's NOT included
These Pro features remain unavailable in this fork (they require significant UI/infrastructure work): These Pro features remain unavailable in this fork (they require significant UI/infrastructure work):

@ -15,7 +15,8 @@ module Api
author: current_user, author: current_user,
source: :api, source: :api,
name: params[:name].presence || extract_default_name, name: params[:name].presence || extract_default_name,
external_id: params[:external_id] external_id: params[:external_id],
team_id: @template.team_id || current_user.team_id
) )
if params[:folder_name].present? if params[:folder_name].present?

@ -2,7 +2,7 @@
class ProfileController < ApplicationController class ProfileController < ApplicationController
before_action do before_action do
authorize!(:manage, current_user) authorize!(:update, current_user)
end end
def index; end def index; end

@ -28,6 +28,8 @@ class SetupController < ApplicationController
return render :index, status: :unprocessable_content unless @account.valid? return render :index, status: :unprocessable_content unless @account.valid?
@user.team = @account.teams.new(name: 'Default')
if @user.save if @user.save
encrypted_configs = [ encrypted_configs = [
{ key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] }, { key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] },

@ -0,0 +1,53 @@
# frozen_string_literal: true
class TeamsController < ApplicationController
load_and_authorize_resource :team
before_action :set_teams, only: :index
def index; end
def new; end
def edit; end
def create
@team.account = current_account
if @team.save
redirect_back fallback_location: settings_teams_path, notice: I18n.t('team_has_been_created')
else
render turbo_stream: turbo_stream.replace(:modal, template: 'teams/new'), status: :unprocessable_content
end
end
def update
if @team.update(team_params)
redirect_back fallback_location: settings_teams_path, notice: I18n.t('team_has_been_updated')
else
render turbo_stream: turbo_stream.replace(:modal, template: 'teams/edit'), status: :unprocessable_content
end
end
def destroy
@team.update!(archived_at: Time.current)
redirect_back fallback_location: settings_teams_path, notice: I18n.t('team_has_been_archived')
end
private
def set_teams
@teams = current_account.teams.active
.left_joins(:users)
.where(users: { archived_at: nil })
.or(current_account.teams.active.left_joins(:users).where(users: { id: nil }))
.select('teams.*, COUNT(users.id) AS active_users_count')
.group('teams.id')
.order(:name)
end
def team_params
params.require(:team).permit(:name)
end
end

@ -43,8 +43,20 @@ class TemplateFoldersController < ApplicationController
def edit; end def edit; end
def update def update
if @template_folder != current_account.default_template_folder && if @template_folder == current_account.default_template_folder
@template_folder.update(template_folder_params) redirect_to folder_path(@template_folder), alert: I18n.t('unable_to_rename_folder')
return
end
new_team_id = template_folder_params[:team_id]
team_changed = new_team_id.present? && new_team_id.to_i != @template_folder.team_id
if team_changed
authorize! :manage, Team.new(account: current_account)
target_team = current_account.teams.active.find(new_team_id)
move_folder_to_team(@template_folder, target_team)
redirect_to folder_path(@template_folder), notice: I18n.t('folder_has_been_moved')
elsif @template_folder.update(template_folder_params.except(:team_id))
redirect_to folder_path(@template_folder), notice: I18n.t('folder_name_has_been_updated') redirect_to folder_path(@template_folder), notice: I18n.t('folder_name_has_been_updated')
else else
redirect_to folder_path(@template_folder), alert: I18n.t('unable_to_rename_folder') redirect_to folder_path(@template_folder), alert: I18n.t('unable_to_rename_folder')
@ -64,7 +76,19 @@ class TemplateFoldersController < ApplicationController
end end
def template_folder_params def template_folder_params
params.require(:template_folder).permit(:name) params.require(:template_folder).permit(:name, :team_id)
end
def move_folder_to_team(folder, target_team)
template_ids = Template.where(folder_id: folder.id).pluck(:id)
submission_ids = Submission.where(template_id: template_ids).pluck(:id)
ActiveRecord::Base.transaction do
folder.update!(team_id: target_team.id)
Template.where(id: template_ids).update_all(team_id: target_team.id) if template_ids.any?
Submission.where(id: submission_ids).update_all(team_id: target_team.id) if submission_ids.any?
Submitter.where(submission_id: submission_ids).update_all(team_id: target_team.id) if submission_ids.any?
end
end end
def load_related_submissions def load_related_submissions

@ -29,6 +29,7 @@ class TemplatesCloneController < ApplicationController
@template.account = current_account @template.account = current_account
end end
@template.team_id ||= current_user.team_id
Templates.maybe_assign_access(@template) Templates.maybe_assign_access(@template)
if @template.save if @template.save

@ -52,6 +52,7 @@ class TemplatesController < ApplicationController
@template.author = current_user @template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.account = current_account @template.account = current_account
@template.team_id ||= current_user.team_id
Templates.maybe_assign_access(@template) Templates.maybe_assign_access(@template)

@ -43,6 +43,7 @@ class TemplatesUploadsController < ApplicationController
def save_template!(template, url_params) def save_template!(template, url_params)
template.account = current_account template.account = current_account
template.author = current_user template.author = current_user
template.team_id ||= current_user.team_id
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
template.name = File.basename((url_params || params)[:files].first.original_filename, '.*') template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')

@ -53,6 +53,7 @@ class UsersController < ApplicationController
@user.password = SecureRandom.hex if @user.password.blank? @user.password = SecureRandom.hex if @user.password.blank?
@user.role = User::ADMIN_ROLE unless role_valid?(@user.role) @user.role = User::ADMIN_ROLE unless role_valid?(@user.role)
@user.team_id = current_user.team_id unless current_account.teams.exists?(id: @user.team_id)
if @user.save if @user.save
UserMailer.invitation_email(@user).deliver_later! UserMailer.invitation_email(@user).deliver_later!
@ -113,7 +114,7 @@ class UsersController < ApplicationController
def user_params def user_params
if params.key?(:user) if params.key?(:user)
permitted_params = %i[email first_name last_name password archived_at otp_required_for_login] permitted_params = %i[email first_name last_name password archived_at otp_required_for_login team_id]
permitted_params << :role if role_valid?(params.dig(:user, :role)) permitted_params << :role if role_valid?(params.dig(:user, :role))

@ -22,6 +22,7 @@ class Account < ApplicationRecord
has_one_attached :logo has_one_attached :logo
has_many :teams, dependent: :destroy
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy has_many :account_configs, dependent: :destroy
@ -71,4 +72,8 @@ class Account < ApplicationRecord
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME, super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
author_id: users.minimum(:id)).tap(&:save!) author_id: users.minimum(:id)).tap(&:save!)
end end
def default_team
teams.active.order(:id).first || teams.create!(name: 'Default')
end
end end

@ -40,6 +40,7 @@
class Submission < ApplicationRecord class Submission < ApplicationRecord
belongs_to :template, optional: true belongs_to :template, optional: true
belongs_to :account belongs_to :account
belongs_to :team, optional: true
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists? has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?

@ -42,6 +42,7 @@
class Submitter < ApplicationRecord class Submitter < ApplicationRecord
belongs_to :submission belongs_to :submission
belongs_to :account belongs_to :account
belongs_to :team, optional: true
has_one :template, through: :submission has_one :template, through: :submission
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists? has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
has_many :submitter_versions, dependent: :destroy has_many :submitter_versions, dependent: :destroy

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Team < ApplicationRecord
belongs_to :account
has_many :users, dependent: :nullify
has_many :templates, dependent: :nullify
has_many :submissions, dependent: :nullify
has_many :submitters, dependent: :nullify
has_many :template_folders, dependent: :nullify
attribute :uuid, :string, default: -> { SecureRandom.uuid }
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
validates :name, presence: true
end

@ -44,6 +44,7 @@ class Template < ApplicationRecord
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :account belongs_to :account
belongs_to :folder, class_name: 'TemplateFolder' belongs_to :folder, class_name: 'TemplateFolder'
belongs_to :team, optional: true
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists? has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?

@ -30,6 +30,7 @@ class TemplateFolder < ApplicationRecord
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :account belongs_to :account
belongs_to :team, optional: true
belongs_to :parent_folder, class_name: 'TemplateFolder', optional: true belongs_to :parent_folder, class_name: 'TemplateFolder', optional: true
has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder

@ -48,7 +48,8 @@
# #
class User < ApplicationRecord class User < ApplicationRecord
ROLES = [ ROLES = [
ADMIN_ROLE = 'admin' ADMIN_ROLE = 'admin',
EDITOR_ROLE = 'editor'
].freeze ].freeze
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
@ -60,6 +61,8 @@ class User < ApplicationRecord
has_one_attached :initials has_one_attached :initials
belongs_to :account belongs_to :account
belongs_to :team
has_one :access_token, dependent: :destroy has_one :access_token, dependent: :destroy
has_many :access_tokens, dependent: :destroy has_many :access_tokens, dependent: :destroy
has_many :mcp_tokens, dependent: :destroy has_many :mcp_tokens, dependent: :destroy
@ -77,6 +80,7 @@ class User < ApplicationRecord
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :admins, -> { where(role: ADMIN_ROLE) } scope :admins, -> { where(role: ADMIN_ROLE) }
scope :editors, -> { where(role: EDITOR_ROLE) }
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ } validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }

@ -9,9 +9,11 @@
<li> <li>
<%= link_to t('profile'), settings_profile_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('profile'), settings_profile_index_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<li> <% if can?(:manage, current_account) %>
<%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %> <li>
</li> <%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% unless Docuseal.multitenant? %> <% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %> <% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
<li> <li>
@ -44,11 +46,16 @@
<%= link_to t('personalization'), settings_personalization_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('personalization'), settings_personalization_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, User) %> <% if can?(:read, User.new(account: current_account)) %>
<li> <li>
<%= link_to t('users'), settings_users_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('users'), settings_users_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, Team.new(account: current_account)) %>
<li>
<%= link_to t('teams'), settings_teams_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<%= render 'shared/settings_nav_extra' %> <%= render 'shared/settings_nav_extra' %>
<% if Docuseal.demo? || !Docuseal.multitenant? %> <% if Docuseal.demo? || !Docuseal.multitenant? %>
<% if can?(:read, AccessToken) %> <% if can?(:read, AccessToken) %>

@ -0,0 +1,11 @@
<%= form_for team, url: team.persisted? ? settings_team_path(team) : settings_teams_path, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :name, t('name'), class: 'label' %>
<%= f.text_field :name, required: true, class: 'base-input w-full', dir: 'auto' %>
</div>
</div>
<div class="form-control pt-2">
<%= f.button team.persisted? ? t('save') : t('create'), class: 'base-button' %>
</div>
<% end %>

@ -0,0 +1,3 @@
<%= render 'shared/turbo_modal', title: t('edit_team') do %>
<%= render 'form', team: @team %>
<% end %>

@ -0,0 +1,49 @@
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="md:flex-grow">
<div class="flex flex-col md:flex-row md:flex-wrap gap-2 md:justify-between md:items-end mb-4 min-h-12">
<h1 class="text-4xl font-bold">
<%= t('teams') %>
</h1>
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
<% if can?(:create, Team.new(account: current_account)) %>
<%= link_to new_settings_team_path, class: 'btn btn-primary btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
<span><%= t('new_team') %></span>
<% end %>
<% end %>
</div>
</div>
<div class="overflow-x-auto">
<table class="table w-full table-lg rounded-b-none overflow-hidden">
<thead class="bg-base-200">
<tr class="text-neutral uppercase">
<th><%= t('name') %></th>
<th><%= t('members') %></th>
<th class="text-right" width="1px"></th>
</tr>
</thead>
<tbody>
<% @teams.each do |team| %>
<tr scope="row">
<td><%= team.name %></td>
<td><%= team.active_users_count %></td>
<td class="flex items-center space-x-2 justify-end">
<% if can?(:update, team) %>
<%= link_to edit_settings_team_path(team), class: 'btn btn-outline btn-xs', title: t('edit'), data: { turbo_frame: 'modal' } do %>
<%= t('edit') %>
<% end %>
<% end %>
<% if can?(:destroy, team) && team.active_users_count == 0 %>
<%= button_to settings_team_path(team), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: t('archive'), data: { turbo_confirm: t('are_you_sure_') } do %>
<%= t('archive') %>
<% end %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>

@ -0,0 +1,3 @@
<%= render 'shared/turbo_modal', title: t('new_team') do %>
<%= render 'form', team: @team %>
<% end %>

@ -3,8 +3,16 @@
<div class="form-control my-6"> <div class="form-control my-6">
<%= f.text_field :name, required: true, placeholder: "#{t('folder_name')}...", class: 'base-input w-full', autofocus: true, dir: 'auto' %> <%= f.text_field :name, required: true, placeholder: "#{t('folder_name')}...", class: 'base-input w-full', autofocus: true, dir: 'auto' %>
</div> </div>
<% if can?(:manage, Team.new(account: current_account)) %>
<div class="form-control mb-6">
<label class="label">
<span class="label-text"><%= t('team') %></span>
</label>
<%= f.select :team_id, current_account.teams.active.order(:name).map { |t| [t.name, t.id] }, { selected: @template_folder.team_id }, class: 'base-select w-full' %>
</div>
<% end %>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: t('rename'), disabled_with: t('saving')), class: 'base-button' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>

@ -38,6 +38,12 @@
</div> </div>
<% end %> <% end %>
<%= render 'role_select', f: %> <%= render 'role_select', f: %>
<% if can?(:manage, Team.new(account: current_account)) %>
<div class="form-control">
<%= f.label :team_id, t('team'), class: 'label' %>
<%= f.select :team_id, current_account.teams.active.order(:name).map { |t| [t.name, t.id] }, { selected: f.object.team_id }, class: 'base-select' %>
</div>
<% end %>
<% end %> <% end %>
<% if local_assigns[:extra_fields_html].present? %> <% if local_assigns[:extra_fields_html].present? %>
<%= local_assigns[:extra_fields_html] %> <%= local_assigns[:extra_fields_html] %>

@ -1,20 +1,4 @@
<div class="form-control"> <div class="form-control">
<%= f.label :role, class: 'label' %> <%= f.label :role, class: 'label' %>
<%= f.select :role, nil, {}, class: 'base-select' do %> <%= f.select :role, [['Admin', 'admin'], ['Editor', 'editor']], { selected: f.object.role }, class: 'base-select' %>
<option value="admin"><%= t('admin') %></option>
<option value="editor" disabled><%= t('editor') %></option>
<option value="viewer" disabled><%= t('viewer') %></option>
<% 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>

@ -570,6 +570,7 @@ en: &en
form_has_been_declined: Form has been declined. form_has_been_declined: Form has been declined.
file_is_missing: File is missing file_is_missing: File is missing
folder_name_has_been_updated: Folder name has been updated. folder_name_has_been_updated: Folder name has been updated.
folder_has_been_moved: Folder has been moved to the selected team.
unable_to_rename_folder: Unable to rename folder. unable_to_rename_folder: Unable to rename folder.
template_has_been_removed: Template has been removed. template_has_been_removed: Template has been removed.
template_has_been_archived: Template has been archived. template_has_been_archived: Template has been archived.

@ -190,6 +190,7 @@ Rails.application.routes.draw do
resources :sso, only: %i[index], controller: 'sso_settings' resources :sso, only: %i[index], controller: 'sso_settings'
resources :notifications, only: %i[index create], controller: 'notifications_settings' resources :notifications, only: %i[index create], controller: 'notifications_settings'
resource :esign, only: %i[show create new update destroy], controller: 'esign_settings' resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
resources :teams, only: %i[index new create edit update destroy]
resources :users, only: %i[index] resources :users, only: %i[index]
resources :archived_users, only: %i[index], path: 'users/:status', controller: 'users', resources :archived_users, only: %i[index], path: 'users/:status', controller: 'users',
defaults: { status: :archived } defaults: { status: :archived }

@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateTeams < ActiveRecord::Migration[8.0]
def change
create_table :teams do |t|
t.string :name, null: false
t.references :account, null: false, foreign_key: true
t.string :uuid, null: false
t.datetime :archived_at
t.timestamps
end
add_index :teams, :uuid, unique: true
add_index :teams, %i[account_id name], unique: true, where: "archived_at IS NULL"
end
end

@ -0,0 +1,11 @@
# frozen_string_literal: true
class AddTeamIdToResources < ActiveRecord::Migration[8.0]
def change
add_reference :users, :team, foreign_key: true
add_reference :templates, :team, foreign_key: true
add_reference :submissions, :team, foreign_key: true
add_reference :submitters, :team, foreign_key: true
add_reference :template_folders, :team, foreign_key: true
end
end

@ -0,0 +1,27 @@
# frozen_string_literal: true
class BackfillTeams < ActiveRecord::Migration[8.0]
def up
Account.find_each do |account|
ActiveRecord::Base.transaction do
team = Team.create!(
name: 'Default',
account: account,
uuid: SecureRandom.uuid
)
User.where(account_id: account.id, team_id: nil).update_all(team_id: team.id)
Template.where(account_id: account.id, team_id: nil).update_all(team_id: team.id)
Submission.where(account_id: account.id, team_id: nil).update_all(team_id: team.id)
Submitter.where(account_id: account.id, team_id: nil).update_all(team_id: team.id)
TemplateFolder.where(account_id: account.id, team_id: nil).update_all(team_id: team.id)
end
end
change_column_null :users, :team_id, false
end
def down
change_column_null :users, :team_id, true
end
end

@ -4,6 +4,23 @@ class Ability
include CanCan::Ability include CanCan::Ability
def initialize(user) def initialize(user)
return unless user
can :manage, EncryptedUserConfig, user_id: user.id
can :manage, UserConfig, user_id: user.id
can :manage, AccessToken, user_id: user.id
can :manage, McpToken, user_id: user.id
if user.role == User::ADMIN_ROLE
admin_abilities(user)
elsif user.role == User::EDITOR_ROLE
editor_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
@ -15,14 +32,25 @@ class Ability
can :manage, Submitter, account_id: user.account_id can :manage, Submitter, account_id: user.account_id
can :manage, User, account_id: user.account_id can :manage, User, account_id: user.account_id
can :manage, EncryptedConfig, 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, AccountConfig, account_id: user.account_id
can :manage, UserConfig, user_id: user.id
can :manage, Account, id: user.account_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, WebhookUrl, account_id: user.account_id
can :manage, Team, account_id: user.account_id
can :manage, :mcp can :manage, :mcp
end end
def editor_abilities(user)
can %i[read create update], Template, team_id: user.team_id, account_id: user.account_id
can :destroy, Template, team_id: user.team_id, account_id: user.account_id
can :manage, TemplateFolder, team_id: user.team_id, account_id: user.account_id
can :read, TemplateSharing, template: { team_id: user.team_id, account_id: user.account_id }
can :manage, Submission, team_id: user.team_id, account_id: user.account_id
can :manage, Submitter, team_id: user.team_id, account_id: user.account_id
can :manage, AccountConfig, account_id: user.account_id
can :manage, WebhookUrl, account_id: user.account_id
can :read, User, id: user.id
can :update, User, id: user.id
can :read, Team, id: user.team_id
end
end end

@ -93,6 +93,7 @@ module Submissions
parse_emails(emails, user).uniq.map do |email| parse_emails(emails, user).uniq.map do |email|
submission = template.submissions.new(created_by_user: user, submission = template.submissions.new(created_by_user: user,
account_id: user.account_id, account_id: user.account_id,
team_id: template.team_id,
source:, source:,
expire_at:, expire_at:,
template_submitters: template.submitters) template_submitters: template.submitters)
@ -100,6 +101,7 @@ module Submissions
submission.submitters.new(email: normalize_email(email), submission.submitters.new(email: normalize_email(email),
uuid: template.submitters.first['uuid'], uuid: template.submitters.first['uuid'],
account_id: user.account_id, account_id: user.account_id,
team_id: template.team_id,
preferences:, preferences:,
sent_at: mark_as_sent ? Time.current : nil) sent_at: mark_as_sent ? Time.current : nil)

@ -22,6 +22,7 @@ module Submissions
submission = template.submissions.new( submission = template.submissions.new(
created_by_user: user, source:, created_by_user: user, source:,
account_id: user.account_id, account_id: user.account_id,
team_id: template.team_id,
preferences: set_submission_preferences, preferences: set_submission_preferences,
name: with_template ? attrs[:name] : (attrs[:name].presence || template.name), name: with_template ? attrs[:name] : (attrs[:name].presence || template.name),
variables: attrs[:variables] || {}, variables: attrs[:variables] || {},
@ -375,6 +376,7 @@ module Submissions
phone: (attrs[:phone] || values[phone_field_uuid]).to_s.gsub(/[^0-9+]/, ''), phone: (attrs[:phone] || values[phone_field_uuid]).to_s.gsub(/[^0-9+]/, ''),
name: attrs[:name], name: attrs[:name],
account_id: user.account_id, account_id: user.account_id,
team_id: submission.team_id,
external_id: attrs[:external_id].presence || attrs[:application_key], external_id: attrs[:external_id].presence || attrs[:application_key],
completed_at: attrs[:completed].present? ? Time.current : nil, completed_at: attrs[:completed].present? ? Time.current : nil,
values: values.except(phone_field_uuid), values: values.except(phone_field_uuid),

@ -82,12 +82,12 @@ module TemplateFolders
parent_name, name = name.to_s.split(' / ', 2).map(&:squish) parent_name, name = name.to_s.split(' / ', 2).map(&:squish)
if name.present? if name.present?
parent_folder = author.account.template_folders.create_with(author:) parent_folder = author.account.template_folders.create_with(author:, team_id: author.team_id)
.find_or_create_by(name: parent_name, parent_folder_id: nil) .find_or_create_by(name: parent_name, parent_folder_id: nil)
else else
name = parent_name name = parent_name
end end
author.account.template_folders.create_with(author:).find_or_create_by(name:, parent_folder:) author.account.template_folders.create_with(author:, team_id: author.team_id).find_or_create_by(name:, parent_folder:)
end end
end end

Loading…
Cancel
Save