spree_api 6.0.0.beta2 → 6.0.0.beta3

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/embed_permissions.rb +55 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +34 -5
  4. data/app/controllers/concerns/spree/api/v3/current_password_confirmation.rb +8 -0
  5. data/app/controllers/concerns/spree/api/v3/orders/fulfillment_actions.rb +15 -2
  6. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +14 -0
  7. data/app/controllers/concerns/spree/api/v3/store/storefront_products.rb +35 -0
  8. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +9 -0
  9. data/app/controllers/spree/api/v3/admin/base_controller.rb +1 -0
  10. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +8 -3
  11. data/app/controllers/spree/api/v3/admin/customers_controller.rb +0 -14
  12. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +29 -2
  13. data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +4 -1
  14. data/app/controllers/spree/api/v3/admin/invitations/acceptance_links_controller.rb +41 -0
  15. data/app/controllers/spree/api/v3/admin/me_controller.rb +11 -0
  16. data/app/controllers/spree/api/v3/admin/orders/claims_controller.rb +14 -1
  17. data/app/controllers/spree/api/v3/admin/orders/exchanges_controller.rb +22 -1
  18. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +1 -1
  19. data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +22 -1
  20. data/app/controllers/spree/api/v3/admin/orders_controller.rb +27 -1
  21. data/app/controllers/spree/api/v3/admin/password_resets_controller.rb +31 -11
  22. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +1 -19
  23. data/app/controllers/spree/api/v3/admin/prices_controller.rb +33 -2
  24. data/app/controllers/spree/api/v3/admin/products_controller.rb +1 -1
  25. data/app/controllers/spree/api/v3/admin/resource_controller.rb +1 -0
  26. data/app/controllers/spree/api/v3/admin/sellers/invitations/acceptance_links_controller.rb +37 -0
  27. data/app/controllers/spree/api/v3/admin/setup_controller.rb +1 -1
  28. data/app/controllers/spree/api/v3/admin/store_controller.rb +4 -0
  29. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +1 -1
  30. data/app/controllers/spree/api/v3/admin/translations_controller.rb +1 -1
  31. data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +12 -0
  32. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +27 -0
  33. data/app/controllers/spree/api/v3/base_controller.rb +7 -3
  34. data/app/controllers/spree/api/v3/resource_controller.rb +11 -1
  35. data/app/controllers/spree/api/v3/seller/auth_controller.rb +1 -1
  36. data/app/controllers/spree/api/v3/seller/invitation_acceptances_controller.rb +5 -2
  37. data/app/controllers/spree/api/v3/seller/invitations/acceptance_links_controller.rb +34 -0
  38. data/app/controllers/spree/api/v3/seller/me_controller.rb +25 -2
  39. data/app/controllers/spree/api/v3/seller/orders_controller.rb +19 -24
  40. data/app/controllers/spree/api/v3/seller/password_resets_controller.rb +1 -1
  41. data/app/controllers/spree/api/v3/seller/resource_controller.rb +4 -0
  42. data/app/controllers/spree/api/v3/store/carts/items_controller.rb +7 -4
  43. data/app/controllers/spree/api/v3/store/carts/payment_sessions_controller.rb +10 -1
  44. data/app/controllers/spree/api/v3/store/carts/payments_controller.rb +5 -1
  45. data/app/controllers/spree/api/v3/store/companies/members_controller.rb +10 -11
  46. data/app/controllers/spree/api/v3/store/companies/orders_controller.rb +6 -0
  47. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +1 -0
  48. data/app/controllers/spree/api/v3/store/products_controller.rb +6 -12
  49. data/app/controllers/spree/api/v3/store/resource_controller.rb +4 -0
  50. data/app/controllers/spree/api/v3/store/wishlist_items_controller.rb +11 -0
  51. data/app/controllers/spree/api/v3/webhooks/fulfillments_controller.rb +10 -6
  52. data/app/controllers/spree/api/v3/webhooks/payments_controller.rb +6 -2
  53. data/app/controllers/spree/api/v3/webhooks/payouts_controller.rb +6 -2
  54. data/app/jobs/spree/webhook_delivery_job.rb +18 -4
  55. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +6 -0
  56. data/app/serializers/spree/api/v3/admin/invitation_acceptance_link_serializer.rb +15 -0
  57. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +0 -11
  58. data/app/serializers/spree/api/v3/admin/order_group_serializer.rb +7 -0
  59. data/app/serializers/spree/api/v3/admin/store_serializer.rb +8 -0
  60. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +6 -2
  61. data/app/serializers/spree/api/v3/gift_card_serializer.rb +3 -1
  62. data/app/serializers/spree/api/v3/line_item_serializer.rb +2 -1
  63. data/app/serializers/spree/api/v3/market_serializer.rb +5 -0
  64. data/app/serializers/spree/api/v3/product_filters_serializer.rb +3 -0
  65. data/app/serializers/spree/api/v3/seller/account_serializer.rb +19 -0
  66. data/app/serializers/spree/api/v3/seller/invitation_acceptance_link_serializer.rb +15 -0
  67. data/app/serializers/spree/api/v3/seller/invitation_serializer.rb +1 -10
  68. data/app/services/spree/api/v3/filters_aggregator.rb +5 -2
  69. data/config/routes.rb +7 -0
  70. data/lib/spree/api/dependencies.rb +3 -0
  71. data/lib/spree/api/openapi/schema_helper.rb +10 -4
  72. metadata +14 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66c29980b5060309f11b480faec57266dc0bf8423b03e820a365e346a7f5f8a7
