about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/account_actions_controller.rb4
-rw-r--r--app/controllers/admin/accounts_controller.rb2
-rw-r--r--app/controllers/admin/action_logs_controller.rb5
-rw-r--r--app/controllers/admin/base_controller.rb2
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb2
-rw-r--r--app/controllers/admin/dashboard_controller.rb4
-rw-r--r--app/controllers/admin/email_domain_blocks_controller.rb2
-rw-r--r--app/controllers/admin/follow_recommendations_controller.rb2
-rw-r--r--app/controllers/admin/ip_blocks_controller.rb2
-rw-r--r--app/controllers/admin/relationships_controller.rb2
-rw-r--r--app/controllers/admin/roles_controller.rb65
-rw-r--r--app/controllers/admin/statuses_controller.rb2
-rw-r--r--app/controllers/admin/subscriptions_controller.rb20
-rw-r--r--app/controllers/admin/trends/links/preview_card_providers_controller.rb4
-rw-r--r--app/controllers/admin/trends/links_controller.rb4
-rw-r--r--app/controllers/admin/trends/statuses_controller.rb4
-rw-r--r--app/controllers/admin/trends/tags_controller.rb4
-rw-r--r--app/controllers/admin/users/roles_controller.rb33
-rw-r--r--app/controllers/admin/users/two_factor_authentications_controller.rb (renamed from app/controllers/admin/two_factor_authentications_controller.rb)2
-rw-r--r--app/controllers/api/v1/admin/account_actions_controller.rb7
-rw-r--r--app/controllers/api/v1/admin/accounts_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/dimensions_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/domain_allows_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/domain_blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/measures_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/reports_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/retention_controller.rb6
-rw-r--r--app/controllers/api/v1/admin/trends/links_controller.rb20
-rw-r--r--app/controllers/api/v1/admin/trends/statuses_controller.rb20
-rw-r--r--app/controllers/api/v1/admin/trends/tags_controller.rb20
-rw-r--r--app/controllers/api/v1/trends/links_controller.rb10
-rw-r--r--app/controllers/api/v1/trends/statuses_controller.rb10
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb12
-rw-r--r--app/controllers/api/v2/admin/accounts_controller.rb13
-rw-r--r--app/controllers/application_controller.rb8
-rw-r--r--app/controllers/custom_css_controller.rb2
-rw-r--r--app/helpers/accounts_helper.rb14
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js6
-rw-r--r--app/javascript/mastodon/containers/mastodon.js1
-rw-r--r--app/javascript/mastodon/features/account/components/header.js9
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js10
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js6
-rw-r--r--app/javascript/mastodon/features/ui/components/link_footer.js9
-rw-r--r--app/javascript/mastodon/initial_state.js2
-rw-r--r--app/javascript/mastodon/permissions.js3
-rw-r--r--app/javascript/mastodon/reducers/meta.js3
-rw-r--r--app/javascript/styles/mastodon/admin.scss15
-rw-r--r--app/javascript/styles/mastodon/forms.scss4
-rw-r--r--app/lib/admin/system_check.rb6
-rw-r--r--app/lib/admin/system_check/base_check.rb10
-rw-r--r--app/lib/admin/system_check/database_schema_check.rb4
-rw-r--r--app/lib/admin/system_check/elasticsearch_check.rb8
-rw-r--r--app/lib/admin/system_check/rules_check.rb4
-rw-r--r--app/lib/admin/system_check/sidekiq_process_check.rb4
-rw-r--r--app/models/account.rb9
-rw-r--r--app/models/account_filter.rb27
-rw-r--r--app/models/concerns/user_roles.rb68
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/trends.rb2
-rw-r--r--app/models/user.rb38
-rw-r--r--app/models/user_role.rb179
-rw-r--r--app/policies/account_moderation_note_policy.rb4
-rw-r--r--app/policies/account_policy.rb40
-rw-r--r--app/policies/account_warning_policy.rb2
-rw-r--r--app/policies/account_warning_preset_policy.rb8
-rw-r--r--app/policies/announcement_policy.rb8
-rw-r--r--app/policies/appeal_policy.rb8
-rw-r--r--app/policies/application_policy.rb6
-rw-r--r--app/policies/audit_log_policy.rb7
-rw-r--r--app/policies/custom_emoji_policy.rb14
-rw-r--r--app/policies/dashboard_policy.rb7
-rw-r--r--app/policies/delivery_policy.rb6
-rw-r--r--app/policies/domain_allow_policy.rb8
-rw-r--r--app/policies/domain_block_policy.rb10
-rw-r--r--app/policies/email_domain_block_policy.rb6
-rw-r--r--app/policies/follow_recommendation_policy.rb6
-rw-r--r--app/policies/instance_policy.rb6
-rw-r--r--app/policies/invite_policy.rb12
-rw-r--r--app/policies/ip_block_policy.rb6
-rw-r--r--app/policies/preview_card_policy.rb4
-rw-r--r--app/policies/preview_card_provider_policy.rb4
-rw-r--r--app/policies/relay_policy.rb2
-rw-r--r--app/policies/report_note_policy.rb4
-rw-r--r--app/policies/report_policy.rb6
-rw-r--r--app/policies/rule_policy.rb8
-rw-r--r--app/policies/settings_policy.rb6
-rw-r--r--app/policies/status_policy.rb8
-rw-r--r--app/policies/tag_policy.rb8
-rw-r--r--app/policies/user_policy.rb38
-rw-r--r--app/policies/user_role_policy.rb19
-rw-r--r--app/policies/webhook_policy.rb16
-rw-r--r--app/presenters/initial_state_presenter.rb4
-rw-r--r--app/serializers/initial_state_serializer.rb3
-rw-r--r--app/serializers/rest/credential_account_serializer.rb6
-rw-r--r--app/serializers/rest/instance_serializer.rb2
-rw-r--r--app/serializers/rest/role_serializer.rb13
-rw-r--r--app/services/account_search_service.rb4
-rw-r--r--app/services/appeal_service.rb2
-rw-r--r--app/services/bootstrap_timeline_service.rb2
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/report_service.rb2
-rw-r--r--app/views/admin/accounts/index.html.haml55
-rw-r--r--app/views/admin/accounts/show.html.haml9
-rw-r--r--app/views/admin/action_logs/index.html.haml2
-rw-r--r--app/views/admin/instances/show.html.haml53
-rw-r--r--app/views/admin/roles/_form.html.haml37
-rw-r--r--app/views/admin/roles/_role.html.haml18
-rw-r--r--app/views/admin/roles/edit.html.haml8
-rw-r--r--app/views/admin/roles/index.html.haml17
-rw-r--r--app/views/admin/roles/new.html.haml4
-rw-r--r--app/views/admin/settings/edit.html.haml6
-rw-r--r--app/views/admin/tags/show.html.haml87
-rw-r--r--app/views/admin/users/roles/show.html.haml9
-rw-r--r--app/views/custom_css/show.css.erb10
-rwxr-xr-xapp/views/layouts/application.html.haml4
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml10
116 files changed, 927 insertions, 491 deletions
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
index ea56fa0ac..3f2e28b6a 100644
--- a/app/controllers/admin/account_actions_controller.rb
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -5,11 +5,15 @@ module Admin
     before_action :set_account
 
     def new
+      authorize @account, :show?
+
       @account_action  = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true)
       @warning_presets = AccountWarningPreset.all
     end
 
     def create
+      authorize @account, :show?
+
       account_action                 = Admin::AccountAction.new(resource_params)
       account_action.target_account  = @account
       account_action.current_account = current_account
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index e0ae71b9f..46c9aba91 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -14,6 +14,8 @@ module Admin
     end
 
     def batch
+      authorize :account, :index?
+
       @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb
index 2d77620df..42edec15a 100644
--- a/app/controllers/admin/action_logs_controller.rb
+++ b/app/controllers/admin/action_logs_controller.rb
@@ -4,7 +4,10 @@ module Admin
   class ActionLogsController < BaseController
     before_action :set_action_logs
 
-    def index; end
+    def index
+      authorize :audit_log, :index?
+      @auditable_accounts = Account.where(id: Admin::ActionLog.reorder(nil).select('distinct account_id')).select(:id, :username)
+    end
 
     private
 
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 7b81a2b01..5b7a7ec11 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -7,8 +7,8 @@ module Admin
 
     layout 'admin'
 
-    before_action :require_staff!
     before_action :set_body_classes
+    after_action :verify_authorized
 
     private
 
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index 71efb543e..2c33e9f8f 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -29,6 +29,8 @@ module Admin
     end
 
     def batch
+      authorize :custom_emoji, :index?
+
       @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index da9c6dd16..924b623ad 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -5,7 +5,9 @@ module Admin
     include Redisable
 
     def index
-      @system_checks         = Admin::SystemCheck.perform
+      authorize :dashboard, :index?
+
+      @system_checks         = Admin::SystemCheck.perform(current_user)
       @time_period           = (29.days.ago.to_date...Time.now.utc.to_date)
       @pending_users_count   = User.pending.count
       @pending_reports_count = Report.unresolved.count
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb
index a4bbbba5b..593457b94 100644
--- a/app/controllers/admin/email_domain_blocks_controller.rb
+++ b/app/controllers/admin/email_domain_blocks_controller.rb
@@ -12,6 +12,8 @@ module Admin
     end
 
     def batch
