spree_cm_commissioner 2.12.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10936d8185cc09d4bd8eca8c3c2626cb9bb113d2e94695030be009f9b38edbcc
4
- data.tar.gz: 8b1493341611af89e52c06017d1abc3ad6adc838a541dd3ec93dd76877985eaf
3
+ metadata.gz: 91f4f80720e207b672673f48cdfbf75d0efeaba681a61a7177c537c8741d5ccf
4
+ data.tar.gz: af1ca6770a66095ff54bf84dcb2840fa0a5c6578de079e8679cd28307182bc20
5
5
  SHA512:
6
- metadata.gz: f945b868565fa7e4f699c07c35f20bb324b4c87d910f96738d4d4049e5b17d7c1455fd3227c4ab699bf85a449349ab4bc0cdd52b1b3d55bd519c48762da39ab6
7
- data.tar.gz: d6fd25adafc7a2ad5e710043fbd8dc062008292df46d4819598a0a45333fd4dbbccae1428b6bc389ec6c1bcde2ffd9f7ddd1665a5974ff14989148622fabcadd
6
+ metadata.gz: 86639ec7ba1a728feaa59ed52ddf25e02687988611ff687ede1d0a8af1eb86590a26f95585a5f793ae549193cc2caa6aadee71fe6f0d5b51cdaa58b26437aebe
7
+ data.tar.gz: abae4f7a29cbe9f9963b682eb68c27f1dc77320a4495832da75d3ced6db2f8438a0c78b017362d49b21bdb800e1f78762eb03ce3122ce0834062a118ec6510f3
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.12.0)
37
+ spree_cm_commissioner (2.12.1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -3,6 +3,7 @@ module Spree
3
3
  module UsersControllerDecorator
4
4
  def self.prepended(base)
5
5
  base.before_action :build_profile, only: %i[create update]
6
+ base.before_action :downcase_normalized_login_search_param, only: :index
6
7
  end
7
8
 
8
9
  # override
@@ -81,6 +82,14 @@ module Spree
81
82
 
82
83
  @user.build_profile(attachment: permitted_resource_params.delete(:profile))
83
84
  end
85
+
86
+ # normalized_login is stored lowercase, so a mixed-case search term would never
87
+ # match under a plain LIKE/ILIKE — downcase it before Ransack builds the query.
88
+ def downcase_normalized_login_search_param
89
+ return unless params[:q]&.key?(:normalized_login_cont)
90
+
91
+ params[:q][:normalized_login_cont] = params[:q][:normalized_login_cont].to_s.downcase
92
+ end
84
93
  end
85
94
  end
86
95
  end
@@ -3,8 +3,8 @@ module Spree
3
3
  module V2
4
4
  module Storefront
5
5
  class QueueDraftOrdersController < ::Spree::Api::V2::ResourceController
6
- # Creating the order (once the job runs) acquires the inventory hold, so honor the
7
- # load-test bypass here too, same as DraftOrdersController.
6
+ # Sets Current.load_test_bypass from the X-Request-Verify secret. The hold is acquired later
7
+ # in ProcessJob, so `create` passes this flag through options for the job to restore there.
8
8
  include SpreeCmCommissioner::LoadTestBypassable
9
9
 
10
10
  # Accepts a draft order request without building it: writes a queued Firestore status
@@ -27,7 +27,9 @@ module Spree
27
27
  options: {
28
28
  device_id: device_id,
29
29
  last_ip_address: request.remote_ip,
30
- private_sale_token: params[:st]
30
+ private_sale_token: params[:st],
31
+ # Carried into the job so it can bypass hold limits for a load test (see ProcessJob).
32
+ load_test_bypass: SpreeCmCommissioner::Current.load_test_bypass
31
33
  }
32
34
  },
33
35
  locale: I18n.locale.to_s
@@ -13,9 +13,13 @@ module SpreeCmCommissioner
13
13
 
