From 23978bef933eb6c763aaf2a743f0a9fb4856747a Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Thu, 2 Oct 2025 19:13:01 +0200 Subject: [PATCH 1/4] Add Spree.admin_user_class and use it across admin/backend; default to user_class. Update initializer template, menu conditions, controllers, views, and helper. --- .../solidus_admin/users/edit/component.html.erb | 2 +- .../solidus_admin/users/index/component.rb | 2 +- .../solidus_admin/users_and_roles/component.rb | 4 ++-- .../solidus_admin/orders_controller.rb | 2 +- .../solidus_admin/store_credits_controller.rb | 2 +- .../solidus_admin/users_controller.rb | 10 +++++----- admin/docs/index_pages.md | 12 ++++++------ .../admin/orders/customer_details_controller.rb | 2 +- .../spree/admin/orders_controller.rb | 2 +- .../spree/admin/search_controller.rb | 4 ++-- .../spree/admin/store_credits_controller.rb | 2 +- .../spree/admin/users/api_key_controller.rb | 2 +- .../controllers/spree/admin/users_controller.rb | 4 ++-- .../spree/admin/store_credit_events_helper.rb | 4 ++-- .../admin/store_credits/edit_amount.html.erb | 2 +- .../app/views/spree/admin/users/edit.html.erb | 2 +- .../app/views/spree/admin/users/index.html.erb | 12 ++++++------ backend/lib/spree/backend_configuration.rb | 2 +- .../templates/config/initializers/spree.rb.tt | 6 ++++++ core/lib/spree/core.rb | 17 +++++++++++++++++ 20 files changed, 59 insertions(+), 36 deletions(-) diff --git a/admin/app/components/solidus_admin/users/edit/component.html.erb b/admin/app/components/solidus_admin/users/edit/component.html.erb index 98401c5d7df..6ab0b956feb 100644 --- a/admin/app/components/solidus_admin/users/edit/component.html.erb +++ b/admin/app/components/solidus_admin/users/edit/component.html.erb @@ -18,7 +18,7 @@ <%= page_with_sidebar do %> <%= page_with_sidebar_main do %> - <%= render component('ui/panel').new(title: Spree.user_class.model_name.human) do %> + <%= render component('ui/panel').new(title: Spree.admin_user_class.model_name.human) do %> <%= solidus_form_for @user, url: solidus_admin.user_path(@user), html: { id: form_id, autocomplete: "off" } do |f| %>
<%= f.text_field(:email) %> diff --git a/admin/app/components/solidus_admin/users/index/component.rb b/admin/app/components/solidus_admin/users/index/component.rb index 1a5c3743911..222694eb949 100644 --- a/admin/app/components/solidus_admin/users/index/component.rb +++ b/admin/app/components/solidus_admin/users/index/component.rb @@ -4,7 +4,7 @@ class SolidusAdmin::Users::Index::Component < SolidusAdmin::UsersAndRoles::Compo include SolidusAdmin::LastLoginHelper def model_class - Spree.user_class + Spree.admin_user_class end def search_key diff --git a/admin/app/components/solidus_admin/users_and_roles/component.rb b/admin/app/components/solidus_admin/users_and_roles/component.rb index f3a81b1dda0..a460eb9449a 100644 --- a/admin/app/components/solidus_admin/users_and_roles/component.rb +++ b/admin/app/components/solidus_admin/users_and_roles/component.rb @@ -10,9 +10,9 @@ def title def tabs [ { - text: Spree.user_class.model_name.human(count: 2), + text: Spree.admin_user_class.model_name.human(count: 2), href: solidus_admin.users_path, - current: model_class == Spree.user_class + current: model_class == Spree.admin_user_class }, { text: Spree::Role.model_name.human(count: 2), diff --git a/admin/app/controllers/solidus_admin/orders_controller.rb b/admin/app/controllers/solidus_admin/orders_controller.rb index a2829f8b3c2..921e5bb96ad 100644 --- a/admin/app/controllers/solidus_admin/orders_controller.rb +++ b/admin/app/controllers/solidus_admin/orders_controller.rb @@ -96,7 +96,7 @@ def variants_for def customers_for load_order - @users = Spree.user_class + @users = Spree.admin_user_class .where.not(id: @order.user_id) .order(created_at: :desc, id: :desc) .ransack(params[:q]) diff --git a/admin/app/controllers/solidus_admin/store_credits_controller.rb b/admin/app/controllers/solidus_admin/store_credits_controller.rb index 2eb000746ef..24813e4d3f2 100644 --- a/admin/app/controllers/solidus_admin/store_credits_controller.rb +++ b/admin/app/controllers/solidus_admin/store_credits_controller.rb @@ -136,7 +136,7 @@ def set_store_credit end def set_user - @user = Spree.user_class.find(params[:user_id]) + @user = Spree.admin_user_class.find(params[:user_id]) end def set_store_credit_reasons diff --git a/admin/app/controllers/solidus_admin/users_controller.rb b/admin/app/controllers/solidus_admin/users_controller.rb index e8a00373628..cfa36ea04b0 100644 --- a/admin/app/controllers/solidus_admin/users_controller.rb +++ b/admin/app/controllers/solidus_admin/users_controller.rb @@ -15,7 +15,7 @@ class UsersController < SolidusAdmin::BaseController def index users = apply_search_to( - Spree.user_class.order(created_at: :desc, id: :desc), + Spree.admin_user_class.order(created_at: :desc, id: :desc), param: :q ) @@ -73,9 +73,9 @@ def edit end def destroy - @users = Spree.user_class.where(id: params[:id]) + @users = Spree.admin_user_class.where(id: params[:id]) - Spree.user_class.transaction { @users.destroy_all } + Spree.admin_user_class.transaction { @users.destroy_all } flash[:notice] = t(".success") redirect_back_or_to users_path, status: :see_other @@ -84,7 +84,7 @@ def destroy private def set_user - @user = Spree.user_class.find(params[:id]) + @user = Spree.admin_user_class.find(params[:id]) end def user_params @@ -123,7 +123,7 @@ def set_items end def authorization_subject - Spree.user_class + Spree.admin_user_class end end end diff --git a/admin/docs/index_pages.md b/admin/docs/index_pages.md index d18c4b93592..7bfefa1d422 100644 --- a/admin/docs/index_pages.md +++ b/admin/docs/index_pages.md @@ -16,7 +16,7 @@ class SolidusAdmin::UsersController < SolidusAdmin::BaseController include SolidusAdmin::ControllerHelpers::Search def index - users = apply_search_to(Spree.user_class.order(id: :desc), param: :q) + users = apply_search_to(Spree.admin_user_class.order(id: :desc), param: :q) # ... ``` @@ -24,7 +24,7 @@ For pagination support, the index action should also call the `set_page_and_extr ```ruby def index - users = apply_search_to(Spree.user_class.order(id: :desc), param: :q) + users = apply_search_to(Spree.admin_user_class.order(id: :desc), param: :q) set_page_and_extract_portion_from(users) # ... ``` @@ -33,7 +33,7 @@ Finally, the index action should render the `index` component passing the `@page ```ruby def index - users = apply_search_to(Spree.user_class.order(id: :desc), param: :q) + users = apply_search_to(Spree.admin_user_class.order(id: :desc), param: :q) set_page_and_extract_portion_from(users) render component('users/index').new(page: @page) end @@ -50,7 +50,7 @@ The index component requires only the `page` argument during initialization, all ```ruby class SolidusAdmin::Users::Index < Solidus::Admin::UI::Pages::Index def model_class - Spree.user_class + Spree.admin_user_class end end @@ -92,7 +92,7 @@ end ```ruby # in the controller def delete - @users = Spree.user_class.where(id: params[:id]) + @users = Spree.admin_user_class.where(id: params[:id]) @users.destroy_all flash[:notice] = "Admin users deleted" redirect_to solidus_admin.users_path, status: :see_other @@ -124,7 +124,7 @@ module SolidusAdmin search_scope(:all) def index - users = apply_search_to(Spree.user_class.order(id: :desc), param: :q) + users = apply_search_to(Spree.admin_user_class.order(id: :desc), param: :q) # ... ``` diff --git a/backend/app/controllers/spree/admin/orders/customer_details_controller.rb b/backend/app/controllers/spree/admin/orders/customer_details_controller.rb index 9aa48007d70..554f5f7563e 100644 --- a/backend/app/controllers/spree/admin/orders/customer_details_controller.rb +++ b/backend/app/controllers/spree/admin/orders/customer_details_controller.rb @@ -26,7 +26,7 @@ def update if @order.contents.update_cart(order_params) if should_associate_user? - requested_user = Spree.user_class.find(params[:user_id]) + requested_user = Spree.admin_user_class.find(params[:user_id]) @order.associate_user!(requested_user, @order.email.blank?) end diff --git a/backend/app/controllers/spree/admin/orders_controller.rb b/backend/app/controllers/spree/admin/orders_controller.rb index b84d5623405..53a8ab1cf6e 100644 --- a/backend/app/controllers/spree/admin/orders_controller.rb +++ b/backend/app/controllers/spree/admin/orders_controller.rb @@ -61,7 +61,7 @@ def index end def new - user = Spree.user_class.find_by(id: params[:user_id]) if params[:user_id] + user = Spree.admin_user_class.find_by(id: params[:user_id]) if params[:user_id] order_importer_params = order_params order_importer_params[:bill_address] = user&.bill_address order_importer_params[:ship_address] = user&.ship_address diff --git a/backend/app/controllers/spree/admin/search_controller.rb b/backend/app/controllers/spree/admin/search_controller.rb index bc96b33fe63..24c1ba4e721 100644 --- a/backend/app/controllers/spree/admin/search_controller.rb +++ b/backend/app/controllers/spree/admin/search_controller.rb @@ -11,9 +11,9 @@ class SearchController < Spree::Admin::BaseController def users @users = if params[:ids] # split here may be String#split or Array#split, so we must flatten the results - Spree.user_class.where(id: params[:ids].split(",").flatten) + Spree.admin_user_class.where(id: params[:ids].split(",").flatten) else - Spree.user_class.ransack({ + Spree.admin_user_class.ransack({ m: "or", email_start: params[:q], name_start: params[:q] diff --git a/backend/app/controllers/spree/admin/store_credits_controller.rb b/backend/app/controllers/spree/admin/store_credits_controller.rb index de861f4b344..f33ec572e77 100644 --- a/backend/app/controllers/spree/admin/store_credits_controller.rb +++ b/backend/app/controllers/spree/admin/store_credits_controller.rb @@ -3,7 +3,7 @@ module Spree module Admin class StoreCreditsController < ResourceController - belongs_to "spree/user", model_class: Spree.user_class + belongs_to "spree/user", model_class: Spree.admin_user_class before_action :load_categories, only: [:new] before_action :load_reasons, only: [:edit_amount, :edit_validity] before_action :ensure_store_credit_reason, only: [:update_amount, :invalidate] diff --git a/backend/app/controllers/spree/admin/users/api_key_controller.rb b/backend/app/controllers/spree/admin/users/api_key_controller.rb index 7920393e440..dbde3c07162 100644 --- a/backend/app/controllers/spree/admin/users/api_key_controller.rb +++ b/backend/app/controllers/spree/admin/users/api_key_controller.rb @@ -21,7 +21,7 @@ def destroy private def user - @user ||= Spree.user_class.find(params[:user_id]) + @user ||= Spree.admin_user_class.find(params[:user_id]) end end end diff --git a/backend/app/controllers/spree/admin/users_controller.rb b/backend/app/controllers/spree/admin/users_controller.rb index 0275b2c7503..4b69778d2b2 100644 --- a/backend/app/controllers/spree/admin/users_controller.rb +++ b/backend/app/controllers/spree/admin/users_controller.rb @@ -21,7 +21,7 @@ def show end def create - @user = Spree.user_class.new(user_params) + @user = Spree.admin_user_class.new(user_params) if @user.save set_roles set_stock_locations @@ -82,7 +82,7 @@ def items end def model_class - Spree.user_class + Spree.admin_user_class end private diff --git a/backend/app/helpers/spree/admin/store_credit_events_helper.rb b/backend/app/helpers/spree/admin/store_credit_events_helper.rb index 5f03d5a5bca..e1c4bad690b 100644 --- a/backend/app/helpers/spree/admin/store_credit_events_helper.rb +++ b/backend/app/helpers/spree/admin/store_credit_events_helper.rb @@ -73,11 +73,11 @@ def store_credit_event_originator_link(store_credit_event) private # Cannot set the value for a user originator - # because Spree.user_class is not defined at that time. + # because Spree.admin_user_class is not defined at that time. # Spree::UserClassHandle does not work here either as # the assignment is evaluated before user_class is set def add_user_originator_link - originator_links[Spree.user_class.to_s] = { + originator_links[Spree.admin_user_class.to_s] = { new_tab: true, href_type: :user, translation_key: "admin.store_credits.user_originator" diff --git a/backend/app/views/spree/admin/store_credits/edit_amount.html.erb b/backend/app/views/spree/admin/store_credits/edit_amount.html.erb index b3ce218b0fb..24b668e4c55 100644 --- a/backend/app/views/spree/admin/store_credits/edit_amount.html.erb +++ b/backend/app/views/spree/admin/store_credits/edit_amount.html.erb @@ -1,4 +1,4 @@ -<% admin_breadcrumb(link_to plural_resource_name(Spree::LegacyUser), spree.admin_users_path) %> +<% admin_breadcrumb(link_to plural_resource_name(Spree.admin_user_class), spree.admin_users_path) %> <% admin_breadcrumb(link_to @user.email, edit_admin_user_url(@user)) %> <% admin_breadcrumb(link_to plural_resource_name(Spree::StoreCredit), spree.admin_user_store_credits_path(@user)) %> <% admin_breadcrumb(link_to Spree::StoreCredit.model_name.human, admin_user_store_credit_path(@user, @store_credit)) %> diff --git a/backend/app/views/spree/admin/users/edit.html.erb b/backend/app/views/spree/admin/users/edit.html.erb index a8b9c8aa76c..71e5d12b264 100644 --- a/backend/app/views/spree/admin/users/edit.html.erb +++ b/backend/app/views/spree/admin/users/edit.html.erb @@ -6,7 +6,7 @@ <%= render partial: 'spree/admin/users/user_page_actions' %>
- <%= Spree.user_class.model_name.human %> + <%= Spree.admin_user_class.model_name.human %>
<%= render partial: 'spree/shared/error_messages', locals: { target: @user } %> diff --git a/backend/app/views/spree/admin/users/index.html.erb b/backend/app/views/spree/admin/users/index.html.erb index fd922000edf..5a73f2e8083 100644 --- a/backend/app/views/spree/admin/users/index.html.erb +++ b/backend/app/views/spree/admin/users/index.html.erb @@ -1,7 +1,7 @@ -<% admin_breadcrumb(plural_resource_name(Spree::LegacyUser)) %> +<% admin_breadcrumb(plural_resource_name(Spree.admin_user_class)) %> <% content_for :page_actions do %> - <% if can?(:admin, Spree.user_class) && can?(:create, Spree.user_class) %> + <% if can?(:admin, Spree.admin_user_class) && can?(:create, Spree.admin_user_class) %>
  • <%= link_to t('spree.new_user'), new_admin_user_url, id: 'admin_new_user_link', class: 'btn btn-primary' %>
  • @@ -24,7 +24,7 @@
    - <%= f.label :spree_roles_id_in, Spree.user_class.human_attribute_name(:spree_roles) %> + <%= f.label :spree_roles_id_in, Spree.admin_user_class.human_attribute_name(:spree_roles) %> <%= f.collection_select :spree_roles_id_in, @roles, :id, :name, {}, multiple: true, class: 'select2 fullwidth' %>
    @@ -69,10 +69,10 @@ - <%= sort_link @search, :email, Spree.user_class.human_attribute_name(:email), {title: 'users_email_title'} %> - <%= Spree.user_class.human_attribute_name(:spree_roles) %> + <%= sort_link @search, :email, Spree.admin_user_class.human_attribute_name(:email), {title: 'users_email_title'} %> + <%= Spree.admin_user_class.human_attribute_name(:spree_roles) %> <%= t('spree.num_orders') %> - <%= Spree.user_class.human_attribute_name(:lifetime_value) %> + <%= Spree.admin_user_class.human_attribute_name(:lifetime_value) %> <%= sort_link @search, :created_at, t('spree.member_since') %> diff --git a/backend/lib/spree/backend_configuration.rb b/backend/lib/spree/backend_configuration.rb index 85d97cb8769..cfc7dad84f2 100644 --- a/backend/lib/spree/backend_configuration.rb +++ b/backend/lib/spree/backend_configuration.rb @@ -234,7 +234,7 @@ def menu_items label: :users, icon: admin_updated_navbar ? "ri-user-line" : "user", match_path: %r{/(users|store_credits)}, - condition: -> { Spree.user_class && can?(:admin, Spree.user_class) }, + condition: -> { Spree.admin_user_class && can?(:admin, Spree.admin_user_class) }, url: :admin_users_path ), MenuItem.new( diff --git a/core/lib/generators/solidus/install/templates/config/initializers/spree.rb.tt b/core/lib/generators/solidus/install/templates/config/initializers/spree.rb.tt index 8512588b178..c1010127b87 100644 --- a/core/lib/generators/solidus/install/templates/config/initializers/spree.rb.tt +++ b/core/lib/generators/solidus/install/templates/config/initializers/spree.rb.tt @@ -86,6 +86,12 @@ end Spree.user_class = <%= options[:user_class].inspect %> <% end -%> +# By default, admin users share the same class as storefront users. +# Override this if you use a separate model for admin users. +<% if options[:user_class].present? -%> + Spree.admin_user_class = <%= options[:user_class].inspect %> +<% end -%> + # Rules for avoiding to store the current path into session for redirects # When at least one rule is matched, the request path will not be stored # in session. diff --git a/core/lib/spree/core.rb b/core/lib/spree/core.rb index 5b2a43bf04b..414e880964b 100644 --- a/core/lib/spree/core.rb +++ b/core/lib/spree/core.rb @@ -29,6 +29,7 @@ module Spree autoload :Deprecation, "spree/deprecation" mattr_accessor :user_class, default: "Spree::LegacyUser" + mattr_accessor :admin_user_class, default: nil def self.user_class if @@user_class.is_a?(Class) @@ -42,6 +43,22 @@ def self.user_class_name @@user_class end + # The class used for admin/backoffice users. + # Falls back to `Spree.user_class` for backwards compatibility. + def self.admin_user_class + return user_class if @@admin_user_class.nil? + + if @@admin_user_class.is_a?(Class) + raise "Spree.admin_user_class MUST be a String or Symbol object, not a Class object." + elsif @@admin_user_class.is_a?(String) || @@admin_user_class.is_a?(Symbol) + @@admin_user_class.to_s.constantize + end + end + + def self.admin_user_class_name + @@admin_user_class || @@user_class + end + # Load the same version defaults for all available Solidus components # # @see Spree::Preferences::Configuration#load_defaults From 87984aa86f045ca4138629be7c42754c39187da8 Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Sat, 1 Aug 2026 17:25:01 +0200 Subject: [PATCH 2/4] Extract lazy class-name proxy for user classes `Spree::UserClassHandle` existed to defer resolving `Spree.user_class` until runtime, since classes can be loaded before the initializer sets it. `Spree.admin_user_class` needs exactly the same treatment, so pull the deferral into a reusable `Spree::ClassProxy`. The proxy takes the setting's name plus a block that reads the current value, and both handles become three-line subclasses. Naming the setting means the "has not been set yet" error points at whichever one is missing rather than always blaming `Spree.user_class`. `UserClassHandle#to_s` is unchanged in behaviour: it still reads `Spree.user_class_name` and still raises the same message. Only `AdminUserClassHandle` is new. Co-Authored-By: Claude Opus 5 (1M context) --- core/lib/spree/user_class_handle.rb | 42 ++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/core/lib/spree/user_class_handle.rb b/core/lib/spree/user_class_handle.rb index 8e01cd83446..8df57e3339b 100644 --- a/core/lib/spree/user_class_handle.rb +++ b/core/lib/spree/user_class_handle.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true module Spree - # Configuration point for User model implementation. + # Configuration point for User model implementations. # - # `Spree::UserClassHandle` allows you to configure your own implementation of a - # User class or use an extension like `solidus_auth_devise`. + # `Spree::UserClassHandle` and `Spree::AdminUserClassHandle` allow you to + # configure your own implementation of a User class or use an extension like + # `solidus_auth_devise`. # - # @note Placeholder for name of Spree.user_class to ensure later evaluation at - # runtime. + # @note Placeholder for the name of a configured class, to ensure later + # evaluation at runtime. # # Unfortunately, it is possible for classes to get loaded before # Spree.user_class has been set in the initializer. As a result, they end up @@ -17,12 +18,33 @@ module Spree # For now, Rails does not call to_s on the instance passed in until runtime. # So this little hack provides a wrapper around Spree.user_class so that we # can basically lazy-evaluate it. Yay! Problem solved forever. - class UserClassHandle - # @return [String] the name of the user class as a string. - # @raise [RuntimeError] if Spree.user_class is nil + class ClassProxy + # @param setting [String] name of the setting the class name is read from, + # used to build the error message when it is unset. + # @yieldreturn [String, nil] the configured class name, resolved lazily. + def initialize(setting, &class_name) + @setting = setting + @class_name = class_name + end + + # @return [String] the name of the configured class as a string. + # @raise [RuntimeError] if the configured class name is nil def to_s - fail "'Spree.user_class' has not been set yet." unless Spree.user_class_name - "::#{Spree.user_class_name}" + class_name = @class_name.call + fail "'#{@setting}' has not been set yet." unless class_name + "::#{class_name}" + end + end + + class UserClassHandle < ClassProxy + def initialize + super("Spree.user_class") { Spree.user_class_name } + end + end + + class AdminUserClassHandle < ClassProxy + def initialize + super("Spree.admin_user_class") { Spree.admin_user_class_name } end end end From 1e91f1b34892661a821fd3ff3f0b11e7b7ced2dd Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Sat, 1 Aug 2026 17:26:00 +0200 Subject: [PATCH 3/4] Build the admin_user factory from admin_user_class The `:admin_user` factory was nested inside `:user`, so it inherited `Spree::UserClassHandle` and always built a `Spree.user_class`. Now that admin users can be a separate model, it should follow `Spree.admin_user_class` instead. Move it to its own file, built on `Spree::AdminUserClassHandle`, and give it its own spec. It no longer inherits from `:user`, so the `email`, `password` and `password_confirmation` attributes are repeated here; the `:with_api_key` and `:with_orders` traits are not, since an admin user model is not assumed to have an API key or orders. This changes what the existing callers of `create(:admin_user)` build. It is a no-op today because `Spree.admin_user_class` falls back to `Spree.user_class`, but applications configuring a dedicated admin user model will get that model in specs from now on. Co-Authored-By: Claude Opus 5 (1M context) --- .../factories/admin_user_factory.rb | 19 +++++++++++++++++++ .../testing_support/factories/user_factory.rb | 7 ------- .../factories/admin_user_factory_spec.rb | 18 ++++++++++++++++++ .../factories/user_factory_spec.rb | 5 ----- 4 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 core/lib/spree/testing_support/factories/admin_user_factory.rb create mode 100644 core/spec/lib/spree/core/testing_support/factories/admin_user_factory_spec.rb diff --git a/core/lib/spree/testing_support/factories/admin_user_factory.rb b/core/lib/spree/testing_support/factories/admin_user_factory.rb new file mode 100644 index 00000000000..00ae889112e --- /dev/null +++ b/core/lib/spree/testing_support/factories/admin_user_factory.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +FactoryBot.define do + # Builds an instance of `Spree.admin_user_class` carrying the admin role. + # + # `Spree.admin_user_class` falls back to `Spree.user_class`, so this behaves + # exactly like a `:user` with the admin role until a host application + # configures a dedicated admin user model. + factory :admin_user, class: Spree::AdminUserClassHandle.new do + email { generate(:email) } + password { "secret" } + password_confirmation { password } + + after(:create) do |user, _| + admin_role = Spree::Role.find_by(name: "admin") || create(:role, name: "admin") + user.spree_roles << admin_role + end + end +end diff --git a/core/lib/spree/testing_support/factories/user_factory.rb b/core/lib/spree/testing_support/factories/user_factory.rb index 315e3b69317..39ea8291bc1 100644 --- a/core/lib/spree/testing_support/factories/user_factory.rb +++ b/core/lib/spree/testing_support/factories/user_factory.rb @@ -18,13 +18,6 @@ end end - factory :admin_user do - after(:create) do |user, _| - admin_role = Spree::Role.find_by(name: "admin") || create(:role, name: "admin") - user.spree_roles << admin_role - end - end - factory :user_with_addresses do |_u| bill_address ship_address diff --git a/core/spec/lib/spree/core/testing_support/factories/admin_user_factory_spec.rb b/core/spec/lib/spree/core/testing_support/factories/admin_user_factory_spec.rb new file mode 100644 index 00000000000..7d8d65ff6f0 --- /dev/null +++ b/core/spec/lib/spree/core/testing_support/factories/admin_user_factory_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" +require "spree/testing_support/shared_examples/working_factory" + +RSpec.describe "admin user factory" do + let(:factory_class) { Spree.admin_user_class } + + describe "admin user" do + let(:factory) { :admin_user } + + it_behaves_like "a working factory" + + it "has the admin role" do + expect(create(factory).spree_roles.map(&:name)).to include("admin") + end + end +end diff --git a/core/spec/lib/spree/core/testing_support/factories/user_factory_spec.rb b/core/spec/lib/spree/core/testing_support/factories/user_factory_spec.rb index 4b3112116e9..0e9b7d93744 100644 --- a/core/spec/lib/spree/core/testing_support/factories/user_factory_spec.rb +++ b/core/spec/lib/spree/core/testing_support/factories/user_factory_spec.rb @@ -11,11 +11,6 @@ it_behaves_like "a working factory" end - describe "admin user" do - let(:factory) { :admin_user } - - it_behaves_like "a working factory" - end describe "user with addresses" do let(:factory) { :user_with_addresses } From cae97a364221b698182d3040f358a994a03b0f4f Mon Sep 17 00:00:00 2001 From: Elia Schito Date: Sun, 2 Aug 2026 01:35:44 +0200 Subject: [PATCH 4/4] Document the admin_user_class setting Explain why the setting exists, not only what it does: sharing one model between customers and administrators means customer signup and admin access share validations and authentication, which is the problem this separation solves. Placed under core/docs/ following the per-gem convention of admin/docs/ and storefront/docs/, since the setting is defined in core. Records two constraints that are easy to trip over: both settings must be given a String or Symbol rather than a Class, because they are resolved lazily; and the install generator writes both explicitly, pointing them at the same class, so the fallback is not what a generated application relies on. Also states what is still missing, so nobody reads the setting as more than it is: it redirects which model the admin interfaces use, but provides no authentication for that model and no generator for a separate admin Devise scope. Co-Authored-By: Claude Opus 5 (1M context) --- core/docs/admin_user_class.md | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 core/docs/admin_user_class.md diff --git a/core/docs/admin_user_class.md b/core/docs/admin_user_class.md new file mode 100644 index 00000000000..3b7f23c4fa2 --- /dev/null +++ b/core/docs/admin_user_class.md @@ -0,0 +1,53 @@ +# Admin User Class Configuration + +Historically Solidus used a single user model for both storefront customers and +admin users, with access to the backoffice decided purely by roles. That means a +customer record and an administrator record share one table, one set of +validations and one authentication configuration, so any weakness in customer +signup is a weakness in admin access. + +`Spree.admin_user_class` lets an application separate the two: + +- `Spree.user_class` is the storefront (customer) user model. +- `Spree.admin_user_class` is the admin/backoffice user model. + +Separating them lets each side carry its own rules: stricter password +requirements, a restricted email domain, or a different authentication setup +entirely for admins, without imposing any of that on customers. + +## Configuration + +In `config/initializers/spree.rb`: + +```ruby +# Storefront user model +Spree.user_class = 'YourApp::User' + +# Admin/backoffice user model +Spree.admin_user_class = 'YourApp::AdminUser' +``` + +`Spree.admin_user_class` falls back to `Spree.user_class` when it is not set, so +existing applications keep working unchanged with a single user model. Note that +the install generator writes both settings explicitly, pointing them at the same +class. + +Both settings must be assigned a String or Symbol, never a Class — they are +resolved lazily, because user models are often loaded before this initializer +runs. + +## Where it's used + +Admin and backoffice code resolves the user model through +`Spree.admin_user_class`, including the classic backend controllers, views and +navigation, and the Solidus Admin controllers and components. In specs, the +`:admin_user` factory builds a `Spree.admin_user_class`. + +Storefront and checkout code continues to use `Spree.user_class`. + +## What this does not do yet + +Setting `Spree.admin_user_class` changes which model the admin interfaces read +and write. It does not by itself give that model authentication — there is no +generator for a separate admin Devise scope yet, so wiring up admin login for a +dedicated model is currently left to the application.