4
- data.tar.gz: 0b3f632bfaa9cb6158694e492c3c8266a7b4e5c05c942abad15ce45a99662aea
3
+ metadata.gz: d30acba1d9395e7575f92eefe7c86b5cd8c73eae44cceb1e178a853963e856c4
4
+ data.tar.gz: 562e93949b2a3e5b124c06896821d26554329f0e332faa8f9de2d35b6d40dc71
5
5
  SHA512:
6
- metadata.gz: bde5239802c9065c5b69fd3473b424dc2245c446035ff5ee1dc9cc475ea1de6cfcfbeffd65d11659427df2de94883bed3ae671916605e30155eb7ae9291e7ffe
7
- data.tar.gz: ad098d05870290fae2ac19c12cf4ed92d91584466723f7992f8ac45beba9669ee1ad1c4f2a3a61c1a292e66b6b2568e6b027b42ba959efcb8f7662e4a5774c8f
6
+ metadata.gz: 401bbdc3ddb5eb121c8ad9eac1d5341aa130c0f1f61135e54b14c8eabe577c1845442f28e20e4bf608e35d5dc5f757b25b4b41697dd1010c78ae9f2ede161362
7
+ data.tar.gz: e27e19331d0e63339aa5bd21586f1f4ed74b095689a3c31252faee126f9d896f3579cef931a6fa66afa5b6e0c001f7759f19838f3a2914de105090f598b4c2ae
@@ -0,0 +1,55 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Keeps records another permission guards out of a response.
6
+ #
7
+ # An endpoint is gated on its own resource's key, but expanding an
8
+ # association pulls in whole records of another kind — a customer's
9
+ # orders, an order's payments, a gift card's customer. Without this a
10
+ # role cleared only for customers could read every order through
11
+ # `?expand=orders`. Each segment of an expand path is checked, so
12
+ # `orders.payments` needs both keys.
13
+ #
14
+ # Gift card codes are bearer credentials that ride along on every
15
+ # order, so serializers show them in full only to a caller who may read
16
+ # gift cards and masked to everyone else.
17
+ module EmbedPermissions
18
+ extend ActiveSupport::Concern
19
+
20
+ # Association name in an expand path => the catalog key it needs.
21
+ EXPANSION_PERMISSIONS = {
22
+ 'order' => 'read_orders',
23
+ 'orders' => 'read_orders',
24
+ 'payment' => 'read_payments',
25
+ 'payments' => 'read_payments',
26
+ 'payment_splits' => 'read_payments',
27
+ 'customer' => 'read_customers',
28
+ 'customers' => 'read_customers',
29
+ 'gift_card' => 'read_gift_cards',
30
+ 'gift_cards' => 'read_gift_cards',
31
+ 'store_credit' => 'read_store_credits',
32
+ 'store_credits' => 'read_store_credits'
33
+ }.freeze
34
+
35
+ protected
36
+
37
+ def expand_list
38
+ super.select { |path| path.split('.').all? { |segment| expansion_permitted?(segment) } }
39
+ end
40
+
41
+ def serializer_params
42
+ super.merge(gift_card_codes: holds_permission?('read_gift_cards'))
43
+ end
44
+
45
+ private
46
+
47
+ def expansion_permitted?(segment)
48
+ key = EXPANSION_PERMISSIONS[segment]
49
+ key.nil? || holds_permission?(key)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -23,13 +23,13 @@ module Spree
23
23
  # flow leaves it off (its gate is `manage Invitation`).
