diff --git a/admin/app/components/solidus_admin/roles/index/component.rb b/admin/app/components/solidus_admin/roles/index/component.rb
new file mode 100644
index 00000000000..6d2063e0fc0
--- /dev/null
+++ b/admin/app/components/solidus_admin/roles/index/component.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::Roles::Index::Component < SolidusAdmin::UsersAndRoles::Component
+ def model_class
+ Spree::Role
+ end
+
+ def search_key
+ :name_cont
+ end
+
+ def search_url
+ solidus_admin.roles_path
+ end
+
+ def row_url(role)
+ solidus_admin.roles_path(role)
+ end
+
+ def page_actions
+ render component("ui/button").new(
+ tag: :a,
+ text: t('.add'),
+ href: solidus_admin.new_role_path, data: { turbo_frame: :new_role_modal },
+ icon: "add-line",
+ )
+ end
+
+ def turbo_frames
+ %w[
+ new_role_modal
+ ]
+ end
+
+ def batch_actions
+ [
+ {
+ label: t('.batch_actions.delete'),
+ action: solidus_admin.roles_path,
+ method: :delete,
+ icon: 'delete-bin-7-line',
+ },
+ ]
+ end
+
+ def scopes
+ [
+ { name: :all, label: t('.scopes.all'), default: true },
+ { name: :admin, label: t('.scopes.admin') },
+ ]
+ end
+
+ def filters
+ []
+ end
+
+ def columns
+ [
+ {
+ header: :role,
+ data: :name,
+ }
+ ]
+ end
+end
diff --git a/admin/app/components/solidus_admin/roles/index/component.yml b/admin/app/components/solidus_admin/roles/index/component.yml
new file mode 100644
index 00000000000..3934c0825e9
--- /dev/null
+++ b/admin/app/components/solidus_admin/roles/index/component.yml
@@ -0,0 +1,6 @@
+en:
+ batch_actions:
+ delete: 'Delete'
+ scopes:
+ admin: Admin
+ all: All
diff --git a/admin/app/components/solidus_admin/roles/new/component.html.erb b/admin/app/components/solidus_admin/roles/new/component.html.erb
new file mode 100644
index 00000000000..dcd5d78d38c
--- /dev/null
+++ b/admin/app/components/solidus_admin/roles/new/component.html.erb
@@ -0,0 +1,17 @@
+<%= turbo_frame_tag :new_role_modal do %>
+ <%= render component("ui/modal").new(title: t(".title")) do |modal| %>
+ <%= form_for @role, url: solidus_admin.roles_path, html: { id: form_id } do |f| %>
+
+ <%= render component("ui/forms/field").text_field(f, :name, class: "required") %>
+
+ <% modal.with_actions do %>
+
+ <%= render component("ui/button").new(form: form_id, type: :submit, text: t('.submit')) %>
+ <% end %>
+ <% end %>
+ <% end %>
+<% end %>
+
+<%= render component("roles/index").new(page: @page) %>
diff --git a/admin/app/components/solidus_admin/roles/new/component.rb b/admin/app/components/solidus_admin/roles/new/component.rb
new file mode 100644
index 00000000000..8c849014bf4
--- /dev/null
+++ b/admin/app/components/solidus_admin/roles/new/component.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::Roles::New::Component < SolidusAdmin::BaseComponent
+ def initialize(page:, role:)
+ @page = page
+ @role = role
+ end
+
+ def form_id
+ dom_id(@role, "#{stimulus_id}_new_role_form")
+ end
+end
diff --git a/admin/app/components/solidus_admin/roles/new/component.yml b/admin/app/components/solidus_admin/roles/new/component.yml
new file mode 100644
index 00000000000..6c318631b74
--- /dev/null
+++ b/admin/app/components/solidus_admin/roles/new/component.yml
@@ -0,0 +1,6 @@
+# Add your component translations here.
+# Use the translation in the example in your template with `t(".hello")`.
+en:
+ title: "New Role"
+ cancel: "Cancel"
+ submit: "Add Role"
diff --git a/admin/app/components/solidus_admin/users/index/component.rb b/admin/app/components/solidus_admin/users/index/component.rb
index 5b555b0912e..46b20878508 100644
--- a/admin/app/components/solidus_admin/users/index/component.rb
+++ b/admin/app/components/solidus_admin/users/index/component.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SolidusAdmin::Users::Index::Component < SolidusAdmin::UI::Pages::Index::Component
+class SolidusAdmin::Users::Index::Component < SolidusAdmin::UsersAndRoles::Component
def model_class
Spree.user_class
end
diff --git a/admin/app/components/solidus_admin/users_and_roles/component.rb b/admin/app/components/solidus_admin/users_and_roles/component.rb
new file mode 100644
index 00000000000..450aa352d0c
--- /dev/null
+++ b/admin/app/components/solidus_admin/users_and_roles/component.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::UsersAndRoles::Component < SolidusAdmin::UI::Pages::Index::Component
+ def title
+ page_header_title safe_join([
+ tag.div(t(".title")),
+ ])
+ end
+
+ def tabs
+ [
+ {
+ text: Spree.user_class.model_name.human(count: 2),
+ href: solidus_admin.users_path,
+ current: model_class == Spree.user_class,
+ },
+ {
+ text: Spree::Role.model_name.human(count: 2),
+ href: solidus_admin.roles_path,
+ current: model_class == Spree::Role,
+ },
+ ]
+ end
+end
diff --git a/admin/app/components/solidus_admin/users_and_roles/component.yml b/admin/app/components/solidus_admin/users_and_roles/component.yml
new file mode 100644
index 00000000000..5fa0dabe81b
--- /dev/null
+++ b/admin/app/components/solidus_admin/users_and_roles/component.yml
@@ -0,0 +1,2 @@
+en:
+ title: "Users and Roles"
diff --git a/admin/app/controllers/solidus_admin/roles_controller.rb b/admin/app/controllers/solidus_admin/roles_controller.rb
new file mode 100644
index 00000000000..7d986e6b679
--- /dev/null
+++ b/admin/app/controllers/solidus_admin/roles_controller.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module SolidusAdmin
+ class RolesController < SolidusAdmin::BaseController
+ include SolidusAdmin::ControllerHelpers::Search
+
+ search_scope(:all)
+ search_scope(:admin) { _1.joins(:role_users).distinct }
+
+ def index
+ set_index_page
+
+ respond_to do |format|
+ format.html { render component('roles/index').new(page: @page) }
+ end
+ end
+
+ def new
+ @role = Spree::Role.new
+
+ set_index_page
+
+ respond_to do |format|
+ format.html { render component('roles/new').new(page: @page, role: @role) }
+ end
+ end
+
+ def create
+ @role = Spree::Role.new(role_params)
+
+ if @role.save
+ respond_to do |format|
+ flash[:notice] = t('.success')
+
+ format.html do
+ redirect_to solidus_admin.roles_path, status: :see_other
+ end
+
+ format.turbo_stream do
+ render turbo_stream: ''
+ end
+ end
+ else
+ set_index_page
+
+ respond_to do |format|
+ format.html do
+ page_component = component('roles/new').new(page: @page, role: @role)
+ render page_component, status: :unprocessable_entity
+ end
+ end
+ end
+ end
+
+ def destroy
+ @roles = Spree::Role.where(id: params[:id])
+
+ Spree::Role.transaction { @roles.destroy_all }
+
+ flash[:notice] = t('.success')
+ redirect_back_or_to solidus_admin.roles_path, status: :see_other
+ end
+
+ private
+
+ def set_index_page
+ roles = apply_search_to(
+ Spree::Role.unscoped.order(id: :desc),
+ param: :q,
+ )
+
+ set_page_and_extract_portion_from(roles)
+ end
+
+ def role_params
+ params.require(:role).permit(:role_id, :name, :description, :type)
+ end
+ end
+end
diff --git a/admin/config/locales/roles.en.yml b/admin/config/locales/roles.en.yml
new file mode 100644
index 00000000000..3017e99ac94
--- /dev/null
+++ b/admin/config/locales/roles.en.yml
@@ -0,0 +1,8 @@
+en:
+ solidus_admin:
+ roles:
+ title: "Roles"
+ destroy:
+ success: "Roles were successfully removed."
+ create:
+ success: "Role was successfully created."
diff --git a/admin/config/routes.rb b/admin/config/routes.rb
index f82f324a81b..d1dc259973c 100644
--- a/admin/config/routes.rb
+++ b/admin/config/routes.rb
@@ -63,6 +63,7 @@
admin_resources :refund_reasons, except: [:show]
admin_resources :reimbursement_types, only: [:index]
admin_resources :return_reasons, only: [:index, :destroy]
+ admin_resources :roles, only: [:index, :new, :create, :destroy]
admin_resources :adjustment_reasons, except: [:show]
admin_resources :store_credit_reasons, except: [:show]
end
diff --git a/admin/spec/features/roles_spec.rb b/admin/spec/features/roles_spec.rb
new file mode 100644
index 00000000000..a05e4f88c47
--- /dev/null
+++ b/admin/spec/features/roles_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe "Roles", :js, type: :feature do
+ before { sign_in create(:admin_user, email: 'admin@example.com') }
+
+ it "lists roles and allows deleting them" do
+ create(:role, name: "Customer Role" )
+ Spree::Role.find_or_create_by(name: 'admin')
+
+ visit "/admin/roles"
+ expect(page).to have_content("Users and Roles")
+ expect(page).to have_content("Customer Role")
+ expect(page).to have_content("admin")
+ click_on "Admin"
+ expect(page).to have_content("admin")
+ expect(page).not_to have_content("Customer Role")
+ click_on "All"
+ expect(page).to have_content("Customer Role")
+ expect(page).to have_content("admin")
+
+ expect(page).to be_axe_clean
+
+ select_row("Customer Role")
+ click_on "Delete"
+ expect(page).to have_content("Roles were successfully removed.")
+ expect(page).not_to have_content("Customer Role")
+ expect(Spree::Role.count).to eq(1)
+ end
+
+ context "when creating a role" do
+ let(:query) { "?page=1&q%5Bname_cont%5D=new" }
+
+ before do
+ visit "/admin/roles#{query}"
+ click_on "Add new"
+ expect(page).to have_content("New Role")
+ expect(page).to be_axe_clean
+ end
+
+ it "opens a modal" do
+ expect(page).to have_selector("dialog")
+ within("dialog") { click_on "Cancel" }
+ expect(page).not_to have_selector("dialog")
+ expect(page.current_url).to include(query)
+ end
+
+ context "with valid data" do
+ it "successfully creates a new role, keeping page and q params" do
+ fill_in "Name", with: "Purchaser"
+
+ click_on "Add Role"
+
+ expect(page).to have_content("Role was successfully created.")
+ expect(Spree::Role.find_by(name: "Purchaser")).to be_present
+ expect(page.current_url).to include(query)
+ end
+ end
+
+ context "with invalid data" do
+ # @note: The only validation that Roles currently have is that names must
+ # be unique (but they can still be blank).
+ before do
+ create(:role, name: "Customer Role" )
+ end
+
+ it "fails to create a new role, keeping page and q params" do
+ fill_in "Name", with: "Customer Role"
+ click_on "Add Role"
+
+ expect(page).to have_content("has already been taken")
+ expect(page.current_url).to include(query)
+ end
+ end
+ end
+end
diff --git a/admin/spec/features/users_spec.rb b/admin/spec/features/users_spec.rb
index ceef0b54d75..2149c1a20b7 100644
--- a/admin/spec/features/users_spec.rb
+++ b/admin/spec/features/users_spec.rb
@@ -11,6 +11,7 @@
create(:user, :with_orders, email: "customer-with-order@example.com")
visit "/admin/users"
+ expect(page).to have_content("Users and Roles")
expect(page).to have_content("customer@example.com")
expect(page).not_to have_content("admin-2@example.com")
click_on "Admins"
diff --git a/admin/spec/requests/solidus_admin/roles_spec.rb b/admin/spec/requests/solidus_admin/roles_spec.rb
new file mode 100644
index 00000000000..fea41daabf6
--- /dev/null
+++ b/admin/spec/requests/solidus_admin/roles_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "SolidusAdmin::RolesController", type: :request do
+ let(:admin_user) { create(:admin_user) }
+ let(:role) { create(:role) }
+
+ before do
+ allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user)
+ Spree::Role.find_or_create_by(name: 'admin')
+ end
+
+ describe "GET /index" do
+ it "renders the index template with a 200 OK status" do
+ get solidus_admin.roles_path
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "GET /new" do
+ it "renders the new template with a 200 OK status" do
+ get solidus_admin.new_role_path
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "POST /create" do
+ context "with valid parameters" do
+ let(:valid_attributes) { { name: "Customer" } }
+
+ it "creates a new Role" do
+ expect {
+ post solidus_admin.roles_path, params: { role: valid_attributes }
+ }.to change(Spree::Role, :count).by(1)
+ end
+
+ it "redirects to the index page with a 303 See Other status" do
+ post solidus_admin.roles_path, params: { role: valid_attributes }
+ expect(response).to redirect_to(solidus_admin.roles_path)
+ expect(response).to have_http_status(:see_other)
+ end
+
+ it "displays a success flash message" do
+ post solidus_admin.roles_path, params: { role: valid_attributes }
+ follow_redirect!
+ expect(response.body).to include("Role was successfully created.")
+ end
+ end
+
+ context "with invalid parameters" do
+ let(:invalid_attributes) { { name: "admin" } }
+
+ it "does not create a new Role" do
+ expect {
+ post solidus_admin.roles_path, params: { role: invalid_attributes }
+ }.not_to change(Spree::Role, :count)
+ end
+
+ it "renders the new template with unprocessable_entity status" do
+ post solidus_admin.roles_path, params: { role: invalid_attributes }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe "DELETE /destroy" do
+ let!(:role_to_delete) { create(:role) }
+
+ it "deletes the role and redirects to the index page with a 303 See Other status" do
+ expect {
+ delete solidus_admin.role_path(role_to_delete)
+ }.to change(Spree::Role, :count).by(-1)
+
+ expect(response).to redirect_to(solidus_admin.roles_path)
+ expect(response).to have_http_status(:see_other)
+ end
+
+ it "displays a success flash message after deletion" do
+ delete solidus_admin.role_path(role_to_delete)
+ follow_redirect!
+ expect(response.body).to include("Roles were successfully removed.")
+ end
+ end
+end