plutonium 0.63.0 → 0.65.0

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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +4 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-dashboard/SKILL.md +161 -0
  5. data/.claude/skills/plutonium-resource/SKILL.md +94 -14
  6. data/.claude/skills/plutonium-ui/SKILL.md +22 -1
  7. data/.standard.yml +1 -1
  8. data/CHANGELOG.md +65 -0
  9. data/CLAUDE.md +282 -210
  10. data/SECURITY.md +1 -1
  11. data/app/assets/plutonium-charts.js +20604 -0
  12. data/app/assets/plutonium-charts.js.map +7 -0
  13. data/app/assets/plutonium-charts.min.js +41 -0
  14. data/app/assets/plutonium-charts.min.js.map +7 -0
  15. data/app/assets/plutonium.css +1 -1
  16. data/app/assets/plutonium.js +794 -629
  17. data/app/assets/plutonium.js.map +4 -4
  18. data/app/assets/plutonium.min.js +48 -48
  19. data/app/assets/plutonium.min.js.map +4 -4
  20. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  21. data/app/views/plutonium/_resource_header.html.erb +2 -2
  22. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  23. data/app/views/plutonium/_toast.html.erb +2 -2
  24. data/app/views/rodauth/_login_form.html.erb +1 -1
  25. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  26. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  27. data/app/views/rodauth/change_login.html.erb +2 -2
  28. data/app/views/rodauth/create_account.html.erb +4 -4
  29. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  30. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  31. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  32. data/config/brakeman.ignore +4 -4
  33. data/config/initializers/rabl.rb +0 -40
  34. data/config/locales/en/api_client.yml +22 -0
  35. data/config/locales/en/async.yml +34 -0
  36. data/config/locales/en/dashboard.yml +25 -0
  37. data/config/locales/en/invites.yml +37 -0
  38. data/config/locales/en/js.yml +40 -0
  39. data/config/locales/en/kanban.yml +28 -0
  40. data/config/locales/en/plutonium.yml +26 -0
  41. data/config/locales/en/positioning.yml +14 -0
  42. data/config/locales/en/profile.yml +29 -0
  43. data/config/locales/en/resource.yml +60 -0
  44. data/config/locales/en/ui.yml +93 -0
  45. data/config/locales/en/wizard.yml +46 -0
  46. data/docs/.vitepress/config.ts +34 -1
  47. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  48. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  49. data/docs/.vitepress/theme/custom.css +34 -377
  50. data/docs/blog/introducing-plutonium-dashboards.md +175 -0
  51. data/docs/blog/introducing-plutonium-i18n.md +91 -0
  52. data/docs/blog/introducing-plutonium.md +4 -5
  53. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  54. data/docs/guides/dashboards.md +300 -0
  55. data/docs/guides/index.md +1 -0
  56. data/docs/guides/kanban.md +2 -2
  57. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  58. data/docs/public/images/blog/dashboards-loading.png +0 -0
  59. data/docs/public/images/blog/dashboards-overview.png +0 -0
  60. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  61. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  62. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  63. data/docs/public/images/guides/dashboard-loading.png +0 -0
  64. data/docs/public/images/guides/dashboard-overview.png +0 -0
  65. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  66. data/docs/reference/app/generators.md +2 -2
  67. data/docs/reference/app/portals.md +2 -0
  68. data/docs/reference/configuration.md +84 -27
  69. data/docs/reference/dashboard/dsl.md +123 -0
  70. data/docs/reference/dashboard/index.md +35 -0
  71. data/docs/reference/dashboard/registration.md +76 -0
  72. data/docs/reference/i18n.md +239 -0
  73. data/docs/reference/index.md +10 -1
  74. data/docs/reference/kanban/dsl.md +1 -1
  75. data/docs/reference/kanban/positioning.md +2 -2
  76. data/docs/reference/resource/actions.md +1 -1
  77. data/docs/reference/resource/definition.md +96 -18
  78. data/docs/reference/resource/export.md +5 -1
  79. data/docs/reference/resource/index.md +2 -0
  80. data/docs/reference/{positioning.md → resource/positioning.md} +2 -1
  81. data/docs/reference/resource/query.md +2 -0
  82. data/docs/reference/ui/assets.md +8 -0
  83. data/docs/reference/ui/displays.md +5 -8
  84. data/docs/reference/ui/index.md +1 -1
  85. data/docs/reference/wizard/dsl.md +2 -2
  86. data/esbuild.config.js +5 -2
  87. data/gemfiles/rails_7.gemfile.lock +3 -3
  88. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  89. data/gemfiles/rails_8.1.gemfile.lock +2 -2
  90. data/lib/active_model/validations/array_validator.rb +1 -1
  91. data/lib/active_model/validations/attached_validator.rb +1 -1
  92. data/lib/active_model/validations/url_validator.rb +2 -2
  93. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  94. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  95. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  96. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  97. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  98. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  99. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  100. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  101. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  102. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  103. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  104. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  105. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  106. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  107. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  108. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  109. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  110. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  111. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  112. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  113. data/lib/plutonium/action/base.rb +27 -3
  114. data/lib/plutonium/action/interactive.rb +17 -4
  115. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  116. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  117. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  118. data/lib/plutonium/configuration.rb +5 -0
  119. data/lib/plutonium/core/controller.rb +25 -1
  120. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  121. data/lib/plutonium/dashboard/base.rb +114 -0
  122. data/lib/plutonium/dashboard/card.rb +200 -0
  123. data/lib/plutonium/dashboard/controller.rb +101 -0
  124. data/lib/plutonium/dashboard/dsl.rb +104 -0
  125. data/lib/plutonium/dashboard/register.rb +40 -0
  126. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  127. data/lib/plutonium/dashboard.rb +42 -0
  128. data/lib/plutonium/definition/actions.rb +6 -1
  129. data/lib/plutonium/definition/base.rb +41 -3
  130. data/lib/plutonium/definition/index_views.rb +1 -1
  131. data/lib/plutonium/definition/positioning.rb +1 -1
  132. data/lib/plutonium/definition/presentable.rb +2 -2
  133. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  134. data/lib/plutonium/helpers/display_helper.rb +10 -2
  135. data/lib/plutonium/interaction/async/context.rb +6 -8
  136. data/lib/plutonium/interaction/async/executor.rb +6 -7
  137. data/lib/plutonium/interaction/async/run.rb +2 -2
  138. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  139. data/lib/plutonium/interaction/base.rb +2 -0
  140. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  141. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  142. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  143. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  144. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  145. data/lib/plutonium/invites/controller.rb +11 -11
  146. data/lib/plutonium/kanban/column.rb +13 -3
  147. data/lib/plutonium/kanban/dsl.rb +7 -4
  148. data/lib/plutonium/models/has_cents.rb +1 -1
  149. data/lib/plutonium/profile/security_section.rb +10 -18
  150. data/lib/plutonium/query/filter.rb +10 -1
  151. data/lib/plutonium/query/filters/association.rb +27 -2
  152. data/lib/plutonium/query/filters/boolean.rb +4 -4
  153. data/lib/plutonium/query/filters/date.rb +4 -17
  154. data/lib/plutonium/query/filters/date_range.rb +2 -2
  155. data/lib/plutonium/query/filters/select.rb +10 -1
  156. data/lib/plutonium/query/filters/text.rb +9 -12
  157. data/lib/plutonium/railtie.rb +17 -0
  158. data/lib/plutonium/resource/controller.rb +10 -5
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  160. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  161. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  162. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  163. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  164. data/lib/plutonium/resource/query_object.rb +30 -3
  165. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  166. data/lib/plutonium/routing/resource_registration.rb +5 -0
  167. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  168. data/lib/plutonium/translation.rb +227 -0
  169. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  170. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  171. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  172. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  173. data/lib/plutonium/ui/component/methods.rb +4 -0
  174. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  175. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  176. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  177. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  178. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  179. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  180. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  181. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  182. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  183. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  184. data/lib/plutonium/ui/display/resource.rb +23 -16
  185. data/lib/plutonium/ui/export_button.rb +3 -3
  186. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  187. data/lib/plutonium/ui/form/components/password.rb +2 -2
  188. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  189. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  190. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  192. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  194. data/lib/plutonium/ui/form/query.rb +8 -8
  195. data/lib/plutonium/ui/form/resource.rb +54 -26
  196. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  197. data/lib/plutonium/ui/grid/card.rb +3 -3
  198. data/lib/plutonium/ui/grid/resource.rb +3 -3
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  201. data/lib/plutonium/ui/kanban/column.rb +6 -6
  202. data/lib/plutonium/ui/layout/base.rb +19 -1
  203. data/lib/plutonium/ui/layout/header.rb +1 -1
  204. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  205. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  206. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  207. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  208. data/lib/plutonium/ui/modal/base.rb +4 -4
  209. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  210. data/lib/plutonium/ui/nav_user.rb +2 -2
  211. data/lib/plutonium/ui/page/base.rb +10 -2
  212. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  213. data/lib/plutonium/ui/page/edit.rb +3 -3
  214. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  215. data/lib/plutonium/ui/page/new.rb +3 -3
  216. data/lib/plutonium/ui/page/show.rb +2 -2
  217. data/lib/plutonium/ui/page/wizard.rb +22 -16
  218. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  219. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  220. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  221. data/lib/plutonium/ui/table/base.rb +2 -2
  222. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  223. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  224. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  225. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  226. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  227. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  228. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  229. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  230. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  231. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  232. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  233. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  234. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  235. data/lib/plutonium/ui/table/resource.rb +53 -17
  236. data/lib/plutonium/ui/wizard/review.rb +7 -7
  237. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  238. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  239. data/lib/plutonium/version.rb +1 -1
  240. data/lib/plutonium/wizard/base.rb +2 -0
  241. data/lib/plutonium/wizard/driving.rb +8 -2
  242. data/lib/plutonium/wizard/dsl.rb +4 -2
  243. data/lib/plutonium/wizard/field_importer.rb +1 -8
  244. data/lib/plutonium/wizard/review_step.rb +8 -1
  245. data/lib/plutonium/wizard/step.rb +14 -3
  246. data/package.json +4 -2
  247. data/plutonium.gemspec +15 -30
  248. data/postcss-gem-import.cjs +34 -0
  249. data/postcss-gem-import.js +3 -28
  250. data/src/css/components.css +91 -0
  251. data/src/css/slim_select.css +0 -38
  252. data/src/css/tokens.css +24 -0
  253. data/src/js/controllers/attachment_input_controller.js +15 -6
  254. data/src/js/controllers/chart_controller.js +162 -0
  255. data/src/js/controllers/clipboard_controller.js +3 -2
  256. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  257. data/src/js/controllers/easymde_controller.js +3 -0
  258. data/src/js/controllers/filter_panel_controller.js +15 -6
  259. data/src/js/controllers/flatpickr_controller.js +8 -0
  260. data/src/js/controllers/frame_refresh_controller.js +59 -0
  261. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  262. data/src/js/controllers/register_controllers.js +4 -0
  263. data/src/js/controllers/slim_select_controller.js +79 -176
  264. data/src/js/core.js +9 -1
  265. data/src/js/i18n.js +69 -0
  266. data/src/js/plutonium-charts.js +15 -0
  267. data/src/js/turbo/turbo_confirm.js +5 -3
  268. metadata +71 -31
  269. data/.yarnrc.yml +0 -8
  270. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  271. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  272. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  273. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  274. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  275. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  276. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  277. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  278. data/public/plutonium-assets/application.js +0 -31419
  279. data/public/plutonium-assets/plutonium.ico +0 -0
  280. data/yarn.lock +0 -6858
