mirror of https://github.com/docusealco/docuseal
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
parent
812c162ea2
commit
3f80171163
@ -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
|
||||||
@ -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
|
||||||
@ -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 %>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
Loading…
Reference in new issue