spree_cm_commissioner 2.13.3 → 2.13.4

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 (283) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +0 -1
  3. data/Gemfile.lock +1 -1
  4. data/app/assets/config/spree_cm_commissioner_manifest.js +0 -1
  5. data/app/assets/javascripts/spree_cm_commissioner/backend.js +0 -8
  6. data/app/assets/stylesheets/spree_cm_commissioner/backend/commissioner_admin.css.scss +0 -1
  7. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +10 -8
  8. data/app/controllers/spree/admin/base_controller_decorator.rb +0 -12
  9. data/app/controllers/spree/admin/oauth_application_api_keys_controller.rb +118 -0
  10. data/app/controllers/spree/admin/oauth_applications_controller.rb +10 -0
  11. data/app/controllers/spree/api/v2/storefront/dynamic_field_options_controller.rb +12 -0
  12. data/app/controllers/spree/api/v2/storefront/trip_search_controller.rb +0 -5
  13. data/app/controllers/spree/api/v2/tenant/trip_search_controller.rb +0 -5
  14. data/app/helpers/spree/{billing → admin}/navigation_helper_decorator.rb +2 -9
  15. data/app/interactors/spree_cm_commissioner/user_acleda_bank_web_app_authenticator.rb +38 -0
  16. data/app/interactors/spree_cm_commissioner/user_wing_bank_web_app_authenticator.rb +39 -0
  17. data/app/models/concerns/spree_cm_commissioner/oauth_application/mini_app.rb +72 -0
  18. data/app/models/spree_cm_commissioner/dynamic_field.rb +12 -1
  19. data/app/models/spree_cm_commissioner/dynamic_field_option.rb +11 -0
  20. data/app/models/spree_cm_commissioner/guest.rb +8 -0
  21. data/app/models/spree_cm_commissioner/mini_app_provider/acleda_bank.rb +28 -0
  22. data/app/models/spree_cm_commissioner/mini_app_provider/base.rb +219 -0
  23. data/app/models/spree_cm_commissioner/mini_app_provider/vattanac_bank.rb +6 -0
  24. data/app/models/spree_cm_commissioner/mini_app_provider/wing_bank.rb +30 -0
  25. data/app/models/spree_cm_commissioner/oauth_application_decorator.rb +7 -0
  26. data/app/models/spree_cm_commissioner/product_dynamic_field.rb +9 -0
  27. data/app/models/spree_cm_commissioner/user_identity_provider.rb +1 -1
  28. data/app/services/spree_cm_commissioner/mini_app_provider/api_key_history.rb +128 -0
  29. data/app/services/spree_cm_commissioner/mini_app_provider/ip_rules.rb +26 -0
  30. data/app/services/spree_cm_commissioner/mini_app_provider/session_gate.rb +79 -0
  31. data/app/services/spree_cm_commissioner/mini_app_provider/session_throttle.rb +66 -0
  32. data/app/services/spree_cm_commissioner/payment_method_type_mapper.rb +3 -1
  33. data/app/services/spree_cm_commissioner/rate_limiter/result.rb +20 -0
  34. data/app/services/spree_cm_commissioner/rate_limiter.rb +62 -0
  35. data/app/services/spree_cm_commissioner/seeds/user_usernames.rb +3 -1
  36. data/app/services/spree_cm_commissioner/ticket_transfers/eligibility_checker.rb +5 -6
  37. data/app/services/spree_cm_commissioner/ticket_transfers/preview_rule.rb +3 -5
  38. data/app/services/spree_cm_commissioner/user_authenticator.rb +12 -2
  39. data/app/services/spree_cm_commissioner/wing_mini_app/config.rb +44 -0
  40. data/app/services/spree_cm_commissioner/wing_mini_app/find_or_create_user.rb +89 -0
  41. data/app/services/spree_cm_commissioner/wing_mini_app/profile_verifier.rb +65 -0
  42. data/app/views/spree/admin/guests/_table.html.erb +4 -0
  43. data/app/views/spree/admin/oauth_applications/_api_key_minted_modal.html.erb +71 -0
  44. data/app/views/spree/admin/oauth_applications/_api_key_modal.html.erb +68 -0
  45. data/app/views/spree/admin/oauth_applications/_api_key_restore_list.html.erb +40 -0
  46. data/app/views/spree/admin/oauth_applications/_api_key_restore_modal.html.erb +72 -0
  47. data/app/views/spree/admin/oauth_applications/_api_key_revoke_modal.html.erb +54 -0
  48. data/app/views/spree/admin/oauth_applications/_form.html.erb +152 -0
  49. data/app/views/spree/admin/oauth_applications/edit.html.erb +36 -0
  50. data/app/views/spree/admin/shared/_ticket_transfer_details.html.erb +8 -2
  51. data/config/locales/en.yml +12 -0
  52. data/config/routes.rb +15 -113
  53. data/db/migrate/20260928070109_add_provider_and_api_key_to_spree_oauth_applications.rb +11 -0
  54. data/lib/spree_cm_commissioner/test_helper/factories/vendor_factory.rb +16 -2
  55. data/lib/spree_cm_commissioner/version.rb +1 -1
  56. data/lib/spree_cm_commissioner.rb +8 -1
  57. metadata +28 -229
  58. data/app/assets/javascripts/spree_cm_commissioner/address_hide_form.js +0 -17
  59. data/app/assets/javascripts/spree_cm_commissioner/location_checkbox_manager.js +0 -45
  60. data/app/assets/javascripts/spree_cm_commissioner/report_hide_filter.js +0 -37
  61. data/app/assets/javascripts/spree_cm_commissioner/role_form.js +0 -51
  62. data/app/assets/javascripts/spree_cm_commissioner/selected_field_filter_origin_destination.js +0 -37
  63. data/app/assets/javascripts/spree_cm_commissioner/trip_stop_selection.js +0 -41
  64. data/app/assets/javascripts/spree_cm_commissioner/update_popup_confirmation.js +0 -42
  65. data/app/assets/javascripts/spree_cm_commissioner/vehicle_seat.js +0 -291
  66. data/app/assets/stylesheets/spree_cm_commissioner/backend/invoice.scss +0 -92
  67. data/app/assets/stylesheets/spree_cm_commissioner/backend/vehicle_seat.scss +0 -28
  68. data/app/controllers/concerns/spree/billing/order_parents_concern.rb +0 -31
  69. data/app/controllers/concerns/spree/billing/payment_creatable.rb +0 -64
  70. data/app/controllers/concerns/spree/billing/payment_fireable.rb +0 -35
  71. data/app/controllers/concerns/spree_cm_commissioner/billing/role_authorization.rb +0 -53
  72. data/app/controllers/spree/billing/addresses_controller.rb +0 -32
  73. data/app/controllers/spree/billing/adjustments_controller.rb +0 -72
  74. data/app/controllers/spree/billing/base_controller.rb +0 -82
  75. data/app/controllers/spree/billing/businesses_controller.rb +0 -135
  76. data/app/controllers/spree/billing/customers_controller.rb +0 -107
  77. data/app/controllers/spree/billing/errors_controller.rb +0 -14
  78. data/app/controllers/spree/billing/invoice_controller.rb +0 -36
  79. data/app/controllers/spree/billing/option_types_controller.rb +0 -32
  80. data/app/controllers/spree/billing/orders_controller.rb +0 -90
  81. data/app/controllers/spree/billing/payment_qrcodes_controller.rb +0 -14
  82. data/app/controllers/spree/billing/payments_controller.rb +0 -148
  83. data/app/controllers/spree/billing/places_controller.rb +0 -35
  84. data/app/controllers/spree/billing/products_controller.rb +0 -56
  85. data/app/controllers/spree/billing/refunds_controller.rb +0 -9
  86. data/app/controllers/spree/billing/reports_controller.rb +0 -202
  87. data/app/controllers/spree/billing/roles_controller.rb +0 -107
  88. data/app/controllers/spree/billing/store_credits_controller.rb +0 -117
  89. data/app/controllers/spree/billing/subscriptions_controller.rb +0 -49
  90. data/app/controllers/spree/billing/users_controller.rb +0 -19
  91. data/app/controllers/spree/billing/variants_controller.rb +0 -36
  92. data/app/controllers/spree/billing/vendors_controller.rb +0 -83
  93. data/app/controllers/spree/transit/amenities_controller.rb +0 -79
  94. data/app/controllers/spree/transit/amenity_values_controller.rb +0 -16
  95. data/app/controllers/spree/transit/base_controller.rb +0 -53
  96. data/app/controllers/spree/transit/branches_controller.rb +0 -35
  97. data/app/controllers/spree/transit/locations_controller.rb +0 -38
  98. data/app/controllers/spree/transit/places_controller.rb +0 -151
  99. data/app/controllers/spree/transit/reservations_controller.rb +0 -15
  100. data/app/controllers/spree/transit/routes_controller.rb +0 -66
  101. data/app/controllers/spree/transit/service_calendars_controller.rb +0 -109
  102. data/app/controllers/spree/transit/stops_controller.rb +0 -32
  103. data/app/controllers/spree/transit/trip_stops_controller.rb +0 -35
  104. data/app/controllers/spree/transit/trips_controller.rb +0 -90
  105. data/app/controllers/spree/transit/vectors/amenity_values_controller.rb +0 -40
  106. data/app/controllers/spree/transit/vectors/icons_controller.rb +0 -26
  107. data/app/controllers/spree/transit/vehicle_photos_controller.rb +0 -52
  108. data/app/controllers/spree/transit/vehicle_seats_controller.rb +0 -29
  109. data/app/controllers/spree/transit/vehicle_types_controller.rb +0 -81
  110. data/app/controllers/spree/transit/vehicles_controller.rb +0 -54
  111. data/app/controllers/spree/transit/vendors_controller.rb +0 -83
  112. data/app/helpers/spree/billing/sortable_tree_helper.rb +0 -32
  113. data/app/helpers/spree/transit/navigation_helper_decorator.rb +0 -14
  114. data/app/helpers/spree/transit/sortable_tree_helper.rb +0 -32
  115. data/app/helpers/spree_cm_commissioner/billing/monthly_orders_helper.rb +0 -66
  116. data/app/helpers/spree_cm_commissioner/billing/qrcodes_helper.rb +0 -11
  117. data/app/helpers/spree_cm_commissioner/transit/service_calendars_helper.rb +0 -17
  118. data/app/views/spree/billing/addresses/_form.html.erb +0 -38
  119. data/app/views/spree/billing/addresses/index.html.erb +0 -19
  120. data/app/views/spree/billing/adjustments/_adjustment.html.erb +0 -34
  121. data/app/views/spree/billing/adjustments/_adjustments_table.html.erb +0 -32
  122. data/app/views/spree/billing/adjustments/_form.html.erb +0 -12
  123. data/app/views/spree/billing/adjustments/edit.html.erb +0 -13
  124. data/app/views/spree/billing/adjustments/index.html.erb +0 -32
  125. data/app/views/spree/billing/adjustments/new.html.erb +0 -9
  126. data/app/views/spree/billing/businesses/_assets_form.html.erb +0 -8
  127. data/app/views/spree/billing/businesses/_form.html.erb +0 -88
  128. data/app/views/spree/billing/businesses/edit.html.erb +0 -21
  129. data/app/views/spree/billing/businesses/index.html.erb +0 -32
  130. data/app/views/spree/billing/businesses/new.html.erb +0 -16
  131. data/app/views/spree/billing/customers/_form.html.erb +0 -44
  132. data/app/views/spree/billing/customers/edit.html.erb +0 -23
  133. data/app/views/spree/billing/customers/index.html.erb +0 -160
  134. data/app/views/spree/billing/customers/new.html.erb +0 -15
  135. data/app/views/spree/billing/errors/forbidden.html.erb +0 -7
  136. data/app/views/spree/billing/invoice/show.html.erb +0 -21
  137. data/app/views/spree/billing/option_types/_form.html.erb +0 -29
  138. data/app/views/spree/billing/option_types/_option_value_fields.html.erb +0 -13
  139. data/app/views/spree/billing/option_types/edit.html.erb +0 -56
  140. data/app/views/spree/billing/option_types/index.html.erb +0 -50
  141. data/app/views/spree/billing/option_types/new.html.erb +0 -13
  142. data/app/views/spree/billing/orders/_form.html.erb +0 -54
  143. data/app/views/spree/billing/orders/_line_item.html.erb +0 -57
  144. data/app/views/spree/billing/orders/_search.html.erb +0 -62
  145. data/app/views/spree/billing/orders/edit.html.erb +0 -16
  146. data/app/views/spree/billing/orders/index.html.erb +0 -84
  147. data/app/views/spree/billing/orders/show.html.erb +0 -1
  148. data/app/views/spree/billing/payments/_list.html.erb +0 -49
  149. data/app/views/spree/billing/payments/_state_changes.html.erb +0 -33
  150. data/app/views/spree/billing/payments/index.html.erb +0 -35
  151. data/app/views/spree/billing/payments/new.html.erb +0 -24
  152. data/app/views/spree/billing/payments/show.html.erb +0 -15
  153. data/app/views/spree/billing/places/_form.html.erb +0 -16
  154. data/app/views/spree/billing/places/edit.html.erb +0 -12
  155. data/app/views/spree/billing/places/index.html.erb +0 -72
  156. data/app/views/spree/billing/places/new.html.erb +0 -15
  157. data/app/views/spree/billing/products/_add_stock_form.html.erb +0 -40
  158. data/app/views/spree/billing/products/_filters.html.erb +0 -31
  159. data/app/views/spree/billing/products/_form.html.erb +0 -123
  160. data/app/views/spree/billing/products/edit.html.erb +0 -8
  161. data/app/views/spree/billing/products/index.html.erb +0 -72
  162. data/app/views/spree/billing/products/new.html.erb +0 -85
  163. data/app/views/spree/billing/products/stock.html.erb +0 -77
  164. data/app/views/spree/billing/reports/_date_range_picker.html.erb +0 -78
  165. data/app/views/spree/billing/reports/_overview.html.erb +0 -48
  166. data/app/views/spree/billing/reports/active_subscribers.html.erb +0 -97
  167. data/app/views/spree/billing/reports/balance_due.html.erb +0 -188
  168. data/app/views/spree/billing/reports/export.html.erb +0 -70
  169. data/app/views/spree/billing/reports/failed_orders.html.erb +0 -58
  170. data/app/views/spree/billing/reports/index.html.erb +0 -62
  171. data/app/views/spree/billing/reports/overdue.html.erb +0 -61
  172. data/app/views/spree/billing/reports/paid.html.erb +0 -60
  173. data/app/views/spree/billing/roles/_form.html.erb +0 -52
  174. data/app/views/spree/billing/roles/edit.html.erb +0 -13
  175. data/app/views/spree/billing/roles/index.html.erb +0 -44
  176. data/app/views/spree/billing/roles/new.html.erb +0 -13
  177. data/app/views/spree/billing/shared/_customer_credit_form.html.erb +0 -33
  178. data/app/views/spree/billing/shared/_customer_promotion_form.html.erb +0 -29
  179. data/app/views/spree/billing/shared/_customer_promotion_table.html.erb +0 -39
  180. data/app/views/spree/billing/shared/_customer_tabs.html.erb +0 -46
  181. data/app/views/spree/billing/shared/_header.html.erb +0 -21
  182. data/app/views/spree/billing/shared/_invoice.html.erb +0 -280
  183. data/app/views/spree/billing/shared/_language_switcher.html.erb +0 -5
  184. data/app/views/spree/billing/shared/_main_menu.html.erb +0 -73
  185. data/app/views/spree/billing/shared/_order_tabs.html.erb +0 -41
  186. data/app/views/spree/billing/shared/_pop_up_confirmation.html.erb +0 -103
  187. data/app/views/spree/billing/shared/_product_tabs.html.erb +0 -24
  188. data/app/views/spree/billing/shared/_role_tabs.html.erb +0 -13
  189. data/app/views/spree/billing/shared/_subscription_tabs.html.erb +0 -20
  190. data/app/views/spree/billing/shared/_user_tabs.html.erb +0 -13
  191. data/app/views/spree/billing/shared/_vendor_logo.html.erb +0 -1
  192. data/app/views/spree/billing/shared/_vendor_switcher.html.erb +0 -57
  193. data/app/views/spree/billing/shared/sortable_tree/_taxonomy.html.erb +0 -29
  194. data/app/views/spree/billing/shared/sub_menu/_product.html.erb +0 -8
  195. data/app/views/spree/billing/shared/sub_menu/_report.html.erb +0 -23
  196. data/app/views/spree/billing/shared/sub_menu/_setting.html.erb +0 -11
  197. data/app/views/spree/billing/store_credits/_form.html.erb +0 -11
  198. data/app/views/spree/billing/store_credits/index.html.erb +0 -44
  199. data/app/views/spree/billing/store_credits/new.html.erb +0 -17
  200. data/app/views/spree/billing/subscriptions/_form.html.erb +0 -27
  201. data/app/views/spree/billing/subscriptions/_subscribed_variants_table.html.erb +0 -24
  202. data/app/views/spree/billing/subscriptions/edit.html.erb +0 -12
  203. data/app/views/spree/billing/subscriptions/index.html.erb +0 -86
  204. data/app/views/spree/billing/subscriptions/new.html.erb +0 -21
  205. data/app/views/spree/billing/users/_form.html.erb +0 -64
  206. data/app/views/spree/billing/users/edit.html.erb +0 -25
  207. data/app/views/spree/billing/users/index.html.erb +0 -116
  208. data/app/views/spree/billing/users/new.html.erb +0 -17
  209. data/app/views/spree/billing/variants/_form.html.erb +0 -28
  210. data/app/views/spree/billing/variants/edit.html.erb +0 -15
  211. data/app/views/spree/billing/variants/index.html.erb +0 -66
  212. data/app/views/spree/billing/variants/new.html.erb +0 -15
  213. data/app/views/spree/billing/vendors/_form.html.erb +0 -75
  214. data/app/views/spree/billing/vendors/edit.html.erb +0 -9
  215. data/app/views/spree/layouts/billing.html.erb +0 -74
  216. data/app/views/spree/layouts/transit.html.erb +0 -74
  217. data/app/views/spree/transit/amenities/_form.html.erb +0 -50
  218. data/app/views/spree/transit/amenities/_option_value_fields.html.erb +0 -15
  219. data/app/views/spree/transit/amenities/edit.html.erb +0 -53
  220. data/app/views/spree/transit/branches/_filter_form.erb +0 -23
  221. data/app/views/spree/transit/branches/_form.html.erb +0 -36
  222. data/app/views/spree/transit/branches/edit.html.erb +0 -15
  223. data/app/views/spree/transit/branches/index.html.erb +0 -43
  224. data/app/views/spree/transit/branches/new.html.erb +0 -19
  225. data/app/views/spree/transit/locations/_filter_form.erb +0 -30
  226. data/app/views/spree/transit/locations/_form.html.erb +0 -19
  227. data/app/views/spree/transit/locations/edit.html.erb +0 -15
  228. data/app/views/spree/transit/locations/index.html.erb +0 -49
  229. data/app/views/spree/transit/locations/new.html.erb +0 -17
  230. data/app/views/spree/transit/places/_assets_form.html.erb +0 -8
  231. data/app/views/spree/transit/places/_form.html.erb +0 -128
  232. data/app/views/spree/transit/places/edit.html.erb +0 -21
  233. data/app/views/spree/transit/places/index.html.erb +0 -32
  234. data/app/views/spree/transit/places/new.html.erb +0 -16
  235. data/app/views/spree/transit/reservations/index.html.erb +0 -5
  236. data/app/views/spree/transit/routes/_filter_form.erb +0 -24
  237. data/app/views/spree/transit/routes/_form.html.erb +0 -74
  238. data/app/views/spree/transit/routes/edit.html.erb +0 -13
  239. data/app/views/spree/transit/routes/index.html.erb +0 -79
  240. data/app/views/spree/transit/routes/new.html.erb +0 -18
  241. data/app/views/spree/transit/service_calendars/_exception_rules.html.erb +0 -44
  242. data/app/views/spree/transit/service_calendars/_filters.html.erb +0 -17
  243. data/app/views/spree/transit/service_calendars/_form.html.erb +0 -107
  244. data/app/views/spree/transit/service_calendars/index.html.erb +0 -77
  245. data/app/views/spree/transit/service_calendars/new.html.erb +0 -13
  246. data/app/views/spree/transit/shared/_amenity_tabs.html.erb +0 -21
  247. data/app/views/spree/transit/shared/_header.html.erb +0 -20
  248. data/app/views/spree/transit/shared/_main_menu.html.erb +0 -34
  249. data/app/views/spree/transit/shared/_route_tabs.html.erb +0 -25
  250. data/app/views/spree/transit/shared/_vehicle_tabs.html.erb +0 -15
  251. data/app/views/spree/transit/shared/_vendor_switcher.html.erb +0 -40
  252. data/app/views/spree/transit/shared/sortable_tree/_taxonomy.html.erb +0 -29
  253. data/app/views/spree/transit/shared/sub_menu/_setting.html.erb +0 -3
  254. data/app/views/spree/transit/stops/_filters.html.erb +0 -33
  255. data/app/views/spree/transit/stops/_form.html.erb +0 -68
  256. data/app/views/spree/transit/stops/edit.html.erb +0 -16
  257. data/app/views/spree/transit/stops/index.html.erb +0 -58
  258. data/app/views/spree/transit/stops/new.html.erb +0 -16
  259. data/app/views/spree/transit/trip_stops/index.html.erb +0 -38
  260. data/app/views/spree/transit/trips/_form.html.erb +0 -82
  261. data/app/views/spree/transit/trips/edit.html.erb +0 -15
  262. data/app/views/spree/transit/trips/new.html.erb +0 -15
  263. data/app/views/spree/transit/vectors/amenity_values/index.html.erb +0 -111
  264. data/app/views/spree/transit/vehicle_photos/_form.html.erb +0 -22
  265. data/app/views/spree/transit/vehicle_photos/edit.html.erb +0 -9
  266. data/app/views/spree/transit/vehicle_photos/index.html.erb +0 -61
  267. data/app/views/spree/transit/vehicle_photos/new.html.erb +0 -9
  268. data/app/views/spree/transit/vehicle_seats/_form.html.erb +0 -48
  269. data/app/views/spree/transit/vehicle_seats/_seats.html.erb +0 -63
  270. data/app/views/spree/transit/vehicle_types/_filter_form.html.erb +0 -29
  271. data/app/views/spree/transit/vehicle_types/_form.html.erb +0 -65
  272. data/app/views/spree/transit/vehicle_types/_seat_view.erb +0 -83
  273. data/app/views/spree/transit/vehicle_types/edit.html.erb +0 -93
  274. data/app/views/spree/transit/vehicle_types/index.html.erb +0 -94
  275. data/app/views/spree/transit/vehicle_types/new.html.erb +0 -14
  276. data/app/views/spree/transit/vehicles/_filter_form.html.erb +0 -23
  277. data/app/views/spree/transit/vehicles/_form.html.erb +0 -19
  278. data/app/views/spree/transit/vehicles/edit.html.erb +0 -19
  279. data/app/views/spree/transit/vehicles/index.html.erb +0 -57
  280. data/app/views/spree/transit/vehicles/new.html.erb +0 -19
  281. data/app/views/spree/transit/vendors/_form.html.erb +0 -34
  282. data/app/views/spree/transit/vendors/_tabs.html.erb +0 -16
  283. data/app/views/spree/transit/vendors/edit.html.erb +0 -10