24
24
  # @return [Boolean] true if the request was rejected (caller should return)
25
25
  def reject_unauthorized_role_grant!(role_ids, require_role_management: false)
26
- return false if role_ids.blank?
27
-
28
26
  # The management gate runs whenever a role mutation is attempted —
29
- # before resolving ids — so passing unknown ids can't slip the
30
- # reconciliation (which would still remove the user's current roles)
31
- # past a caller without role-management authority.
27
+ # before resolving ids, and even for an empty list — so passing
28
+ # unknown ids, or none at all, can't slip the reconciliation (which
29
+ # would still remove the user's current roles) past a caller
30
+ # without role-management authority.
32
31
  return true if require_role_management && reject_without_role_management!
32
+ return false if role_ids.blank?
33
33
 
34
34
  roles = current_store.roles.where(id: role_ids.map(&:to_s)).to_a
35
35
  return false if roles.empty?
@@ -40,6 +40,30 @@ module Spree
40
40
  false
41
41
  end
42
42
 
43
+ # Removing a role is bounded like granting it: a caller may only take
44
+ # away authority they could hand out, so a staff member who can
45
+ # manage staff cannot strip the admin role from the store's owners.
46
+ # The store also keeps at least one admin, whoever asks.
47
+ #
48
+ # @param user [Object] whose roles are being removed
49
+ # @param roles [Array<Spree::Role>] the store roles being removed
50
+ # @return [Boolean] true if the request was rejected (caller should return)
51
+ def reject_unauthorized_role_removal!(user, roles)
52
+ return false if roles.empty?
53
+
54
+ if roles.any?(&:admin?)
55
+ return deny_role_grant!('You cannot remove the admin role.') unless caller_holds_admin_role?
56
+ return deny_role_grant!('The store must keep at least one admin.') if last_admin?(user)
57
+ end
58
+
59
+ return false if caller_holds_admin_role?
60
+
61
+ beyond_own = roles.reject { |role| excess_permission_keys(role.permissions).empty? }
62
+ return false if beyond_own.empty?
63
+
64
+ deny_role_grant!("You cannot remove roles beyond your own privileges: #{beyond_own.map(&:name).join(', ')}")
65
+ end
66
+
43
67
  # Rejects granting permission keys beyond the caller's own — used by
44
68
  # roles#create/#update on the requested `permissions` array.
45
69
  #
@@ -125,6 +149,11 @@ module Spree
125
149
  end
126
150
  end
127
151
 
152
+ def last_admin?(user)
153
+ admin_roles = current_store.roles.select(&:admin?)
154
+ Spree::RoleUser.where(role: admin_roles).where.not(user: user).none?
155
+ end
156
+
128
157
  def caller_holds_admin_role?
129
158
  return current_api_key.has_scope?('write_all') if scope_limited_principal?
130
159
 
@@ -13,12 +13,20 @@ module Spree
13
13
 
14
14
  private
15
15
 
16
+ # Runs under the login lockout, so a stolen session cannot use this
17
+ # check to guess the password: misses count towards the lockout, and a
18
+ # locked account is refused even with the right password.
19
+ #
16
20
  # @return [Boolean] whether `current_password` matches the signed-in