+      authorize :email_domain_block, :index?
+
       @form = Form::EmailDomainBlockBatch.new(form_email_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb
index e3eac62b3..841e3cc7f 100644
--- a/app/controllers/admin/follow_recommendations_controller.rb
+++ b/app/controllers/admin/follow_recommendations_controller.rb
@@ -12,6 +12,8 @@ module Admin
     end
 
     def update
+      authorize :follow_recommendation, :show?
+
       @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb
index 92b8b0d2b..a87520f4e 100644
--- a/app/controllers/admin/ip_blocks_controller.rb
+++ b/app/controllers/admin/ip_blocks_controller.rb
@@ -29,6 +29,8 @@ module Admin
     end
 
     def batch
+      authorize :ip_block, :index?
+
       @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/relationships_controller.rb b/app/controllers/admin/relationships_controller.rb
index 085ded21c..67645f054 100644
--- a/app/controllers/admin/relationships_controller.rb
+++ b/app/controllers/admin/relationships_controller.rb
@@ -7,7 +7,7 @@ module Admin
     PER_PAGE = 40
 
     def index
-      authorize :account, :index?
+      authorize @account, :show?
 
       @accounts = RelationshipFilter.new(@account, filter_params).results.includes(:account_stat, user: [:ips, :invite_request]).page(params[:page]).per(PER_PAGE)
       @form     = Form::AccountBatch.new
diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb
index 13f56e9be..3e502ccc4 100644
--- a/app/controllers/admin/roles_controller.rb
+++ b/app/controllers/admin/roles_controller.rb
@@ -2,20 +2,63 @@
 
 module Admin
   class RolesController < BaseController
-    before_action :set_user
+    before_action :set_role, except: [:index, :new, :create]
 
-    def promote
-      authorize @user, :promote?
-      @user.promote!
-      log_action :promote, @user
-      redirect_to admin_account_path(@user.account_id)
+    def index
+      authorize :user_role, :index?
+
+      @roles = UserRole.order(position: :desc).page(params[:page])
+    end
+
+    def new
+      authorize :user_role, :create?
+
+      @role = UserRole.new
+    end
+
+    def create
+      authorize :user_role, :create?
+
+      @role = UserRole.new(resource_params)
+      @role.current_account = current_account
+
+      if @role.save
+        redirect_to admin_roles_path
+      else
+        render :new
+      end
+    end
+
+    def edit
+      authorize @role, :update?
+    end
+
+    def update
+      authorize @role, :update?
+
+      @role.current_account = current_account
+
+      if @role.update(resource_params)
+        redirect_to admin_roles_path
+      else
+        render :edit
+      end
+    end
+
+    def destroy
+      authorize @role, :destroy?
+      @role.destroy!
+      redirect_to admin_roles_path
+    end
+
+    private
+
+    def set_role
+      @role = UserRole.find(params[:id])
     end
 
-    def demote
-      authorize @user, :demote?
-      @user.demote!
-      log_action :demote, @user
-      redirect_to admin_account_path(@user.account_id)
+    def resource_params
+      params.require(:user_role).permit(:name, :color, :highlighted, :position, permissions_as_keys: [])
     end
   end
 end
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index 817c0caa9..084921ceb 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -14,6 +14,8 @@ module Admin
     end
 
     def batch
+      authorize :status, :index?
+
       @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
       @status_batch_action.save!
     rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb
deleted file mode 100644
index 40500ef43..000000000
--- a/app/controllers/admin/subscriptions_controller.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class SubscriptionsController < BaseController
-    def index
-      authorize :subscription, :index?
-      @subscriptions = ordered_subscriptions.page(requested_page)
-    end
-
-    private
-
-    def ordered_subscriptions
-      Subscription.order(id: :desc).includes(:account)
-    end
-
-    def requested_page
-      params[:page].to_i
-    end
-  end
-end
diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
index 40a466cd6..97dee8eca 100644
--- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb
+++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseController
   def index
-    authorize :preview_card_provider, :index?
+    authorize :preview_card_provider, :review?
 
     @preview_card_providers = filtered_preview_card_providers.page(params[:page])
     @form = Trends::PreviewCardProviderBatch.new
   end
 
   def batch
+    authorize :preview_card_provider, :review?
+
     @form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb
index 434eec5fe..a497eae41 100644
--- a/app/controllers/admin/trends/links_controller.rb
+++ b/app/controllers/admin/trends/links_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::LinksController < Admin::BaseController
   def index
-    authorize :preview_card, :index?
+    authorize :preview_card, :review?
 
     @preview_cards = filtered_preview_cards.page(params[:page])
     @form          = Trends::PreviewCardBatch.new
   end
 
   def batch
+    authorize :preview_card, :review?
+
     @form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb
index 766242738..c538962f9 100644
--- a/app/controllers/admin/trends/statuses_controller.rb
+++ b/app/controllers/admin/trends/statuses_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::StatusesController < Admin::BaseController
   def index
-    authorize :status, :index?
+    authorize :status, :review?
 
     @statuses = filtered_statuses.page(params[:page])
     @form     = Trends::StatusBatch.new
   end
 
   def batch
+    authorize :status, :review?
+
     @form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb
index f4d1ec0d1..98dd6c8ec 100644
--- a/app/controllers/admin/trends/tags_controller.rb
+++ b/app/controllers/admin/trends/tags_controller.rb
@@ -2,13 +2,15 @@
 
 class Admin::Trends::TagsController < Admin::BaseController
   def index
-    authorize :tag, :index?
+    authorize :tag, :review?
 
     @tags = filtered_tags.page(params[:page])
     @form = Trends::TagBatch.new
   end
 
   def batch
+    authorize :tag, :review?
+
     @form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb
new file mode 100644
index 000000000..0db50cee9
--- /dev/null
+++ b/app/controllers/admin/users/roles_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Admin
+  class Users::RolesController < BaseController
+    before_action :set_user
+
+    def show
+      authorize @user, :change_role?
+    end
+
+    def update
+      authorize @user, :change_role?
+
+      @user.current_account = current_account
+
+      if @user.update(resource_params)
+        redirect_to admin_account_path(@user.account_id), notice: I18n.t('admin.accounts.change_role.changed_msg')
+      else
+        render :show
+      end
+    end
+
+    private
+
+    def set_user
+      @user = User.find(params[:user_id])
+    end
+
+    def resource_params
+      params.require(:user).permit(:role_id)
+    end
+  end
+end
diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/users/two_factor_authentications_controller.rb
index f7fb7eb8f..5e3fb2b3c 100644
--- a/app/controllers/admin/two_factor_authentications_controller.rb
+++ b/app/controllers/admin/users/two_factor_authentications_controller.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module Admin
-  class TwoFactorAuthenticationsController < BaseController
+  class Users::TwoFactorAuthenticationsController < BaseController
     before_action :set_target_user
 
     def destroy
diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb
index 6c9e04402..7249797a4 100644
--- a/app/controllers/api/v1/admin/account_actions_controller.rb
+++ b/app/controllers/api/v1/admin/account_actions_controller.rb
@@ -1,11 +1,16 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::AccountActionsController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }
-  before_action :require_staff!
   before_action :set_account
 
+  after_action :verify_authorized
+
   def create
+    authorize @account, :show?
+
     account_action                 = Admin::AccountAction.new(resource_params)
     account_action.target_account  = @account
     account_action.current_account = current_account
diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb
index 65ed69f7b..0dee02e94 100644
--- a/app/controllers/api/v1/admin/accounts_controller.rb
+++ b/app/controllers/api/v1/admin/accounts_controller.rb
@@ -8,11 +8,11 @@ class Api::V1::Admin::AccountsController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_accounts, only: :index
   before_action :set_account, except: :index
   before_action :require_local_account!, only: [:enable, :approve, :reject]
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   FILTER_PARAMS = %i(
@@ -119,7 +119,9 @@ class Api::V1::Admin::AccountsController < Api::BaseController
       translated_params[:status] = status.to_s if params[status].present?
     end
 
-    translated_params[:permissions] = 'staff' if params[:staff].present?
+    if params[:staff].present?
+      translated_params[:role_ids] = UserRole.that_can(:manage_reports).map(&:id)
+    end
 
     translated_params
   end
diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb
index 49a5be1c3..4a72ad08b 100644
--- a/app/controllers/api/v1/admin/dimensions_controller.rb
+++ b/app/controllers/api/v1/admin/dimensions_controller.rb
@@ -1,11 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::DimensionsController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
   before_action :set_dimensions
 
+  after_action :verify_authorized
+
   def create
+    authorize :dashboard, :index?
     render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer
   end
 
diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb
index 838978ddb..59aa807d6 100644
--- a/app/controllers/api/v1/admin/domain_allows_controller.rb
+++ b/app/controllers/api/v1/admin/domain_allows_controller.rb
@@ -8,10 +8,10 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_domain_allows, only: :index
   before_action :set_domain_allow, only: [:show, :destroy]
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   PAGINATION_PARAMS = %i(limit).freeze
diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb
index 229870eee..de8fd9d08 100644
--- a/app/controllers/api/v1/admin/domain_blocks_controller.rb
+++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb
@@ -8,10 +8,10 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_domain_blocks, only: :index
   before_action :set_domain_block, only: [:show, :update, :destroy]
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   PAGINATION_PARAMS = %i(limit).freeze
diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb
index da95d3422..d78d7e10b 100644
--- a/app/controllers/api/v1/admin/measures_controller.rb
+++ b/app/controllers/api/v1/admin/measures_controller.rb
@@ -1,11 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::MeasuresController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
   before_action :set_measures
 
+  after_action :verify_authorized
+
   def create
+    authorize :dashboard, :index?
     render json: @measures, each_serializer: REST::Admin::MeasureSerializer
   end
 
diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb
index 865ba3d23..9dfb181a2 100644
--- a/app/controllers/api/v1/admin/reports_controller.rb
+++ b/app/controllers/api/v1/admin/reports_controller.rb
@@ -8,10 +8,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController
 
   before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
   before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
-  before_action :require_staff!
   before_action :set_reports, only: :index
   before_action :set_report, except: :index
 
+  after_action :verify_authorized
   after_action :insert_pagination_headers, only: :index
 
   FILTER_PARAMS = %i(
diff --git a/app/controllers/api/v1/admin/retention_controller.rb b/app/controllers/api/v1/admin/retention_controller.rb
index 98d1a3d81..59d6b8388 100644
--- a/app/controllers/api/v1/admin/retention_controller.rb
+++ b/app/controllers/api/v1/admin/retention_controller.rb
@@ -1,11 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::RetentionController < Api::BaseController
+  include Authorization
+
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
   before_action :set_cohorts
 
+  after_action :verify_authorized
+
   def create
+    authorize :dashboard, :index?
     render json: @cohorts, each_serializer: REST::Admin::CohortSerializer
   end
 
diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb
index 0a191fe4b..cc6388980 100644
--- a/app/controllers/api/v1/admin/trends/links_controller.rb
+++ b/app/controllers/api/v1/admin/trends/links_controller.rb
@@ -1,17 +1,19 @@
 # frozen_string_literal: true
 
-class Api::V1::Admin::Trends::LinksController < Api::BaseController
+class Api::V1::Admin::Trends::LinksController < Api::V1::Trends::LinksController
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
-  before_action :set_links
-
-  def index
-    render json: @links, each_serializer: REST::Trends::LinkSerializer
-  end
 
   private
 
-  def set_links
-    @links = Trends.links.query.limit(limit_param(10))
+  def enabled?
+    super || current_user&.can?(:manage_taxonomies)
+  end
+
+  def links_from_trends
+    if current_user&.can?(:manage_taxonomies)
+      Trends.links.query
+    else
+      super
+    end
   end
 end
diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb
index cb145f165..c39f77363 100644
--- a/app/controllers/api/v1/admin/trends/statuses_controller.rb
+++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb
@@ -1,17 +1,19 @@
 # frozen_string_literal: true
 
-class Api::V1::Admin::Trends::StatusesController < Api::BaseController
+class Api::V1::Admin::Trends::StatusesController < Api::V1::Trends::StatusesController
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
-  before_action :set_statuses
-
-  def index
-    render json: @statuses, each_serializer: REST::StatusSerializer
-  end
 
   private
 
-  def set_statuses
-    @statuses = cache_collection(Trends.statuses.query.limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status)
+  def enabled?
+    super || current_user&.can?(:manage_taxonomies)
+  end
+
+  def statuses_from_trends
+    if current_user&.can?(:manage_taxonomies)
+      Trends.statuses.query
+    else
+      super
+    end
   end
 end
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
index 9c28b0412..f3c0c4b6b 100644
--- a/app/controllers/api/v1/admin/trends/tags_controller.rb
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -1,17 +1,19 @@
 # frozen_string_literal: true
 
-class Api::V1::Admin::Trends::TagsController < Api::BaseController
+class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
   before_action -> { authorize_if_got_token! :'admin:read' }
-  before_action :require_staff!
-  before_action :set_tags
-
-  def index
-    render json: @tags, each_serializer: REST::Admin::TagSerializer
-  end
 
   private
 
-  def set_tags
-    @tags = Trends.tags.query.limit(limit_param(10))
+  def enabled?
+    super || current_user&.can?(:manage_taxonomies)
+  end
+
+  def tags_from_trends
+    if current_user&.can?(:manage_taxonomies)
+      Trends.tags.query
+    else
+      super
+    end
   end
 end
diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb
index 2385fe438..1a9f918f2 100644
--- a/app/controllers/api/v1/trends/links_controller.rb
+++ b/app/controllers/api/v1/trends/links_controller.rb
@@ -13,10 +13,14 @@ class Api::V1::Trends::LinksController < Api::BaseController
 
   private
 
+  def enabled?
+    Setting.trends
+  end
+
   def set_links
     @links = begin
-      if Setting.trends
-        links_from_trends
+      if enabled?
+        links_from_trends.offset(offset_param).limit(limit_param(DEFAULT_LINKS_LIMIT))
       else
         []
       end
@@ -24,7 +28,7 @@ class Api::V1::Trends::LinksController < Api::BaseController
   end
 
   def links_from_trends
-    Trends.links.query.allowed.in_locale(content_locale).offset(offset_param).limit(limit_param(DEFAULT_LINKS_LIMIT))
+    Trends.links.query.allowed.in_locale(content_locale)
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb
index 1f2fff582..c275d5fc8 100644
--- a/app/controllers/api/v1/trends/statuses_controller.rb
+++ b/app/controllers/api/v1/trends/statuses_controller.rb
@@ -11,10 +11,14 @@ class Api::V1::Trends::StatusesController < Api::BaseController
 
   private
 
+  def enabled?
+    Setting.trends
+  end
+
   def set_statuses
     @statuses = begin
-      if Setting.trends
-        cache_collection(statuses_from_trends, Status)
+      if enabled?
+        cache_collection(statuses_from_trends.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status)
       else
         []
       end
@@ -24,7 +28,7 @@ class Api::V1::Trends::StatusesController < Api::BaseController
   def statuses_from_trends
     scope = Trends.statuses.query.allowed.in_locale(content_locale)
     scope = scope.filtered_for(current_account) if user_signed_in?
-    scope.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT))
+    scope
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 38003f599..41f9ffac1 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -13,16 +13,24 @@ class Api::V1::Trends::TagsController < Api::BaseController
 
   private
 