@@ -0,0 +1,62 @@
1
+ module SpreeCmCommissioner
2
+ # Fixed-window counter in Redis: "at most `limit` hits on `key` per `window`".
3
+ #
4
+ # Owns the atomic increment and the fail-open policy so callers can't get either
5
+ # wrong. A non-positive `limit` disables the check without touching Redis, the
6
+ # same "0 = disabled" convention FraudCheck's thresholds use.
7
+ class RateLimiter
8
+ # KEYS[1] - counter key, ARGV[1] - window in seconds.
9
+ #
10
+ # INCR and EXPIRE issued as two commands race: two callers can both observe
11
+ # the first write and each re-apply EXPIRE, sliding the window forward so the
12
+ # counter never resets. Lua runs them as a single atomic unit in Redis.
13
+ INCREMENT_SCRIPT = <<~LUA.freeze
14
+ local count = redis.call('INCR', KEYS[1])
15
+ if count == 1 then
16
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
17
+ end
18
+ return count
19
+ LUA
20
+
21
+ def self.call(key:, limit:, window:)
22
+ new(key: key, limit: limit, window: window).call
23
+ end
24
+
25
+ def initialize(key:, limit:, window:)
26
+ @key = key
27
+ @limit = limit.to_i
28
+ @window = window.to_i
29
+ end
30
+
31
+ def call
32
+ return within_limit unless enabled?
33
+
34
+ count = SpreeCmCommissioner.redis_pool.with do |redis|
35
+ redis.eval(INCREMENT_SCRIPT, keys: [key], argv: [window])
36
+ end
37
+
38
+ Result.new(count: count, limit: limit, exceeded: count > limit)
39
+ rescue Redis::BaseError, ConnectionPool::TimeoutError => e
40
+ # Fail open: a Redis outage must not lock users out of whatever the caller
41
+ # protects. Log it so the blind spot is visible in monitoring — while this
42
+ # is firing, the limit is not being enforced at all.
43
+ CmAppLogger.error(
44
+ label: 'RateLimiter Redis error — failing open',
45
+ data: { key: key, error_class: e.class.name, error_message: e.message }
46
+ )
47
+ within_limit
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :key, :limit, :window
53
+
54
+ def enabled?
55
+ limit.positive? && window.positive?
56
+ end
57
+
58
+ def within_limit
59
+ Result.new(count: 0, limit: limit, exceeded: false)
60
+ end
61
+ end
62
+ end
@@ -19,7 +19,9 @@ module SpreeCmCommissioner
19
19
  begin