14
14
  def perform(document_path, create_type, creation_params, locale = nil)
15
15
  I18n.with_locale(locale.presence || I18n.default_locale) do
16
- SpreeCmCommissioner::QueueDraftOrders::Process.call(
17
- document_path: document_path, create_type: create_type, creation_params: creation_params
18
- )
16
+ # Current doesn't cross into the job, so restore the load-test-bypass flag here — otherwise
17
+ # ValidateLimits would reject a trusted load test.
18
+ SpreeCmCommissioner::Current.set(load_test_bypass: creation_params.dig(:options, :load_test_bypass) || false) do
19
+ SpreeCmCommissioner::QueueDraftOrders::Process.call(
20
+ document_path: document_path, create_type: create_type, creation_params: creation_params
21
+ )
22
+ end
19
23
  end
20
24
  end
21
25
  end
@@ -21,8 +21,10 @@ module SpreeCmCommissioner
21
21
  validates :phone_number, uniqueness: { scope: :tenant_id, allow_blank: true }
22
22
  validates :phone_number, format: { with: /^\d{8,12}$/, :multiline => true, allow_blank: true }
23
23
 
24
+ before_validation :normalize_login
25
+
24
26
  validates :login, presence: true, if: -> (u) { u.require_login_identity_all_blank_for(:login) && !u.registering_via_social_network }
25
- validates :login, uniqueness: { scope: :tenant_id, case_sensitive: false, allow_blank: true }
27
+ validate :validate_normalized_login_uniqueness
26
28
 
27
29
  _validators.reject! { |key, _| key == :email }
28
30
  _validate_callbacks.each { |c| c.filter.attributes.delete(:email) if c.filter.respond_to?(:attributes) }
@@ -46,7 +48,7 @@ module SpreeCmCommissioner
46
48
  elsif login =~ URI::MailTo::EMAIL_REGEXP
47
49
  scope.find_by(email: login)
48
50
  else
49
- scope.find_by(login: login)
51
+ scope.find_by(normalized_login: login)
50
52
  end
51
53
  end
52
54
  end
@@ -58,6 +60,23 @@ module SpreeCmCommissioner
58
60
 
59
61
  protected
60
62
 
63
+ # Keeps a plain, indexable lowercase copy of `login` so uniqueness and lookups can
64
+ # use a straight equality match instead of LOWER(login) everywhere, without ever
65
+ # rewriting the user's own `login` value (shown to them as a real handle elsewhere).
66
+ def normalize_login
67
+ self.normalized_login = login.present? ? login.downcase : nil
68
+ end
69
+
70
+ # Attaches the error to :login (the field shown to and edited by the user), not
71
+ # :normalized_login, so existing forms/specs keyed on `errors[:login]` keep working.
72
+ def validate_normalized_login_uniqueness
73
+ return if normalized_login.blank?
74
+
75
+ scope = self.class.where(tenant_id: tenant_id, normalized_login: normalized_login)
76
+ scope = scope.where.not(id: id) if persisted?
77
+ errors.add(:login, :taken) if scope.exists?
78
+ end
79
+
61
80
  def require_login_identity_all_blank_for(field)
62
81
  required_fields = %i[phone_number email user_identity_providers login].reject { |item| item == field }
63
82
  required_fields.each do |required_field|
@@ -94,7 +94,7 @@ module SpreeCmCommissioner
94
94
  base.before_save :update_otp_enabled
95
95
 
96
96
  base.attr_accessor :assigned_roles
97
- base.whitelisted_ransackable_attributes = %w[email first_name last_name gender phone_number blocked_at]
97
+ base.whitelisted_ransackable_attributes = %w[email first_name last_name gender phone_number blocked_at normalized_login]
98
98
  base.devise :two_factor_authenticatable
99
99
  def base.end_users
100
100
  joins('LEFT JOIN spree_vendor_users ON spree_users.id = spree_vendor_users.user_id').where(spree_vendor_users: { user_id: nil })