17
21
  # account. False when the parameter is missing — an absent password
18
22
  # is not a correct one.
19
23
  def valid_current_password?
20
24
  return false if params[:current_password].blank?
21
25
 
26
+ Spree::Authentication::Lockout.check(current_user) { current_password_matches? } == :valid
27
+ end
28
+
29
+ def current_password_matches?
22
30
  if current_user.respond_to?(:valid_password?)
23
31
  current_user.valid_password?(params[:current_password])
24
32
  elsif current_user.respond_to?(:authenticate)
@@ -135,9 +135,22 @@ module Spree
135
135
  end
136
136
 
137
137
  # The attributes handed to the update workflow. The workflow assigns
138
- # ids raw, so a branch that must narrow one resolves it here first.
138
+ # ids raw, so the stock location is resolved first, through the same
139
+ # lookup create and split use.
139
140
  def update_attributes
140
- permitted_params.to_h
141
+ attributes = permitted_params.to_h
142
+
143
+ if attributes['stock_location_id'].present?
144
+ attributes['stock_location_id'] = find_stock_location!(attributes['stock_location_id']).id
145
+ end
146
+
147
+ attributes
148
+ end
149
+
150
+ # A stock location of the current store. A branch that must narrow it
151
+ # further (by ability, by seller) overrides this.
152
+ def find_stock_location!(id)
153
+ current_store.stock_locations.find_by_prefix_id!(id)
141
154
  end
142
155
 
143
156
  # What may be split out of this parcel, and where the split half
@@ -204,6 +204,20 @@ module Spree
204
204
  def scope_limited_principal?
205
205
  current_api_key.present? && current_user.blank?
206
206
  end
207
+
208
+ # Whether the caller holds a catalog key beyond the one the action
209
+ # needs — for data another permission guards riding along in a
210
+ # response (an expanded association, an embedded record). Mirrors the
211
+ # key gate's two principals: a secret key carries scopes, a signed-in
212
+ # staffer carries their roles' catalog keys.
213
+ def holds_permission?(key)
214
+ return current_api_key.has_scope?(key) if scope_limited_principal?
215
+
216
+ ability = current_ability
217
+ return true unless ability.respond_to?(:permission_keys)
218
+
219
+ ability.permission_keys.include?(key.to_s)
220
+ end
207
221
  end
208
222
  end
209
223
  end
@@ -0,0 +1,35 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Store
5
+ # The products a storefront buyer may see: available now (or on
6
+ # pre-order) in the current currency, narrowed to the catalogs their
7
+ # company, customer group or channel resolve to. The product listing
8
+ # reads through it, and so does anything else that accepts a product
9
+ # or variant from the buyer, so an id cannot reach what the listing
10
+ # would never show.
11
+ module StorefrontProducts
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ # @param base [ActiveRecord::Relation] products to narrow; the store's by default
17
+ # @return [ActiveRecord::Relation<Spree::Product>]
18
+ def storefront_products(base = current_store.products)
19
+ Spree.products_for_context_service.call(
20
+ store: current_store,
21
+ channel: current_channel,
22
+ customer: current_user,
23
+ base: base.available(Time.current, Spree::Current.currency, include_preorderable: true)
24
+ ).value
25
+ end
26
+
27
+ # @return [ActiveRecord::Relation<Spree::Variant>]
28
+ def storefront_variants
29
+ Spree::Variant.where(product_id: storefront_products.reorder(nil).select(Spree::Product.arel_table[:id]))
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -24,6 +24,8 @@ module Spree
24
24
  # the account globally. The user keeps access to any other stores.
25
25
  def destroy
26
26
  authorize!(:destroy, @resource)
27
+ return if reject_unauthorized_role_removal!(@resource, @resource.spree_roles.for_resource(current_store).to_a)
28
+
27
29
  @resource.role_users.where(role: current_store.roles).destroy_all
28
30
  head :no_content
29
31
  end
@@ -42,6 +44,7 @@ module Spree
42
44
  # RoleManagement permission set, not by profile-edit rights.