@@ -336,13 +336,25 @@ module Plutonium
336
336
  # equivalent of that.
337
337
  #
338
338
  # No-op for ActiveStorage fields, and for uploaders declaring no rules.
339
+ #
340
+ # Which attributes are files is decided by their INPUT DECLARATION (an
341
+ # `as:` file alias), not by the shape of the value they currently hold.
342
+ # A direct upload arrives already staged, as a token String, so keying
343
+ # off IO-shape (respond_to?(:read)) skipped exactly those — an oversized
344
+ # cached token validated clean and dispatched, the rule surfacing as a
345
+ # run failure the submitter never sees. This is how a wizard decides the
346
+ # same question (Plutonium::Wizard::Attachments.field?).
339
347
  def validate_dispatch_attachments
340
- staged = attributes.except("resource", "resources").select { |_, v| dispatch_attachment?(v) }.keys
341
348
  stage_dispatch_attachments!
342
349
 
343
- staged.each do |name|
350
+ self.class.defined_inputs.each do |name, config|
351
+ next unless Plutonium::Definition::InputAliases.file_input?(config.dig(:options, :as))
352
+
353
+ value = public_send(name)
354
+ next if value.blank?
355
+
344
356
  messages = Plutonium::Attachments.validation_errors(
345
- public_send(name), **dispatch_attachment_options(name)
357
+ value, **dispatch_attachment_options(name)
346
358
  )