@@ -4,13 +4,13 @@
4
4
  service_types = SpreeCmCommissioner::ServiceType.translated_service_types.map { |key, label| [label, key.to_s] }
5
5
  %>
6
6
 
7
- <div data-hook="admin_user_form_user_section" class="p-3 mt-4 rounded border form-group bg-light">
7
+ <div data-hook="admin_user_form_user_section" class="p-3 mt-4 border rounded form-group bg-light">
8
8
  <strong class="mb-3 d-block"><%= Spree.t(:add_on) %></strong>
9
9
 
10
10
  <%= f.field_container :primary_service_type do %>
11
11
  <%= f.label :primary_service_type, Spree.t(:primary_service_type) %>
12
12
  <%= f.select :primary_service_type,
13
- options_for_select(service_types, f.object.primary_service_type),
13
+ options_for_select(service_types, f.object.primary_service_type || service_types.first&.last),
14
14
  { include_blank: Spree.t(:select_service_type) },
15
15
  class: 'form-control select2-clear' %>
16
16
  <%= f.error_message_on :primary_service_type %>
@@ -7,6 +7,9 @@
7
7
  <span class="badge badge-danger text-uppercase"><%= I18n.t('spree_user.blocked') %></span>
8
8
  <% end %>
9
9
  </td>
10
+ <td class='login'>
11
+ <%= user.login %>
12
+ </td>
10
13
  <td class='first_name'>
11
14
  <%= user.first_name %>
12
15
  </td>
@@ -4,6 +4,9 @@
4
4
  <th>
5
5
  <%= sort_link @search, :email, Spree.t(:email), {}, {title: 'users_email_title'} %>
6
6
  </th>
7
+ <th>
8
+ <%= sort_link @search, :login, Spree.t(:login), {}, {title: 'login_title'} %>
9
+ </th>
7
10
  <th>
8
11
  <%= sort_link @search, :first_name, Spree.t(:first_name), {}, {title: 'first_name_title'} %>
9
12
  </th>
@@ -39,6 +39,12 @@
39
39
  <%= f.telephone_field :phone_number_cont, class: 'form-control js-filterable', autofocus: true, placeholder: 'xxx-xxx-xxxx'%>
40
40
  </div>
41
41
  </div>
42
+ <div class="col-12 col-lg-6">
43
+ <div class="form-group">
44
+ <%= f.label :normalized_login_cont, Spree.t(:login) %>
45
+ <%= f.text_field :normalized_login_cont, class: 'form-control js-filterable' %>
46
+ </div>
47
+ </div>
42
48
  <div class="col-12 col-lg-6">
43
49
  <div class="form-group">
44
50
  <%# blocked_at_null: false = blocked, true = active. Ransack drops the blank option, so an
@@ -0,0 +1,48 @@
1
+ module SpreeCmCommissioner
2
+ module Users
3
+ class DedupeLogins
4
+ prepend Spree::ServiceModule::Base
5
+
6
+ def call
7
+ renamed_count = duplicate_groups.sum { |tenant_id, normalized_login| rename_duplicates(tenant_id, normalized_login) }
8
+ success(renamed_count: renamed_count)
9
+ rescue StandardError => e
10
+ failure(nil, e.message)
11
+ end
12
+
13
+ private
14
+
15
+ # login uniqueness is scoped to tenant_id, so duplicates must be grouped the same way
16
+ # or users in different tenants would be falsely flagged as colliding.
17
+ def duplicate_groups
18
+ Spree::User.where.not(normalized_login: [nil, ''])
19
+ .group(:tenant_id, :normalized_login)
20
+ .having('count(*) > 1')
21
+ .pluck(:tenant_id, :normalized_login)
22
+ end
23
+
24
+ def rename_duplicates(tenant_id, normalized_login)
25
+ # keep the oldest account's login untouched; rename the rest
26
+ duplicates = Spree::User.where(tenant_id: tenant_id, normalized_login: normalized_login)
27
+ .order(:created_at)
28
+ .offset(1)
29
+ .to_a
30
+
31
+ duplicates.each { |user| rename_user(user, tenant_id) }
32
+ duplicates.size
33
+ end
34
+
35
+ def rename_user(user, tenant_id)
36
+ old_login = user.login
37
+ new_login = SpreeCmCommissioner::Users::Username::Generate.call(user: user)
38
+ # update_column skips callbacks, so normalized_login must be set explicitly here too.
39
+ user.update_columns(login: new_login, normalized_login: new_login.downcase) # rubocop:disable Rails/SkipsModelValidations
40
+
41
+ Rails.logger.info do
42
+ "[SpreeCmCommissioner::Users::DedupeLogins] user_id=#{user.id} tenant_id=#{tenant_id.inspect} " \
43
+ "login=#{old_login.inspect} -> #{new_login.inspect}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -18,7 +18,7 @@ module SpreeCmCommissioner
18
18
  login = payload['login']