43
45
  role_ids = role_ids_param if params.key?(:role_ids)
44
46
  return if role_ids && reject_unauthorized_role_grant!(role_ids, require_role_management: true)
47
+ return if role_ids && reject_unauthorized_role_removal!(@resource, removed_roles(role_ids))
45
48
 
46
49
  if @resource.update(identity_params)
47
50
  apply_role_ids(role_ids) if role_ids
@@ -89,6 +92,12 @@ module Spree
89
92
  ids.map { |id| Spree::PrefixedId.prefixed_id?(id) ? Spree::PrefixedId.decode_prefixed_id(id) : id }.compact
90
93
  end
91
94
 
95
+ # The store roles the user holds that `desired_role_ids` leaves out.
96
+ def removed_roles(desired_role_ids)
97
+ target = desired_role_ids.map(&:to_s)
98
+ @resource.spree_roles.for_resource(current_store).reject { |role| target.include?(role.id.to_s) }
99
+ end
100
+
92
101
  # Reconcile the user's roles on this store to match `desired_role_ids`.
93
102
  # Adds missing assignments and removes extras — no-op for unchanged.
94
103
  def apply_role_ids(desired_role_ids)
@@ -13,6 +13,7 @@ module Spree
13
13
 
14
14
  include Spree::Api::V3::ScopedAuthorization
15
15
  include Spree::Api::V3::Admin::ValidationDetails
16
+ include Spree::Api::V3::Admin::EmbedPermissions
16
17
  end
17
18
  end
18
19
  end
@@ -109,17 +109,22 @@ module Spree
109
109
  end.merge('customer' => Spree.customer_class.name, 'category' => 'Spree::Category')
110
110
  end
111
111
 
112
- # Returns the first segment whose `<segment>_id` is present in params,
112
+ # Returns the first segment whose `<segment>_id` is in the route,
113
113
  # paired with its class and the raw id value, or nil. Memoized — read
114
114
  # by both `set_parent` and `scoped_resource_name`.
115
+ #
116
+ # Read from the path only: a `store_id` or `refund_id` added to the
117
+ # query string or body must not switch the parent to a type that is
118
+ # never routed here and has no store scope to narrow it.
115
119
  def parent_lookup
116
120
  return @parent_lookup if defined?(@parent_lookup)
117
121
 
118
- match = parent_route_map.find { |segment, _| params[:"#{segment}_id"].present? }
122
+ route_params = request.path_parameters
123
+ match = parent_route_map.find { |segment, _| route_params[:"#{segment}_id"].present? }
119
124
  @parent_lookup =
120
125
  if match
121
126
  segment, klass_name = match
122
- ParentLookup.new(klass_name.constantize, params[:"#{segment}_id"], segment)
127
+ ParentLookup.new(klass_name.constantize, route_params[:"#{segment}_id"], segment)
123
128
  end
124
129
  end
125
130
  end
@@ -223,20 +223,6 @@ module Spree
223
223
  false
224
224
  end
225
225
 
226
- # Mirrors the key gate's two principals: a secret key carries scopes,
227
- # a signed-in staffer carries their roles' catalog keys.
228
- #
229
- # @param key [String]
230
- # @return [Boolean]
231
- def holds_permission?(key)
232
- return current_api_key.has_scope?(key) if scope_limited_principal?
233
-
234
- ability = current_ability
235
- return true unless ability.respond_to?(:permission_keys)
236
-
237
- ability.permission_keys.include?(key)
238
- end
239
-
240
226
  # `export` maps to `:show`; `anonymize` is a destructive write, so it
241
227
  # maps to `:destroy` rather than `:update` — a role that may correct
242
228
  # a customer's name should not thereby be able to erase their history.
@@ -13,6 +13,8 @@ module Spree
13
13
  class GiftCardsController < ResourceController
14
14
  scoped_resource :gift_cards
15
15
 
16
+ before_action :resolve_customer, only: [:create, :update]
17
+
16
18
  protected
17
19
 
18
20
  def model_class
@@ -24,8 +26,33 @@ module Spree
24
26
  end
25
27
 