20
20
  new_login = SpreeCmCommissioner::Users::Username::Generate.call(user: user)
21
21
 
22
- if user.update_column(:login, new_login) # rubocop:disable Rails/SkipsModelValidations
22
+ # normalized_login must be written too: Username::Generate checks collisions against it,
23
+ # so a stale value lets later users in this run reuse a login just assigned.
24
+ if user.update_columns(login: new_login, normalized_login: new_login.downcase) # rubocop:disable Rails/SkipsModelValidations
23
25
  updated_count += 1
24
26
 
25
27
  # Log progress every 100 users to avoid log spam
@@ -21,7 +21,7 @@ module SpreeCmCommissioner
21
21
  return failure(nil, error)
22
22
  end
23
23
 
24
- if (error = check_price_range(from_guest, rule, price, transfer_type)) # rubocop:disable Lint/AssignmentInCondition
24
+ if (error = check_price_range(rule, price, transfer_type)) # rubocop:disable Lint/AssignmentInCondition
25
25
  return failure(nil, error)
26
26
  end
27
27
 
@@ -75,17 +75,16 @@ module SpreeCmCommissioner
75
75
  I18n.t('ticket_transfer.event_cutoff_reached', hours: rule.event_cutoff_hours)
76
76
  end
77
77
 
78
- # Check 4 — price range. Enforces min/max price set on the rule.
79
- # If `rule.min_price` is nil, falls back to the variant price.
78
+ # Check 4 — price range. Enforces min/max price set on the rule, and only those: a rule that
79
+ # names no minimum sets no floor, rather than inheriting what the ticket originally sold for.
80
80
  # Skips for gift and manual_payment transfers (no platform pricing).