347
359
  messages.each { |message| errors.add(name, message) }
348
360
  end
@@ -17,7 +17,7 @@ module Plutonium
17
17
  extend ActiveSupport::Concern
18
18
 
19
19
  included do
20
- presents label: "Cancel Invitation", icon: Phlex::TablerIcons::CircleX
20
+ presents label: t("plutonium.invites.cancel_invite.label"), icon: Phlex::TablerIcons::CircleX
21
21
 
22
22
  attribute :resource
23
23
  end
@@ -34,11 +34,11 @@ module Plutonium
34
34
  private
35
35
 
36
36
  def success_message
37
- "Invitation cancelled"
37
+ I18n.t("plutonium.invites.cancel_invite.cancelled")
38
38
  end
39
39
 
40
40
  def not_pending_message
41
- "Can only cancel pending invitations"
41
+ I18n.t("plutonium.invites.cancel_invite.not_pending")
42
42
  end
43
43
  end
44
44
  end
@@ -97,7 +97,7 @@ module Plutonium
97
97
  # @raise [ActiveRecord::RecordInvalid] if constraints are violated
98
98
  def validate_email_constraints!(user_email)
99
99
  if enforce_email? && user_email.downcase != email.downcase
100
- errors.add(:base, "This invitation is for #{email}. You must use an account with that email address.")
100
+ errors.add(:base, :invite_email_mismatch, email: email)
101
101
  raise ActiveRecord::RecordInvalid.new(self)
102
102
  end
103
103
 
@@ -105,7 +105,7 @@ module Plutonium
105
105
  user_domain = extract_domain(user_email)
106
106
 
107
107
  if user_domain != required_domain
108
- errors.add(:base, "This invitation requires an email from the #{required_domain} domain.")
108
+ errors.add(:base, :invite_domain_mismatch, domain: required_domain)
109
109
  raise ActiveRecord::RecordInvalid.new(self)
110
110
  end
111
111
  end
@@ -115,16 +115,34 @@ module Plutonium
115
115
  #
116
116
  # This method:
117
117
  # 1. Validates email constraints
