spree_api 5.5.3 → 5.6.0.rc1

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.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +3 -1
  3. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +1 -1
  4. data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +4 -2
  5. data/app/controllers/concerns/spree/api/v3/locale_and_currency.rb +4 -1
  6. data/app/controllers/concerns/spree/api/v3/storefront_gating.rb +65 -0
  7. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +1 -1
  8. data/app/controllers/spree/api/v3/admin/categories/products_controller.rb +98 -0
  9. data/app/controllers/spree/api/v3/admin/categories_controller.rb +102 -2
  10. data/app/controllers/spree/api/v3/admin/customers_controller.rb +8 -2
  11. data/app/controllers/spree/api/v3/admin/exports_controller.rb +3 -1
  12. data/app/controllers/spree/api/v3/admin/import_rows_controller.rb +49 -0
  13. data/app/controllers/spree/api/v3/admin/imports_controller.rb +274 -0
  14. data/app/controllers/spree/api/v3/admin/locales_controller.rb +29 -0
  15. data/app/controllers/spree/api/v3/admin/me_controller.rb +39 -9
  16. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +62 -2
  17. data/app/controllers/spree/api/v3/admin/password_resets_controller.rb +105 -0
  18. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +1 -8
  19. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +1 -1
  20. data/app/controllers/spree/api/v3/admin/products_controller.rb +8 -7
  21. data/app/controllers/spree/api/v3/admin/promotions_controller.rb +1 -1
  22. data/app/controllers/spree/api/v3/admin/resource_controller.rb +31 -0
  23. data/app/controllers/spree/api/v3/admin/store_controller.rb +3 -0
  24. data/app/controllers/spree/api/v3/admin/translatable_resources_controller.rb +38 -0
  25. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +113 -0
  26. data/app/controllers/spree/api/v3/admin/translations_controller.rb +106 -0
  27. data/app/controllers/spree/api/v3/resource_controller.rb +0 -2
  28. data/app/controllers/spree/api/v3/store/auth_controller.rb +3 -1
  29. data/app/controllers/spree/api/v3/store/base_controller.rb +5 -0
  30. data/app/controllers/spree/api/v3/store/carts/fulfillments_controller.rb +1 -3
  31. data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -0
  32. data/app/controllers/spree/api/v3/store/countries_controller.rb +1 -0
  33. data/app/controllers/spree/api/v3/store/currencies_controller.rb +1 -0
  34. data/app/controllers/spree/api/v3/store/customer/password_resets_controller.rb +5 -1
  35. data/app/controllers/spree/api/v3/store/customers_controller.rb +3 -1
  36. data/app/controllers/spree/api/v3/store/digitals_controller.rb +1 -0
  37. data/app/controllers/spree/api/v3/store/locales_controller.rb +3 -8
  38. data/app/controllers/spree/api/v3/store/markets/countries_controller.rb +1 -0
  39. data/app/controllers/spree/api/v3/store/markets_controller.rb +1 -0
  40. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +1 -0
  41. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +5 -2
  42. data/app/controllers/spree/api/v3/store/products_controller.rb +1 -1
  43. data/app/controllers/spree/api/v3/store/resource_controller.rb +1 -0
  44. data/app/serializers/concerns/spree/api/v3/admin/translatable.rb +38 -0
  45. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +7 -1
  46. data/app/serializers/spree/api/v3/admin/category_serializer.rb +5 -3
  47. data/app/serializers/spree/api/v3/admin/channel_serializer.rb +5 -1
  48. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +7 -2
  49. data/app/serializers/spree/api/v3/admin/discount_serializer.rb +4 -0
  50. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +9 -0
  51. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +7 -0
  52. data/app/serializers/spree/api/v3/admin/import_mapping_serializer.rb +20 -0
  53. data/app/serializers/spree/api/v3/admin/import_row_serializer.rb +17 -0
  54. data/app/serializers/spree/api/v3/admin/import_serializer.rb +70 -0
  55. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +11 -0
  56. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +2 -0
  57. data/app/serializers/spree/api/v3/admin/order_serializer.rb +15 -1
  58. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +4 -0
  59. data/app/serializers/spree/api/v3/admin/product_serializer.rb +1 -0
  60. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +8 -0
  61. data/app/serializers/spree/api/v3/admin/resource_translations_serializer.rb +76 -0
  62. data/app/serializers/spree/api/v3/admin/setup_task_serializer.rb +18 -0
  63. data/app/serializers/spree/api/v3/admin/store_serializer.rb +27 -1
  64. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +8 -1
  65. data/app/serializers/spree/api/v3/base_serializer.rb +13 -0
  66. data/app/serializers/spree/api/v3/cart_serializer.rb +25 -22
  67. data/app/serializers/spree/api/v3/discount_serializer.rb +6 -2
  68. data/app/serializers/spree/api/v3/fulfillment_serializer.rb +16 -13
  69. data/app/serializers/spree/api/v3/gift_card_serializer.rb +15 -11
  70. data/app/serializers/spree/api/v3/import_row_serializer.rb +1 -1
  71. data/app/serializers/spree/api/v3/line_item_serializer.rb +35 -17
  72. data/app/serializers/spree/api/v3/locale_serializer.rb +6 -1
  73. data/app/serializers/spree/api/v3/order_serializer.rb +25 -21
  74. data/app/serializers/spree/api/v3/payment_serializer.rb +6 -2
  75. data/app/serializers/spree/api/v3/product_serializer.rb +9 -1
  76. data/app/serializers/spree/api/v3/variant_serializer.rb +12 -1
  77. data/app/services/spree/api/v3/filters_aggregator.rb +4 -4
  78. data/app/subscribers/spree/webhook_event_subscriber.rb +6 -0
  79. data/config/initializers/typelizer.rb +10 -0
  80. data/config/locales/en.yml +4 -0
  81. data/config/routes.rb +54 -4
  82. data/lib/spree/api/dependencies.rb +5 -0
  83. data/lib/spree/api/openapi/schema_helper.rb +29 -0
  84. metadata +21 -6