26
28
  def permitted_params
27
- params.permit(*model_additional_permitted_attributes, :code, :amount, :expires_at,
28
- :customer_id, :currency)
29
+ params.permit(*model_additional_permitted_attributes, :code, :amount, :expires_at, :currency).tap do |attributes|
30
+ attributes[:customer] = @customer if params.key?(:customer_id)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Assigning a customer embeds their record in the response, so it is
37
+ # a read of customers: the caller must hold that permission, and the
38
+ # customer is resolved through their ability — never a raw id the
39
+ # model would look up across every customer.
40
+ def resolve_customer
41
+ return unless params.key?(:customer_id)
42
+ return @customer = nil if params[:customer_id].blank?
43
+
44
+ unless holds_permission?('read_customers')
45
+ return render_error(
46
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
47
+ message: 'Missing permission: read_customers',
48
+ status: :forbidden,
49
+ details: { required_permission: 'read_customers' }
50
+ )
51
+ end
52
+
53
+ @customer = Spree.customer_class.
54
+ accessible_by(current_ability, :show).
55
+ find_by_prefix_id!(params[:customer_id])
29
56
  end
30
57
  end
31
58
  end
@@ -72,7 +72,10 @@ module Spree
72
72
  end
73
73
 
74
74
  def authenticate_existing(user)
75
- return user if user.valid_password?(params[:password].to_s)
75
+ # Under the login lockout: otherwise this is an unthrottled way to
76
+ # guess an existing account's password with an invitation token.
77
+ verdict = Spree::Authentication::Lockout.check(user) { user.valid_password?(params[:password].to_s) }
78
+ return user if verdict == :valid
76
79
 
77
80
  render_error(
78
81
  code: ERROR_CODES[:authentication_failed],
@@ -0,0 +1,41 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Invitations
6
+ # The acceptance link of a pending staff invitation.
7
+ #
8
+ # The link carries the token that creates the account and grants the
9
+ # role, so it is authorized like sending the invitation: the caller
10
+ # needs write access to staff and must be allowed to grant the
11
+ # invitation's role. Reading staff is not enough.
12
+ class AcceptanceLinksController < Admin::BaseController
13
+ include Spree::Api::V3::Admin::RoleGrantGuard
14
+
15
+ scoped_resource :staff
16
+
17
+ # GET /api/v3/admin/invitations/:invitation_id/acceptance_link
18
+ def show
19
+ invitation = Spree::Invitation.
20
+ where(resource: current_store).
21
+ pending.
22
+ find_by_prefix_id!(params[:invitation_id])
23
+ authorize!(:update, invitation)
24
+ return if reject_unauthorized_role_grant!([invitation.role_id])
25
+
26
+ render json: Spree.api.admin_invitation_acceptance_link_serializer.new(
27
+ invitation, params: { store: current_store }
28
+ ).to_h
29
+ end
30
+
31
+ protected
32
+
33
+ def read_actions
34
+ []
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -58,6 +58,17 @@ module Spree
58
58
  )
59
59
  end
60
60
 
61
+ # /me describes the signed-in user rather than a store, and the
62
+ # dashboard reads it before any store is selected. A user who holds
63
+ # no role on any store (not yet given one, or removed from all of
64
+ # them) must still reach it, or they cannot finish signing in. A
65
+ # request that names a store keeps the membership check.
66
+ def require_store_membership!
67
+ return true if store_id_header.nil?
68
+
69
+ super
70
+ end
71
+
61
72
  def permitted_params
62
73
  params.permit(:selected_locale, :first_name, :last_name, :avatar)
63
74
  end
@@ -18,7 +18,7 @@ module Spree
18
18
  #
19
19
  # Editable fields only — status moves through the member actions.
20
20
  def update
21
- if @resource.update(permitted_params)
21
+ if @resource.update(update_attributes)
22
22
  render json: serialize_resource(@resource.reload)
23
23
  else
24
24
  render_validation_error(@resource.errors)
@@ -41,6 +41,19 @@ module Spree
41
41
 
42
42
  private
43
43
 