81
- def check_price_range(from_guest, rule, price, transfer_type)
81
+ def check_price_range(rule, price, transfer_type)
82
82
  return nil unless SpreeCmCommissioner::TicketTransfer.new(transfer_type: transfer_type).purchase?
83
83
  return nil if price.blank?
84
84
 
85
85
  price_decimal = price.to_d
86
- variant_price = from_guest.variant&.price
86
+ min_allowed = rule.min_price
87
87
 
88
- min_allowed = rule.min_price.presence || variant_price
89
88
  return I18n.t('ticket_transfer.price_below_minimum', minimum: min_allowed) if min_allowed.present? && price_decimal < min_allowed.to_d
90
89
 
91
90
  if rule.max_price.present? && price_decimal > rule.max_price.to_d
@@ -12,21 +12,19 @@ module SpreeCmCommissioner
12
12
 
13
13
  success(
14
14
  currency: currency,
15
- rule: rule_summary(fee_result.value[:rule], from_guest)
15
+ rule: rule_summary(fee_result.value[:rule])
16
16
  )
17
17
  end
18
18
 
19
19
  private
20
20
 
21
- def rule_summary(rule, from_guest)
21
+ def rule_summary(rule)
22
22
  return nil if rule.nil?
23
23
 
24
- variant_price = from_guest.variant&.price
25
-
26
24
  {
27
25
  fee_percentage: rule.fee_percentage,
28
26
  fee_flat_amount: rule.fee_flat_amount,
29
- min_price: rule.min_price.presence || variant_price,
27
+ min_price: rule.min_price,
30
28
  max_price: rule.max_price,
31
29
  cooldown_hours: rule.cooldown_hours,
32
30
  max_transfers_per_ticket: rule.max_transfers_per_ticket,
@@ -9,6 +9,8 @@ module SpreeCmCommissioner
9
9
  # :fb_access_token
10
10
  # :telegram_init_data, :tg_bot
11
11
  # :session_id
12
+ # :session_key (ACLEDA Mini App JWT, self-identifying, minted by Vpago::AcledaMiniApp::Session::Create)
13
+ # :wing_session_key (Wing Mini App JWT, self-identifying, minted by Vpago::WingMiniApp::Session::Create)
12
14
 
13
15
  def self.call!(params)
14
16
  context = auth_context(params)
@@ -31,7 +33,7 @@ module SpreeCmCommissioner
31
33
  user
32
34
  end
33
35
 
34
- def self.auth_context(params)
36
+ def self.auth_context(params) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
35
37
  oauth_application = find_oauth_application(params)
36
38
  tenant_id = oauth_application&.tenant_id
37
39
 
@@ -62,6 +64,12 @@ module SpreeCmCommissioner
62
64
  when 'vattanac_bank_web_app_auth'
63
65
  options = { session_id: params[:session_id] }
64
66
  SpreeCmCommissioner::UserVattanacBankWebAppAuthenticator.call(options)
67
+ when 'acleda_bank_web_app_auth'
68
+ options = { session_key: params[:session_key] }
69
+ SpreeCmCommissioner::UserAcledaBankWebAppAuthenticator.call(options)
70
+ when 'wing_bank_web_app_auth'
71
+ options = { session_key: params[:wing_session_key] }
72
+ SpreeCmCommissioner::UserWingBankWebAppAuthenticator.call(options)
65
73
  end
66
74
  end
67
75
 
@@ -76,7 +84,9 @@ module SpreeCmCommissioner
76
84
  'social_auth' => -> (params) { params.key?(:id_token) },
77
85
  'facebook_auth' => -> (params) { params.key?(:fb_access_token) },
78
86
  'telegram_web_app_auth' => -> (params) { params.key?(:telegram_init_data) && params.key?(:tg_bot) },
79
- 'vattanac_bank_web_app_auth' => -> (params) { params.key?(:session_id) }
87
+ 'vattanac_bank_web_app_auth' => -> (params) { params.key?(:session_id) },
88
+ 'acleda_bank_web_app_auth' => -> (params) { params.key?(:session_key) },
89
+ 'wing_bank_web_app_auth' => -> (params) { params.key?(:wing_session_key) }
80
90
  }.freeze