19
19
 
20
20
  # we don't reveal whether user exists or not in error messages for security reasons.
21
- user = Spree::User.find_by(login: login)
21
+ user = Spree::User.find_by(normalized_login: login.to_s.downcase)
22
22
  raise JWT::DecodeError, 'QR code is invalid' if user.nil?
23
23
 
24
24
  JWT.decode(qr_data, user.secure_token, true, { algorithm: 'HS256' })
@@ -27,7 +27,7 @@ module SpreeCmCommissioner
27
27
  def call
28
28
  @max_attempts.times do
29
29
  username = generate_clean_username
30
- return username unless Spree::User.exists?(login: username)
30
+ return username unless Spree::User.exists?(normalized_login: username.downcase)
31
31
  end
32
32
 
33
33
  # Last resort: use extended number range
@@ -1,15 +1,15 @@
1
1
  <%= render partial: 'spree/admin/users/tabs', locals: { current: :security } %>
2
2
 
3
- <div class="card shadow-sm">
3
+ <div class="shadow-sm card">
4
4
  <div class="card-header bg-light">
5
- <h5 class="card-title mb-0 h6">
5
+ <h5 class="mb-0 card-title h6">
6
6
  <%= Spree.t(:two_factor_authentication) %>
7
7
  </h5>
8
8
  </div>
9
9
 
10
- <div class="card-body text-center">
10
+ <div class="text-center card-body">
11
11
  <% if @user.otp_secret.present? && @user.otp_required_for_login == true %>
12
- <p class="text-muted mb-3">
12
+ <p class="mb-3 text-muted">
13
13
  Two-factor authentication is currently active for this user.
14
14
  </p>
15
15
  <%= button_to disable_authenticator_admin_user_security_path, method: :delete, class: "btn btn-danger btn-md",
@@ -17,9 +17,50 @@
17
17
  Disable 2FA
18
18
  <% end %>
19
19
  <% else %>
20
- <p class="text-muted mb-0">
20
+ <p class="mb-0 text-muted">
21
21
  Two-factor authentication is <strong>not enabled</strong> for this user.
22
22
  </p>
23
23
  <% end %>
24
24
  </div>
25
25
  </div>