118
- # 2. Marks the invite as accepted
119
- # 3. Creates the entity membership
120
- # 4. Notifies the invitable (if present)
118
+ # 2. Acquires a row lock and re-checks the pending state to serialize
119
+ # concurrent acceptances (prevents a TOCTOU race from creating
120
+ # memberships for multiple users from a single invite when
121
+ # `enforce_email?` is false)
122
+ # 3. Marks the invite as accepted
123
+ # 4. Creates the entity membership
124
+ # 5. Notifies the invitable (if present)
121
125
  #
122
126
  # @param user [Object] the user accepting the invitation
123
- # @raise [ActiveRecord::RecordInvalid] if acceptance fails
127
+ # @raise [ActiveRecord::RecordInvalid] if acceptance fails (e.g. the
128
+ # invite was already accepted by a concurrent request)
124
129
  def accept_for_user!(user)
125
130
  validate_email_constraints!(user.email)
126
131
 
127
- transaction do
132
+ with_lock do
133
+ unless pending?
134
+ error =
135
+ if accepted?
136
+ :invite_already_accepted
137
+ elsif cancelled?
138
+ :invite_cancelled
139
+ else
140
+ :invite_expired
141
+ end
142
+ errors.add(:base, error)
143
+ raise ActiveRecord::RecordInvalid.new(self)
144
+ end
145
+
128
146
  update!(
129
147
  :state => :accepted,
130
148
  :accepted_at => Time.current,
@@ -69,10 +69,15 @@ module Plutonium
69
69
  ::Invites::UserInvite
70
70
  end
71
71
 
72
- # Override to specify the membership model class
72
+ # Override to specify the membership model class. There is no default
73
+ # because the membership join model is app-specific (e.g. OrganizationUser).
74
+ # Omitting the override raises NotImplementedError so the failure is
75
+ # actionable instead of a confusing NameError on a non-existent constant.
73
76
  # @return [Class]
74
77
  def membership_class
75
- EntityMembership
78
+ raise NotImplementedError,
79
+ "#{self.class}#membership_class must return the membership model class. " \
80
+ "Specify --membership-model when generating, or override this method."
76
81
  end
77
82
 
78
83
  # Override to specify the user model class
@@ -109,7 +114,7 @@ module Plutonium
109
114
 
110
115
  # Override to customize success message
111
116
  def success_message
112
- "Invitation sent to #{email}"
117
+ I18n.t("plutonium.invites.invite_user.sent", email: email)
113
118
  end
114
119
 
115
120
  # Override to specify the entity association name on membership
@@ -141,7 +146,7 @@ module Plutonium
141
146
  membership_entity_attribute => entity,
142
147
  user_association => existing_user
143
148
  )
144
- errors.add(:email, "is already a member") if membership
149
+ errors.add(:email, :already_a_member) if membership
145
150
  end
146
151
 
147
152
  def no_pending_invitation
@@ -152,7 +157,7 @@ module Plutonium
152
157
  :email => email,
153
158
  :state => :pending
154
159
  )
155
- errors.add(:email, "already has a pending invitation") if pending
160
+ errors.add(:email, :already_has_pending_invitation) if pending
156
161
  end
157
162
 
158
163
  # Override if user association has a different name
@@ -26,14 +26,14 @@ module Plutonium
26
26
  extend ActiveSupport::Concern
27
27
 
28
28
  included do
29
- presents label: "Resend Invitation", icon: Phlex::TablerIcons::MailForward
29
+ presents label: t("plutonium.invites.resend_invite.label"), icon: Phlex::TablerIcons::MailForward
30
30
 
31
31
  attribute :resource
32
32
  end
33
33
 
34
34
  def execute
35
35
  unless resource.pending?
36
- return failed("Can only resend pending invitations")
36
+ return failed(I18n.t("plutonium.invites.resend_invite.not_pending"))
37
37
  end
38
38
 
39
39
  resource.update!(expires_at: new_expiry)
@@ -41,7 +41,7 @@ module Plutonium
41
41
 
42
42
  succeed(resource).with_message(success_message)
43
43
  rescue => error
44
- failed("Failed to resend: #{error.message}")
44
+ failed(I18n.t("plutonium.invites.resend_invite.failed", error: error.message))
45
45
  end
46
46
 
47
47
  private
@@ -58,7 +58,7 @@ module Plutonium
58
58
 
59
59
  # Override to customize success message
60
60
  def success_message
61
- "Invitation resent to #{resource.email}"
61
+ I18n.t("plutonium.invites.resend_invite.resent", email: resource.email)
62
62
  end
63
63
  end
64
64
  end
@@ -57,7 +57,7 @@ module Plutonium
57
57
  @invite.validate_email_constraints!(current_user.email)
58
58
  render :show
59
59
  rescue ActiveRecord::RecordInvalid => e
60
- @error_title = "Email Validation Error"
60
+ @error_title = t("plutonium.invites.controller.email_validation_error")
61
61
  @error_message = e.record.errors.full_messages.join(", ")
62
62
  render :error, status: :forbidden
63
63
  end
@@ -74,7 +74,7 @@ module Plutonium
74
74
 
75
75
  unless current_user
76
76
  redirect_to invitation_path_for(params[:token]),
77
- alert: "Please sign in to accept this invitation"
77
+ alert: t("plutonium.invites.controller.sign_in_to_accept")
78
78
  return