@@ -0,0 +1,274 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # See `docs/plans/5.6-admin-spa-csv-import.md`.
6
+ #
7
+ # There is no standalone imports scope: an import is a bulk write of
8
+ # the records it creates, so every action — including reads, which
9
+ # expose the uploaded data — maps to the write scope of the imported
10
+ # resource (Spree::Imports::Customers => `write_customers`; see
11
+ # Spree::Import.required_scope). The index is filtered to the types
12
+ # the key can write.
13
+ class ImportsController < ResourceController
14
+ include ActiveStorage::SetCurrent
15
+
16
+ # The index spans many import types — `scope` filters it to the
17
+ # writable ones instead of gating on a single scope.
18
+ skip_scope_check! only: :index
19
+
20
+ # POST /api/v3/admin/imports
21
+ #
22
+ # `attachment` is an ActiveStorage signed blob id obtained from
23
+ # POST /api/v3/admin/direct_uploads. On success the import advances
24
+ # straight into `mapping` (auto-assigning file columns from the CSV
25
+ # headers), so the response already carries the mapping payload.
26
+ def create
27
+ @resource = build_resource
28
+ authorize_resource!(@resource, :create)
29
+
30
+ if @resource.save
31
+ begin
32
+ @resource.start_mapping!
33
+ rescue ::CSV::MalformedCSVError, EncodingError => e
34
+ @resource.update_columns(status: 'failed', processing_errors: e.message, updated_at: Time.current)
35
+ return render_error(
36
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:validation_error],
37
+ message: "Could not parse CSV: #{e.message}",
38
+ status: :unprocessable_content
39
+ )
40
+ end
41
+
42
+ render json: serialize_resource(@resource), status: :created
43
+ else
44
+ render_errors(@resource.errors)
45
+ end
46
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
47
+ render_error(
48
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:validation_error],
49
+ message: 'Invalid attachment signed id',
50
+ status: :unprocessable_content
51
+ )
52
+ end
53
+
54
+ # PATCH /api/v3/admin/imports/:id/complete_mapping
55
+ #
56
+ # Applies the submitted mappings atomically, then transitions out of
57
+ # mapping (which enqueues row creation + processing). 422 when
58
+ # required schema fields remain unmapped or a file column is
59
+ # assigned twice.
60
+ def complete_mapping
61
+ @resource = find_resource
62
+ authorize_resource!(@resource, :update)
63
+
64
+ unless @resource.mapping?
65
+ return render_error(
66
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:validation_error],
67
+ message: 'Import is not in the mapping state',
68
+ status: :unprocessable_content
69
+ )
70
+ end
71
+
72
+ apply_mappings!(@resource)
73
+
74
+ if @resource.mapping_done?
75
+ @resource.complete_mapping!
76
+ render json: serialize_resource(@resource)
77
+ else
78
+ missing = @resource.required_fields - @resource.mappings.mapped.pluck(:schema_field)
79
+ render_error(
80
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:validation_error],
81
+ message: "Required fields are not mapped: #{missing.join(', ')}",
82
+ status: :unprocessable_content,
83
+ details: { missing_required_fields: missing }
84
+ )
85
+ end
86
+ rescue ActiveRecord::RecordInvalid => e
87
+ render_validation_error(e.record.errors)
88
+ end
89
+
90
+ # PATCH /api/v3/admin/imports/:id/retry_failed_rows
91
+ #
92
+ # Re-dispatches processing over the rows still `failed` (the
93
+ # dispatcher's pending_and_failed scope picks them up). 422 unless
94
+ # the import is `completed` with failures.
95
+ def retry_failed_rows
96
+ @resource = find_resource
97
+ authorize_resource!(@resource, :update)
98
+
99
+ if @resource.retry_failed_rows
100
+ render json: serialize_resource(@resource)
101
+ else
102
+ render_error(
103
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:validation_error],
104
+ message: 'Import has no failed rows to retry',
105
+ status: :unprocessable_content
106
+ )
107
+ end
108
+ end
109
+
110
+ # GET /api/v3/admin/imports/:id/download
111
+ #
112
+ # Streams the originally uploaded CSV — the audit trail for what
113
+ # was actually imported. Same inline-streaming rationale as
114
+ # ExportsController#download: a signed ActiveStorage URL neither
115
+ # survives the SPA's `/api/*`-only dev proxy nor carries the JWT.
116
+ def download
117
+ @resource = find_resource
118
+ authorize_resource!(@resource, :show)
119
+
120
+ attachment = @resource.attachment
121
+ unless attachment.attached?
122
+ return render_error(
123
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:validation_error],
124
+ message: 'Import has no attached file',
125
+ status: :unprocessable_content
126
+ )
127
+ end
128
+
129
+ send_data(
130
+ attachment.download,
131
+ filename: attachment.filename.to_s,
132
+ type: attachment.content_type || 'text/csv',
133
+ disposition: 'attachment'
134
+ )
135
+ end
136
+
137
+ # GET /api/v3/admin/imports/template?type=Spree::Imports::Products
138
+ #
139
+ # CSV header row for the type's schema (including the metafield
140
+ # columns available for the model) — the "Download template" link
141
+ # in the admin dashboard.
142
+ def template
143
+ klass = resolve_import_type(params[:type])
144
+
145
+ unless klass
146
+ return render_error(
147
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:validation_error],
148
+ message: 'Unknown import type',
149
+ status: :unprocessable_content
150
+ )
151
+ end
152
+
153
+ headers = klass.new.schema_fields.map { |field| field[:name] }
154
+ send_data ::CSV.generate_line(headers),
155
+ filename: "#{klass.name.demodulize.underscore}_import_template.csv",
156
+ type: 'text/csv',
157
+ disposition: 'attachment'
158
+ end
159
+
160
+ protected
161
+
162
+ def model_class
163
+ Spree::Import
164
+ end
165
+
166
+ def serializer_class
167
+ Spree.api.admin_import_serializer
168
+ end
169
+
170
+ def scope_includes
171
+ [:user, :mappings, { attachment_attachment: :blob }]
172
+ end
173
+
174
+ def scope
175
+ collection = super
176
+ return collection unless scope_limited_principal?
177
+
178
+ collection.where(type: writable_import_types)
179
+ end
180
+
181
+ # Loaded by both the scope gate (before_action) and the member
182
+ # actions — memoize so the record is fetched once.
183
+ def find_resource
184
+ @find_resource ||= super
185
+ end
186
+
187
+ # Every action on an import maps to the write scope of the imported
188
+ # resource: creating is a bulk write, and status/rows expose the
189
+ # uploaded data, so read-only keys get nothing.
190
+ def action_kind
191
+ 'write'
192
+ end
193
+
194
+ # Unresolvable types (blank/unknown `type` on create/template) fall
195
+ # back to `:all`, so only `write_all` keys reach the model's own
196
+ # validation.
197
+ def scoped_resource_name
198
+ import_class&.required_scope || :all
199
+ end
200
+
201
+ def build_resource
202
+ klass = resolve_import_type(permitted_params[:type]) || Spree::Import
203
+ attrs = permitted_params.except(:type).merge(
204
+ owner: current_store,
205
+ user: acting_admin_user
206
+ )
207
+ # The done email links back to results_url — allowed origins only.
208
+ attrs[:results_url] = validated_allowed_origin_url(attrs[:results_url])
209
+ klass.new(attrs)
210
+ end
211
+
212
+ def permitted_params
213
+ params.permit(:type, :attachment, :preferred_delimiter, :results_url)
214
+ end
215
+
216
+ # Returns the registered Import subclass matching `name`, or nil.
217
+ #
218
+ # The constantize target comes from `available_types` (a trusted
219
+ # in-process registry), not from the request — `name` is only used
220
+ # to *select* an entry in the allowlist (same pattern as
221
+ # ExportsController#resolve_export_type).
222
+ def resolve_import_type(name)
223
+ return nil if name.blank?
224
+
225
+ target = Spree::Import.available_types.map(&:to_s).find { |t| t == name.to_s }
226
+ target&.constantize
227
+ end
228
+
229
+ private
230
+
231
+ # Imports require a user: row processors resolve records through
232
+ # `import.current_ability`. JWT requests use the signed-in admin;
233
+ # API-key requests attribute to the key's creator when that is an
234
+ # admin user, otherwise the model's presence validation renders 422.
235
+ def acting_admin_user
236
+ return try_spree_current_user if try_spree_current_user
237
+
238
+ created_by = current_api_key&.created_by
239
+ created_by if created_by.is_a?(Spree.admin_user_class)
240
+ end
241
+
242
+ def import_class
243
+ case action_name
244
+ when 'create', 'template' then resolve_import_type(params[:type])
245
+ else find_resource.class
246
+ end
247
+ end
248
+
249
+ def writable_import_types
250
+ Spree::Import.available_types.select do |type|
251
+ required = type.required_scope
252
+ required ? current_api_key.has_scope?("write_#{required}") : current_api_key.has_scope?('write_all')
253
+ end.map(&:to_s)
254
+ end
255
+
256
+ def apply_mappings!(import)
257
+ submitted = params[:mappings]
258
+ return if submitted.blank?
259
+
260
+ ApplicationRecord.transaction do
261
+ submitted.each do |entry|
262
+ mapping = import.mappings.find_by(schema_field: entry[:schema_field])
263
+ next unless mapping
264
+
265
+ mapping.update!(file_column: entry[:file_column].presence)
266
+ end
267
+ end
268
+ import.mappings.reload
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,29 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Lists the locales a merchant can translate content into for the
6
+ # current store (its supported_locales_list), so the dashboard's locale
7
+ # switcher enumerates locales instead of hardcoding them.
8
+ class LocalesController < Admin::BaseController
9
+ scoped_resource :settings
10
+
11
+ # GET /api/v3/admin/locales
12
+ def index
13
+ render json: {
14
+ data: current_store.supported_locales.map do |locale|
15
+ Spree.api.locale_serializer.new(locale).to_h
16
+ end
17
+ }
18
+ end
19
+
20
+ private
21
+
22
+ def action_kind
23
+ 'read'
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -5,6 +5,8 @@ module Spree
5
5
  class MeController < Admin::BaseController