26
+
27
+ <div data-hook="admin_user_account_status" class="mt-3 shadow-sm card">
28
+ <div class="card-header bg-light">
29
+ <h5 class="mb-0 card-title h6">
30
+ <%= I18n.t('spree_user.account_status') %>
31
+ </h5>
32
+ </div>
33
+
34
+ <div class="card-body">
35
+ <% if @user.blocked? %>
36
+ <p>
37
+ <span class="badge badge-danger text-uppercase"><%= I18n.t('spree_user.blocked') %></span>
38
+ <span class="ml-2 text-muted"><%= I18n.t('spree_user.blocked_at', time: l(@user.blocked_at, format: :long)) %></span>
39
+ </p>
40
+
41
+ <% if @user.blocked_reason.present? %>
42
+ <p class="text-muted"><%= @user.blocked_reason %></p>
43
+ <% end %>
44
+
45
+ <%= form_tag spree.unblock_admin_user_path(@user), method: :put do %>
46
+ <%= button_tag I18n.t('spree_user.unblock_user'),
47
+ type: 'submit',
48
+ class: 'btn btn-primary',
49
+ data: { confirm: I18n.t('spree_user.unblock_confirmation') } %>
50
+ <% end %>
51
+ <% else %>
52
+ <%= form_tag spree.block_admin_user_path(@user), method: :put do %>
53
+ <div class="form-group">
54
+ <%= label_tag :blocked_reason, I18n.t('spree_user.blocked_reason') %>
55
+ <%= text_field_tag :blocked_reason, nil, class: 'form-control' %>
56
+ <small class="form-text text-muted"><%= I18n.t('spree_user.blocked_reason_hint') %></small>
57
+ </div>
58
+
59
+ <%= button_tag I18n.t('spree_user.block_user'),
60
+ type: 'submit',
61
+ class: 'btn btn-danger',
62
+ data: { confirm: I18n.t('spree_user.block_confirmation') } %>
63
+ <% end %>
64
+ <% end %>
65
+ </div>
66
+ </div>
@@ -0,0 +1,39 @@
1
+ class AddNormalizedLoginToSpreeUsers < ActiveRecord::Migration[7.0]
2
+ def up
3
+ add_column :spree_users, :normalized_login, :string unless column_exists?(:spree_users, :normalized_login)
4
+
5
+ execute <<~SQL.squish
6
+ UPDATE spree_users SET normalized_login = LOWER(login)
7
+ WHERE login IS NOT NULL AND login != ''
8
+ SQL
9
+
10
+ # Case-insensitive login uniqueness has only ever been enforced by the Rails
11
+ # validation, not the DB, so case-variant duplicates already exist. Clean them
12
+ # up first or the unique index below fails to create.
13
+ result = SpreeCmCommissioner::Users::DedupeLogins.call
14
+ raise "Failed to dedupe user logins before indexing: #{result.error}" if result.failure?
15
+
16
+ unless index_exists?(:spree_users, %i[tenant_id normalized_login], name: 'index_spree_users_on_tenant_id_and_normalized_login')
17
+ add_index :spree_users, %i[tenant_id normalized_login],
18
+ unique: true,
19
+ where: 'tenant_id IS NOT NULL AND normalized_login IS NOT NULL',
20
+ name: 'index_spree_users_on_tenant_id_and_normalized_login'
21
+ end
22
+
23
+ # tenant_id IS NULL rows aren't covered by the composite index above (NULL never
24
+ # equals NULL in a unique index), but the validation's `scope: :tenant_id` treats
25
+ # all null-tenant users as one group, so they need their own partial index to match.
26
+ return if index_exists?(:spree_users, :normalized_login, name: 'index_spree_users_on_normalized_login_when_tenant_id_null')
27
+
28
+ add_index :spree_users, :normalized_login,
29
+ unique: true,
30
+ where: 'tenant_id IS NULL AND normalized_login IS NOT NULL',
31
+ name: 'index_spree_users_on_normalized_login_when_tenant_id_null'
32
+ end
33
+
34
+ def down
35
+ remove_index :spree_users, name: 'index_spree_users_on_tenant_id_and_normalized_login', if_exists: true
36
+ remove_index :spree_users, name: 'index_spree_users_on_normalized_login_when_tenant_id_null', if_exists: true
37
+ remove_column :spree_users, :normalized_login if column_exists?(:spree_users, :normalized_login)
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.12.0'.freeze
2
+ VERSION = '2.12.1'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12.0
4
+ version: 2.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-27 00:00:00.000000000 Z
11
+ date: 2026-08-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -1938,7 +1938,6 @@ files:
1938
1938
  - app/overrides/spree/admin/users/_form/roles_fields.html.erb.deface