81
91
 
82
92
  def self.flow_type(params)
@@ -0,0 +1,44 @@
1
+ module SpreeCmCommissioner
2
+ module WingMiniApp
3
+ # Partner credentials for the Wing Web JS Bridge SDK, read from Wing's own mini app row
4
+ # (MiniAppProvider::WingBank) so they can be rotated from the admin, and so clearing one
5
+ # revokes the mini app instead of falling through to another source.
6
+ #
7
+ # Both halves of the integration read here — login, and Spree::Gateway::WingMiniApp for
8
+ # doPayment and webhook signing. Wing issues one pair, so they must come from one place:
9
+ # two sources would let the halves act as different partners, a mismatch that only shows
10
+ # up at the webhook, once the customer has already paid.
11
+ class Config
12
+ PROVIDER = 'wing_bank'.freeze
13
+
14
+ # The lowest getProfile data level whose hash formula includes `phone`, which is
15
+ # how a Wing customer is matched to an existing BookMe+ account.
16
+ ACCEPTED_LEVELS_FLOOR = %w[level_1a level_1b level_2 level_3].freeze
17
+
18
+ # Wing sends no api key — its WebView is opened by the partner, so the customer's
19
+ # browser arrives. Nil until someone configures the row.
20
+ def self.mini_app
21
+ Spree::OauthApplication.keyless_mini_app_for(PROVIDER)
22
+ end
23
+
24
+ def self.app_id
25
+ mini_app&.preferred_app_id.presence
26
+ end
27
+
28
+ def self.secret_api_key
29
+ mini_app&.preferred_secret_api_key.presence
30
+ end
31
+
32
+ # Blank (default) accepts level_1a or above. Set to pin verification to exactly
33
+ # one level (level_0..level_3, level_1a, level_1b), including a weaker one if
34
+ # Wing insists.
35
+ def self.data_level
36
+ mini_app&.preferred_data_level.presence
37
+ end
38
+
39
+ def self.accepted_levels
40
+ data_level.present? ? [data_level] : ACCEPTED_LEVELS_FLOOR
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,89 @@
1
+ module SpreeCmCommissioner
2
+ module WingMiniApp
3
+ # Finds or creates the BookMe+ customer for a verified Wing getProfile response.
4
+ #
5
+ # Unlike ACLEDA (server-to-server, phone only), Wing hands us a stable per-user
6
+ # `id`, so that — not phone — is what the wing_bank identity is keyed on, the
7
+ # same way the google/facebook/telegram identities key on the provider's own
8
+ # account id rather than a mutable field. Phone is still how a *new* Wing login
9
+ # gets matched to an existing BookMe+ account.
10
+ class FindOrCreateUser
11
+ prepend Spree::ServiceModule::Base
12
+
13
+ # `gate` is optional: without one nothing is configured to cap new accounts, which is
14
+ # how this behaved before mini apps had a row to configure.
15
+ def call(profile:, gate: nil)
16
+ @profile = profile.with_indifferent_access
17
+ @gate = gate
18
+
19
+ return failure(nil, 'id is required') if profile['id'].blank?
20
+ return failure({ status: :too_many_requests }, 'too many requests') if new_user_throttled?
21
+
22
+ user = find_or_create
23
+ return failure(user, 'user creation failed') if user.nil? || !user.persisted?
24
+
25
+ success(user: user)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :profile, :gate
31
+
32
+ # Only asked where this call would mint an account — the damage a leaked key does.
33
+ # Returning customers never spend that quota.
34
+ def new_user_throttled?
35
+ return false if gate.nil?
36
+ return false if wing_identity.persisted? || find_existing_user.present?
37
+
38
+ gate.new_user_exceeded?(phone: intel_phone)
39
+ end
40
+
41
+ def find_or_create
42
+ identity = wing_identity
43
+
44
+ # Returning customer: the Wing identity already points at a user.
45
+ return identity.user if identity.persisted?
46
+
47
+ # New to Wing: reuse a customer matched by phone, otherwise build one, then
48
+ # tag them with the wing_bank identity so the lookup above hits next time.
49
+ user = find_existing_user || build_user
50
+ identity.name = profile['fullName'].presence
51
+ user.user_identity_providers << identity
52
+ user.save
53
+
54
+ user
55
+ end
56
+
57
+ # Memoized: #new_user_throttled? asks the same questions, and this row is the one
58
+ # that gets tagged onto the user.
59
+ def wing_identity
60
+ @wing_identity ||= SpreeCmCommissioner::UserIdentityProvider.wing_bank.find_or_initialize_by(sub: profile['id'])
61
+ end
62
+
63
+ def find_existing_user
64
+ return @find_existing_user if defined?(@find_existing_user)
65
+ return @find_existing_user = nil if intel_phone.blank?
66
+
67
+ @find_existing_user = Spree::User.by_non_tenant.find_by(intel_phone_number: intel_phone)
68
+ end
69
+
70
+ def build_user
71
+ Spree::User.new(
72
+ first_name: profile['firstName'].presence,
73
+ last_name: profile['lastName'].presence,
74
+ phone_number: profile['phone'].presence,
75
+ intel_phone_number: intel_phone,
76
+ password: SecureRandom.base64(16),
77
+ confirmed_at: Time.zone.now
78
+ )
79
+ end
80
+
81
+ def intel_phone
82
+ return @intel_phone if defined?(@intel_phone)
83
+ return @intel_phone = nil if profile['phone'].blank?
84
+
85
+ @intel_phone = SpreeCmCommissioner::PhoneNumberParser.call(phone_number: profile['phone']).intel_phone_number
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,65 @@
1
+ module SpreeCmCommissioner
2
+ module WingMiniApp
3
+ # Verifies the `secret_key` a Wing getProfile response carries (Wing Web JS
4
+ # Bridge SDK spec, Native API #1).
5
+ #
6
+ # Each data level has its own exact hash formula, and they are NOT simply "the
7
+ # response fields that came back non-blank" — `sex` is a case in point: Wing's
8
+ # spec returns it in the response starting at level_1a, but it only enters the
9
+ # *hash formula* one tier later, at level_1b. So which fields are present in the
10
+ # payload cannot be used to infer which formula to check; every level's formula
11
+ # has to be tried explicitly against LEVEL_FIELDS, which is transcribed directly
12
+ # from the spec rather than derived.
13
+ class ProfileVerifier
14
+ prepend Spree::ServiceModule::Base
15
+
16
+ # Field order per data level, transcribed from the spec's plainText formulas.
17
+ # Order matters. Every level's field list is a prefix of the next level's.
18
+ LEVEL_FIELDS = {
19
+ 'level_0' => %w[id appId],
20
+ 'level_1' => %w[id appId fullName],
21
+ 'level_1a' => %w[id appId fullName phone],
22
+ 'level_1b' => %w[id appId fullName phone sex age nationality],
23
+ 'level_2' => %w[id appId fullName phone sex age nationality occupation],
24
+ 'level_3' => %w[id appId fullName phone sex age nationality occupation dobFull nidNumber nidType]
25
+ }.freeze
26
+
27
+ def call(profile:)
28
+ @profile = profile.with_indifferent_access
29
+
30
+ return failure(nil, 'appId does not match configured partner') unless app_id_matches?
31
+ return failure(nil, 'secret_key is missing or invalid') unless matched_level
32
+ return failure(nil, "data level #{matched_level} is below the accepted floor") unless level_accepted?
33
+
34
+ success(profile: profile, level: matched_level)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :profile
40
+
41
+ def app_id_matches?
42
+ profile['appId'].present? && profile['appId'] == Config.app_id
43
+ end
44
+
45
+ # The one level (if any) whose exact formula produces the received secret_key.
46
+ # Cryptographically, at most one level can match unless there's a SHA-256
47
+ # collision, since every formula is salted with the same secret_api_key.
48
+ def matched_level
49
+ return @matched_level if defined?(@matched_level)
50
+
51
+ received = profile['secret_key']
52
+ @matched_level = received.present? && LEVEL_FIELDS.keys.find { |level| hash_for(level) == received.to_s.upcase }
53
+ end
54
+
55
+ def hash_for(level)
56
+ plaintext = LEVEL_FIELDS.fetch(level).map { |field| profile[field].to_s }.join + Config.secret_api_key.to_s
57
+ Digest::SHA256.hexdigest(plaintext.upcase).upcase
58
+ end
59
+
60
+ def level_accepted?
61
+ Config.accepted_levels.include?(matched_level)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -114,6 +114,10 @@
114
114
 