79
79
  end
80
80
 
@@ -82,9 +82,9 @@ module Plutonium
82
82
  cookies.delete(:pending_invitation)
83
83
 
84
84
  redirect_to after_accept_path,
85
- notice: "Invitation accepted! Welcome to #{@invite.entity.to_label}!"
85
+ notice: t("plutonium.invites.controller.accepted", entity: @invite.entity.to_label)
86
86
  rescue ActiveRecord::RecordInvalid => e
87
- @error_title = "Acceptance Error"
87
+ @error_title = t("plutonium.invites.controller.acceptance_error")
88
88
  @error_message = e.record.errors.full_messages.join(", ")
89
89
  render :error, status: :forbidden
90
90
  end
@@ -112,8 +112,8 @@ module Plutonium
112
112
  invite = invite_class.find_for_acceptance(token)
113
113
 
114
114
  unless invite
115
- @error_title = "Invalid or Expired Invitation"
116
- @error_message = "This invitation link is no longer valid. It may have expired or already been used."
115
+ @error_title = t("plutonium.invites.controller.invalid_or_expired")
116
+ @error_message = t("plutonium.invites.controller.no_longer_valid")
117
117
  render :error, status: :not_found
118
118
  return nil
119
119
  end
@@ -131,7 +131,7 @@ module Plutonium
131
131
  password_confirmation = params[:password_confirmation]
132
132
 
133
133
  if password != password_confirmation
134
- flash.now[:alert] = "Passwords don't match"
134
+ flash.now[:alert] = t("plutonium.invites.controller.passwords_dont_match")
135
135
  render :signup
136
136
  return
137
137
  end
@@ -140,7 +140,7 @@ module Plutonium
140
140
  ActiveRecord::Base.transaction do
141
141
  existing_user = user_class.find_by(email: email)
142
142
  if existing_user
143
- flash.now[:alert] = "An account with this email already exists. Please sign in instead."
143
+ flash.now[:alert] = t("plutonium.invites.controller.account_exists")
144
144
  render :signup
145
145
  return
146
146
  end
@@ -153,7 +153,7 @@ module Plutonium
153
153
  sign_in_user(user)
154
154
  redirect_to after_accept_path
155
155
  else
156
- flash.now[:alert] = "Failed to create account"
156
+ flash.now[:alert] = t("plutonium.invites.controller.create_account_failed")
157
157
  render :signup
158
158
  end
159
159
  end
@@ -161,11 +161,11 @@ module Plutonium
161
161
  flash.now[:alert] = if e.record.is_a?(invite_class)
162
162
  e.record.errors.full_messages.join(", ")
163
163
  else
164
- "Failed to create account: #{e.record.errors.full_messages.join(", ")}"
164
+ t("plutonium.invites.controller.create_account_failed_with_error", error: e.record.errors.full_messages.join(", "))
165
165
  end
166
166
  render :signup
167
167
  rescue => e
168
- flash.now[:alert] = "Failed to create account: #{e.message}"
168
+ flash.now[:alert] = t("plutonium.invites.controller.create_account_failed_with_error", error: e.message)
169
169
  render :signup
170
170
  end
171
171
  end
@@ -12,10 +12,19 @@ module Plutonium
12
12
  lost: {color: :red, collapsed: true}
13
13
  }.freeze
14
14
 
15
- attr_reader :key, :label, :color, :wip, :scope, :on_enter, :on_exit, :accepts, :actions, :enter_interaction
15
+ attr_reader :key, :color, :wip, :scope, :on_enter, :on_exit, :accepts, :actions, :enter_interaction
16
+
17
+ # `label:` if given, else plutonium.kanban_columns.<model>.<key>, else the
18
+ # titleized key. Resolved on every read so it follows the request locale.
19
+ def label
20
+ Plutonium::Translation.resolve(@label) ||
21
+ Plutonium::Translation.label_for(:kanban_column, @resource_class, key) ||
22
+ key.to_s.titleize
23
+ end
16
24
 
17
25
  def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_enter: nil, on_exit: nil, on_drop: nil,
18
- collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil, enter_interaction: nil, drop_interaction: nil)
26
+ collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil, enter_interaction: nil, drop_interaction: nil,
27
+ resource_class: nil)
19
28
  # on_drop:/drop_interaction: were renamed to on_enter:/enter_interaction:.
20
29
  # Resolve the deprecated aliases first (dev/test raise; deployed envs warn
21
30
  # and map — see resolve_renamed_option) so the rest of initialize only
@@ -48,7 +57,8 @@ module Plutonium
48
57
  raise ArgumentError, "kanban column `accepts:` no longer accepts a Proc; use true/false or an Array of source keys, and put record/user conditions in the kanban_move? policy."
49
58
  end
50
59
  @key = key.to_sym
51
- @label = label || key.to_s.titleize
60
+ @label = label
61
+ @resource_class = resource_class
52
62
  @color = color.nil? ? preset[:color] : color
53
63
  @wip = wip
54
64
  @scope = scope
@@ -3,13 +3,16 @@
3
3
  module Plutonium
4
4
  module Kanban
5
5
  class DSL
