diff --git a/README.md b/README.md index fbefe81c..27c4f7c7 100644 --- a/README.md +++ b/README.md @@ -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. | | 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. | +| 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. +## 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 These Pro features remain unavailable in this fork (they require significant UI/infrastructure work): diff --git a/app/controllers/api/templates_pdf_controller.rb b/app/controllers/api/templates_pdf_controller.rb index 27c9dad2..c36c1688 100644 --- a/app/controllers/api/templates_pdf_controller.rb +++ b/app/controllers/api/templates_pdf_controller.rb @@ -15,7 +15,8 @@ module Api author: current_user, source: :api, 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? diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb index 1bbdb14a..8dd93679 100644 --- a/app/controllers/profile_controller.rb +++ b/app/controllers/profile_controller.rb @@ -2,7 +2,7 @@ class ProfileController < ApplicationController before_action do - authorize!(:manage, current_user) + authorize!(:update, current_user) end def index; end diff --git a/app/controllers/setup_controller.rb b/app/controllers/setup_controller.rb index 734edbb5..f1147e37 100644 --- a/app/controllers/setup_controller.rb +++ b/app/controllers/setup_controller.rb @@ -28,6 +28,8 @@ class SetupController < ApplicationController return render :index, status: :unprocessable_content unless @account.valid? + @user.team = @account.teams.new(name: 'Default') + if @user.save encrypted_configs = [ { key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] }, diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb new file mode 100644 index 00000000..9a215c51 --- /dev/null +++ b/app/controllers/teams_controller.rb @@ -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 diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb index 25e489ce..27a09cfe 100644 --- a/app/controllers/template_folders_controller.rb +++ b/app/controllers/template_folders_controller.rb @@ -43,8 +43,20 @@ class TemplateFoldersController < ApplicationController def edit; end def update - if @template_folder != current_account.default_template_folder && - @template_folder.update(template_folder_params) + if @template_folder == current_account.default_template_folder + 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') else redirect_to folder_path(@template_folder), alert: I18n.t('unable_to_rename_folder') @@ -64,7 +76,19 @@ class TemplateFoldersController < ApplicationController end 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 def load_related_submissions diff --git a/app/controllers/templates_clone_controller.rb b/app/controllers/templates_clone_controller.rb index d001087f..2791f66e 100644 --- a/app/controllers/templates_clone_controller.rb +++ b/app/controllers/templates_clone_controller.rb @@ -29,6 +29,7 @@ class TemplatesCloneController < ApplicationController @template.account = current_account end + @template.team_id ||= current_user.team_id Templates.maybe_assign_access(@template) if @template.save diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index f32e1e1e..a6a5cbe8 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -52,6 +52,7 @@ class TemplatesController < ApplicationController @template.author = current_user @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.account = current_account + @template.team_id ||= current_user.team_id Templates.maybe_assign_access(@template) diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index 7d8b1bae..88e012d9 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -43,6 +43,7 @@ class TemplatesUploadsController < ApplicationController def save_template!(template, url_params) template.account = current_account 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.name = File.basename((url_params || params)[:files].first.original_filename, '.*') diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2d8f818f..262871fc 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -53,6 +53,7 @@ class UsersController < ApplicationController @user.password = SecureRandom.hex if @user.password.blank? @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 UserMailer.invitation_email(@user).deliver_later! @@ -113,7 +114,7 @@ class UsersController < ApplicationController def user_params 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)) diff --git a/app/models/account.rb b/app/models/account.rb index 6ca102ef..f987eced 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -22,6 +22,7 @@ class Account < ApplicationRecord has_one_attached :logo + has_many :teams, dependent: :destroy has_many :users, dependent: :destroy has_many :encrypted_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, author_id: users.minimum(:id)).tap(&:save!) end + + def default_team + teams.active.order(:id).first || teams.create!(name: 'Default') + end end diff --git a/app/models/submission.rb b/app/models/submission.rb index 92101b02..08748521 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -40,6 +40,7 @@ class Submission < ApplicationRecord belongs_to :template, optional: true belongs_to :account + belongs_to :team, 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? diff --git a/app/models/submitter.rb b/app/models/submitter.rb index 0e4bc6c2..cb1b0abd 100644 --- a/app/models/submitter.rb +++ b/app/models/submitter.rb @@ -42,6 +42,7 @@ class Submitter < ApplicationRecord belongs_to :submission belongs_to :account + belongs_to :team, optional: true has_one :template, through: :submission has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists? has_many :submitter_versions, dependent: :destroy diff --git a/app/models/team.rb b/app/models/team.rb new file mode 100644 index 00000000..e2d95faa --- /dev/null +++ b/app/models/team.rb @@ -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 diff --git a/app/models/template.rb b/app/models/template.rb index 3c6f2e93..fe98d068 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -44,6 +44,7 @@ class Template < ApplicationRecord belongs_to :author, class_name: 'User' belongs_to :account 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? diff --git a/app/models/template_folder.rb b/app/models/template_folder.rb index 58539a2e..3b827702 100644 --- a/app/models/template_folder.rb +++ b/app/models/template_folder.rb @@ -30,6 +30,7 @@ class TemplateFolder < ApplicationRecord belongs_to :author, class_name: 'User' belongs_to :account + belongs_to :team, optional: true belongs_to :parent_folder, class_name: 'TemplateFolder', optional: true has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder diff --git a/app/models/user.rb b/app/models/user.rb index b80ae769..ab6cd3d9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -48,7 +48,8 @@ # class User < ApplicationRecord ROLES = [ - ADMIN_ROLE = 'admin' + ADMIN_ROLE = 'admin', + EDITOR_ROLE = 'editor' ].freeze EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ @@ -60,6 +61,8 @@ class User < ApplicationRecord has_one_attached :initials belongs_to :account + belongs_to :team + has_one :access_token, dependent: :destroy has_many :access_tokens, dependent: :destroy has_many :mcp_tokens, dependent: :destroy @@ -77,6 +80,7 @@ class User < ApplicationRecord scope :active, -> { where(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) } 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/ } diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index 147b1c31..0450999f 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -9,9 +9,11 @@
| <%= t('name') %> | +<%= t('members') %> | ++ |
|---|---|---|
| <%= team.name %> | +<%= team.active_users_count %> | ++ <% 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 %> + | +