115
115
  <td class="text-left"><%= guest_state_badge(guest) %></td>
116
116
  <td class="text-right">
117
+ <% buyer_order = guest.transferred_to_order %>
118
+ <% if buyer_order.present? %>
119
+ <%= link_to_with_icon 'box-arrow-up-right.svg', 'View order', spree.edit_admin_order_path(buyer_order.number), no_text: true, class: 'btn btn-light btn-sm' %>
120
+ <% end %>
117
121
  <%= link_to_edit guest, url: edit_admin_order_guest_path(@order, guest), no_text: true %>
118
122
  <% if guest&.id_card&.front_image.present? || guest&.id_card&.back_image.present? %>
119
123
  <%= link_to_with_icon 'images.svg', 'Edit Id Card Images', edit_admin_order_guest_id_card_path(order_id: @order, guest_id: guest.id, id: guest.id_card.id), no_text: true, class: 'mx-2' %>
@@ -0,0 +1,71 @@
1
+ <%# Rendered already open, with a backdrop of its own and its own close handler, so it needs nothing
2
+ from Bootstrap's JS to appear. The other dialogs here are opened by a data-toggle button and can
3
+ afford to wait for the plugin; this one carries the only copy of a freshly minted key, and a dialog
4
+ that fails to open would lose it. Bootstrap's CSS still does the styling. %>
5
+
6
+ <div class="modal show" id="mini-app-api-key-minted-modal" tabindex="-1" role="dialog"
7
+ aria-labelledby="miniAppApiKeyMintedModalLabel" aria-modal="true" style="display: block;">
8
+ <div class="modal-dialog" role="document">
9
+ <div class="modal-content">
10
+ <div class="modal-header">
11
+ <h5 class="modal-title" id="miniAppApiKeyMintedModalLabel">Copy this API key</h5>
12
+ <button type="button" class="close" aria-label="Close" data-minted-key-close>
13
+ <span aria-hidden="true">&times;</span>
14
+ </button>
15
+ </div>
16
+
17
+ <div class="modal-body">
18
+ <div class="alert alert-warning">
19
+ Send it to the partner now — <strong>closing this dialog is the last you see of it</strong>.
20
+ </div>
21
+
22
+ <div class="input-group">
23
+ <%= tag.input type: 'text', class: 'form-control', readonly: true,
24
+ value: api_key, id: 'mini-app-api-key-minted-value' %>
25
+ <div class="input-group-append">
26
+ <button type="button" class="btn btn-primary" id="mini-app-api-key-copy">Copy</button>
27
+ </div>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="modal-footer">
32
+ <button type="button" class="btn btn-secondary" data-minted-key-close>Done</button>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="modal-backdrop show" id="mini-app-api-key-minted-backdrop"></div>
39
+
40
+ <script>
41
+ (function () {
42
+ var modal = document.getElementById('mini-app-api-key-minted-modal');
43
+ var backdrop = document.getElementById('mini-app-api-key-minted-backdrop');
44
+ var field = document.getElementById('mini-app-api-key-minted-value');
45
+ var button = document.getElementById('mini-app-api-key-copy');
46
+
47
+ if (field && button) {
48
+ button.addEventListener('click', function () {
49
+ field.select();
50
+
51
+ // navigator.clipboard is absent outside a secure context. select() has still put the key on
52
+ // screen selected, so it can be copied by hand rather than lost.
53
+ if (navigator.clipboard) navigator.clipboard.writeText(field.value);
54
+
55
+ button.textContent = 'Copied';
56
+ });
57
+ }
58
+
59
+ // Our own close rather than data-dismiss: the dialog was never handed to Bootstrap, so Bootstrap
60
+ // has no state to hide. Removing both nodes is the whole of it.
61
+ function close() {
62
+ if (modal) modal.remove();
63
+ if (backdrop) backdrop.remove();
64
+ }
65
+
66
+ Array.prototype.forEach.call(
67
+ document.querySelectorAll('[data-minted-key-close]'),
68
+ function (el) { el.addEventListener('click', close); }
69
+ );
70
+ })();
71
+ </script>
@@ -0,0 +1,68 @@
1
+ <%# Outside the application's form — forms cannot nest. Replacing a key cuts the mini app off
2
+ until they deploy the new one, so only that asks for the name typed back; a first key has
3
+ nothing to lose. The controller checks it again — a stale page walks straight past this. %>
4
+
5
+ <% replacing_key = mini_app.api_key.present? %>
6
+
7
+ <div class="modal fade" id="mini-app-api-key-modal" tabindex="-1" role="dialog" aria-labelledby="miniAppApiKeyModalLabel" aria-hidden="true">
8
+ <div class="modal-dialog" role="document">
9
+ <div class="modal-content">
10
+ <div class="modal-header">
11
+ <h5 class="modal-title" id="miniAppApiKeyModalLabel">
12
+ <%= replacing_key ? 'Regenerate this API key?' : 'Generate an API key?' %>
13
+ </h5>
14
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
15
+ <span aria-hidden="true">&times;</span>
16
+ </button>
17
+ </div>
18
+
19
+ <%= form_with url: spree.admin_oauth_application_api_key_path(application), method: :post, local: true do %>
20
+ <div class="modal-body">
21
+ <% if replacing_key %>
22
+ <div class="alert alert-warning mb-3">
23
+ The current key stops working the moment this is done.
24
+ <strong>The partner cannot reach us again until they deploy the new one.</strong>
25
+ </div>
26
+
27
+ <div class="form-group mb-0">
28
+ <label>Type the application name <code class="text-danger"><%= application.name %></code> to confirm</label>
29
+ <%= text_field_tag :api_key_confirm, nil, class: 'form-control', autocomplete: 'off', id: 'mini-app-api-key-confirm' %>
30
+ </div>
31
+ <% else %>
32
+ <div class="alert alert-info mb-0">
33
+ This key names the application on its server-to-server calls.
34
+ <strong>Send it to the partner</strong> — they have to include it on every request,
35
+ and nothing they send reaches us until they do.
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+
40
+ <div class="modal-footer">
41
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
42
+ <%= submit_tag replacing_key ? 'Regenerate key' : 'Generate key',
43
+ class: "btn #{replacing_key ? 'btn-danger' : 'btn-primary'}",
44
+ id: 'mini-app-api-key-generate' %>
45
+ </div>
46
+ <% end %>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <% if replacing_key %>
52
+ <%# Left enabled in the markup so a script that never runs cannot strand an admin who needs to
53
+ rotate a leaked key; the controller is what actually refuses. %>
54
+ <script>
55
+ (function () {
56
+ var input = document.getElementById('mini-app-api-key-confirm');
57
+ var submit = document.getElementById('mini-app-api-key-generate');
58
+ if (!input || !submit) return;
59
+
60
+ var expected = '<%= j application.name %>';
61
+
62
+ function sync() { submit.disabled = input.value.trim() !== expected; }
63
+
64
+ sync();
65
+ input.addEventListener('input', sync);
66
+ })();
67
+ </script>
68
+ <% end %>
@@ -0,0 +1,40 @@
1
+ <%# Fetched when the dialog is opened, not rendered with the page: reading history costs a scan of the
2
+ application's versions, and this way the keys are never in the markup of a page nobody asked. %>
3
+
4
+ <% if entries.empty? %>
5
+ <div class="alert alert-info mb-0">
6
+ This application has held no other key — there is nothing to restore.
7
+ </div>
8
+ <% else %>
9
+ <p class="text-muted mb-3">
10
+ Pick the key the partner is still using — their calls start working again with no deploy on
11
+ their side.
12
+ </p>
13
+
14
+ <div class="list-group mb-3">
15
+ <% entries.each do |entry| %>
16
+ <label class="list-group-item list-group-item-action d-flex align-items-start mb-0">
17
+ <%= radio_button_tag :version_id, entry.version_id, false, class: 'mt-1 mr-3' %>
18
+
19
+ <span class="text-break">
20
+ <code class="text-body"><%= entry.key %></code>
21
+ <% if entry.revoked %>
22
+ <span class="badge badge-danger ml-1" title="Cleared rather than replaced — usually a leak">revoked</span>
23
+ <% end %>
24
+
25
+ <small class="d-block text-muted">
26
+ <%= entry.source.to_s.titleize.presence || 'Unknown' %>
27
+ <%= entry.created_at&.strftime('%d %b %Y %H:%M') %>
28
+ <%= "· #{entry.created_by}" if entry.created_by.present? %>
29
+ </small>
30
+ </span>
31
+ </label>
32
+ <% end %>
33
+ </div>
34
+
35
+ <div class="form-group mb-0">
36
+ <label>Type the application name <code class="text-danger"><%= application.name %></code> to confirm</label>
37
+ <%= text_field_tag :api_key_confirm, nil, class: 'form-control', autocomplete: 'off',
38
+ id: 'mini-app-api-key-restore-confirm' %>
39
+ </div>
40
+ <% end %>
@@ -0,0 +1,72 @@
1
+ <%# The form submits a version id, never a key — the plaintext is read out of history in the controller,
2
+ so nothing weak or foreign can be typed into the column. The body arrives from #history the first
3
+ time the dialog is opened. %>
4
+
5
+ <div class="modal fade" id="mini-app-api-key-restore-modal" tabindex="-1" role="dialog"
6
+ aria-labelledby="miniAppApiKeyRestoreModalLabel" aria-hidden="true">
7
+ <div class="modal-dialog modal-lg" role="document">
8
+ <div class="modal-content">
9
+ <div class="modal-header">
10
+ <h5 class="modal-title" id="miniAppApiKeyRestoreModalLabel">Restore a previous API key</h5>
11
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
12
+ <span aria-hidden="true">&times;</span>
13
+ </button>
14
+ </div>
15
+
16
+ <%= form_with url: spree.admin_oauth_application_api_key_path(application), method: :patch, local: true do %>
17
+ <div class="modal-body" id="mini-app-api-key-restore-body">
18
+ <p class="text-muted mb-0">Loading previous keys…</p>
19
+ </div>
20
+
21
+ <div class="modal-footer">
22
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
23
+ <%= submit_tag 'Restore key', class: 'btn btn-primary', id: 'mini-app-api-key-restore-submit',
24
+ disabled: true %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ </div>
29
+ </div>
30
+
31
+ <script>
32
+ (function () {
33
+ var modal = document.getElementById('mini-app-api-key-restore-modal');
34
+ var opener = document.getElementById('mini-app-api-key-restore-open');
35
+ var body = document.getElementById('mini-app-api-key-restore-body');
36
+ var submit = document.getElementById('mini-app-api-key-restore-submit');
37
+ if (!modal || !opener || !body || !submit) return;
38
+
39
+ var url = '<%= j spree.history_admin_oauth_application_api_key_path(application) %>';
40
+ var expected = '<%= j application.name %>';
41
+ var requested = false;
42
+
43
+ // Delegated, because the radios and the confirmation field do not exist until the body arrives.
44
+ function sync() {
45
+ var chosen = modal.querySelector('input[name="version_id"]:checked');
46
+ var typed = modal.querySelector('#mini-app-api-key-restore-confirm');
47
+
48
+ submit.disabled = !chosen || !typed || typed.value.trim() !== expected;
49
+ }
50
+
51
+ function load() {
52
+ if (requested) return;
53
+ requested = true;
54
+
55
+ fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'text/html' } })
56
+ .then(function (response) {
57
+ if (!response.ok) throw new Error(response.status);
58
+ return response.text();
59
+ })
60
+ .then(function (html) { body.innerHTML = html; sync(); })
61
+ .catch(function () {
62
+ // Left retryable: the next click asks again rather than stranding an admin mid-incident.
63
+ requested = false;
64
+ body.innerHTML = '<div class="alert alert-danger mb-0">Could not load previous keys. Close this and try again.</div>';
65
+ });
66
+ }
67
+
68
+ opener.addEventListener('click', load);
69
+ modal.addEventListener('change', sync);
70
+ modal.addEventListener('input', sync);
71
+ })();
72
+ </script>