spree_api 5.4.3 → 5.5.0.rc2

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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +36 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
  4. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +52 -0
  5. data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
  6. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
  7. data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
  8. data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
  9. data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
  10. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
  11. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +104 -0
  12. data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
  13. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +109 -0
  14. data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
  15. data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +84 -0
  16. data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
  17. data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
  18. data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
  19. data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
  20. data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
  21. data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
  22. data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
  23. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +129 -0
  24. data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
  25. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +83 -0
  26. data/app/controllers/spree/api/v3/admin/customers/base_controller.rb +33 -0
  27. data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +25 -0
  28. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +92 -0
  29. data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
  30. data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
  31. data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
  32. data/app/controllers/spree/api/v3/admin/exports_controller.rb +136 -0
  33. data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
  34. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
  35. data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
  36. data/app/controllers/spree/api/v3/admin/invitations_controller.rb +81 -0
  37. data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
  38. data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
  39. data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
  40. data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
  41. data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
  42. data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +31 -0
  43. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
  44. data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
  45. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
  46. data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
  47. data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
  48. data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
  49. data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
  50. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
  51. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +179 -0
  52. data/app/controllers/spree/api/v3/admin/prices_controller.rb +157 -0
  53. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
  54. data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
  55. data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
  56. data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
  57. data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
  58. data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
  59. data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
  60. data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
  61. data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
  62. data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
  63. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
  64. data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
  65. data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
  66. data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
  67. data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
  68. data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
  69. data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
  70. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
  71. data/app/controllers/spree/api/v3/resource_controller.rb +117 -8
  72. data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
  73. data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
  74. data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
  75. data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
  76. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
  77. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
  78. data/app/controllers/spree/api/v3/store/products_controller.rb +4 -3
  79. data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
  80. data/app/jobs/spree/webhook_delivery_job.rb +5 -0
  81. data/app/models/spree/api_key_ability.rb +16 -0
  82. data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
  83. data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
  84. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
  85. data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
  86. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
  87. data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
  88. data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
  89. data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
  90. data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
  91. data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
  92. data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
  93. data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
  94. data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
  95. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
  96. data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
  97. data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
  98. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
  99. data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
  100. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
  101. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
  102. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
  103. data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
  104. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
  105. data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
  106. data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
  107. data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
  108. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
  109. data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
  110. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
  111. data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
  112. data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
  113. data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
  114. data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
  115. data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
  116. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
  117. data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
  118. data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
  119. data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
  120. data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
  121. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
  122. data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
  123. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
  124. data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
  125. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
  126. data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
  127. data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
  128. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
  129. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
  130. data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
  131. data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
  132. data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
  133. data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
  134. data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
  135. data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
  136. data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
  137. data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
  138. data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
  139. data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
  140. data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
  141. data/config/locales/en.yml +2 -0
  142. data/config/routes.rb +235 -1
  143. data/lib/spree/api/configuration.rb +2 -2
  144. data/lib/spree/api/dependencies.rb +25 -1
  145. data/lib/spree/api/openapi/path_sorter.rb +126 -0
  146. data/lib/spree/api/openapi/schema_helper.rb +185 -6
  147. data/lib/spree/api/testing_support/v3/base.rb +28 -0
  148. metadata +98 -8
  149. data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +0 -14
