diff --git a/app/controllers/admin/bulk_assign_role_controller.rb b/app/controllers/admin/bulk_assign_role_controller.rb
new file mode 100644
index 000000000..9f1d82ee9
--- /dev/null
+++ b/app/controllers/admin/bulk_assign_role_controller.rb
@@ -0,0 +1,70 @@
+module Admin
+ class BulkAssignRoleController < Admin::ApplicationController
+ layout "admin"
+ include Admin::UsersHelper
+
+ def assign_role
+ if permitted_params[:role].blank?
+ raise ArgumentError,
+ I18n.t("admin.bulk_assign_role_controller.role_blank")
+ end
+
+ role = permitted_params[:role]
+ usernames = permitted_params[:usernames].downcase.split(/\s*,\s*/)
+ note = permitted_params[:note_for_current_role].presence
+ note ||= I18n.t("admin.bulk_assign_role_controller.role_assigment", role: role)
+
+ begin
+ usernames.each do |username|
+ user = User.find_by(username: username)
+ user_action_status = user_action_status(user, role)
+ if user
+ Moderator::ManageActivityAndRoles.handle_user_roles(
+ admin: current_user,
+ user: user,
+ user_params: { user_status: role, note_for_current_role: note },
+ )
+ end
+
+ AuditLog.create(
+ category: "admin.bulk_assign_role.add_role",
+ user: current_user,
+ roles: current_user.roles_name,
+ slug: "bulk_assign_role",
+ data: {
+ role: role,
+ username: username,
+ user_action_status: user_action_status
+ },
+ )
+ end
+ flash[:success] = I18n.t("admin.bulk_assign_role_controller.success_message")
+ rescue StandardError => e
+ flash[:danger] = e.message
+ end
+ redirect_to admin_bulk_assign_role_index_path
+ rescue ArgumentError => e
+ flash[:danger] = e.message
+ redirect_to admin_bulk_assign_role_index_path
+ end
+
+ private
+
+ # We need to override this method from Admin::ApplicationController since
+ # there is no resource to authorize.
+ def authorization_resource; end
+
+ def user_action_status(user, role)
+ if user
+ return "user_already_has_the_role" if user_status(user) == role
+
+ return "role_applied_successfully"
+ end
+ "user_not_found"
+ end
+
+ def permitted_params
+ params.permit(:usernames, :role, :note_for_current_role)
+ end
+ end
+end
diff --git a/app/models/admin_menu.rb b/app/models/admin_menu.rb
index 2d97b1efe..56d20ceb2 100644
--- a/app/models/admin_menu.rb
+++ b/app/models/admin_menu.rb
@@ -8,6 +8,7 @@ class AdminMenu
item(name: "members", controller: "users"),
item(name: "invited members", controller: "invitations"),
item(name: "gdpr actions", controller: "gdpr_delete_requests"),
+ item(name: "bulk assign role", controller: "bulk_assign_role"),
]
scope :content_manager, "dashboard-line", [
diff --git a/app/views/admin/bulk_assign_role/index.html.erb b/app/views/admin/bulk_assign_role/index.html.erb
new file mode 100644
index 000000000..80988155c
--- /dev/null
+++ b/app/views/admin/bulk_assign_role/index.html.erb
@@ -0,0 +1,25 @@
+
+ <%= t("views.admin.users.actions.bulk_assign_role.text") %>
+
+
+ <%= form_with(url: admin_bulk_assign_role_path, local: true, class: "flex flex-col gap-4") do |f| %>
+
+ <%= f.label :role, t("views.admin.users.actions.assign_role.role"), class: "crayons-field__label" %>
+ <%= f.select("role", role_options(current_user)["Statuses"], { include_blank: t("views.admin.users.actions.assign_role.select") }, class: "crayons-select") %>
+
+
+ <%= f.label :usernames, "Usernames", class: "crayons-field__label" %>
+
+ <%= t("views.admin.users.actions.assign_role.warning") %>
+
+ <%= f.text_area :usernames, placeholder: "username1, username2, username3", required: true, size: "40x3", class: "crayons-textfield" %>
+
+
+ <%= f.label :note_for_current_role, t("views.admin.users.actions.assign_role.note"), class: "crayons-field__label" %>
+ <%= f.text_area :note_for_current_role, required: true, class: "crayons-textfield", placeholder: "" %>
+
+
+ <%= f.submit t("views.admin.users.actions.assign_role.text"), class: "c-btn c-btn--primary" %>
+
+ <% end %>
+
diff --git a/config/locales/controllers/admin/en.yml b/config/locales/controllers/admin/en.yml
index 439e99b11..dc90e5f95 100644
--- a/config/locales/controllers/admin/en.yml
+++ b/config/locales/controllers/admin/en.yml
@@ -19,6 +19,10 @@ en:
deleted: Broadcast has been deleted!
updated: Broadcast has been updated!
wrong: Something went wrong with deleting the broadcast.
+ bulk_assign_role_controller:
+ role_assigment: Role %{role} assigned to user.
+ role_blank: Please choose a role to add.
+ success_message: Roles are being added. The task will finish shortly.
consumer_apps_controller:
created: '%{app} has been created!'
deleted: '%{app} has been deleted!'
diff --git a/config/locales/controllers/admin/fr.yml b/config/locales/controllers/admin/fr.yml
index f5d4bede0..7f84b26b9 100644
--- a/config/locales/controllers/admin/fr.yml
+++ b/config/locales/controllers/admin/fr.yml
@@ -19,6 +19,10 @@ fr:
deleted: Le Broadcast a été supprimée !
updated: Le Broadcast a été mise à jour !
wrong: Quelque chose s'est mal passé avec la suppression du Broadcast.
+ bulk_assign_role_controller:
+ role_assigment: Role %{role} assigned to user.
+ role_blank: Please choose a role to add.
+ success_message: Roles are being added. The task will finish shortly.
consumer_apps_controller:
created: '%{app} a été créé !'
deleted: '%{app} a été supprimé !'
diff --git a/config/locales/views/admin/en.yml b/config/locales/views/admin/en.yml
index 301832142..eb1b6d639 100644
--- a/config/locales/views/admin/en.yml
+++ b/config/locales/views/admin/en.yml
@@ -113,6 +113,7 @@ en:
select: Select role
note: "Add a note to this action:"
submit: Add
+ warning: "Comma Delimited, All Lowercase, No @ Symbol Required In Front Of Usernames Warning: Usernames are case-sensitive and must be all lowercase! This task will fail silently when supplied with incorrect usernames."
add_org:
text: Add organization
heading: Add organization
@@ -136,6 +137,8 @@ en:
aria_credits: Amount of credits to add or remove
note: "Add a note to this action:"
submit: Adjust balance
+ bulk_assign_role:
+ text: Bulk Assign Role
copy_email: Copy email address
danger:
desc1: This action is irreversible.
diff --git a/config/locales/views/admin/fr.yml b/config/locales/views/admin/fr.yml
index d6cd02357..3da193b26 100644
--- a/config/locales/views/admin/fr.yml
+++ b/config/locales/views/admin/fr.yml
@@ -113,6 +113,7 @@ fr:
select: Select role
note: "Add a note to this action:"
submit: Add
+ warning: "Comma Delimited, All Lowercase, No @ Symbol Required In Front Of Usernames Warning: Usernames are case-sensitive and must be all lowercase! This task will fail silently when supplied with incorrect usernames."
add_org:
text: Add organization
heading: Add organization
@@ -136,6 +137,8 @@ fr:
aria_credits: Amount of credits to add or remove
note: "Add a note to this action:"
submit: Adjust balance
+ bulk_assign_role:
+ text: Bulk Assign Role
copy_email: Copy email address
danger:
desc1: This action is irreversible.
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 5fce1d6b8..38e446e4f 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -67,6 +67,9 @@ namespace :admin do
end
resources :gdpr_delete_requests, only: %i[index destroy]
+
+ resources :bulk_assign_role, only: %i[index]
+ post "/bulk_assign_role", to: "bulk_assign_role#assign_role"
end
scope :content_manager do
diff --git a/cypress/e2e/seededFlows/adminFlows/bulk_assign_role/bulkAssignRole.spec.js b/cypress/e2e/seededFlows/adminFlows/bulk_assign_role/bulkAssignRole.spec.js
new file mode 100644
index 000000000..8a847b297
--- /dev/null
+++ b/cypress/e2e/seededFlows/adminFlows/bulk_assign_role/bulkAssignRole.spec.js
@@ -0,0 +1,42 @@
+import {
+ verifyAndDismissUserUpdatedMessage,
+ verifyAndDismissDangerMessage,
+} from '../users/userAdminUtilitites';
+describe('Bulk Assign Role', () => {
+ beforeEach(() => {
+ cy.testSetup();
+ cy.fixture('users/adminUser.json').as('user');
+
+ cy.get('@user').then((user) => {
+ cy.loginAndVisit(user, '/admin/member_manager/bulk_assign_role');
+ });
+ });
+
+ it('should show success message for correct fields ', () => {
+ cy.get('.crayons-select').select('Trusted');
+
+ cy.get('.crayons-textfield[name="usernames"]').type('test1, test2, test3');
+
+ cy.get('.crayons-textfield[name="note_for_current_role"]').type(
+ 'Sample note',
+ );
+
+ cy.findByText('Assign role').click();
+
+ verifyAndDismissUserUpdatedMessage(
+ 'Roles are being added. The task will finish shortly.',
+ );
+ });
+
+ it('should show error message if role is not selected ', () => {
+ cy.get('.crayons-textfield[name="usernames"]').type('test1, test2, test3');
+
+ cy.get('.crayons-textfield[name="note_for_current_role"]').type(
+ 'Sample note',
+ );
+
+ cy.findByText('Assign role').click();
+
+ verifyAndDismissDangerMessage('Please choose a role to add.');
+ });
+});
diff --git a/cypress/e2e/seededFlows/adminFlows/users/userAdminUtilitites.js b/cypress/e2e/seededFlows/adminFlows/users/userAdminUtilitites.js
index 85a17bb1f..4a49e47ee 100644
--- a/cypress/e2e/seededFlows/adminFlows/users/userAdminUtilitites.js
+++ b/cypress/e2e/seededFlows/adminFlows/users/userAdminUtilitites.js
@@ -20,3 +20,26 @@ export function verifyAndDismissUserUpdatedMessage(
cy.findByTestId('flash-success').should('not.exist');
}
+
+/**
+ * E2E helper function for user admin tests that validates the correct flash danger message appears
+ *
+ * @param {string} [message="There was some error"] The expected flash message text
+ */
+export function verifyAndDismissDangerMessage(
+ message = 'There was some error',
+) {
+ cy.findByTestId('flash-danger')
+ .as('danger')
+ .then((element) => {
+ expect(element.text().trim()).equal(message);
+ });
+
+ cy.get('@danger').within(() => {
+ cy.findByRole('button', { name: 'Dismiss message' })
+ .should('have.focus')
+ .click();
+ });
+
+ cy.findByTestId('flash-danger').should('not.exist');
+}
diff --git a/spec/requests/admin/bulk_assign_role_spec.rb b/spec/requests/admin/bulk_assign_role_spec.rb
new file mode 100644
index 000000000..2f59e3e0c
--- /dev/null
+++ b/spec/requests/admin/bulk_assign_role_spec.rb
@@ -0,0 +1,118 @@
+require "rails_helper"
+
+RSpec.describe "Admin::BulkAssignRole" do
+ let(:admin) { create(:user, :super_admin) }
+ let(:user1) { create(:user) }
+ let(:user2) { create(:user) }
+ let(:user3) { create(:user) }
+ let(:usernames_string) { "#{user1.username}, #{user2.username}, #{user3.username}" }
+ let(:role) { "Trusted" }
+ let(:note_for_current_role) { "Assigned Trusted role" }
+ let(:extra_space_usernames_string) { "#{user1.username}, #{user2.username}, #{user3.username}" }
+ let(:invalid_role) { "Invalid Role" }
+
+ describe "POST /admin/member_manager/bulk_assign_role" do
+ before do
+ sign_in admin
+ end
+
+ it "adds trusted role successfully" do
+ params = { usernames: usernames_string, role: role, note_for_current_role: note_for_current_role }
+ post admin_bulk_assign_role_path, params: params
+
+ expect(response).to redirect_to(admin_bulk_assign_role_index_path)
+ expect(flash[:success]).to eq(I18n.t("admin.bulk_assign_role_controller.success_message"))
+
+ expect(user1.roles.count).to eq(1)
+ expect(user1.roles.last.name).to eq(role.downcase)
+
+ expect(user2.roles.count).to eq(1)
+ expect(user2.roles.last.name).to eq(role.downcase)
+
+ expect(user3.roles.count).to eq(1)
+ expect(user3.roles.last.name).to eq(role.downcase)
+ end
+
+ it "adds role with extra whitespace in usernames" do
+ params = { usernames: extra_space_usernames_string, role: role, note_for_current_role: note_for_current_role }
+ post admin_bulk_assign_role_path, params: params
+
+ expect(user1.roles.count).to eq(1)
+ expect(user1.roles.last.name).to eq(role.downcase)
+
+ expect(user2.roles.count).to eq(1)
+ expect(user2.roles.last.name).to eq(role.downcase)
+
+ expect(user3.roles.count).to eq(1)
+ expect(user3.roles.last.name).to eq(role.downcase)
+ end
+
+ it "shows error if role is blank" do
+ params = { usernames: usernames_string, note_for_current_role: note_for_current_role }
+ post admin_bulk_assign_role_path, params: params
+
+ expect(response).to redirect_to(admin_bulk_assign_role_index_path)
+ expect(flash[:danger]).to eq(I18n.t("admin.bulk_assign_role_controller.role_blank"))
+
+ expect(user1.roles.count).to eq(0)
+ expect(user2.roles.count).to eq(0)
+ expect(user3.roles.count).to eq(0)
+ end
+
+ it "adds default note if user input is empty" do
+ params = { usernames: usernames_string, role: role }
+ post admin_bulk_assign_role_path, params: params
+
+ expect(user1.notes.last.content).to eq(I18n.t("admin.bulk_assign_role_controller.role_assigment", role: role))
+ end
+
+ it "adds role to valid usernames only and creates user_not_found AuditLog" do
+ invalid_username = "invalidusername"
+ params = { usernames: "#{user1.username}, #{user2.username}, #{invalid_username}", role: role,
+ note_for_current_role: note_for_current_role }
+ post admin_bulk_assign_role_path, params: params
+
+ expect(user1.roles.count).to eq(1)
+ expect(user1.roles.last.name).to eq(role.downcase)
+
+ expect(user2.roles.count).to eq(1)
+ expect(user2.roles.last.name).to eq(role.downcase)
+
+ log = AuditLog.last
+ expect(log.data["role"]).to eq(role)
+ expect(log.data["username"]).to eq(invalid_username)
+ expect(log.data["user_action_status"]).to eq("user_not_found")
+ end
+
+ it "adds successful AuditLog" do
+ params = { usernames: usernames_string, role: role, note_for_current_role: note_for_current_role }
+ post admin_bulk_assign_role_path, params: params
+
+ logs = AuditLog.last(3)
+ expect(logs[0].category).to eq("admin.bulk_assign_role.add_role")
+ expect(logs[0].user).to eq(admin)
+
+ expect(logs[0].data["username"]).to eq(user1.username)
+ expect(logs[0].data["user_action_status"]).to eq("role_applied_successfully")
+
+ expect(logs[1].data["username"]).to eq(user2.username)
+ expect(logs[1].data["user_action_status"]).to eq("role_applied_successfully")
+
+ expect(logs[2].data["username"]).to eq(user3.username)
+ expect(logs[2].data["user_action_status"]).to eq("role_applied_successfully")
+ end
+
+ it "adds role already applied AuditLog" do
+ params = { usernames: user1.username.to_s, role: role, note_for_current_role: note_for_current_role }
+ post admin_bulk_assign_role_path, params: params
+
+ # Adding the role again to trigger the "user_already_has_the_role" AuditLog.
+ post admin_bulk_assign_role_path, params: params
+
+ log = AuditLog.last
+ expect(log.data["role"]).to eq(role)
+ expect(log.data["username"]).to eq(user1.username)
+ expect(log.data["user_action_status"]).to eq("user_already_has_the_role")
+ end
+ end
+end