6
6
  skip_scope_check!
7
7
 
8
+ before_action :require_current_user!
9
+
8
10
  # GET /api/v3/admin/me
9
11
  # Returns the current admin user along with a serialized representation
10
12
  # of their permissions (derived from CanCanCan rules). The SPA uses
@@ -21,22 +23,50 @@ module Spree
21
23
  # a 500 from serializing a nil user — mirroring how #current 404s for
22
24
  # a JWT principal that has no single key.
23
25
  def show
24
- unless current_user
25
- return render_error(
26
- code: ERROR_CODES[:record_not_found],
27
- message: Spree.t(:me_no_current_user),
28
- status: :not_found
29
- )
26
+ render json: me_response
27
+ end
28
+
29
+ # PATCH /api/v3/admin/me
30
+ # Self-service update of the signed-in admin's own profile (display
31
+ # name, admin UI language, and avatar) — it operates on `current_user`
32
+ # directly, so it needs no per-record authorization. Distinct from
33
+ # PATCH /admin_users/:id, which is store-scoped staff management of
34
+ # *other* users. `avatar` accepts an ActiveStorage direct-upload
35
+ # signed id to set the photo, or `null` to remove it.
36
+ def update
37
+ if current_user.update(permitted_params)
38
+ render json: me_response
39
+ else
40
+ render_validation_error(current_user.errors)
30
41
  end