1939
1939
  - app/overrides/spree/admin/users/_form/user_add_on.html.erb.deface
1940
1940
  - app/overrides/spree/admin/users/_tabs/tabs.html.erb.deface
1941
- - app/overrides/spree/admin/users/edit/account_status.html.erb.deface
1942
1941
  - app/overrides/spree/admin/users/index/body.html.erb.deface
1943
1942
  - app/overrides/spree/admin/users/index/headers.html.erb.deface
1944
1943
  - app/overrides/spree/admin/users/index/ransack.html.erb.deface
@@ -2424,6 +2423,7 @@ files:
2424
2423
  - app/services/spree_cm_commissioner/user_counters_service.rb
2425
2424
  - app/services/spree_cm_commissioner/user_roles_assigner.rb
2426
2425
  - app/services/spree_cm_commissioner/users/block.rb
2426
+ - app/services/spree_cm_commissioner/users/dedupe_logins.rb
2427
2427
  - app/services/spree_cm_commissioner/users/qr_data/extract_login.rb
2428
2428
  - app/services/spree_cm_commissioner/users/qr_data/generate.rb
2429
2429
  - app/services/spree_cm_commissioner/users/qr_data/invalidate.rb
@@ -3547,6 +3547,7 @@ files:
3547
3547
  - db/migrate/20260820100000_create_cm_private_sales.rb
3548
3548
  - db/migrate/20260824120000_add_user_id_to_cm_waiting_room_sessions.rb
3549
3549
  - db/migrate/20260825092416_add_live_stock_window_to_spree_taxons.rb
3550
+ - db/migrate/20260828080349_add_normalized_login_to_spree_users.rb
3550
3551
  - docker-compose.yml
3551
3552
  - docs/api/scoped-access-token-endpoints.md
3552
3553
  - docs/option_types/attr_types.md
@@ -1,42 +0,0 @@
1
- <!-- insert_after "[data-hook='admin_user_edit_general_settings']" -->
2
-
3
- <div data-hook="admin_user_account_status" class="card mb-3">
4
- <div class="card-header">
5
- <h5 class="card-title mb-0 h6">
6
- <%= I18n.t('spree_user.account_status') %>
7
- </h5>
8
- </div>
9
-
10
- <div class="card-body">
11
- <% if @user.blocked? %>
12
- <p>
13
- <span class="badge badge-danger text-uppercase"><%= I18n.t('spree_user.blocked') %></span>
14
- <span class="text-muted ml-2"><%= I18n.t('spree_user.blocked_at', time: l(@user.blocked_at, format: :long)) %></span>
15
- </p>
16
-
17
- <% if @user.blocked_reason.present? %>
18
- <p class="text-muted"><%= @user.blocked_reason %></p>
19
- <% end %>
20
-
21
- <%= form_tag spree.unblock_admin_user_path(@user), method: :put do %>
22
- <%= button_tag I18n.t('spree_user.unblock_user'),
23
- type: 'submit',
24
- class: 'btn btn-primary',
25
- data: { confirm: I18n.t('spree_user.unblock_confirmation') } %>
26
- <% end %>
27
- <% else %>
28
- <%= form_tag spree.block_admin_user_path(@user), method: :put do %>
29
- <div class="form-group">
30
- <%= label_tag :blocked_reason, I18n.t('spree_user.blocked_reason') %>
31
- <%= text_field_tag :blocked_reason, nil, class: 'form-control' %>
32
- <small class="form-text text-muted"><%= I18n.t('spree_user.blocked_reason_hint') %></small>
33
- </div>
34
-
35
- <%= button_tag I18n.t('spree_user.block_user'),
36
- type: 'submit',
37
- class: 'btn btn-danger',
38
- data: { confirm: I18n.t('spree_user.block_confirmation') } %>
39
- <% end %>
40
- <% end %>
41
- </div>
42
- </div>