+  def enabled?
+    Setting.trends
+  end
+
   def set_tags
     @tags = begin
-      if Setting.trends
-        Trends.tags.query.allowed.offset(offset_param).limit(limit_param(DEFAULT_TAGS_LIMIT))
+      if enabled?
+        tags_from_trends.offset(offset_param).limit(limit_param(DEFAULT_TAGS_LIMIT))
       else
         []
       end
     end
   end
 
+  def tags_from_trends
+    Trends.tags.query.allowed
+  end
+
   def insert_pagination_headers
     set_pagination_headers(next_path, prev_path)
   end
diff --git a/app/controllers/api/v2/admin/accounts_controller.rb b/app/controllers/api/v2/admin/accounts_controller.rb
index a89e6835e..bcc1a0733 100644
--- a/app/controllers/api/v2/admin/accounts_controller.rb
+++ b/app/controllers/api/v2/admin/accounts_controller.rb
@@ -11,6 +11,7 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
     email
     ip
     invited_by
+    role_ids
   ).freeze
 
   PAGINATION_PARAMS = (%i(limit) + FILTER_PARAMS).freeze
@@ -18,7 +19,17 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
   private
 
   def filtered_accounts
-    AccountFilter.new(filter_params).results
+    AccountFilter.new(translated_filter_params).results
+  end
+
+  def translated_filter_params
+    translated_params = filter_params.slice(*AccountFilter::KEYS)
+
+    if params[:permissions] == 'staff'
+      translated_params[:role_ids] = UserRole.that_can(:manage_reports).map(&:id)
+    end
+
+    translated_params
   end
 
   def filter_params
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 3d2f8280b..615536b96 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -56,14 +56,6 @@ class ApplicationController < ActionController::Base
     store_location_for(:user, request.url) unless [:json, :rss].include?(request.format&.to_sym)
   end
 
-  def require_admin!
-    forbidden unless current_user&.admin?
-  end
-
-  def require_staff!
-    forbidden unless current_user&.staff?
-  end
-
   def require_functional!
     redirect_to edit_user_registration_path unless current_user.functional?
   end
diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb
index e1dc5eaf6..9270c467d 100644
--- a/app/controllers/custom_css_controller.rb
+++ b/app/controllers/custom_css_controller.rb
@@ -13,6 +13,6 @@ class CustomCssController < ApplicationController
   def show
     expires_in 3.minutes, public: true
     request.session_options[:skip] = true
-    render plain: Setting.custom_css || '', content_type: 'text/css'
+    render content_type: 'text/css'
   end
 end
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index d37634964..59664373d 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -61,21 +61,13 @@ module AccountsHelper
     end
   end
 
-  def account_badge(account, all: false)
+  def account_badge(account)
     if account.bot?
       content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
     elsif account.group?
       content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles')
-    elsif (Setting.show_staff_badge && account.user_staff?) || all
-      content_tag(:div, class: 'roles') do
-        if all && !account.user_staff?
-          content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role')
-        elsif account.user_admin?
-          content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin')
-        elsif account.user_moderator?
-          content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator')
-        end
-      end
+    elsif account.user_role&.highlighted?
+      content_tag(:div, content_tag(:div, account.user_role.name, class: "account-role user-role-#{account.user_role.id}"), class: 'roles')
     end
   end
 
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index ab8755be0..d44da482d 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -6,8 +6,9 @@ import IconButton from './icon_button';
 import DropdownMenuContainer from '../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me, isStaff } from '../initial_state';
+import { me } from '../initial_state';
 import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -55,6 +56,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
+    identity: PropTypes.object,
   };
 
   static propTypes = {
@@ -306,7 +308,7 @@ class StatusActionBar extends ImmutablePureComponent {
         }
       }
 