42
+ end
43
+
44
+ private
45
+
46
+ # A request authenticated by a secret API key has no Spree user to
47
+ # describe/update, so it gets a 404 pointing at the key endpoint
48
+ # rather than a 500 from a nil user — mirroring ApiKeysController#current.
49
+ def require_current_user!
50
+ return if current_user
31
51
 
32
- render json: {
52
+ render_error(
53
+ code: ERROR_CODES[:record_not_found],
54
+ message: Spree.t(:me_no_current_user),
55
+ status: :not_found
56
+ )
57
+ end
58
+
59
+ def permitted_params
60
+ params.permit(:selected_locale, :first_name, :last_name, :avatar)
61
+ end
62
+
63
+ def me_response
64
+ {
33
65
  user: admin_user_serializer.new(current_user, params: serializer_params).to_h,
34
66
  permissions: serialize_permissions(current_ability)
35
67
  }
36
68
  end
37
69
 
38
- private
39
-
40
70
  # Serializes CanCanCan's rules into a flat, JSON-safe list of permission rules.
41
71
  #
42
72
  # - Rule order is preserved so the frontend matcher can apply
@@ -8,6 +8,37 @@ module Spree
8
8
 
9
9
  before_action :set_resource, only: [:show, :update, :fulfill, :cancel, :resume, :split]
10
10
 
11
+ # POST /api/v3/admin/orders/:order_id/fulfillments
12
+ #
13
+ # Manually registers a fulfillment on a completed order (external
14
+ # carrier / 3PL sync), bypassing order routing. Moves the requested
15
+ # line item quantities out of their routed fulfillments; when
16
+ # `items` is omitted, everything not yet shipped is moved. An
17
+ # explicit `cost` persists only with `status: 'shipped'` — pending
18
+ # fulfillments are re-priced by the rate engine.
19
+ def create
20
+ authorize!(:create, Spree::Shipment)
21
+
22
+ with_order_lock do
23
+ result = Spree.fulfillment_create_service.call(
24
+ order: @order,
25
+ stock_location: stock_location_for_create,
26
+ items: items_for_create,
27
+ tracking: create_params[:tracking],
28
+ delivery_method: delivery_method_for_create,
29
+ cost: create_params[:cost],
30
+ status: create_params[:status],
31
+ metadata: create_params[:metadata]&.to_h
32
+ )
33
+
34
+ if result.success?
35
+ render json: serialize_resource(result.value), status: :created
36
+ else
37
+ render_result_error(result)
38
+ end
39
+ end
40
+ end
41
+
11
42
  # PATCH /api/v3/admin/orders/:order_id/fulfillments/:id
12
43
  def update
13
44
  with_order_lock do
@@ -61,7 +92,7 @@ module Spree
61
92
  quantity = params[:quantity].to_i
62
93
 
63
94
  stock_location = if params[:stock_location_id].present?
64
- Spree::StockLocation.accessible_by(current_ability, :show).find_by_prefix_id!(params[:stock_location_id])
95
+ find_stock_location!(params[:stock_location_id])
65
96
  else
66
97
  @resource.stock_location
67
98
  end
@@ -96,7 +127,36 @@ module Spree
96
127
  # State changes go through the dedicated `fulfill`/`cancel`/`resume`
97
128
  # member actions, not mass assignment.
98
129
  def permitted_params
99
- params.permit(:tracking, :selected_shipping_rate_id, :stock_location_id)
130
+ params.permit(:tracking, :selected_delivery_rate_id, :stock_location_id)
131
+ end
132
+
133
+ def create_params
134
+ @create_params ||= params.permit(:stock_location_id, :tracking, :delivery_method_id, :cost, :status, metadata: {}, items: [:item_id, :quantity])
135
+ end
136
+
137
+ def find_stock_location!(id)
138
+ Spree::StockLocation.accessible_by(current_ability, :show).find_by_prefix_id!(id)
139
+ end
140
+
141
+ def stock_location_for_create
142
+ find_stock_location!(create_params.require(:stock_location_id))
143
+ end
144
+
145
+ def delivery_method_for_create
146
+ return if create_params[:delivery_method_id].blank?
147
+
148
+ Spree::ShippingMethod.accessible_by(current_ability, :show).find_by_prefix_id!(create_params[:delivery_method_id])
149
+ end
150
+
151
+ def items_for_create
152
+ return if create_params[:items].nil?
153
+
154
+ create_params[:items].map do |item|
155
+ {
156
+ line_item: @order.line_items.find_by_prefix_id!(item[:item_id]),
157
+ quantity: Integer(item[:quantity].to_s, exception: false)
158
+ }
159
+ end
100
160
  end
101
161
  end
102
162
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Api
5
+ module V3
6
+ module Admin
7
+ class PasswordResetsController < Admin::BaseController
8
+ include Spree::Api::V3::Admin::AuthCookies
9
+
10
+ skip_scope_check!
11
+ rate_limit to: Spree::Api::Config[:rate_limit_password_reset],
12
+ within: Spree::Api::Config[:rate_limit_window].seconds,
13
+ store: Rails.cache,
14
+ with: RATE_LIMIT_RESPONSE
15
+
16
+ skip_before_action :authenticate_admin!
17
+
18
+ # POST /api/v3/admin/password_resets
19
+ def create
20
+ redirect_url = params[:redirect_url]
21
+
22
+ # Validate redirect_url against allowed origins (secure by default).
23
+ # If no allowed origins are configured, redirect_url is silently ignored
24
+ # to prevent open redirect / token exfiltration attacks.
25
+ if redirect_url.present?
26
+ unless current_store.allowed_origins.exists? && current_store.allowed_origin?(redirect_url)
27
+ redirect_url = nil
28
+ end
29
+ end
30
+
31
+ user = Spree.admin_user_class.find_by(email: params[:email])
32
+
33
+ if user
34
+ token = user.generate_token_for(:password_reset)
35
+ event_payload = { reset_token: token, email: user.email, store_id: current_store.prefixed_id }
36
+ event_payload[:redirect_url] = redirect_url if redirect_url.present?
37
+ user.publish_event('admin_user.password_reset_requested', event_payload)
38
+ end
39
+
40
+ # Always return 202 to prevent email enumeration
41
+ render json: { message: Spree.t(:password_reset_requested, scope: :api) }, status: :accepted
42
+ end
43
+
44
+ # PATCH /api/v3/admin/password_resets/:id
45
+ def update
46
+ user = Spree.admin_user_class.find_by_password_reset_token(params[:id])
47
+
48
+ unless user
49
+ return render_error(
50
+ code: ERROR_CODES[:password_reset_token_invalid],
51
+ message: Spree.t(:password_reset_token_invalid, scope: :api),
52
+ status: :unprocessable_content
53
+ )
54
+ end
55
+
56
+ if user.update(password: params[:password], password_confirmation: params[:password_confirmation])
57
+ user.publish_event('admin_user.password_reset')
58
+ # A password reset must kill every existing session — a stolen
59
+ # refresh token must not survive the victim resetting their
60
+ # password. Revoke first, then mint the fresh token for this
61
+ # browser (the auto-sign-in below).
62
+ Spree::RefreshToken.revoke_all_for(user)
63
+ refresh_token = Spree::RefreshToken.create_for(user, request_env: request_env_for_token)
64
+ set_refresh_cookie(refresh_token)
65
+
66
+ render json: auth_response(user)
67
+ else
68
+ render_errors(user.errors)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def auth_response(user)
75
+ {
76
+ token: generate_jwt(user, audience: JWT_AUDIENCE_ADMIN),
77
+ user: admin_user_serializer.new(user, params: serializer_params).to_h
78
+ }
79
+ end
80
+
81
+ def serializer_params
82
+ {
83
+ store: current_store,
84
+ locale: current_locale,
85
+ currency: current_currency,
86
+ user: nil,
87
+ includes: []
88
+ }
89
+ end
90
+
91
+ def admin_user_serializer
92
+ Spree.api.admin_admin_user_serializer
93
+ end
94
+
95
+ def request_env_for_token
96
+ {
97
+ ip_address: request.remote_ip,
98
+ user_agent: request.user_agent&.truncate(255)
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -22,14 +22,7 @@ module Spree
22
22
  def types
23
23
  authorize! :create, model_class
24
24
 
25
- # Query via direct join rather than `current_store.payment_methods`
26
- # — the has_many-through association can cache stale results when
27
- # `current_store` was loaded earlier in the request (e.g. by the
28
- # auth layer).
29
- installed_class_names = Spree::PaymentMethod
30
- .joins(:store_payment_methods)
31
- .where(spree_payment_methods_stores: { store_id: current_store.id })
32
- .pluck(:type)
25
+ installed_class_names = current_store.payment_methods.pluck(:type)
33
26
  installed_shorthands = installed_class_names.filter_map do |name|
34
27
  name.safe_constantize&.api_type
35
28
  end
@@ -34,7 +34,7 @@ module Spree
34
34
  :sku, :barcode,
35
35
  :cost_price, :cost_currency,
36
36
  :weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
37
- :track_inventory, :tax_category_id, :position,
37
+ :track_inventory, :preorderable, :preorder_ships_at, :backorder_limit, :tax_category_id, :position,
38
38
  options: [:name, :value],
39
39
  prices: [:amount, :compare_at_amount, :currency],
40
40
  stock_items: [:stock_location_id, :count_on_hand, :backorderable]
@@ -171,11 +171,11 @@ module Spree
171
171
  prices: [:amount, :compare_at_amount, :currency],
172
172
  # Inline custom field values keyed by definition id. The model
173
173
  # setter (`Spree::Metafields#custom_fields=`) validates each
174
- # entry against its definition. We permit `value` as both a
175
- # scalar AND `value: {}` (any-shape Hash/Array) Strong
176
- # Parameters merges them so JSON metafields can ship parsed
177
- # objects while text/number/boolean ship scalars.
178
- custom_fields: [:id, :custom_field_definition_id, :value, value: {}],
174
+ # entry against its definition. We permit `value` as a scalar AND
175
+ # as `value: []` / `value: {}` so JSON metafields round-trip
176
+ # whether the parsed payload is an array or an object, while
177
+ # text/number/boolean ship scalars.
178
+ custom_fields: [:id, :custom_field_definition_id, :value, { value: [] }, { value: {} }],
179
179
  # Inline media. Entries with `id` patch an existing asset
180
180
  # (alt, position, variant_ids). Entries with `signed_id` create
181
181
  # + attach a fresh upload. Lets the dashboard ship media changes
@@ -187,7 +187,7 @@ module Spree
187
187
  :id, :sku, :barcode,
188
188
  :cost_price, :cost_currency,
189
189
  :weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
190
- :track_inventory, :tax_category_id, :position,
190
+ :track_inventory, :preorderable, :preorder_ships_at, :backorder_limit, :tax_category_id, :position,
191
191
  options: [:name, :value],
192
192
  prices: [:amount, :compare_at_amount, :currency],
193
193
  stock_items: [:id, :stock_location_id, :count_on_hand, :backorderable]
@@ -218,7 +218,8 @@ module Spree
218
218
  authorize! :update, model_class
219
219
 
220
220
  category_ids = decode_ids(params[:category_ids])
221
- categories = current_store.taxons.accessible_by(current_ability, :update).where(id: category_ids)
221
+ categories = current_store.categories.
222
+ accessible_by(current_ability, :update).where(id: category_ids)
222
223
 
223
224
  service.call(taxons: categories, products: bulk_collection)
224
225
  Spree::Product.bulk_auto_match_taxons(current_store, bulk_collection.ids)
@@ -69,7 +69,7 @@ module Spree
69
69
  private
70
70
 
71
71
  def promotion_includes
72
- [:stores, :promotion_actions, :promotion_rules]
72
+ [:promotion_actions, :promotion_rules]
73
73
  end
74
74
  end
75
75
  end
@@ -39,6 +39,37 @@ module Spree
39
39
  Spree::PrefixedId.prefixed_id?(id) ? Spree::PrefixedId.decode_prefixed_id(id) : id
40
40
  end
41
41
  end
42
+
43
+ # Returns +url+ only when it matches one of the store's configured
44
+ # allowed origins — the gate for caller-provided URLs that later
45
+ # surface outside the request (e.g. the results_url the import/export
46
+ # done emails link back to). Anything else is silently dropped,
47
+ # mirroring the password-reset redirect_url behavior: no configured
48
+ # origins means no caller URLs are honored.
49
+ # @return [String, nil]
50
+ def validated_allowed_origin_url(url)
51
+ return if url.blank?
52
+ return unless current_store.allowed_origin?(url)
53
+
54
+ url
55
+ end
56
+
57
+ # Parses a strictly-integer param, returning nil for missing/blank/
58
+ # non-integer values (so callers can reject rather than coerce to 0).
59
+ # @return [Integer, nil]
60
+ def integer_param(name)
61
+ value = params[name]
62
+ Integer(value, exception: false) if value.is_a?(Integer) || value.to_s.match?(/\A-?\d+\z/)
63
+ end
64
+
65
+ # Renders a 422 for a missing/invalid +new_position+.
66
+ def render_invalid_position
67
+ render_error(
68
+ code: ERROR_CODES[:validation_error],
69
+ message: Spree.t('api.errors.invalid_position', default: 'new_position must be an integer'),
70
+ status: :unprocessable_content
71
+ )
72
+ end
42
73
  end
43
74
  end
44
75
  end