44
+ # The reason is resolved through the store rather than assigned
45
+ # raw, so another store's reads as missing.
46
+ def update_attributes
47
+ attributes = permitted_params.to_h.except('reason_id')
48
+
49
+ if params.key?(:reason_id)
50
+ attributes['reason'] =
51
+ (current_store.claim_reasons.find_by_prefix_id!(params[:reason_id]) if params[:reason_id].present?)
52
+ end
53
+
54
+ attributes
55
+ end
56
+
44
57
  # The whole store's catalogue: an operator may promise anything
45
58
  # the store sells as a replacement.
46
59
  def replacement_variant_for(variant_id)
@@ -19,7 +19,7 @@ module Spree
19
19
  #
20
20
  # Editable fields only — status moves through the member actions.
21
21
  def update
22
- if @resource.update(permitted_params)
22
+ if @resource.update(update_attributes)
23
23
  render json: serialize_resource(@resource.reload)
24
24
  else
25
25
  render_validation_error(@resource.errors)
@@ -42,6 +42,27 @@ module Spree
42
42
 
43
43
  private
44
44
 
45
+ # The ids are resolved through the store rather than assigned raw,
46
+ # so another store's reason or warehouse reads as missing.
47
+ def update_attributes
48
+ attributes = permitted_params.to_h.except('reason_id', 'stock_location_id')
49
+
50
+ if params.key?(:reason_id)
51
+ attributes['reason'] =
52
+ (current_store.return_reasons.find_by_prefix_id!(params[:reason_id]) if params[:reason_id].present?)
53
+ end
54
+
55
+ if params.key?(:stock_location_id)
56
+ attributes['stock_location'] =
57
+ if params[:stock_location_id].present?
58
+ current_store.stock_locations.accessible_by(current_ability, :show).
59
+ find_by_prefix_id!(params[:stock_location_id])
60
+ end
61
+ end
62
+
63
+ attributes
64
+ end
65
+
45
66
  # The whole store's catalogue, and any warehouse the caller may
46
67
  # see: an operator exchanges into anything the store sells.
47
68
  def replacement_variant_for(variant_id)
@@ -113,7 +113,7 @@ module Spree
113
113
  end
114
114
 
115
115
  def find_stock_location!(id)
116
- Spree::StockLocation.accessible_by(current_ability, :show).find_by_prefix_id!(id)
116
+ current_store.stock_locations.accessible_by(current_ability, :show).find_by_prefix_id!(id)
117
117
  end
118
118
 
119
119
  def stock_location_for_create
@@ -25,7 +25,7 @@ module Spree
25
25
  #
26
26
  # Editable fields only — status moves through the member actions.
27
27
  def update
28
- if @resource.update(permitted_params)
28
+ if @resource.update(update_attributes)
29
29
  render json: serialize_resource(@resource.reload)
30
30
  else
31
31
  render_validation_error(@resource.errors)
@@ -55,6 +55,27 @@ module Spree
55
55
 
56
56
  private
57
57
 
58
+ # The ids are resolved through the store rather than assigned raw,
59
+ # so another store's reason or warehouse reads as missing.
60
+ def update_attributes
61
+ attributes = permitted_params.to_h.except('reason_id', 'stock_location_id')
62
+
63
+ if params.key?(:reason_id)
64
+ attributes['reason'] =
65
+ (current_store.return_reasons.find_by_prefix_id!(params[:reason_id]) if params[:reason_id].present?)
66
+ end
67
+
68
+ if params.key?(:stock_location_id)
69
+ attributes['stock_location'] =
70
+ if params[:stock_location_id].present?
71
+ current_store.stock_locations.accessible_by(current_ability, :show).
72
+ find_by_prefix_id!(params[:stock_location_id])
73
+ end
74
+ end
75
+
76
+ attributes
77
+ end
78
+
58
79
  # Any of the store's warehouses the caller may see.
59
80
  def stock_location_for_create
60
81
  return nil if create_params[:stock_location_id].blank?
@@ -52,6 +52,10 @@ module Spree
52
52
  end
53
53
 
54
54
  # PATCH /api/v3/admin/orders/:id/complete