-      if (isStaff) {
+      if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
         menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 0c3f6afa8..f4bef4686 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -26,6 +26,7 @@ const createIdentityContext = state => ({
   signedIn: !!state.meta.me,
   accountId: state.meta.me,
   accessToken: state.meta.access_token,
+  permissions: state.role.permissions,
 });
 
 export default class Mastodon extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 8e6b9f063..1ad9341c7 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Button from 'mastodon/components/button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
+import { autoPlayGif, me } from 'mastodon/initial_state';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 import IconButton from 'mastodon/components/icon_button';
@@ -14,6 +14,7 @@ import ShortNumber from 'mastodon/components/short_number';
 import { NavLink } from 'react-router-dom';
 import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
 import AccountNoteContainer from '../containers/account_note_container';
+import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -64,6 +65,10 @@ const dateFormatOptions = {
 export default @injectIntl
 class Header extends ImmutablePureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     account: ImmutablePropTypes.map,
     identity_props: ImmutablePropTypes.list,
@@ -241,7 +246,7 @@ class Header extends ImmutablePureComponent {
       }
     }
 
-    if (account.get('id') !== me && isStaff) {
+    if (account.get('id') !== me && (this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` });
     }
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 61df79b46..b1618c1b4 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -5,10 +5,14 @@ import { FormattedMessage } from 'react-intl';
 import ClearColumnButton from './clear_column_button';
 import GrantPermissionButton from './grant_permission_button';
 import SettingToggle from './setting_toggle';
-import { isStaff } from 'mastodon/initial_state';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
 
 export default class ColumnSettings extends React.PureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     settings: ImmutablePropTypes.map.isRequired,
     pushSettings: ImmutablePropTypes.map.isRequired,
@@ -166,7 +170,7 @@ export default class ColumnSettings extends React.PureComponent {
           </div>
         </div>
 
-        {isStaff && (
+        {(this.context.identity.permissions & PERMISSION_MANAGE_USERS === PERMISSION_MANAGE_USERS) && (
           <div role='group' aria-labelledby='notifications-admin-sign-up'>
             <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span>
 
@@ -179,7 +183,7 @@ export default class ColumnSettings extends React.PureComponent {
           </div>
         )}
 
-        {isStaff && (
+        {(this.context.identity.permissions & PERMISSION_MANAGE_REPORTS === PERMISSION_MANAGE_REPORTS) && (
           <div role='group' aria-labelledby='notifications-admin-report'>
             <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span>
 
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index edaff959e..50bda69f8 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -5,8 +5,9 @@ import IconButton from '../../../components/icon_button';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
-import { me, isStaff } from '../../../initial_state';
+import { me } from '../../../initial_state';
 import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -50,6 +51,7 @@ class ActionBar extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
+    identity: PropTypes.object,
   };
 
   static propTypes = {
@@ -248,7 +250,7 @@ class ActionBar extends React.PureComponent {
         }
       }
 
-      if (isStaff) {
+      if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
         menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js
index edf1104c4..bbb9b122a 100644
--- a/app/javascript/mastodon/features/ui/components/link_footer.js
+++ b/app/javascript/mastodon/features/ui/components/link_footer.js
@@ -3,9 +3,10 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
-import { invitesEnabled, limitedFederationMode, version, repository, source_url, profile_directory as profileDirectory } from 'mastodon/initial_state';
+import { limitedFederationMode, version, repository, source_url, profile_directory as profileDirectory } from 'mastodon/initial_state';
 import { logOut } from 'mastodon/utils/log_out';
 import { openModal } from 'mastodon/actions/modal';
+import { PERMISSION_INVITE_USERS } from 'mastodon/permissions';
 
 const messages = defineMessages({
   logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
@@ -27,6 +28,10 @@ export default @injectIntl
 @connect(null, mapDispatchToProps)
 class LinkFooter extends React.PureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     withHotkeys: PropTypes.bool,
     onLogout: PropTypes.func.isRequired,
@@ -48,7 +53,7 @@ class LinkFooter extends React.PureComponent {
     return (
       <div className='getting-started__footer'>
         <ul>
-          {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
+          {((this.context.identity.permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS) && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
           {withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
           <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
           {!limitedFederationMode && <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>}
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index a6d54f134..709975270 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -12,14 +12,12 @@ export const boostModal = getMeta('boost_modal');
 export const deleteModal = getMeta('delete_modal');
 export const me = getMeta('me');
 export const searchEnabled = getMeta('search_enabled');
-export const invitesEnabled = getMeta('invites_enabled');
 export const limitedFederationMode = getMeta('limited_federation_mode');
 export const repository = getMeta('repository');
 export const source_url = getMeta('source_url');
 export const version = getMeta('version');
 export const mascot = getMeta('mascot');
 export const profile_directory = getMeta('profile_directory');
-export const isStaff = getMeta('is_staff');
 export const forceSingleColumn = !getMeta('advanced_layout');
 export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
diff --git a/app/javascript/mastodon/permissions.js b/app/javascript/mastodon/permissions.js
new file mode 100644
index 000000000..752ddd6c5
--- /dev/null
+++ b/app/javascript/mastodon/permissions.js
@@ -0,0 +1,3 @@
+export const PERMISSION_INVITE_USERS   = 0x0000000000010000;
+export const PERMISSION_MANAGE_USERS   = 0x0000000000000400;
+export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js
index 65becc44f..5040a340f 100644
--- a/app/javascript/mastodon/reducers/meta.js
+++ b/app/javascript/mastodon/reducers/meta.js
@@ -7,12 +7,13 @@ const initialState = ImmutableMap({
   streaming_api_base_url: null,
   access_token: null,
   layout: layoutFromWindow(),
+  permissions: '0',
 });
 
 export default function meta(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    return state.merge(action.state.get('meta'));
+    return state.merge(action.state.get('meta')).set('permissions', action.state.getIn(['role', 'permissions']));
   case APP_LAYOUT_CHANGE:
     return state.set('layout', action.layout);
   default:
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 4ce5cd101..1c5494cde 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -924,6 +924,10 @@ a.name-tag,
   margin-top: 15px;
 }
 
+.user-role {
+  color: var(--user-role-accent);
+}
+
 .announcements-list,
 .filters-list {
   border: 1px solid lighten($ui-base-color, 4%);
@@ -960,6 +964,17 @@ a.name-tag,
     &__meta {
       padding: 0 15px;
       color: $dark-text-color;
+
+      a {
+        color: inherit;
+        text-decoration: underline;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: none;
+        }
+      }
     }
 
     &__action-bar {
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index da699dd25..990903859 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -256,6 +256,10 @@ code {
     }
   }
 
+  .input.with_block_label.user_role_permissions_as_keys ul {
+    columns: unset;
+  }
+
   .input.datetime .label_input select {
     display: inline-block;
     width: auto;
diff --git a/app/lib/admin/system_check.rb b/app/lib/admin/system_check.rb
index 877a42ef6..f512635ab 100644
--- a/app/lib/admin/system_check.rb
+++ b/app/lib/admin/system_check.rb
@@ -8,11 +8,11 @@ class Admin::SystemCheck
     Admin::SystemCheck::ElasticsearchCheck,
   ].freeze
 
-  def self.perform
+  def self.perform(current_user)
     ACTIVE_CHECKS.each_with_object([]) do |klass, arr|
-      check = klass.new
+      check = klass.new(current_user)
 
-      if check.pass?
+      if check.skip? || check.pass?
         arr
       else
         arr << check.message
diff --git a/app/lib/admin/system_check/base_check.rb b/app/lib/admin/system_check/base_check.rb
index fcad8daca..c2974c218 100644
--- a/app/lib/admin/system_check/base_check.rb
+++ b/app/lib/admin/system_check/base_check.rb
@@ -1,6 +1,16 @@
 # frozen_string_literal: true
 
 class Admin::SystemCheck::BaseCheck
+  attr_reader :current_user
+
+  def initialize(current_user)
+    @current_user = current_user
+  end
+
+  def skip?
+    false
+  end
+
   def pass?
     raise NotImplementedError
   end
diff --git a/app/lib/admin/system_check/database_schema_check.rb b/app/lib/admin/system_check/database_schema_check.rb
index b93d1954e..c2f01fd55 100644
--- a/app/lib/admin/system_check/database_schema_check.rb
+++ b/app/lib/admin/system_check/database_schema_check.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Admin::SystemCheck::DatabaseSchemaCheck < Admin::SystemCheck::BaseCheck
+  def skip?
+    !current_user.can?(:view_devops)
+  end
+
   def pass?
     !ActiveRecord::Base.connection.migration_context.needs_migration?
   end
diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb
index 1b48a5415..8aee18267 100644
--- a/app/lib/admin/system_check/elasticsearch_check.rb
+++ b/app/lib/admin/system_check/elasticsearch_check.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
+  def skip?
+    !current_user.can?(:view_devops)
+  end
+
   def pass?
     return true unless Chewy.enabled?
 
@@ -32,8 +36,4 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
   def compatible_version?
     Gem::Version.new(running_version) >= Gem::Version.new(required_version)
   end
-
-  def missing_queues
-    @missing_queues ||= Sidekiq::ProcessSet.new.reduce(SIDEKIQ_QUEUES) { |queues, process| queues - process['queues'] }
-  end
 end
diff --git a/app/lib/admin/system_check/rules_check.rb b/app/lib/admin/system_check/rules_check.rb
index 1fbdf955d..8206a5df3 100644
--- a/app/lib/admin/system_check/rules_check.rb
+++ b/app/lib/admin/system_check/rules_check.rb
@@ -3,6 +3,10 @@
 class Admin::SystemCheck::RulesCheck < Admin::SystemCheck::BaseCheck
   include RoutingHelper
 
+  def skip?
+    !current_user.can?(:manage_rules)
+  end
+
   def pass?
     Rule.kept.exists?
   end
diff --git a/app/lib/admin/system_check/sidekiq_process_check.rb b/app/lib/admin/system_check/sidekiq_process_check.rb
index 22446edaf..648811d6c 100644
--- a/app/lib/admin/system_check/sidekiq_process_check.rb
+++ b/app/lib/admin/system_check/sidekiq_process_check.rb
@@ -9,6 +9,10 @@ class Admin::SystemCheck::SidekiqProcessCheck < Admin::SystemCheck::BaseCheck
     scheduler
   ).freeze
 
+  def skip?
+    !current_user.can?(:view_devops)
+  end
+
   def pass?
     missing_queues.empty?
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index 730ef6293..628692d22 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -116,7 +116,7 @@ class Account < ApplicationRecord
   scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
   scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
   scope :popular, -> { order('account_stats.followers_count desc') }
-  scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
+  scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches("%.#{domain}"))) }
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
 
@@ -132,9 +132,6 @@ class Account < ApplicationRecord
            :unconfirmed?,
            :unconfirmed_or_pending?,
            :role,
-           :admin?,
-           :moderator?,
-           :staff?,
            :locale,
            :shows_application?,
            to: :user,
@@ -454,7 +451,7 @@ class Account < ApplicationRecord
       DeliveryFailureTracker.without_unavailable(urls)
     end
 
-    def search_for(terms, limit = 10, offset = 0)
+    def search_for(terms, limit: 10, offset: 0)
       tsquery = generate_query_for_search(terms)
 
       sql = <<-SQL.squish
@@ -476,7 +473,7 @@ class Account < ApplicationRecord
       records
     end
 
-    def advanced_search_for(terms, account, limit = 10, following = false, offset = 0)
+    def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
       tsquery = generate_query_for_search(terms)
       sql = advanced_search_for_sql_template(following)
       records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index ec309ce09..e214e0bad 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -4,7 +4,7 @@ class AccountFilter
   KEYS = %i(
     origin
     status
-    permissions
+    role_ids
     username
     by_domain
     display_name
@@ -26,7 +26,7 @@ class AccountFilter
     params.each do |key, value|
       next if key.to_s == 'page'
 
-      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+      scope.merge!(scope_for(key, value)) if value.present?
     end
 
     scope
@@ -38,18 +38,18 @@ class AccountFilter
     case key.to_s
     when 'origin'
       origin_scope(value)
-    when 'permissions'
-      permissions_scope(value)
+    when 'role_ids'
+      role_scope(value)
     when 'status'
       status_scope(value)
     when 'by_domain'
-      Account.where(domain: value)
+      Account.where(domain: value.to_s)
     when 'username'
-      Account.matches_username(value)
+      Account.matches_username(value.to_s)
     when 'display_name'
-      Account.matches_display_name(value)
+      Account.matches_display_name(value.to_s)
     when 'email'
-      accounts_with_users.merge(User.matches_email(value))
+      accounts_with_users.merge(User.matches_email(value.to_s))
     when 'ip'
       valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group('users.id, accounts.id')) : Account.none
     when 'invited_by'
@@ -104,13 +104,8 @@ class AccountFilter
     Account.left_joins(user: :invite).merge(Invite.where(user_id: value.to_s))
   end
 
-  def permissions_scope(value)
-    case value.to_s
-    when 'staff'
-      accounts_with_users.merge(User.staff)
-    else
-      raise "Unknown permissions: #{value}"
-    end
+  def role_scope(value)
+    accounts_with_users.merge(User.where(role_id: Array(value).map(&:to_s)))
   end
 
   def accounts_with_users
@@ -118,7 +113,7 @@ class AccountFilter
   end
 
   def valid_ip?(value)
-    IPAddr.new(value) && true
+    IPAddr.new(value.to_s) && true
   rescue IPAddr::InvalidAddressError
     false
   end
diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb
deleted file mode 100644
index a42b4a172..000000000
--- a/app/models/concerns/user_roles.rb
+++ /dev/null
@@ -1,68 +0,0 @@
-# frozen_string_literal: true
-
-module UserRoles
-  extend ActiveSupport::Concern
-
-  included do
-    scope :admins, -> { where(admin: true) }
-    scope :moderators, -> { where(moderator: true) }
-    scope :staff, -> { admins.or(moderators) }
-  end
-
-  def staff?
-    admin? || moderator?
-  end
-
-  def role=(value)
-    case value
-    when 'admin'
-      self.admin     = true
-      self.moderator = false
-    when 'moderator'
-      self.admin     = false
-      self.moderator = true
-    else
-      self.admin     = false
-      self.moderator = false
-    end
-  end
-
-  def role
-    if admin?
-      'admin'
-    elsif moderator?
-      'moderator'
-    else
-      'user'
-    end
-  end
-
-  def role?(role)
-    case role
-    when 'user'
-      true
-    when 'moderator'
-      staff?
-    when 'admin'
-      admin?
-    else
-      false
-    end
-  end
-
-  def promote!
-    if moderator?
-      update!(moderator: false, admin: true)
-    elsif !admin?
-      update!(moderator: true)
-    end
-  end
-
-  def demote!
-    if admin?
-      update!(admin: false, moderator: true)
-    elsif moderator?
-      update!(moderator: false)
-    end
-  end
-end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 6fc7c56fd..97fabc6ac 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -15,10 +15,8 @@ class Form::AdminSettings
     closed_registrations_message
     open_deletion
     timeline_preview
-    show_staff_badge
     bootstrap_timeline_accounts
     theme
-    min_invite_role
     activity_api_enabled
     peers_api_enabled
     show_known_fediverse_at_about_page
@@ -39,7 +37,6 @@ class Form::AdminSettings
   BOOLEAN_KEYS = %i(
     open_deletion
     timeline_preview
-    show_staff_badge
     activity_api_enabled
     peers_api_enabled
     show_known_fediverse_at_about_page
@@ -62,7 +59,6 @@ class Form::AdminSettings
   validates :site_short_description, :site_description, html: { wrap_with: :p }
   validates :site_extended_description, :site_terms, :closed_registrations_message, html: true
   validates :registrations_mode, inclusion: { in: %w(open approved none) }
-  validates :min_invite_role, inclusion: { in: %w(disabled user moderator admin) }
   validates :site_contact_email, :site_contact_username, presence: true
   validates :site_contact_username, existing_username: true
   validates :bootstrap_timeline_accounts, existing_username: { multiple: true }
diff --git a/app/models/trends.rb b/app/models/trends.rb
index f8864e55f..d886be89a 100644
--- a/app/models/trends.rb
+++ b/app/models/trends.rb
@@ -34,7 +34,7 @@ module Trends
 
     return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
 
-    User.staff.includes(:account).find_each do |user|
+    User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
       AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
     end
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index 81f6a58f6..60abaf77e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -37,6 +37,7 @@
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
 #  sign_up_ip                :inet
+#  role_id                   :bigint(8)
 #
 
 class User < ApplicationRecord
@@ -50,7 +51,6 @@ class User < ApplicationRecord
   )
 
   include Settings::Extend
-  include UserRoles
   include Redisable
   include LanguagesHelper
 
@@ -79,6 +79,7 @@ class User < ApplicationRecord
   belongs_to :account, inverse_of: :user
   belongs_to :invite, counter_cache: :uses, optional: true
   belongs_to :created_by_application, class_name: 'Doorkeeper::Application', optional: true
+  belongs_to :role, class_name: 'UserRole', optional: true
   accepts_nested_attributes_for :account
 
   has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
@@ -103,6 +104,7 @@ class User < ApplicationRecord
   validates_with RegistrationFormTimeValidator, on: :create
   validates :website, absence: true, on: :create
   validates :confirm_password, absence: true, on: :create
+  validate :validate_role_elevation
 
   scope :recent, -> { order(id: :desc) }
   scope :pending, -> { where(approved: false) }
@@ -117,6 +119,7 @@ class User < ApplicationRecord
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
+  before_validation :sanitize_role
   before_create :set_approved
   after_commit :send_pending_devise_notifications
   after_create_commit :trigger_webhooks
@@ -135,8 +138,28 @@ class User < ApplicationRecord
            :disable_swiping, :always_send_emails,
            to: :settings, prefix: :setting, allow_nil: false
 
+  delegate :can?, to: :role
+
   attr_reader :invite_code
-  attr_writer :external, :bypass_invite_request_check
+  attr_writer :external, :bypass_invite_request_check, :current_account
+
+  def self.those_who_can(*any_of_privileges)
+    matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id)
+
+    if matching_role_ids.empty?
+      none
+    else
+      where(role_id: matching_role_ids)
+    end
+  end
+
+  def role
+    if role_id.nil?
+      UserRole.everyone
+    else
+      super
+    end
+  end
 
   def confirmed?
     confirmed_at.present?
@@ -441,6 +464,11 @@ class User < ApplicationRecord
     self.chosen_languages = nil if chosen_languages.empty?
   end
 
+  def sanitize_role
+    return if role.nil?
+    self.role = nil if role.everyone?
+  end
+
   def prepare_new_user!
     BootstrapTimelineWorker.perform_async(account_id)
     ActivityTracker.increment('activity:accounts:local')
@@ -453,7 +481,7 @@ class User < ApplicationRecord
   end
 
   def notify_staff_about_pending_account!
-    User.staff.includes(:account).find_each do |u|
+    User.those_who_can(:manage_users).includes(:account).find_each do |u|
       next unless u.allows_pending_account_emails?
       AdminMailer.new_pending_account(u.account, self).deliver_later
     end
@@ -471,6 +499,10 @@ class User < ApplicationRecord
     email_changed? && !external? && !(Rails.env.test? || Rails.env.development?)
   end
 
+  def validate_role_elevation
+    errors.add(:role_id, :elevated) if defined?(@current_account) && role&.overrides?(@current_account&.user_role)
+  end
+
   def invite_text_required?
     Setting.require_invite_text && !invited? && !external? && !bypass_invite_request_check?
   end
diff --git a/app/models/user_role.rb b/app/models/user_role.rb
new file mode 100644
index 000000000..833b96d71
--- /dev/null
+++ b/app/models/user_role.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: user_roles
+#
+#  id          :bigint(8)        not null, primary key
+#  name        :string           default(""), not null
+#  color       :string           default(""), not null
+#  position    :integer          default(0), not null
+#  permissions :bigint(8)        default(0), not null
+#  highlighted :boolean          default(FALSE), not null
+#  created_at  :datetime         not null
+#  updated_at  :datetime         not null
+#
+
+class UserRole < ApplicationRecord
+  FLAGS = {
+    administrator: (1 << 0),
+    view_devops: (1 << 1),
+    view_audit_log: (1 << 2),
+    view_dashboard: (1 << 3),
+    manage_reports: (1 << 4),
+    manage_federation: (1 << 5),
+    manage_settings: (1 << 6),
+    manage_blocks: (1 << 7),
+    manage_taxonomies: (1 << 8),
+    manage_appeals: (1 << 9),
+    manage_users: (1 << 10),
+    manage_invites: (1 << 11),
+    manage_rules: (1 << 12),
+    manage_announcements: (1 << 13),
+    manage_custom_emojis: (1 << 14),
+    manage_webhooks: (1 << 15),
+    invite_users: (1 << 16),
+    manage_roles: (1 << 17),
+    manage_user_access: (1 << 18),
+    delete_user_data: (1 << 19),
+  }.freeze
+
+  module Flags
+    NONE = 0
+    ALL  = FLAGS.values.reduce(&:|)
+
+    DEFAULT = FLAGS[:invite_users]
+
+    CATEGORIES = {
+      invites: %i(
+        invite_users
+      ).freeze,
+
+      moderation: %w(
+        view_dashboard
+        view_audit_log
+        manage_users
+        manage_user_access
+        delete_user_data
+        manage_reports
+        manage_appeals
+        manage_federation
+        manage_blocks
+        manage_taxonomies
+        manage_invites
+      ).freeze,
+
+      administration: %w(
+        manage_settings
+        manage_rules
+        manage_roles
+        manage_webhooks
+        manage_custom_emojis
+        manage_announcements
+      ).freeze,
+
+      devops: %w(
+        view_devops
+      ).freeze,
+
+      special: %i(
+        administrator
+      ).freeze,
+    }.freeze
+  end
+
+  attr_writer :current_account
+
+  validates :name, presence: true, unless: :everyone?
+  validates :color, format: { with: /\A#?(?:[A-F0-9]{3}){1,2}\z/i }, unless: -> { color.blank? }
+
+  validate :validate_permissions_elevation
+  validate :validate_position_elevation
+  validate :validate_dangerous_permissions
+
+  before_validation :set_position
+
+  scope :assignable, -> { where.not(id: -99).order(position: :asc) }
+
+  has_many :users, inverse_of: :role, foreign_key: 'role_id', dependent: :nullify
+
+  def self.nobody
+    @nobody ||= UserRole.new(permissions: Flags::NONE, position: -1)
+  end
+
+  def self.everyone
+    UserRole.find(-99)
+  rescue ActiveRecord::RecordNotFound
+    UserRole.create!(id: -99, permissions: Flags::DEFAULT)
+  end
+
+  def self.that_can(*any_of_privileges)
+    all.select { |role| role.can?(*any_of_privileges) }
+  end
+
+  def everyone?
+    id == -99
+  end
+
+  def nobody?
+    id.nil?
+  end
+
+  def permissions_as_keys
+    FLAGS.keys.select { |privilege| permissions & FLAGS[privilege] == FLAGS[privilege] }.map(&:to_s)
+  end
+
+  def permissions_as_keys=(value)
+    self.permissions = value.map(&:presence).compact.reduce(Flags::NONE) { |bitmask, privilege| FLAGS.key?(privilege.to_sym) ? (bitmask | FLAGS[privilege.to_sym]) : bitmask }
+  end
+
+  def can?(*any_of_privileges)
+    any_of_privileges.any? { |privilege| in_permissions?(privilege) }
+  end
+
+  def overrides?(other_role)
+    other_role.nil? || position > other_role.position
+  end
+
+  def computed_permissions
+    # If called on the everyone role, no further computation needed
+    return permissions if everyone?
+
+    # If called on the nobody role, no permissions are there to be given
+    return Flags::NONE if nobody?
+
+    # Otherwise, compute permissions based on special conditions
+    @computed_permissions ||= begin
+      permissions = self.class.everyone.permissions | self.permissions
+
+      if permissions & FLAGS[:administrator] == FLAGS[:administrator]
+        Flags::ALL
+      else
+        permissions
+      end
+    end
+  end
+
+  private
+
+  def in_permissions?(privilege)
+    raise ArgumentError, "Unknown privilege: #{privilege}" unless FLAGS.key?(privilege)
+    computed_permissions & FLAGS[privilege] == FLAGS[privilege]
+  end
+
+  def set_position
+    self.position = -1 if everyone?
+  end
+
+  def validate_permissions_elevation
+    errors.add(:permissions_as_keys, :elevated) if defined?(@current_account) && @current_account.user_role.computed_permissions & permissions != permissions
+  end
+
+  def validate_position_elevation
+    errors.add(:position, :elevated) if defined?(@current_account) && @current_account.user_role.position < position
+  end
+
+  def validate_dangerous_permissions
+    errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::DEFAULT & permissions != permissions
+  end
+end
diff --git a/app/policies/account_moderation_note_policy.rb b/app/policies/account_moderation_note_policy.rb
index 885411a5b..310ce854c 100644
--- a/app/policies/account_moderation_note_policy.rb
+++ b/app/policies/account_moderation_note_policy.rb
@@ -2,11 +2,11 @@
 
 class AccountModerationNotePolicy < ApplicationPolicy
   def create?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def destroy?
-    admin? || owner?
+    owner? || (role.can?(:manage_reports) && role.overrides?(record.account.user_role))
   end
 
   private
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index cc23771e7..a744af81d 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -2,74 +2,66 @@
 
 class AccountPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_users)
   end
 
   def show?
-    staff?
+    role.can?(:manage_users)
   end
 
   def warn?
-    staff? && !record.user&.staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def suspend?
-    staff? && !record.user&.staff? && !record.instance_actor?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) && !record.instance_actor?
   end
 
   def destroy?
-    record.suspended_temporarily? && admin?
+    record.suspended_temporarily? && role.can?(:delete_user_data)
   end
 
   def unsuspend?
-    staff? && record.suspension_origin_local?
+    role.can?(:manage_users) && record.suspension_origin_local?
   end
 
   def sensitive?
-    staff? && !record.user&.staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def unsensitive?
-    staff?
+    role.can?(:manage_users)
   end
 
   def silence?
-    staff? && !record.user&.staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def unsilence?
-    staff?
+    role.can?(:manage_users)
   end
 
   def redownload?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def remove_avatar?
-    staff?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def remove_header?
-    staff?
-  end
-
-  def subscribe?
-    admin?
-  end
-
-  def unsubscribe?
-    admin?
+    role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role)
   end
 
   def memorialize?
-    admin? && !record.user&.admin? && !record.instance_actor?
+    role.can?(:delete_user_data) && role.overrides?(record.user_role) && !record.instance_actor?
   end
 
   def unblock_email?
-    staff?
+    role.can?(:manage_users)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/account_warning_policy.rb b/app/policies/account_warning_policy.rb
index 65707dfa7..4f8df7420 100644
--- a/app/policies/account_warning_policy.rb
+++ b/app/policies/account_warning_policy.rb
@@ -2,7 +2,7 @@
 
 class AccountWarningPolicy < ApplicationPolicy
   def show?
-    target? || staff?
+    target? || role.can?(:manage_appeals)
   end
 
   def appeal?
diff --git a/app/policies/account_warning_preset_policy.rb b/app/policies/account_warning_preset_policy.rb
index bccbd33ef..59514e951 100644
--- a/app/policies/account_warning_preset_policy.rb
+++ b/app/policies/account_warning_preset_policy.rb
@@ -2,18 +2,18 @@
 
 class AccountWarningPresetPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_settings)
   end
 
   def create?
-    staff?
+    role.can?(:manage_settings)
   end
 
   def update?
-    staff?
+    role.can?(:manage_settings)
   end
 
   def destroy?
-    staff?
+    role.can?(:manage_settings)
   end
 end
diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb
index 0a4e4575c..b5dc6a18a 100644
--- a/app/policies/announcement_policy.rb
+++ b/app/policies/announcement_policy.rb
@@ -2,18 +2,18 @@
 
 class AnnouncementPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_announcements)
   end
 
   def create?
-    admin?
+    role.can?(:manage_announcements)
   end
 
   def update?
-    admin?
+    role.can?(:manage_announcements)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_announcements)
   end
 end
diff --git a/app/policies/appeal_policy.rb b/app/policies/appeal_policy.rb
index a25187172..7466b334b 100644
--- a/app/policies/appeal_policy.rb
+++ b/app/policies/appeal_policy.rb
@@ -2,12 +2,14 @@
 
 class AppealPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_appeals)
   end
 
   def approve?
-    record.pending? && staff?
+    record.pending? && role.can?(:manage_appeals)
   end
 
-  alias reject? approve?
+  def reject?
+    record.pending? && role.can?(:manage_appeals)
+  end
 end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
index d1de5e81a..163b81e9e 100644
--- a/app/policies/application_policy.rb
+++ b/app/policies/application_policy.rb
@@ -8,8 +8,6 @@ class ApplicationPolicy
     @record          = record
   end
 
-  delegate :admin?, :moderator?, :staff?, to: :current_user, allow_nil: true
-
   private
 
   def current_user
@@ -19,4 +17,8 @@ class ApplicationPolicy
   def user_signed_in?
     !current_user.nil?
   end
+
+  def role
+    current_user&.role || UserRole.nobody
+  end
 end
diff --git a/app/policies/audit_log_policy.rb b/app/policies/audit_log_policy.rb
new file mode 100644
index 000000000..f78aa9a8e
--- /dev/null
+++ b/app/policies/audit_log_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AuditLogPolicy < ApplicationPolicy
+  def index?
+    role.can?(:view_audit_log)
+  end
+end
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
index a8c3cbc73..18de71c19 100644
--- a/app/policies/custom_emoji_policy.rb
+++ b/app/policies/custom_emoji_policy.rb
@@ -2,30 +2,30 @@
 
 class CustomEmojiPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_custom_emojis)
   end
 
   def create?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 
   def update?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 
   def copy?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 
   def enable?
-    staff?
+    role.can?(:manage_custom_emojis)
   end
 
   def disable?
-    staff?
+    role.can?(:manage_custom_emojis)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_custom_emojis)
   end
 end
diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb
new file mode 100644
index 000000000..3df1c3088
--- /dev/null
+++ b/app/policies/dashboard_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DashboardPolicy < ApplicationPolicy
+  def index?
+    role.can?(:view_dashboard)
+  end
+end
diff --git a/app/policies/delivery_policy.rb b/app/policies/delivery_policy.rb
index 24d06c168..f6ba2eb18 100644
--- a/app/policies/delivery_policy.rb
+++ b/app/policies/delivery_policy.rb
@@ -2,14 +2,14 @@
 
 class DeliveryPolicy < ApplicationPolicy
   def clear_delivery_errors?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def restart_delivery?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def stop_delivery?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/domain_allow_policy.rb b/app/policies/domain_allow_policy.rb
index 7a5b5d780..45c797ecd 100644
--- a/app/policies/domain_allow_policy.rb
+++ b/app/policies/domain_allow_policy.rb
@@ -2,18 +2,18 @@
 
 class DomainAllowPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def show?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def create?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb
index 543259cce..0fea2e035 100644
--- a/app/policies/domain_block_policy.rb
+++ b/app/policies/domain_block_policy.rb
@@ -2,22 +2,22 @@
 
 class DomainBlockPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def show?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def create?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def update?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/email_domain_block_policy.rb b/app/policies/email_domain_block_policy.rb
index 5a75ee183..1a0ddfa87 100644
--- a/app/policies/email_domain_block_policy.rb
+++ b/app/policies/email_domain_block_policy.rb
@@ -2,14 +2,14 @@
 
 class EmailDomainBlockPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def create?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_blocks)
   end
 end
diff --git a/app/policies/follow_recommendation_policy.rb b/app/policies/follow_recommendation_policy.rb
index 68cd0e547..9245733ea 100644
--- a/app/policies/follow_recommendation_policy.rb
+++ b/app/policies/follow_recommendation_policy.rb
@@ -2,14 +2,14 @@
 
 class FollowRecommendationPolicy < ApplicationPolicy
   def show?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def suppress?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def unsuppress?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb
index 801ca162e..b15e123fe 100644
--- a/app/policies/instance_policy.rb
+++ b/app/policies/instance_policy.rb
@@ -2,14 +2,14 @@
 
 class InstancePolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def show?
-    admin?
+    role.can?(:manage_federation)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb
index 14236f78b..24eacd08e 100644
--- a/app/policies/invite_policy.rb
+++ b/app/policies/invite_policy.rb
@@ -2,19 +2,19 @@
 
 class InvitePolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_invites)
   end
 
   def create?
-    min_required_role?
+    role.can?(:invite_users)
   end
 
   def deactivate_all?
-    admin?
+    role.can?(:manage_invites)
   end
 
   def destroy?
-    owner? || (Setting.min_invite_role == 'admin' ? admin? : staff?)
+    owner? || role.can?(:manage_invites)
   end
 
   private
@@ -22,8 +22,4 @@ class InvitePolicy < ApplicationPolicy
   def owner?
     record.user_id == current_user&.id
   end
-
-  def min_required_role?
-    current_user&.role?(Setting.min_invite_role)
-  end
 end
diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb
index 34dbd746a..1abc97ad8 100644
--- a/app/policies/ip_block_policy.rb
+++ b/app/policies/ip_block_policy.rb
@@ -2,14 +2,14 @@
 
 class IpBlockPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def create?
-    admin?
+    role.can?(:manage_blocks)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_blocks)
   end
 end
diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb
index 0410987e4..a7bb41634 100644
--- a/app/policies/preview_card_policy.rb
+++ b/app/policies/preview_card_policy.rb
@@ -2,10 +2,10 @@
 
 class PreviewCardPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb
index 44d2ad5cf..131ccb5dd 100644
--- a/app/policies/preview_card_provider_policy.rb
+++ b/app/policies/preview_card_provider_policy.rb
@@ -2,10 +2,10 @@
 
 class PreviewCardProviderPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/relay_policy.rb b/app/policies/relay_policy.rb
index bd75e2197..4305bcfaa 100644
--- a/app/policies/relay_policy.rb
+++ b/app/policies/relay_policy.rb
@@ -2,6 +2,6 @@
 
 class RelayPolicy < ApplicationPolicy
   def update?
-    admin?
+    role.can?(:manage_federation)
   end
 end
diff --git a/app/policies/report_note_policy.rb b/app/policies/report_note_policy.rb
index 694bc096b..dc31416e8 100644
--- a/app/policies/report_note_policy.rb
+++ b/app/policies/report_note_policy.rb
@@ -2,11 +2,11 @@
 
 class ReportNotePolicy < ApplicationPolicy
   def create?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def destroy?
-    admin? || owner?
+    owner? || (role.can?(:manage_reports) && role.overrides?(record.account.user_role))
   end
 
   private
diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb
index 95b5c30c8..c9f7639bd 100644
--- a/app/policies/report_policy.rb
+++ b/app/policies/report_policy.rb
@@ -2,14 +2,14 @@
 
 class ReportPolicy < ApplicationPolicy
   def update?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def index?
-    staff?
+    role.can?(:manage_reports)
   end
 
   def show?
-    staff?
+    role.can?(:manage_reports)
   end
 end
diff --git a/app/policies/rule_policy.rb b/app/policies/rule_policy.rb
index 6a4def009..51b2a6977 100644
--- a/app/policies/rule_policy.rb
+++ b/app/policies/rule_policy.rb
@@ -2,18 +2,18 @@
 
 class RulePolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_rules)
   end
 
   def create?
-    admin?
+    role.can?(:manage_rules)
   end
 
   def update?
-    admin?
+    role.can?(:manage_rules)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_rules)
   end
 end
diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb
index 874f97bab..2b052af27 100644
--- a/app/policies/settings_policy.rb
+++ b/app/policies/settings_policy.rb
@@ -2,14 +2,14 @@
 
 class SettingsPolicy < ApplicationPolicy
   def update?
-    admin?
+    role.can?(:manage_settings)
   end
 
   def show?
-    admin?
+    role.can?(:manage_settings)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_settings)
   end
 end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 400f1ec79..2f48b5d70 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -8,7 +8,7 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def index?
-    staff?
+    role.can?(:manage_reports, :manage_users)
   end
 
   def show?
@@ -32,17 +32,17 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def destroy?
-    staff? || owned?
+    role.can?(:manage_reports) || owned?
   end
 
   alias unreblog? destroy?
 
   def update?
-    staff? || owned?
+    role.can?(:manage_reports) || owned?
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   private
diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb
index bdfcec0c9..bb1d37d6c 100644
--- a/app/policies/tag_policy.rb
+++ b/app/policies/tag_policy.rb
@@ -2,18 +2,18 @@
 
 class TagPolicy < ApplicationPolicy
   def index?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def show?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def update?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 
   def review?
-    staff?
+    role.can?(:manage_taxonomies)
   end
 end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 140905e1f..6751b8b8f 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -2,52 +2,38 @@
 
 class UserPolicy < ApplicationPolicy
   def reset_password?
-    staff? && !record.staff?
+    role.can?(:manage_user_access) && role.overrides?(record.role)
   end
 
   def change_email?
-    staff? && !record.staff?
+    role.can?(:manage_user_access) && role.overrides?(record.role)
   end
 
   def disable_2fa?
-    admin? && !record.staff?
+    role.can?(:manage_user_access) && role.overrides?(record.role)
+  end
+
+  def change_role?
+    role.can?(:manage_roles) && role.overrides?(record.role)
   end
 
   def confirm?
-    staff? && !record.confirmed?
+    role.can?(:manage_user_access) && !record.confirmed?
   end
 
   def enable?
-    staff?
+    role.can?(:manage_users)
   end
 
   def approve?
-    staff? && !record.approved?
+    role.can?(:manage_users) && !record.approved?
   end
 
   def reject?
-    staff? && !record.approved?
+    role.can?(:manage_users) && !record.approved?
   end
 
   def disable?
-    staff? && !record.admin?
-  end
-
-  def promote?
-    admin? && promotable?
-  end
-
-  def demote?
-    admin? && !record.admin? && demoteable?
-  end
-
-  private
-
-  def promotable?
-    record.approved? && (!record.staff? || !record.admin?)
-  end
-
-  def demoteable?
-    record.staff?
+    role.can?(:manage_users) && role.overrides?(record.role)
   end
 end
diff --git a/app/policies/user_role_policy.rb b/app/policies/user_role_policy.rb
new file mode 100644
index 000000000..7019637fc
--- /dev/null
+++ b/app/policies/user_role_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class UserRolePolicy < ApplicationPolicy
+  def index?
+    role.can?(:manage_roles)
+  end
+
+  def create?
+    role.can?(:manage_roles)
+  end
+
+  def update?
+    role.can?(:manage_roles) && role.overrides?(record)
+  end
+
+  def destroy?
+    !record.everyone? && role.can?(:manage_roles) && role.overrides?(record) && role.id != record.id
+  end
+end
diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb
index 2c55703a1..a2199a333 100644
--- a/app/policies/webhook_policy.rb
+++ b/app/policies/webhook_policy.rb
@@ -2,34 +2,34 @@
 
 class WebhookPolicy < ApplicationPolicy
   def index?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def create?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def show?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def update?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def enable?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def disable?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def rotate_secret?
-    admin?
+    role.can?(:manage_webhooks)
   end
 
   def destroy?
-    admin?
+    role.can?(:manage_webhooks)
   end
 end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index 06482935c..129ea2a46 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -3,4 +3,8 @@
 class InitialStatePresenter < ActiveModelSerializers::Model
   attributes :settings, :push_subscription, :token,
              :current_account, :admin, :text, :visibility
+
+  def role
+    current_account&.user_role
+  end
 end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 34190a91d..5eda87757 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -6,6 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer
              :languages
 
   has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
+  has_one :role, serializer: REST::RoleSerializer
 
   def meta
     store = {
@@ -19,7 +20,6 @@ class InitialStateSerializer < ActiveModel::Serializer
       repository: Mastodon::Version.repository,
       source_url: Mastodon::Version.source_url,
       version: Mastodon::Version.to_s,
-      invites_enabled: Setting.min_invite_role == 'user',
       limited_federation_mode: Rails.configuration.x.whitelist_mode,
       mascot: instance_presenter.mascot&.file&.url,
       profile_directory: Setting.profile_directory,
@@ -39,7 +39,6 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:advanced_layout]   = object.current_account.user.setting_advanced_layout
       store[:use_blurhash]      = object.current_account.user.setting_use_blurhash
       store[:use_pending_items] = object.current_account.user.setting_use_pending_items
-      store[:is_staff]          = object.current_account.user.staff?
       store[:trends]            = Setting.trends && object.current_account.user.setting_trends
       store[:crop_images]       = object.current_account.user.setting_crop_images
     else
diff --git a/app/serializers/rest/credential_account_serializer.rb b/app/serializers/rest/credential_account_serializer.rb
index be0d763dc..27e1db207 100644
--- a/app/serializers/rest/credential_account_serializer.rb
+++ b/app/serializers/rest/credential_account_serializer.rb
@@ -3,6 +3,8 @@
 class REST::CredentialAccountSerializer < REST::AccountSerializer
   attributes :source
 
+  has_one :role, serializer: REST::RoleSerializer
+
   def source
     user = object.user
 
@@ -15,4 +17,8 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer
       follow_requests_count: FollowRequest.where(target_account: object).limit(40).count,
     }
   end
+
+  def role
+    object.user_role
+  end
 end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index f5dec0dac..9cc245422 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -93,7 +93,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
   end
 
   def invites_enabled
-    Setting.min_invite_role == 'user'
+    UserRole.everyone.can?(:invite_users)
   end
 
   private
diff --git a/app/serializers/rest/role_serializer.rb b/app/serializers/rest/role_serializer.rb
new file mode 100644
index 000000000..5b81c6e04
--- /dev/null
+++ b/app/serializers/rest/role_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class REST::RoleSerializer < ActiveModel::Serializer
+  attributes :id, :name, :permissions, :color, :highlighted
+
+  def id
+    object.id.to_s
+  end
+
+  def permissions
+    object.computed_permissions.to_s
+  end
+end
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index 6fe4b6593..4dcae20eb 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -61,11 +61,11 @@ class AccountSearchService < BaseService
   end
 
   def advanced_search_results
-    Account.advanced_search_for(terms_for_query, account, limit_for_non_exact_results, options[:following], offset)
+    Account.advanced_search_for(terms_for_query, account, limit: limit_for_non_exact_results, following: options[:following], offset: offset)
   end
 
   def simple_search_results
-    Account.search_for(terms_for_query, limit_for_non_exact_results, offset)
+    Account.search_for(terms_for_query, limit: limit_for_non_exact_results, offset: offset)
   end
 
   def from_elasticsearch
diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb
index cef9be05f..399a053d6 100644
--- a/app/services/appeal_service.rb
+++ b/app/services/appeal_service.rb
@@ -22,7 +22,7 @@ class AppealService < BaseService
   end
 
   def notify_staff!
-    User.staff.includes(:account).each do |u|
+    User.those_who_can(:manage_appeals).includes(:account).each do |u|
       AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails?
     end
   end
diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb
index a02e55a6d..126c0fa2e 100644
--- a/app/services/bootstrap_timeline_service.rb
+++ b/app/services/bootstrap_timeline_service.rb
@@ -17,7 +17,7 @@ class BootstrapTimelineService < BaseService
   end
 
   def notify_staff!
-    User.staff.includes(:account).find_each do |user|
+    User.those_who_can(:manage_users).includes(:account).find_each do |user|
       LocalNotificationWorker.perform_async(user.account_id, @source_account.id, 'Account', 'admin.sign_up')
     end
   end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index d30b33876..c7454fc60 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -76,7 +76,7 @@ class NotifyService < BaseService
   end
 
   def from_staff?
-    @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user.staff?
+    @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user_role&.overrides?(@recipient.user_role)
   end
 
   def optional_non_following_and_direct?
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index bd67ff8d3..8c92cf334 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -38,7 +38,7 @@ class ReportService < BaseService
   def notify_staff!
     return if @report.unresolved_siblings?
 
-    User.staff.includes(:account).each do |u|
+    User.those_who_can(:manage_reports).includes(:account).each do |u|
       LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report')
       AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails?
     end
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 60e4894d0..7560fac7a 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -4,45 +4,36 @@
 - content_for :header_tags do
   = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
 
-.filters
-  .filter-subset
-    %strong= t('admin.accounts.location.title')
-    %ul
-      %li= filter_link_to t('generic.all'), origin: nil
-      %li= filter_link_to t('admin.accounts.location.local'), origin: 'local'
-      %li= filter_link_to t('admin.accounts.location.remote'), origin: 'remote'
-  .filter-subset
-    %strong= t('admin.accounts.moderation.title')
-    %ul
-      %li= filter_link_to t('generic.all'), status: nil
-      %li= filter_link_to t('admin.accounts.moderation.active'), status: 'active'
-      %li= filter_link_to t('admin.accounts.moderation.suspended'), status: 'suspended'
-      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), status: 'pending'
-  .filter-subset
-    %strong= t('admin.accounts.role')
-    %ul
-      %li= filter_link_to t('admin.accounts.moderation.all'), permissions: nil
-      %li= filter_link_to t('admin.accounts.roles.staff'), permissions: 'staff'
-  .filter-subset
-    %strong= t 'generic.order_by'
-    %ul
-      %li= filter_link_to t('relationships.most_recent'), order: nil
-      %li= filter_link_to t('relationships.last_active'), order: 'active'
-
 = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
-  .fields-group
-    - (AccountFilter::KEYS - %i(origin status permissions)).each do |key|
-      - if params[key].present?
-        = hidden_field_tag key, params[key]
+  .filters
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.accounts.location.title')
+      .input.select.optional
+        = select_tag :origin, options_for_select([[t('admin.accounts.location.local'), 'local'], [t('admin.accounts.location.remote'), 'remote']], params[:origin]), prompt: I18n.t('generic.all')
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.accounts.moderation.title')
+      .input.select.optional
+        = select_tag :status, options_for_select([[t('admin.accounts.moderation.active'), 'active'], [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.suspended'), 'suspended'], [safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), 'pending']], params[:status]), prompt: I18n.t('generic.all')
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.accounts.role')
+      .input.select.optional
+        = select_tag :role_ids, options_from_collection_for_select(UserRole.assignable, :id, :name, params[:role_ids]), prompt: I18n.t('admin.accounts.moderation.all')
+    .filter-subset.filter-subset--with-select
+      %strong= t 'generic.order_by'
+      .input.select
+        = select_tag :order, options_for_select([[t('relationships.most_recent'), nil], [t('relationships.last_active'), 'active']], params[:order])
 
+  .fields-group
     - %i(username by_domain display_name email ip).each do |key|
       - unless key == :by_domain && params[:origin] != 'remote'
         .input.string.optional
           = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}")
 
-    .actions
-      %button.button= t('admin.accounts.search')
-      = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
+  .actions
+    %button.button= t('admin.accounts.search')
+    = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
+
+%hr.spacer/
 
 = form_for(@form, url: batch_admin_accounts_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index a69832b04..dc3b35956 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -92,10 +92,13 @@
 
           %tr
             %th= t('admin.accounts.role')
-            %td= t("admin.accounts.roles.#{@account.user&.role}")
             %td
-              = table_link_to 'angle-double-up', t('admin.accounts.promote'), promote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:promote, @account.user)
-              = table_link_to 'angle-double-down', t('admin.accounts.demote'), demote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:demote, @account.user)
+              - if @account.user_role&.everyone?
+                = t('admin.accounts.no_role_assigned')
+              - else
+                = @account.user_role&.name
+            %td
+              = table_link_to 'vcard', t('admin.accounts.change_role.label'), admin_user_role_path(@account.user) if can?(:change_role, @account.user)
 
           %tr
             %th{ rowspan: can?(:create, :email_domain_block) ? 3 : 2 }= t('admin.accounts.email')
diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml
index f611bfe9d..d8b7132f5 100644
--- a/app/views/admin/action_logs/index.html.haml
+++ b/app/views/admin/action_logs/index.html.haml
@@ -11,7 +11,7 @@
     .filter-subset.filter-subset--with-select
       %strong= t('admin.action_logs.filter_by_user')
       .input.select.optional
-        = select_tag :account_id, options_from_collection_for_select(Account.joins(:user).merge(User.staff), :id, :username, params[:account_id]), prompt: I18n.t('admin.accounts.moderation.all')
+        = select_tag :account_id, options_from_collection_for_select(@auditable_accounts, :id, :username, params[:account_id]), prompt: I18n.t('admin.accounts.moderation.all')
 
     .filter-subset.filter-subset--with-select
       %strong= t('admin.action_logs.filter_by_action')
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index ef4de602d..ab290912e 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -4,32 +4,33 @@
 - content_for :header_tags do
   = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
 
-- content_for :heading_actions do
-  = l(@time_period.first)
-  = ' - '
-  = l(@time_period.last)
-
-%p
-  = fa_icon 'info fw'
-  = t('admin.instances.totals_time_period_hint_html')
-
-.dashboard
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_accounts_measure'), href: admin_accounts_path(origin: 'remote', by_domain: @instance.domain)
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_statuses', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_statuses_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_media_attachments', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_media_attachments_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_follows', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_follows_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_followers', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_followers_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'instance_reports', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_reports_measure'), href: admin_reports_path(by_target_domain: @instance.domain)
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_accounts_dimension')
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'instance_languages', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_languages_dimension')
+- if current_user.can?(:view_dashboard)
+  - content_for :heading_actions do
+    = l(@time_period.first)
+    = ' - '
+    = l(@time_period.last)
+
+  %p
+    = fa_icon 'info fw'
+    = t('admin.instances.totals_time_period_hint_html')
+
+  .dashboard
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_accounts_measure'), href: admin_accounts_path(origin: 'remote', by_domain: @instance.domain)
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_statuses', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_statuses_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_media_attachments', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_media_attachments_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_follows', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_follows_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_followers', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_followers_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'instance_reports', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_reports_measure'), href: admin_reports_path(by_target_domain: @instance.domain)
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_accounts_dimension')
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'instance_languages', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_languages_dimension')
 
 %hr.spacer/
 
diff --git a/app/views/admin/roles/_form.html.haml b/app/views/admin/roles/_form.html.haml
new file mode 100644
index 000000000..68607ce68
--- /dev/null
+++ b/app/views/admin/roles/_form.html.haml
@@ -0,0 +1,37 @@
+= simple_form_for @role, url: @role.new_record? ? admin_roles_path : admin_role_path(@role) do |f|
+  = render 'shared/error_messages', object: @role
+
+  - if @role.everyone?
+    .flash-message.info
+      = t('admin.roles.everyone_full_description_html')
+  - else
+    .fields-group
+      = f.input :name, wrapper: :with_label
+
+    .fields-group
+      = f.input :position, wrapper: :with_label
+
+    .fields-group
+      = f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000' }
+
+    %hr.spacer/
+
+    .fields-group
+      = f.input :highlighted, wrapper: :with_label
+
+    %hr.spacer/
+
+  .field-group
+    .input.with_block_label
+      %label= t('simple_form.labels.user_role.permissions_as_keys')
+      %span.hint= t('simple_form.hints.user_role.permissions_as_keys')
+
+    - (@role.everyone? ? UserRole::Flags::CATEGORIES.slice(:invites) : UserRole::Flags::CATEGORIES).each do |category, permissions|
+      %h4= t(category, scope: 'admin.roles.categories')
+
+      = f.input :permissions_as_keys, collection: permissions, wrapper: :with_block_label, include_blank: false, label_method: lambda { |privilege| safe_join([t("admin.roles.privileges.#{privilege}"), content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint')]) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label: false, hint: false
+
+  %hr.spacer/
+
+  .actions
+    = f.button :button, @role.new_record? ? t('admin.roles.add_new') : t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/roles/_role.html.haml b/app/views/admin/roles/_role.html.haml
new file mode 100644
index 000000000..6804f4f15
--- /dev/null
+++ b/app/views/admin/roles/_role.html.haml
@@ -0,0 +1,18 @@
+.announcements-list__item
+  = link_to edit_admin_role_path(role), class: 'announcements-list__item__title' do
+    %span.user-role{ class: "user-role-#{role.id}" }
+      = fa_icon 'users fw'
+
+      - if role.everyone?
+        = t('admin.roles.everyone')
+      - else
+        = role.name
+
+  .announcements-list__item__action-bar
+    .announcements-list__item__meta
+      - if role.everyone?
+        = t('admin.roles.everyone_full_description_html')
+      - else
+        = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_id: role.id)
+        •
+        %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
diff --git a/app/views/admin/roles/edit.html.haml b/app/views/admin/roles/edit.html.haml
new file mode 100644
index 000000000..659ccb8dc
--- /dev/null
+++ b/app/views/admin/roles/edit.html.haml
@@ -0,0 +1,8 @@
+- content_for :page_title do
+  = t('admin.roles.edit', name: @role.everyone? ? t('admin.roles.everyone') : @role.name)
+
+- content_for :heading_actions do
+  = link_to t('admin.roles.delete'), admin_role_path(@role), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:destroy, @role)
+
+= render partial: 'form'
+
diff --git a/app/views/admin/roles/index.html.haml b/app/views/admin/roles/index.html.haml
new file mode 100644
index 000000000..4f6c511b4
--- /dev/null
+++ b/app/views/admin/roles/index.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('admin.roles.title')
+
+- content_for :heading_actions do
+  = link_to t('admin.roles.add_new'), new_admin_role_path, class: 'button' if can?(:create, :user_role)
+
+%p= t('admin.roles.description_html')
+
+%hr.spacer/
+
+.applications-list
+  = render partial: 'role', collection: @roles.select(&:everyone?)
+
+%hr.spacer/
+
+.applications-list
+  = render partial: 'role', collection: @roles.reject(&:everyone?)
diff --git a/app/views/admin/roles/new.html.haml b/app/views/admin/roles/new.html.haml
new file mode 100644
index 000000000..821079271
--- /dev/null
+++ b/app/views/admin/roles/new.html.haml
@@ -0,0 +1,4 @@
+- content_for :page_title do
+  = t('admin.roles.add_new')
+
+= render partial: 'form'
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 33bfc43d3..d7896bbc0 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -62,9 +62,6 @@
       = f.input :show_known_fediverse_at_about_page, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_known_fediverse_at_about_page.title'), hint: t('admin.settings.show_known_fediverse_at_about_page.desc_html')
 
   .fields-group
-    = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html')
-
-  .fields-group
     = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html')
 
   - unless whitelist_mode?
@@ -91,9 +88,6 @@
 
   %hr.spacer/
 
-  .fields-group
-    = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
-
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
       = f.input :show_domain_blocks, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index df72bd5f5..fd9acce4a 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -4,49 +4,50 @@
 - content_for :page_title do
   = "##{@tag.name}"
 
-- content_for :heading_actions do
-  = l(@time_period.first)
-  = ' - '
-  = l(@time_period.last)
-
-.dashboard
-  .dashboard__item
-    = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank'
-  .dashboard__item
-    = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure')
-  .dashboard__item
-    = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure')
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension')
-  .dashboard__item
-    = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension')
-  .dashboard__item
-    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do
-      - if @tag.usable?
-        %span= t('admin.trends.tags.usable')
-        = fa_icon 'check fw'
-      - else
-        %span= t('admin.trends.tags.not_usable')
-        = fa_icon 'lock fw'
-
-    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do
-      - if @tag.trendable?
-        %span= t('admin.trends.tags.trendable')
-        = fa_icon 'check fw'
-      - else
-        %span= t('admin.trends.tags.not_trendable')
-        = fa_icon 'lock fw'
-
-
-    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do
-      - if @tag.listable?
-        %span= t('admin.trends.tags.listable')
-        = fa_icon 'check fw'
-      - else
-        %span= t('admin.trends.tags.not_listable')
-        = fa_icon 'lock fw'
-
-%hr.spacer/
+- if current_user.can?(:view_dashboard)
+  - content_for :heading_actions do
+    = l(@time_period.first)
+    = ' - '
+    = l(@time_period.last)
+
+  .dashboard
+    .dashboard__item
+      = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank'
+    .dashboard__item
+      = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure')
+    .dashboard__item
+      = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure')
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension')
+    .dashboard__item
+      = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension')
+    .dashboard__item
+      = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do
+        - if @tag.usable?
+          %span= t('admin.trends.tags.usable')
+          = fa_icon 'check fw'
+        - else
+          %span= t('admin.trends.tags.not_usable')
+          = fa_icon 'lock fw'
+
+      = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do
+        - if @tag.trendable?
+          %span= t('admin.trends.tags.trendable')
+          = fa_icon 'check fw'
+        - else
+          %span= t('admin.trends.tags.not_trendable')
+          = fa_icon 'lock fw'
+
+
+      = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do
+        - if @tag.listable?
+          %span= t('admin.trends.tags.listable')
+          = fa_icon 'check fw'
+        - else
+          %span= t('admin.trends.tags.not_listable')
+          = fa_icon 'lock fw'
+
+  %hr.spacer/
 
 = simple_form_for @tag, url: admin_tag_path(@tag.id) do |f|
   = render 'shared/error_messages', object: @tag
diff --git a/app/views/admin/users/roles/show.html.haml b/app/views/admin/users/roles/show.html.haml
new file mode 100644
index 000000000..821618060
--- /dev/null
+++ b/app/views/admin/users/roles/show.html.haml
@@ -0,0 +1,9 @@
+- content_for :page_title do
+  = t('admin.accounts.change_role.title', username: @user.account.username)
+
+= simple_form_for @user, url: admin_user_role_path(@user) do |f|
+  .fields-group
+    = f.association :role, wrapper: :with_block_label, collection: UserRole.assignable, label_method: :name, include_blank: I18n.t('admin.accounts.change_role.no_role')
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/custom_css/show.css.erb b/app/views/custom_css/show.css.erb
new file mode 100644
index 000000000..521834832
--- /dev/null
+++ b/app/views/custom_css/show.css.erb
@@ -0,0 +1,10 @@
+<%- if Setting.custom_css.present? %>
+<%= Setting.custom_css %>
+
+<%- end %>
+<%- UserRole.where(highlighted: true).select { |role| role.color.present? }.each do |role| %>
+.user-role-<%= role.id %> {
+  --user-role-accent: <%= role.color %>;
+}
+
+<%- end %>
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 25fd5bc34..bf164223c 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -34,9 +34,7 @@
     %meta{ name: 'style-nonce', content: request.content_security_policy_nonce }
 
     = stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style'
-
-    - if Setting.custom_css.present?
-      = stylesheet_link_tag custom_css_path, host: request.host, media: 'all'
+    = stylesheet_link_tag custom_css_path, host: default_url_options[:host], media: 'all'
 
     = yield :header_tags
 
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index 42754a852..bc7afb993 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -18,12 +18,10 @@
       = ff.input :reblog, as: :boolean, wrapper: :with_label
       = ff.input :favourite, as: :boolean, wrapper: :with_label
       = ff.input :mention, as: :boolean, wrapper: :with_label
-
-      - if current_user.staff?
-        = ff.input :report, as: :boolean, wrapper: :with_label
-        = ff.input :appeal, as: :boolean, wrapper: :with_label
-        = ff.input :pending_account, as: :boolean, wrapper: :with_label
-        = ff.input :trending_tag, as: :boolean, wrapper: :with_label
+      = ff.input :report, as: :boolean, wrapper: :with_label if current_user.can?(:manage_reports)
+      = ff.input :appeal, as: :boolean, wrapper: :with_label if current_user.can?(:manage_appeals)
+      = ff.input :pending_account, as: :boolean, wrapper: :with_label if current_user.can?(:manage_users)
+      = ff.input :trending_tag, as: :boolean, wrapper: :with_label if current_user.can?(:manage_taxonomies)
 
   .fields-group
     = f.input :setting_always_send_emails, as: :boolean, wrapper: :with_label