6
- def self.build(&block)
7
- dsl = new
6
+ # `resource_class:` lets column labels resolve through the
7
+ # plutonium.kanban_columns.<model>.<key> convention.
8
+ def self.build(resource_class: nil, &block)
9
+ dsl = new(resource_class:)
8
10
  dsl.instance_eval(&block) if block
9
11
  dsl.to_board
10
12
  end
11
13
 
12
- def initialize
14
+ def initialize(resource_class: nil)
15
+ @resource_class = resource_class
13
16
  @columns = []
14
17
  @columns_block = nil
15
18
  @card_fields = nil
@@ -24,7 +27,7 @@ module Plutonium
24
27
  end
25
28
 
26
29
  def column(key, **opts, &blk)
27
- col = Column.new(key, **opts)
30
+ col = Column.new(key, resource_class: @resource_class, **opts)
28
31
  col.instance_eval(&blk) if blk
29
32
  @columns << col
30
33
  end
@@ -173,7 +173,7 @@ module Plutonium
173
173
  def #{name}=(value)
174
174
  self.#{cents_name} = begin
175
175
  (BigDecimal(value.to_s) * #{rate}).to_i if value.present?
176
- rescue ArgumentError
176
+ rescue ArgumentError, FloatDomainError
177
177
  nil
178
178
  end
179
179
  end
@@ -4,46 +4,34 @@ module Plutonium
4
4
  module Profile
5
5
  # Renders security settings links based on enabled Rodauth features.
6
6
  class SecuritySection < Plutonium::UI::Component::Base
7
+ # Labels and descriptions come from
8
+ # plutonium.profile.security_section.features.<feature>.{label,description}.
7
9
  FEATURES = {
8
10
  change_password: {
9
- label: "Change Password",
10
- description: "Update your account password",
11
11
  icon: Phlex::TablerIcons::Key,
12
12
  path_method: :change_password_path
13
13
  },
14
14
  change_login: {
15
- label: "Change Email",
16
- description: "Update your email address",
17
15
  icon: Phlex::TablerIcons::Mail,
18
16
  path_method: :change_login_path
19
17
  },
20
18
  otp: {
21
- label: "Two-Factor Authentication",
22
- description: "Add an extra layer of security",
23
19
  icon: Phlex::TablerIcons::DeviceMobile,
24
20
  path_method: :otp_setup_path
25
21
  },
26
22
  recovery_codes: {
27
- label: "Recovery Codes",
28
- description: "View or regenerate backup codes",
29
23
  icon: Phlex::TablerIcons::FileCode,
30
24
  path_method: :recovery_codes_path
31
25
  },
32
26
  webauthn: {
33
- label: "Security Keys",
34
- description: "Manage passkeys and security keys",
35
27
  icon: Phlex::TablerIcons::Fingerprint,
36
28
  path_method: :webauthn_setup_path
37
29
  },
38
30
  active_sessions: {
39
- label: "Active Sessions",
40
- description: "View and manage your sessions",
41
31
  icon: Phlex::TablerIcons::DevicesCheck,
42
32
  path_method: :active_sessions_path
43
33
  },
44
34
  close_account: {
45
- label: "Close Account",
46
- description: "Permanently delete your account",
47
35
  icon: Phlex::TablerIcons::Trash,
48
36
  path_method: :close_account_path,
49
37
  danger: true
@@ -61,8 +49,8 @@ module Plutonium
61
49
 
62
50
  def render_section_header
63
51
  div(class: "mb-4") do
64
- h2(class: "text-lg font-semibold text-[var(--pu-text)]") { "Security Settings" }
65
- p(class: "text-sm text-[var(--pu-text-muted)]") { "Manage your account security" }
52
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { t("plutonium.profile.security_section.title") }
53
+ p(class: "text-sm text-[var(--pu-text-muted)]") { t("plutonium.profile.security_section.description") }
66
54
  end
67
55
  end
68
56
 
@@ -95,8 +83,8 @@ module Plutonium
95
83
 
96
84
  # Content
97
85
  div(class: "flex-grow") do
98
- div(class: "font-medium") { config[:label] }
99
- div(class: "text-sm text-[var(--pu-text-muted)]") { config[:description] }
86
+ div(class: "font-medium") { feature_text(feature, :label) }
87
+ div(class: "text-sm text-[var(--pu-text-muted)]") { feature_text(feature, :description) }
100
88
  end
101
89
 
102
90
  # Arrow
@@ -106,6 +94,10 @@ module Plutonium
106
94
  end
107
95
  end
108
96
 
97
+ def feature_text(feature, slot)
98
+ t("plutonium.profile.security_section.features.#{feature}.#{slot}")
99
+ end
100
+
109
101
  def enabled_features
110
102
  FEATURES.select { |feature, _config| feature_enabled?(feature) }
111
103
  end
@@ -19,10 +19,19 @@ module Plutonium
19
19
 
20
20
  attr_reader :resource_class
21
21
 
22
- def initialize(key:, resource_class: nil)
22
+ def initialize(key:, resource_class: nil, label: nil)
23
23
  super()
24
24
  @key = key
25
25
  @resource_class = resource_class
26
+ @label = label
27
+ end
28
+
29
+ # The filter's display label: `label:` if given, else the
30
+ # plutonium.filters.<model>.<key> convention, else the humanized key.
31
+ def label
32
+ Plutonium::Translation.resolve(@label) ||
33
+ Plutonium::Translation.label_for(:filter, resource_class, key) ||
34
+ key.to_s.humanize
26
35
  end
27
36
  end
28
37
  end
@@ -27,7 +27,14 @@ module Plutonium
27
27
  return "" if value.blank?
28
28
  ids = decode_ids(value)
29
29
  return "" if ids.empty?
30
- records = association_class.where(id: ids)
30
+ # Resolve labels through the same scope-honouring relation that the
31
+ # dropdown (ResourceSelect#authorized_relation) and the typeahead
32
+ # (Typeahead#filter_association) already enforce. A bare
33
+ # `association_class.where(id: ids)` here would widen past the
34
+ # configured `scope:` and let a crafted `?q[<key>][value]=<out-of-scope
35
+ # id>` render the hidden record's `to_label` into the active-filter
36
+ # pill even though the matching filter was dropped at submission.
37
+ records = scoped_relation.where(id: ids)
31
38
  records.map { |r| r.respond_to?(:to_label) ? r.to_label : r.to_s }.join(", ")
32
39
  rescue
33
40
  Array(value).reject(&:blank?).join(", ")
@@ -45,7 +52,8 @@ module Plutonium
45
52
  as: :resource_select,
46
53
  association_class: association_class,
47
54
  multiple: @multiple,
48
- include_blank: @multiple ? false : "All"
55
+ include_blank: @multiple ? false : Plutonium::Translation.t("plutonium.query.all"),
56
+ scope: @scope_proc
49
57
  end
50
58
 
51
59
  private
@@ -86,6 +94,23 @@ module Plutonium
86
94
  def infer_class_from_key
87
95
  key.to_s.classify.constantize
88
96
  end
97
+
98
+ # The relation used to resolve `humanize_value` labels, narrowed by the
99
+ # configured `scope:` exactly like ResourceSelect#apply_scope and
100
+ # Typeahead#apply_scope. The arity dispatch — Symbol, zero-arg Proc
101
+ # (kanban form `-> { ... }`), one-arg Proc (documented form
102
+ # `->(s) { s.active }`), nil — must stay in lock-step with those two
103
+ # sites; any divergence re-opens the leak this method closes. Unsupported
104
+ # scope types raise ArgumentError loudly rather than silently widening.
105
+ def scoped_relation
106
+ relation = association_class.all
107
+ case @scope_proc
108
+ when Symbol then relation.public_send(@scope_proc)
109
+ when Proc then @scope_proc.arity.zero? ? relation.instance_exec(&@scope_proc) : @scope_proc.call(relation)
110
+ when nil then relation
111
+ else raise ArgumentError, "Unsupported association scope: #{@scope_proc.inspect} (expected Symbol, Proc, or nil)"
112
+ end
113
+ end
89
114
  end
90
115
  end
91
116
  end
@@ -10,10 +10,10 @@ module Plutonium
10
10
  # filter :published, with: :boolean, true_label: "Published", false_label: "Draft"
11
11
  #
12
12
  class Boolean < Filter
13
- def initialize(true_label: "Yes", false_label: "No", **)
13
+ def initialize(true_label: nil, false_label: nil, **)
14
14
  super(**)
15
- @true_label = true_label
16
- @false_label = false_label
15
+ @true_label = true_label || Plutonium::Translation.t("plutonium.boolean.true")
16
+ @false_label = false_label || Plutonium::Translation.t("plutonium.boolean.false")
17
17
  end
18
18
 
19
19
  def humanize_value(value)
@@ -32,7 +32,7 @@ module Plutonium
32
32
  input :value,
33
33
  as: :select,
34
34
  choices: [[@true_label, "true"], [@false_label, "false"]],
35
- include_blank: "All"
35
+ include_blank: Plutonium::Translation.t("plutonium.query.all")
36
36
  end
37
37
  end
38
38
  end
@@ -73,23 +73,10 @@ module Plutonium
73
73
  end
74
74
 
75
75
  def generate_placeholder
76
- base = key.to_s.humanize
77
- case @predicate
78
- when :eq
79
- base
80
- when :not_eq
81
- "#{base} not on..."
82
- when :lt
83
- "#{base} before..."
84
- when :lteq
85
- "#{base} on or before..."
86
- when :gt
87
- "#{base} after..."
88
- when :gteq
89
- "#{base} on or after..."
90
- else
91
- base
92
- end
76
+ base = label
77
+ return base if @predicate == :eq
78
+
79
+ Plutonium::Translation.t("plutonium.query.filters.date.#{@predicate}", label: base)
93
80
  end
94
81
  end
95
82
  end
@@ -34,8 +34,8 @@ module Plutonium
34
34
  def customize_inputs
35
35
  input :from, as: :date
36
36
  input :to, as: :date
37
- field :from, placeholder: @from_label || "#{key.to_s.humanize} from..."
38
- field :to, placeholder: @to_label || "#{key.to_s.humanize} to..."
37
+ field :from, placeholder: @from_label || Plutonium::Translation.t("plutonium.query.filters.date_range.from", label: label)
38
+ field :to, placeholder: @to_label || Plutonium::Translation.t("plutonium.query.filters.date_range.to", label: label)
39
39
  end
40
40
 
41
41
  private
@@ -29,12 +29,21 @@ module Plutonium
29
29
  end
30
30
  end
31
31
 
32
+ # Active-filter pill text: each chosen value through the enum-value
33
+ # convention (activerecord.attributes.<model>.<key>/<value> or
34
+ # plutonium.values.<model>.<key>.<value>), else the raw value.
35
+ def humanize_value(value)
36
+ Array(value).reject(&:blank?).map do |choice|
37
+ Plutonium::Translation.value_label(resource_class, key, choice) || choice.to_s
38
+ end.join(", ")
39
+ end
40
+
32
41
  def customize_inputs
33
42
  input :value,
34
43
  as: :select,
35
44
  choices: resolved_choices,
36
45
  multiple: @multiple,
37
- include_blank: @multiple ? false : "All"
46
+ include_blank: @multiple ? false : Plutonium::Translation.t("plutonium.query.all")
38
47
  end
39
48
 
40
49
  private
@@ -52,19 +52,16 @@ module Plutonium
52
52
  private
53
53
 
54
54
  def generate_placeholder
55
- base = key.to_s.humanize
56
- case @predicate
57
- when :matches, :not_matches
58
- "#{base} (use * as wildcard)"
59
- when :starts_with
60
- "#{base} starts with..."
61
- when :ends_with
62
- "#{base} ends with..."
63
- when :contains, :not_contains
64
- "#{base} contains..."
65
- else
66
- base
55
+ base = label
56
+ phrase = case @predicate
57
+ when :matches, :not_matches then :matches
58
+ when :starts_with then :starts_with
59
+ when :ends_with then :ends_with
60
+ when :contains, :not_contains then :contains
67
61
  end
62
+ return base unless phrase
63
+
64
+ Plutonium::Translation.t("plutonium.query.filters.text.#{phrase}", label: base)
68
65
  end
69
66
 
70
67
  def sanitize_like(string)
@@ -16,6 +16,7 @@ module Plutonium
16
16
  # end
17
17
  PRECOMPILE_ASSETS = %w[
18
18
  plutonium.js plutonium.js.map plutonium.min.js plutonium.min.js.map
19
+ plutonium-charts.js plutonium-charts.js.map plutonium-charts.min.js plutonium-charts.min.js.map
19
20
  plutonium.css plutonium.png plutonium.ico
20
21
  ].freeze
21
22
 
@@ -25,6 +26,8 @@ module Plutonium
25
26
 
26
27
  initializer "plutonium.rescue_responses", before: "action_dispatch.configure" do |app|
27
28
  app.config.action_dispatch.rescue_responses["ActionPolicy::Unauthorized"] = :forbidden
29
+ app.config.action_dispatch.rescue_responses["Plutonium::Dashboard::UnknownCardError"] = :not_found
30
+ app.config.action_dispatch.rescue_responses["Plutonium::Dashboard::UnknownDashboardError"] = :not_found
28
31
  end
29
32
 
30
33
  initializer "plutonium.deprecator" do |app|
@@ -39,6 +42,19 @@ module Plutonium
39
42
  load_plutonium_initializers
40
43
  end
41
44
 
45
+ # Plutonium is a Railtie, not an Engine, so it gets no config/locales path
46
+ # for free. Register it the way Rails::Engine#add_locales does: through
47
+ # railties_load_path, which the I18n railtie unshifts ahead of the app's
48
+ # own locales in registration order. Registering before every engine's
49
+ # add_locales puts the gem's strings at the lowest precedence: a package or
50
+ # portal engine's config/locales overrides them, and the host app overrides
51
+ # both.
52
+ initializer "plutonium.i18n", before: :add_locales do
53
+ locales = Rails::Paths::Root.new(Plutonium.root.to_s)
54
+ locales.add "config/locales", glob: "**/*.{rb,yml}"
55
+ config.i18n.railties_load_path << locales["config/locales"]
56
+ end
57
+
42
58
  initializer "plutonium.register_migrations" do
43
59
  Plutonium::Migrations.register(:wizards, Plutonium.root.join("db/migrate/wizard").to_s)
44
60
  Plutonium::Migrations.register(:async_interactions, Plutonium.root.join("db/migrate/async_interactions").to_s)
@@ -131,6 +147,7 @@ module Plutonium
131
147
  def extend_action_dispatch
132
148
  ActionDispatch::Routing::Mapper.prepend Plutonium::Routing::MapperExtensions
133
149
  ActionDispatch::Routing::Mapper.prepend Plutonium::Routing::WizardRegistration
150
+ ActionDispatch::Routing::Mapper.prepend Plutonium::Routing::DashboardRegistration
134
151
  ActionDispatch::Routing::RouteSet.prepend Plutonium::Routing::RouteSetExtensions
135
152
  Rails::Engine.include Plutonium::Routing::ResourceRegistration
136
153
  end