@@ -0,0 +1,136 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # See `docs/plans/5.5-admin-spa-csv-export.md`.
6
+ #
7
+ # There is no standalone exports scope: an export is a bulk read of
8
+ # the records it contains, so each export type is gated by the read
9
+ # scope of the exported resource (Spree::Exports::Customers =>
10
+ # `read_customers`; see Spree::Export.required_scope), and the index
11
+ # is filtered to the types the key can read.
12
+ class ExportsController < ResourceController
13
+ include ActiveStorage::SetCurrent
14
+
15
+ # The index spans many export types — `scope` filters it to the
16
+ # readable ones instead of gating on a single scope.
17
+ skip_scope_check! only: :index
18
+
19
+ # We stream the CSV inline rather than redirecting to ActiveStorage's
20
+ # signed-URL endpoint because the SPA's Vite proxy only forwards
21
+ # `/api/*`. A cross-origin redirect to `/rails/active_storage/...`
22
+ # strips the Authorization header and the download fails silently.
23
+ def download
24
+ @resource = find_resource
25
+ authorize_resource!(@resource, :show)
26
+
27
+ unless @resource.done?
28
+ return render_error(
29
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:export_not_ready],
30
+ message: 'Export is not ready yet',
31
+ status: :unprocessable_content
32
+ )
33
+ end
34
+
35
+ attachment = @resource.attachment
36
+ send_data(
37
+ attachment.download,
38
+ filename: attachment.filename.to_s,
39
+ type: attachment.content_type || 'text/csv',
40
+ disposition: 'attachment'
41
+ )
42
+ end
43
+
44
+ protected
45
+
46
+ def model_class
47
+ Spree::Export
48
+ end
49
+
50
+ def serializer_class
51
+ Spree.api.admin_export_serializer
52
+ end
53
+
54
+ def scope_includes
55
+ [:user, { attachment_attachment: :blob }]
56
+ end
57
+
58
+ def scope
59
+ collection = super
60
+ return collection unless scope_limited_principal?
61
+
62
+ collection.where(type: readable_export_types)
63
+ end
64
+
65
+ # Loaded by both the scope gate (before_action) and the member
66
+ # actions — memoize so the record is fetched once.
67
+ def find_resource
68
+ @find_resource ||= super
69
+ end
70
+
71
+ # Exports never mutate commerce data; creating or downloading one
72
+ # is a bulk read, so every action maps to the read-level scope.
73
+ def action_kind
74
+ 'read'
75
+ end
76
+
77
+ # Unresolvable types (blank/unknown `type` on create) fall back to
78
+ # `:all`, so only `read_all`/`write_all` keys reach the model's own
79
+ # validation.
80
+ def scoped_resource_name
81
+ export_class&.required_scope || :all
82
+ end
83
+
84
+ def build_resource
85
+ klass = resolve_export_type(permitted_params[:type]) || Spree::Export
86
+ attrs = permitted_params.except(:type).merge(
87
+ store: current_store,
88
+ user: try_spree_current_user
89
+ )
90
+ klass.new(attrs)
91
+ end
92
+
93
+ # `search_params` carries an arbitrary Ransack hash with nested
94
+ # groupings (`{ g: [{ name_cont: 'foo' }] }`). Rails' `permit(k: {})`
95
+ # rejects nested hashes, so we extract via `to_unsafe_h`. `:format`
96
+ # is intentionally dropped — only CSV is supported and Rails' request
97
+ # format would otherwise overwrite the model's enum.
98
+ def permitted_params
99
+ attrs = params.permit(:type, :record_selection)
100
+ raw = params[:search_params]
101
+ attrs[:search_params] = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw if raw.present?
102
+ attrs
103
+ end
104
+
105
+ # Returns the registered Export subclass matching `name`, or nil.
106
+ #
107
+ # The constantize target comes from `available_types` (a trusted
108
+ # in-process registry), not from the request — `name` is only used
109
+ # to *select* an entry in the allowlist. This keeps the data flow
110
+ # from user input → trusted-string → `constantize` legible to
111
+ # static analyzers (CodeQL otherwise flags the inverse pattern of
112
+ # gating user input with `include?` before calling `constantize`).
113
+ def resolve_export_type(name)
114
+ return nil if name.blank?
115
+
116
+ target = Spree::Export.available_types.map(&:to_s).find { |t| t == name.to_s }
117
+ target&.constantize
118
+ end
119
+
120
+ private
121
+
122
+ def export_class
123
+ action_name == 'create' ? resolve_export_type(params[:type]) : find_resource.class
124
+ end
125
+
126
+ def readable_export_types
127
+ Spree::Export.available_types.select do |type|
128
+ required = type.required_scope
129
+ required ? current_api_key.has_scope?("read_#{required}") : current_api_key.has_scope?('read_all')
130
+ end.map(&:to_s)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,31 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin bulk-issue endpoint for `Spree::GiftCardBatch`. Creating a
6
+ # batch synchronously generates the `codes_count` gift cards inline
7
+ # (or kicks off a background job when the count exceeds
8
+ # `Spree.config.gift_card_batch_web_limit`, default 500). Read-only
9
+ # access lives behind `list`/`show` so the SPA can surface batch
10
+ # context on the gift cards index (filter chip, batch chip on rows).
11
+ class GiftCardBatchesController < ResourceController
12
+ scoped_resource :gift_cards
13
+
14
+ protected
15
+
16
+ def model_class
17
+ Spree::GiftCardBatch
18
+ end
19
+
20
+ def serializer_class
21
+ Spree.api.admin_gift_card_batch_serializer
22
+ end
23
+
24
+ def permitted_params
25
+ params.permit(:prefix, :codes_count, :amount, :expires_at, :currency)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin CRUD for `Spree::GiftCard`. Scoped to the current store via
6
+ # the model's `SingleStoreResource` include — the base controller's
7
+ # `scope` already applies `model_class.for_store(current_store)`.
8
+ #
9
+ # `store` and `created_by` are auto-stamped by `build_resource` in
10
+ # `Spree::Api::V3::ResourceController`, so create requests only need
11
+ # to include user-facing attributes (amount, currency, expires_at,
12
+ # optional code, optional user_id).
13
+ class GiftCardsController < ResourceController
14
+ scoped_resource :gift_cards
15
+
16
+ protected
17
+
18
+ def model_class
19
+ Spree::GiftCard
20
+ end
21
+
22
+ def serializer_class
23
+ Spree.api.admin_gift_card_serializer
24
+ end
25
+
26
+ def permitted_params
27
+ params.permit(:code, :amount, :expires_at, :user_id, :currency)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,138 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Public invitation acceptance — mounted under `/api/v3/admin/auth/...`
6
+ # so the issued refresh-token cookie's path matches `/auth/refresh`.
7
+ class InvitationAcceptancesController < BaseController
8
+ include Spree::Api::V3::Admin::AuthCookies
9
+
10
+ skip_scope_check!
11
+ skip_before_action :authenticate_admin!, only: [:lookup, :accept]
12
+
13
+ rate_limit to: Spree::Api::Config[:rate_limit_login],
14
+ within: Spree::Api::Config[:rate_limit_window].seconds,
15
+ store: Rails.cache,
16
+ only: [:lookup, :accept],
17
+ with: RATE_LIMIT_RESPONSE
18
+
19
+ # GET /api/v3/admin/auth/invitations/:id/lookup?token=:token
20
+ def lookup
21
+ return unless load_invitation
22
+
23
+ render json: Spree.api.admin_invitation_serializer.new(@invitation).serializable_hash
24
+ end
25
+
26
+ # POST /api/v3/admin/auth/invitations/:id/accept?token=:token
27
+ # Body: { password?, password_confirmation?, first_name?, last_name? }
28
+ def accept
29
+ return unless load_invitation
30
+
31
+ user = resolve_or_create_invitee(@invitation)
32
+ return if performed?
33
+
34
+ @invitation.invitee = user
35
+ @invitation.accept!
36
+
37
+ refresh_token = Spree::RefreshToken.create_for(user, request_env: request_env_for_token)
38
+ set_refresh_cookie(refresh_token)
39
+ render json: auth_response(user)
40
+ rescue ActiveRecord::RecordInvalid => e
41
+ render_validation_error(e.record.errors)
42
+ end
43
+
44
+ private
45
+
46
+ # Token mismatch is treated identically to "not found" to avoid
47
+ # leaking whether an ID exists.
48
+ def load_invitation
49
+ decoded_id = Spree::Invitation.decode_prefixed_id(params[:id])
50
+ @invitation = Spree::Invitation.pending.not_expired.find_by(id: decoded_id, token: params[:token])
51
+
52
+ unless @invitation
53
+ render_error(
54
+ code: ERROR_CODES[:record_not_found],
55
+ message: 'Invitation not found, expired, or already accepted',
56
+ status: :not_found
57
+ )
58
+ return false
59
+ end
60
+
61
+ true
62
+ end
63
+
64
+ # Email match between the invitation and any existing account is
65
+ # implicit: we look the user up by `invitation.email`, never by a
66
+ # client-supplied email. The token is the credential.
67
+ def resolve_or_create_invitee(invitation)
68
+ existing = Spree.admin_user_class.find_by(email: invitation.email)
69
+ return authenticate_existing(existing) if existing
70
+
71
+ create_new_invitee(invitation)
72
+ end
73
+
74
+ def authenticate_existing(user)
75
+ return user if user.valid_password?(params[:password].to_s)
76
+
77
+ render_error(
78
+ code: ERROR_CODES[:authentication_failed],
79
+ message: 'Invalid password',
80
+ status: :unauthorized
81
+ )
82
+ nil
83
+ end
84
+
85
+ def create_new_invitee(invitation)
86
+ if params[:password].blank?
87
+ render_error(
88
+ code: ERROR_CODES[:parameter_missing],
89
+ message: 'Password is required to create your account',
90
+ status: :unprocessable_content
91
+ )
92
+ return nil
93
+ end
94
+
95
+ Spree.admin_user_class.create!(signup_params(invitation))
96
+ end
97
+
98
+ def signup_params(invitation)
99
+ params.permit(:password, :password_confirmation, :first_name, :last_name).
100
+ merge(email: invitation.email)
101
+ end
102
+
103
+ def auth_response(user)
104
+ {
105
+ token: generate_jwt(user, audience: JWT_AUDIENCE_ADMIN),
106
+ user: admin_user_serializer.new(user, params: serializer_params).to_h
107
+ }
108
+ end
109
+
110
+ def serializer_params
111
+ {
112
+ store: @invitation&.store || current_store,
113
+ locale: current_locale,
114
+ currency: current_currency,
115
+ user: nil,
116
+ includes: []
117
+ }
118
+ end
119
+
120
+ def admin_user_serializer
121
+ Spree.api.admin_admin_user_serializer
122
+ end
123
+
124
+ def request_env_for_token
125
+ {
126
+ ip_address: request.remote_ip,
127
+ user_agent: request.user_agent&.truncate(255)
128
+ }
129
+ end
130
+
131
+ def jwt_expiration
132
+ Spree::Api::Config[:admin_jwt_expiration]
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,81 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Manages staff invitations for the current store. Each invitation
6
+ # carries an email + role; on accept, a `Spree::RoleUser` is created
7
+ # via the invitation's `after_accept` callback and the invitee
8
+ # becomes a member of the staff list for this store.
9
+ class InvitationsController < ResourceController
10
+ include Spree::Api::V3::Admin::RoleGrantGuard
11
+
12
+ scoped_resource :settings
13
+
14
+ # POST /api/v3/admin/invitations
15
+ # Guards against inviting a new staff member straight into the admin
16
+ # super-role unless the inviter already holds it.
17
+ def create
18
+ return if reject_unauthorized_role_grant!(Array(permitted_params[:role_id]))
19
+
20
+ super
21
+ end
22
+
23
+ # PATCH /api/v3/admin/invitations/:id/resend
24
+ # Issues a fresh token + email for an existing pending invitation.
25
+ # The model's `resend!` is responsible for resetting `expires_at`
26
+ # and dispatching the mailer.
27
+ def resend
28
+ @resource = find_resource
29
+ authorize!(:update, @resource)
30
+
31
+ @resource.resend!
32
+ render json: serialize_resource(@resource)
33
+ end
34
+
35
+ # Invitations are immutable post-create — UI calls `resend` for
36
+ # token rotation, `destroy` to revoke. Clearing the action set
37
+ # keeps the surface honest if a client ever fires PATCH directly.
38
+ def update
39
+ head :method_not_allowed
40
+ end
41
+
42
+ protected
43
+
44
+ def model_class
45
+ Spree::Invitation
46
+ end
47
+
48
+ def serializer_class
49
+ Spree.api.admin_invitation_serializer
50
+ end
51
+
52
+ def collection_includes
53
+ [:role, :inviter]
54
+ end
55
+
56
+ def scope
57
+ Spree::Invitation.
58
+ where(resource: current_store).
59
+ accessible_by(current_ability, :show)
60
+ end
61
+
62
+ def build_resource
63
+ scope.new(permitted_params).tap do |invitation|
64
+ invitation.resource = current_store
65
+ invitation.inviter = try_spree_current_user
66
+ end
67
+ end
68
+
69
+ # `email` and `role_id` are flat — `role_id` accepts a prefixed ID.
70
+ def permitted_params
71
+ attrs = params.permit(:email, :role_id)
72
+ if attrs[:role_id].present? && Spree::PrefixedId.prefixed_id?(attrs[:role_id])
73
+ attrs[:role_id] = Spree::PrefixedId.decode_prefixed_id(attrs[:role_id])
74
+ end
75
+ attrs
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,42 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin Markets surface. Markets are store-scoped (`store_id` column +
6
+ # `acts_as_list scope: :store_id`), so the base ResourceController's
7
+ # `scope` chain narrows appropriately when we restrict to
8
+ # `current_store.markets`.
9
+ #
10
+ # `country_isos` and `supported_locales` are accepted as arrays on the
11
+ # wire and translated by model setters (`Spree::Market#country_isos=`,
12
+ # `#supported_locales=`).
13
+ class MarketsController < ResourceController
14
+ scoped_resource :settings
15
+
16
+ protected
17
+
18
+ def model_class
19
+ Spree::Market
20
+ end
21
+
22
+ def serializer_class
23
+ Spree.api.admin_market_serializer
24
+ end
25
+
26
+ def collection_includes
27
+ [:countries]
28
+ end
29
+
30
+ def permitted_params
31
+ normalize_params(
32
+ params.permit(
33
+ :name, :currency, :default_locale, :tax_inclusive,
34
+ :default, :position, supported_locales: [], country_isos: []
35
+ )
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,69 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class MeController < Admin::BaseController
6
+ skip_scope_check!
7
+
8
+ # GET /api/v3/admin/me
9
+ # Returns the current admin user along with a serialized representation
10
+ # of their permissions (derived from CanCanCan rules). The SPA uses
11
+ # the permissions list to decide which UI elements to show or hide.
12
+ # The actual authorization check is still enforced server-side by
13
+ # CanCanCan — the SPA list is purely for UX.
14
+ def show
15
+ render json: {
16
+ user: admin_user_serializer.new(current_user, params: serializer_params).to_h,
17
+ permissions: serialize_permissions(current_ability)
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ # Serializes CanCanCan's rules into a flat, JSON-safe list of permission rules.
24
+ #
25
+ # - Rule order is preserved so the frontend matcher can apply
26
+ # CanCanCan's "last matching rule wins" semantics.
27
+ # - Per-record conditions are NOT serialized (they often reference
28
+ # scopes or blocks that don't translate to JSON). The frontend
29
+ # receives `has_conditions: true` as a hint that the action might
30
+ # be denied at the per-record level — in practice the SPA shows
31
+ # the action optimistically and handles 403 from the API.
32
+ def serialize_permissions(ability)
33
+ ability.send(:rules).map do |rule|
34
+ {
35
+ allow: rule.base_behavior,
36
+ actions: Array(rule.actions).map(&:to_s),
37
+ subjects: Array(rule.subjects).map { |s| s.is_a?(Class) ? s.name : s.to_s },
38
+ has_conditions: rule_has_conditions?(rule)
39
+ }
40
+ end
41
+ end
42
+
43
+ def rule_has_conditions?(rule)
44
+ return true if rule.block.present?
45
+ conditions = rule.conditions
46
+ return false if conditions.nil?
47
+ return !conditions.empty? if conditions.respond_to?(:empty?)
48
+
49
+ true
50
+ end
51
+
52
+ def admin_user_serializer
53
+ Spree.api.admin_admin_user_serializer
54
+ end
55
+
56
+ def serializer_params
57
+ {
58
+ store: current_store,
59
+ locale: current_locale,
60
+ currency: current_currency,
61
+ user: current_user,
62
+ includes: []
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,119 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class MediaController < ResourceController
6
+ scoped_resource :products
7
+
8
+ def create
9
+ if permitted_params[:url].present?
10
+ create_from_url
11
+ elsif permitted_params[:signed_id].present?
12
+ create_from_signed_id
13
+ else
14
+ @resource = build_resource
15
+ authorize_resource!(@resource, :create)
16
+
17
+ if @resource.save
18
+ render json: serialize_resource(@resource), status: :created
19
+ else
20
+ render_validation_error(@resource.errors)
21
+ end
22
+ end
23
+ end
24
+
25
+ protected
26
+
27
+ def model_class
28
+ Spree::Asset
29
+ end
30
+
31
+ def serializer_class
32
+ Spree.api.admin_media_serializer
33
+ end
34
+
35
+ def set_parent
36
+ @product = current_store.products.find_by_prefix_id!(params[:product_id])
37
+ authorize!(:show, @product)
38
+
39
+ @parent = if params[:variant_id].present?
40
+ @product.variants_including_master.find_by_prefix_id!(params[:variant_id])
41
+ else
42
+ @product
43
+ end
44
+ end
45
+
46
+ # Variants store assets via the polymorphic `images` association; products own
47
+ # their gallery via `media`. Both resolve to `Spree::Asset` rows with different
48
+ # `viewable_type` values.
49
+ def parent_association
50
+ params[:variant_id].present? ? :images : :media
51
+ end
52
+
53
+ # For product-scoped listings we surface BOTH product-level assets and any
54
+ # legacy master-pinned assets, so existing data keeps showing up while
55
+ # merchants migrate. New uploads land on `Spree::Product` (see #set_parent).
56
+ def scope
57
+ return super if params[:variant_id].present?
58
+
59
+ Spree::Asset.where(
60
+ viewable_type: 'Spree::Product', viewable_id: @product.id
61
+ ).or(
62
+ Spree::Asset.where(
63
+ viewable_type: 'Spree::Variant', viewable_id: @product.master&.id
64
+ )
65
+ ).order(:position)
66
+ end
67
+
68
+ ALLOWED_MEDIA_TYPES = -> { [Spree::Asset, *Spree::Asset.descendants].map(&:name).to_set.freeze }
69
+
70
+ def build_resource
71
+ media_type = permitted_params[:type] || 'Spree::Image'
72
+
73
+ unless ALLOWED_MEDIA_TYPES.call.include?(media_type)
74
+ raise ArgumentError, "Invalid media type: #{media_type}"
75
+ end
76
+
77
+ media = @parent.send(parent_association).build(permitted_params.except(:type, :url, :signed_id))
78
+ media.type = media_type
79
+
80
+ media
81
+ end
82
+
83
+ def permitted_params
84
+ params.permit(:type, :alt, :position, :attachment, :url, :signed_id, variant_ids: [])
85
+ end
86
+
87
+ def create_from_url
88
+ authorize!(:create, Spree::Asset)
89
+
90
+ url = permitted_params[:url]
91
+ position = permitted_params[:position]
92
+
93
+ Spree::Images::SaveFromUrlJob.perform_later(
94
+ @parent.id,
95
+ @parent.class.name,
96
+ url,
97
+ nil,
98
+ position
99
+ )
100
+
101
+ head :accepted
102
+ end
103
+
104
+ def create_from_signed_id
105
+ @resource = build_resource
106
+ @resource.attachment.attach(permitted_params[:signed_id])
107
+ authorize_resource!(@resource, :create)
108
+
109
+ if @resource.save
110
+ render json: serialize_resource(@resource), status: :created
111
+ else
112
+ render_validation_error(@resource.errors)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,34 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class OptionTypesController < ResourceController
6
+ scoped_resource :products
7
+
8
+ protected
9
+
10
+ def model_class
11
+ Spree::OptionType
12
+ end
13
+
14
+ def serializer_class
15
+ Spree.api.admin_option_type_serializer
16
+ end
17
+
18
+ def scope_includes
19
+ [:option_values]
20
+ end
21
+
22
+ def permitted_params
23
+ params.permit(
24
+ :name, :label, :position, :filterable, :kind,
25
+ option_values: [
26
+ :id, :name, :label, :position, :color_code, :image
27
+ ]
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end