From 1ee457f2d37c2b77a7fbce246a7c72ac9f9d3056 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 19 Mar 2026 16:25:54 +0100 Subject: [PATCH] Split `invite_users` permission into `invite_bypass_approval` (#38278) --- app/javascript/entrypoints/admin.tsx | 39 +++++++++++++++++++ app/javascript/styles/mastodon/forms.scss | 4 ++ app/models/invite.rb | 6 ++- app/models/user.rb | 6 ++- app/models/user_role.rb | 5 ++- config/locales/en.yml | 2 + ...7_add_invite_approval_bypass_permission.rb | 11 ++++++ db/schema.rb | 2 +- .../auth/registrations_controller_spec.rb | 26 +++++++++++-- 9 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20260318144837_add_invite_approval_bypass_permission.rb diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index 92b9d1d917..91c3bd640a 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -166,6 +166,38 @@ on('change', '#domain_block_severity', ({ target }) => { if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); }); +const onChangeInviteUsersPermission = (target: HTMLInputElement) => { + const inviteBypassApprovalCheckbox = document.querySelector( + 'input#user_role_permissions_as_keys_invite_bypass_approval', + ); + + if (inviteBypassApprovalCheckbox) { + inviteBypassApprovalCheckbox.disabled = !target.checked; + + if (target.checked) { + inviteBypassApprovalCheckbox.parentElement?.classList.remove('disabled'); + inviteBypassApprovalCheckbox.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + inviteBypassApprovalCheckbox.parentElement?.classList.add('disabled'); + inviteBypassApprovalCheckbox.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +on( + 'change', + 'input#user_role_permissions_as_keys_invite_users', + ({ target }) => { + if (target instanceof HTMLInputElement) { + onChangeInviteUsersPermission(target); + } + }, +); + function onEnableBootstrapTimelineAccountsChange(target: HTMLInputElement) { const bootstrapTimelineAccountsField = document.querySelector( @@ -291,6 +323,13 @@ ready(() => { ); if (registrationMode) onChangeRegistrationMode(registrationMode); + const inviteUsersPermissionChecbkox = + document.querySelector( + 'input#user_role_permissions_as_keys_invite_users', + ); + if (inviteUsersPermissionChecbkox) + onChangeInviteUsersPermission(inviteUsersPermissionChecbkox); + const checkAllElement = document.querySelector( '#batch_checkbox_all', ); diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 98cbbf7659..cc6827db4c 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -506,6 +506,10 @@ code { margin: 0; } } + + .checkbox.disabled { + opacity: 0.5; + } } label.checkbox { diff --git a/app/models/invite.rb b/app/models/invite.rb index 2e9371a074..ca692d937e 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -22,7 +22,7 @@ class Invite < ApplicationRecord COMMENT_SIZE_LIMIT = 420 ELIGIBLE_CODE_CHARACTERS = [*('a'..'z'), *('A'..'Z'), *('0'..'9')].freeze HOMOGLYPHS = %w(0 1 I l O).freeze - VALID_CODE_CHARACTERS = ELIGIBLE_CODE_CHARACTERS - HOMOGLYPHS + VALID_CODE_CHARACTERS = (ELIGIBLE_CODE_CHARACTERS - HOMOGLYPHS).freeze belongs_to :user, inverse_of: :invites has_many :users, inverse_of: :invite, dependent: nil @@ -37,6 +37,10 @@ class Invite < ApplicationRecord (max_uses.nil? || uses < max_uses) && !expired? && user&.functional? end + def bypass_approval? + user&.role&.can?(:invite_bypass_approval) + end + private def set_code diff --git a/app/models/user.rb b/app/models/user.rb index a774d8953a..b4b9f9a9bf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -168,6 +168,10 @@ class User < ApplicationRecord invite_id.present? && invite.valid_for_use? end + def valid_bypassing_invitation? + valid_invitation? && invite.bypass_approval? + end + def disable! update!(disabled: true) @@ -420,7 +424,7 @@ class User < ApplicationRecord if requires_approval? false else - open_registrations? || valid_invitation? || external? + open_registrations? || valid_bypassing_invitation? || external? end end end diff --git a/app/models/user_role.rb b/app/models/user_role.rb index afeed324cc..f2597e1c43 100644 --- a/app/models/user_role.rb +++ b/app/models/user_role.rb @@ -38,6 +38,7 @@ class UserRole < ApplicationRecord manage_user_access: (1 << 18), delete_user_data: (1 << 19), view_feeds: (1 << 20), + invite_bypass_approval: (1 << 21), }.freeze EVERYONE_ROLE_ID = -99 @@ -51,10 +52,12 @@ class UserRole < ApplicationRecord ALL = FLAGS.values.reduce(&:|) DEFAULT = FLAGS[:invite_users] + SAFE = FLAGS[:invite_users] | FLAGS[:invite_bypass_approval] CATEGORIES = { invites: %i( invite_users + invite_bypass_approval ).freeze, moderation: %i( @@ -206,6 +209,6 @@ class UserRole < ApplicationRecord end def validate_dangerous_permissions - errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::DEFAULT & permissions != permissions + errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::SAFE & permissions != permissions end end diff --git a/config/locales/en.yml b/config/locales/en.yml index fc20a1da8c..a745021dc1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -778,6 +778,8 @@ en: administrator_description: Users with this permission will bypass every permission delete_user_data: Delete User Data delete_user_data_description: Allows users to delete other users' data without delay + invite_bypass_approval: Invite Users without review + invite_bypass_approval_description: Allows people invited to the server by these users to bypass moderation approval invite_users: Invite Users invite_users_description: Allows users to invite new people to the server manage_announcements: Manage Announcements diff --git a/db/migrate/20260318144837_add_invite_approval_bypass_permission.rb b/db/migrate/20260318144837_add_invite_approval_bypass_permission.rb new file mode 100644 index 0000000000..c42105d561 --- /dev/null +++ b/db/migrate/20260318144837_add_invite_approval_bypass_permission.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddInviteApprovalBypassPermission < ActiveRecord::Migration[8.1] + class UserRole < ApplicationRecord; end + + def up + UserRole.where('permissions & (1 << 16) = 1 << 16').update_all('permissions = permissions | (1 << 21)') + end + + def down; end +end diff --git a/db/schema.rb b/db/schema.rb index c85565211c..fedc9497c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_11_152331) do +ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index c7bbe13092..6531608a01 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -298,7 +298,6 @@ RSpec.describe Auth::RegistrationsController do context 'with Approval-based registrations with valid invite and required invite text' do subject do - inviter = Fabricate(:user, confirmed_at: 2.days.ago) Setting.registrations_mode = 'approved' Setting.require_invite_text = true request.headers['Accept-Language'] = accept_language @@ -306,7 +305,9 @@ RSpec.describe Auth::RegistrationsController do post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', invite_code: invite.code, agreement: 'true' } } end - it 'redirects to setup and creates user' do + let!(:inviter) { Fabricate(:user, confirmed_at: 2.days.ago) } + + it 'redirects to setup and creates user in a non-approved state' do subject expect(response).to redirect_to auth_setup_path @@ -315,9 +316,28 @@ RSpec.describe Auth::RegistrationsController do .to be_present .and have_attributes( locale: eq(accept_language), - approved: be(true) + approved: be(false) ) end + + context 'when the inviting user has the permission to bypass approval' do + before do + inviter.role.update!(permissions: inviter.role.permissions | UserRole::FLAGS[:invite_bypass_approval]) + end + + it 'redirects to setup and creates user in an approved state' do + subject + + expect(response).to redirect_to auth_setup_path + + expect(User.find_by(email: 'test@example.com')) + .to be_present + .and have_attributes( + locale: eq(accept_language), + approved: be(true) + ) + end + end end context 'with an already taken username' do