55
+ #
56
+ # An order holding several sellers' goods divides into one order per
57
+ # seller, so the group is what comes back — the orders it produced
58
+ # are nested inside it.
55
59
  def complete
56
60
  with_order_lock do
57
61
  result = Spree.order_complete_service.call(
@@ -61,7 +65,7 @@ module Spree
61
65
  )
62
66
 
63
67
  if result.success?
64
- render json: serialize_resource(@resource.reload)
68
+ render json: serialize_completion(result.value)
65
69
  else
66
70
  render_service_error(@resource.errors.presence || result.error, code: ERROR_CODES[:order_cannot_complete])
67
71
  end
@@ -149,6 +153,28 @@ module Spree
149
153
  Spree.api.admin_order_serializer
150
154
  end
151
155
 
156
+ # Completion answers with whatever it produced: one order, or the
157
+ # group a multi-seller order divided into.
158
+ #
159
+ # The group is rendered whole rather than through `filter_fields`: a
160
+ # `fields` list names an order's attributes, and narrowing a group by
161
+ # it can drop the very keys that say it is one.
162
+ def serialize_completion(record)
163
+ return serialize_resource(record.reload) unless record.is_a?(Spree::OrderGroup)
164
+
165
+ Spree.api.admin_order_group_serializer.new(completed_group(record), params: serializer_params).to_h
166
+ end
167
+
168
+ # Every child is rendered in full, so its associations are loaded once
169
+ # here rather than per order.
170
+ def completed_group(group)
171
+ Spree::OrderGroup.includes(
172
+ :customer, :ship_address, :bill_address,
173
+ { payments: %i[payment_method source] },
174
+ { orders: [:seller, :market, :gift_card, :customer, { line_items: { variant: :prices } }] }
175
+ ).find(group.id)
176
+ end
177
+
152
178
  # Override scope — Order uses SingleStoreResource (for_store).
153
179
  # Variant prices and each line's price list are named here rather
154
180
  # than via scope_includes, which this override bypasses; the
@@ -17,20 +17,10 @@ module Spree
17
17
 
18
18
  # POST /api/v3/admin/password_resets
19
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
20
  user = Spree.admin_user_class.find_by(email: params[:email])
32
21
 
33
22
  if user
23
+ redirect_url = trusted_redirect_url(user)
34
24
  token = user.generate_token_for(:password_reset)
35
25
  event_payload = { reset_token: token, email: user.email, store_id: current_store.prefixed_id }
36
26
  event_payload[:redirect_url] = redirect_url if redirect_url.present?
@@ -71,6 +61,36 @@ module Spree
71
61
 
72
62
  private
73
63
 
64
+ # The emailed link carries the reset token, so it may only point at
65
+ # the dashboard — never at a storefront origin, which anyone who can
66
+ # edit a store's allowed origins could add — and only when the
67
+ # account belongs to the store the request names. Anything else is
68
+ # ignored and the email links to the dashboard's own reset page.
69
+ #
70
+ # @return [String, nil]
71
+ def trusted_redirect_url(user)
72
+ redirect_url = params[:redirect_url].presence
73
+ return if redirect_url.blank?
74
+ return unless same_origin?(redirect_url, Spree::Stores::DashboardUrl.call(store: current_store))
75
+ return unless staff_of_current_store?(user)
76
+
77
+ redirect_url
78
+ end
79
+
80
+ def same_origin?(url, other)
81
+ first = URI.parse(url.to_s)
82
+ second = URI.parse(other.to_s)
83
+ first.host.present? && [first.scheme, first.host, first.port] == [second.scheme, second.host, second.port]
84
+ rescue URI::InvalidURIError
85
+ false
86
+ end
87
+
88
+ def staff_of_current_store?(user)
89
+ return false unless user.respond_to?(:spree_roles)
90
+
91
+ user.spree_roles.for_resource(current_store).exists?
92
+ end
93
+
74
94
  def auth_response(user)
75
95
  {
76
96
  token: generate_jwt(user, audience: JWT_AUDIENCE_ADMIN),