From edf6b53469270f38b59d85ee0f8a03239a7c3e19 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 29 Nov 2025 13:17:59 -0500 Subject: [PATCH] Introduce an "owner" role, and prevent it from being administered to prevent the owner from being demoted or kicked out. ref: https://app.fizzy.do/5986089/cards/3213 --- AGENTS.md | 4 +- app/helpers/users_helper.rb | 8 +++ app/models/account.rb | 4 +- app/models/account/seedeable.rb | 2 +- app/models/signup.rb | 4 +- app/models/user/role.rb | 16 ++++-- app/views/account/settings/_user.html.erb | 12 +++-- app/views/admin/stats/show.html.erb | 2 +- ...1129175717_promote_first_admin_to_owner.rb | 14 ++++++ db/schema.rb | 2 +- db/schema_sqlite.rb | 2 +- db/seeds.rb | 2 +- script/import-sqlite-database.rb | 6 +-- script/load-prod-db-in-dev.rb | 4 +- .../users/roles_controller_test.rb | 24 +++++++++ test/controllers/users_controller_test.rb | 14 ++++++ test/fixtures/identities.yml | 4 ++ test/fixtures/users.yml | 7 +++ test/models/account_test.rb | 21 ++++---- test/models/user/role_test.rb | 50 +++++++++++++++++++ 20 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 app/helpers/users_helper.rb create mode 100644 db/migrate/20251129175717_promote_first_admin_to_owner.rb diff --git a/AGENTS.md b/AGENTS.md index 8bdaa4481..203422200 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ Fizzy uses **URL path-based multi-tenancy**: **Passwordless magic link authentication**: - Global `Identity` (email-based) can have `Users` in multiple Accounts -- Users belong to an Account and have roles: admin, member, system +- Users belong to an Account and have roles: owner, admin, member, system - Sessions managed via signed cookies - Board-level access control via `Access` records @@ -84,7 +84,7 @@ Fizzy uses **URL path-based multi-tenancy**: **User** → Account membership - Belongs to Account and Identity -- Has role (admin/member/system) +- Has role (owner/admin/member/system) - Board access via explicit `Access` records **Board** → Primary organizational unit diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb new file mode 100644 index 000000000..7cccfe4ec --- /dev/null +++ b/app/helpers/users_helper.rb @@ -0,0 +1,8 @@ +module UsersHelper + def role_display_name(user) + case user.role + when "admin" then "Administrator" + else user.role.titleize + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 887ce708d..9c445f10e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -17,10 +17,10 @@ class Account < ApplicationRecord validates :name, presence: true class << self - def create_with_admin_user(account:, owner:) + def create_with_owner(account:, owner:) create!(**account).tap do |account| account.users.create!(role: :system, name: "System") - account.users.create!(**owner.reverse_merge(role: "admin")) + account.users.create!(**owner.reverse_merge(role: "owner")) end end end diff --git a/app/models/account/seedeable.rb b/app/models/account/seedeable.rb index f0592020a..e50c22e96 100644 --- a/app/models/account/seedeable.rb +++ b/app/models/account/seedeable.rb @@ -2,6 +2,6 @@ module Account::Seedeable extend ActiveSupport::Concern def setup_customer_template - Account::Seeder.new(self, users.active.where(role: :admin).first).seed + Account::Seeder.new(self, users.admin.first).seed end end diff --git a/app/models/signup.rb b/app/models/signup.rb index bdcf3490d..15ac5873c 100644 --- a/app/models/signup.rb +++ b/app/models/signup.rb @@ -54,7 +54,7 @@ class Signup end def create_account - @account = Account.create_with_admin_user( + @account = Account.create_with_owner( account: { external_account_id: @tenant, name: generate_account_name @@ -64,7 +64,7 @@ class Signup identity: identity } ) - @user = @account.users.find_by!(role: :admin) + @user = @account.users.find_by!(role: :owner) @account.setup_customer_template end diff --git a/app/models/user/role.rb b/app/models/user/role.rb index 3c2101f68..e103e94d9 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -2,18 +2,24 @@ module User::Role extend ActiveSupport::Concern included do - enum :role, %i[ admin member system ].index_by(&:itself), scopes: false + enum :role, %i[ owner admin member system ].index_by(&:itself), scopes: false - scope :member, -> { where(role: :member) } - scope :active, -> { where(active: true, role: %i[ admin member ]) } + scope :owner, -> { where(active: true, role: :owner) } + scope :admin, -> { where(active: true, role: %i[ owner admin ]) } + scope :member, -> { where(active: true, role: :member) } + scope :active, -> { where(active: true, role: %i[ owner admin member ]) } + + def admin? + super || owner? + end end def can_change?(other) - admin? || other == self + (admin? && !other.owner?) || other == self end def can_administer?(other) - admin? && other != self + admin? && !other.owner? && other != self end def can_administer_board?(board) diff --git a/app/views/account/settings/_user.html.erb b/app/views/account/settings/_user.html.erb index 45fc77c07..6bc65acd8 100644 --- a/app/views/account/settings/_user.html.erb +++ b/app/views/account/settings/_user.html.erb @@ -10,11 +10,13 @@ <%= form_with model: user, url: user_role_path(user), data: { controller: "form" }, method: :patch do | form | %> - + + + <% end %> <%# FIXME: Move this Current.user check to a stimulus controller that just checks for admin? or the like we so we can cache user list %> diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb index 2fe7ce71a..a3ae18c55 100644 --- a/app/views/admin/stats/show.html.erb +++ b/app/views/admin/stats/show.html.erb @@ -70,7 +70,7 @@