plutonium 0.62.2 → 0.63.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 (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -42,14 +42,46 @@ module PlutoniumGenerators
42
42
  constraint_file = "app/constraints/management_constraint.rb"
43
43
  return if File.exist?(File.expand_path(constraint_file, destination_root))
44
44
 
45
- create_file constraint_file, <<~RUBY
45
+ create_file constraint_file, management_constraint_source
46
+ end
47
+
48
+ # Prefer the Rodauth constraint when the app has an admin account to point
49
+ # at — `Rodauth::Rails.authenticate` redirects unauthenticated requests to
50
+ # the login page instead of 404ing on the mount.
51
+ def management_constraint_source
52
+ account = rodauth_management_account
53
+ return management_constraint_stub unless account
54
+
55
+ <<~RUBY
56
+ # frozen_string_literal: true
57
+
58
+ # Gates the management engines mounted in config/routes.rb.
59
+ class ManagementConstraint
60
+ AUTHENTICATE = Rodauth::Rails.authenticate(#{account})
61
+
62
+ def self.matches?(request)
63
+ AUTHENTICATE.call(request)
64
+ end
65
+ end
66
+ RUBY
67
+ end
68
+
69
+ def management_constraint_stub
70
+ <<~RUBY
46
71
  # frozen_string_literal: true
47
72
 
73
+ # Gates the management engines mounted in config/routes.rb.
48
74
  class ManagementConstraint
75
+ # Rodauth (redirects to the login page when signed out):
76
+ # AUTHENTICATE = Rodauth::Rails.authenticate(:admin)
77
+ #
78
+ # def self.matches?(request)
79
+ # AUTHENTICATE.call(request)
80
+ # end
81
+
49
82
  def self.matches?(request)
50
83
  false # TODO: Implement authentication
51
84
  # Examples:
52
- # Rodauth: request.env["rodauth.admin"]&.logged_in?
53
85
  # Devise: request.env["warden"].user(:admin).present?
54
86
  # Custom: request.session[:admin_id].present?
55
87
  # HTTP Basic: authenticate_with_http_basic(request)
@@ -67,6 +99,19 @@ module PlutoniumGenerators
67
99
  end
68
100
  RUBY
69
101
  end
102
+
103
+ # Returns the `Rodauth::Rails.authenticate` argument for the account that
104
+ # should guard management routes — only ever the `:admin` configuration.
105
+ # `nil` when the app has no admin account, so the constraint is left
106
+ # unimplemented rather than silently handing management routes to whatever
107
+ # other account happens to exist.
108
+ def rodauth_management_account
109
+ rodauth_app = File.expand_path("app/rodauth/rodauth_app.rb", destination_root)
110
+ return unless File.exist?(rodauth_app)
111
+
112
+ names = File.read(rodauth_app).scan(/^\s*configure\s+::\w+\s*,\s*:(\w+)/).flatten
113
+ ":admin" if names.include?("admin")
114
+ end
70
115
  end
71
116
  end
72
117
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlutoniumGenerators
4
+ module Concerns
5
+ module ResourceRegistration
6
+ private
7
+
8
+ # Insert `register_resource ::<Klass>` into a routes file. Idempotent:
9
+ # skips if already present. Falls back when the conventional
10
+ # `# register resources above.` marker is missing.
11
+ def register_resource_in_routes(routes_path, resource, singular: false)
12
+ line = "register_resource ::#{resource}"
13
+ line += ", singular: true" if singular
14
+ content = File.read(File.join(destination_root, routes_path))
15
+
16
+ if /^\s*#{Regexp.escape(line)}\b/.match?(content)
17
+ say_status :identical, "#{routes_path} already registers #{resource}", :blue
18
+ return
19
+ end
20
+
21
+ if /^\s*#\s*register resources above\b/.match?(content)
22
+ insert_into_file routes_path,
23
+ indent("#{line}\n", 2),
24
+ before: /^\s*#\s*register resources above\b.*/
25
+ elsif /^\s*Rails\.application\.routes\.draw do\b/.match?(content)
26
+ insert_into_file routes_path,
27
+ indent("#{line}\n", 2),
28
+ after: /^\s*Rails\.application\.routes\.draw do.*\n/
29
+ elsif (match = content.match(/^(\w+::Engine)\.routes\.draw do.*\n/))
30
+ insert_into_file routes_path,
31
+ indent("#{line}\n", 2),
32
+ after: /^\s*#{Regexp.escape(match[1])}\.routes\.draw do.*\n/
33
+ else
34
+ say_status :warn,
35
+ "Could not locate routes block in #{routes_path}; add manually: #{line}",
36
+ :yellow
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -55,7 +55,7 @@ module Pu
55
55
  return if file_includes?(deploy_file, "litestream:")
56
56
 
57
57
  insert_into_file deploy_file, after: /^servers:.*\n/ do
58
- <<~YAML
58
+ <<~YAML.indent(2)
59
59
  litestream:
60
60
  hosts:
61
61
  - <%= ENV['DEPLOY_HOST'] %>
@@ -75,7 +75,7 @@ module Pu
75
75
  return if file_includes?(deploy_file, "job:")
76
76
 
77
77
  insert_into_file deploy_file, after: /^servers:.*\n/ do
78
- <<~YAML
78
+ <<~YAML.indent(2)
79
79
  job:
80
80
  hosts:
81
81
  - <%= ENV['DEPLOY_HOST'] %>
@@ -7,6 +7,7 @@ module Pu
7
7
  class ConnGenerator < Rails::Generators::Base
8
8
  include PlutoniumGenerators::Generator
9
9
  include PlutoniumGenerators::Concerns::ResourceSelector
10
+ include PlutoniumGenerators::Concerns::ResourceRegistration
10
11
 
11
12
  source_root File.expand_path("templates", __dir__)
12
13
 
@@ -26,6 +27,9 @@ module Pu
26
27
  class_option :definition, type: :boolean, default: false,
27
28
  desc: "Create portal-specific definition even if base definition exists"
28
29
 
30
+ class_option :controller_for, type: :string, default: nil,
31
+ desc: "Explicit controller_for target, when the controller's name won't infer the resource on its own"
32
+
29
33
  def start
30
34
  selected_resources = resources_selection
31
35
  @app_namespace = portal_option(:dest, prompt: "Select destination portal").camelize
@@ -36,7 +40,7 @@ module Pu
36
40
  @resource_class = resource
37
41
 
38
42
  if app_namespace == "MainApp"
39
- register_resource_in_routes("config/routes.rb", resource)
43
+ register_resource_in_routes("config/routes.rb", resource, singular: options[:singular])
40
44
  else
41
45
  if options[:policy] || !expected_parent_policy
42
46
  template "app/policies/resource_policy.rb",
@@ -51,7 +55,8 @@ module Pu
51
55
  template "app/controllers/resource_controller.rb",
52
56
  "packages/#{package_namespace}/app/controllers/#{package_namespace}/#{resource.pluralize.underscore}_controller.rb"
53
57
 
54
- register_resource_in_routes("packages/#{package_namespace}/config/routes.rb", resource)
58
+ register_resource_in_routes("packages/#{package_namespace}/config/routes.rb", resource,
59
+ singular: options[:singular])
55
60
  end
56
61
  end
57
62
  rescue => e
@@ -62,45 +67,10 @@ module Pu
62
67
 
63
68
  attr_reader :app_namespace, :resource_class
64
69
 
65
- # Insert `register_resource ::<Klass>` into a routes file. Idempotent:
66
- # skips if already present. Falls back when the conventional
67
- # `# register resources above.` marker is missing.
68
- def register_resource_in_routes(routes_path, resource)
69
- line = "register_resource ::#{resource}#{singular_option}"
70
- content = File.read(File.join(destination_root, routes_path))
71
-
72
- if /^\s*#{Regexp.escape(line)}\b/.match?(content)
73
- say_status :identical, "#{routes_path} already registers #{resource}", :blue
74
- return
75
- end
76
-
77
- if /^\s*#\s*register resources above\b/.match?(content)
78
- insert_into_file routes_path,
79
- indent("#{line}\n", 2),
80
- before: /^\s*#\s*register resources above\b.*/
81
- elsif /^\s*Rails\.application\.routes\.draw do\b/.match?(content)
82
- insert_into_file routes_path,
83
- indent("#{line}\n", 2),
84
- after: /^\s*Rails\.application\.routes\.draw do.*\n/
85
- elsif (match = content.match(/^(\w+::Engine)\.routes\.draw do.*\n/))
86
- insert_into_file routes_path,
87
- indent("#{line}\n", 2),
88
- after: /^\s*#{Regexp.escape(match[1])}\.routes\.draw do.*\n/
89
- else
90
- say_status :warn,
91
- "Could not locate routes block in #{routes_path}; add manually: #{line}",
92
- :yellow
93
- end
94
- end
95
-
96
70
  def package_namespace
97
71
  app_namespace.underscore
98
72
  end
99
73
 
100
- def singular_option
101
- options[:singular] ? ", singular: true" : ""
102
- end
103
-
104
74
  def resource_namespace
105
75
  app_namespace.underscore
106
76
  end
@@ -114,18 +84,28 @@ module Pu
114
84
  expected_parent_controller || "#{app_namespace}::ResourceController"
115
85
  end
116
86
 
87
+ # A gem-provided resource (e.g. Plutonium::Interaction::Async::Run) already has
88
+ # a working policy that subclasses Plutonium::Resource::Policy directly
89
+ # rather than through this host's ::ResourcePolicy — so the `< ::ResourcePolicy`
90
+ # check alone misses it and would regenerate a redundant, shadowing-prone
91
+ # override by default.
117
92
  def expected_parent_policy
118
93
  expected_parent_policy = "::#{resource_class.classify}Policy".safe_constantize
119
- expected_parent_policy if expected_parent_policy.present? && expected_parent_policy < ::ResourcePolicy
94
+ return unless expected_parent_policy.present?
95
+
96
+ expected_parent_policy if expected_parent_policy < ::ResourcePolicy || expected_parent_policy < Plutonium::Resource::Policy
120
97
  end
121
98
 
122
99
  def parent_policy
123
100
  expected_parent_policy || "ResourcePolicy"
124
101
  end
125
102
 
103
+ # See expected_parent_policy — same reasoning for definitions.
126
104
  def expected_parent_definition
127
105
  expected_parent_definition = "::#{resource_class.classify}Definition".safe_constantize
128
- expected_parent_definition if expected_parent_definition.present? && expected_parent_definition < ::ResourceDefinition
106
+ return unless expected_parent_definition.present?
107
+
108
+ expected_parent_definition if expected_parent_definition < ::ResourceDefinition || expected_parent_definition < Plutonium::Resource::Definition
129
109
  end
130
110
 
131
111
  def parent_definition
@@ -1,3 +1,7 @@
1
1
  class <%= app_namespace %>::<%= resource_class.pluralize %>Controller < ::<%= parent_controller %>
2
+ <%- if options[:controller_for] -%>
3
+ controller_for ::<%= options[:controller_for] %>
4
+
5
+ <%- end -%>
2
6
  include <%= app_namespace %>::Concerns::Controller
3
7
  end
@@ -233,11 +233,11 @@ class <%= account_path.classify %>RodauthPlugin < RodauthPlugin
233
233
 
234
234
  # ==> Remember Feature
235
235
 
236
- # Remember all logged in users.
237
- after_login { remember_login }
236
+ # Only remember users who ticked the "Remember Me" checkbox on the login form.
237
+ after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }
238
238
 
239
- # Or only remember users that have ticked a "Remember Me" checkbox on login.
240
- # after_login { remember_login if param_or_nil("remember") }
239
+ # Or remember every user who logs in, whether they asked to be or not.
240
+ # after_login { remember_login }
241
241
 
242
242
  # Extend user's remember period when remembered via a cookie
243
243
  extend_remember_deadline? true
@@ -246,8 +246,17 @@ class <%= account_path.classify %>RodauthPlugin < RodauthPlugin
246
246
  remember_cookie_key "_<%= table_prefix %>_remember"
247
247
  <% end -%>
248
248
 
249
- # Session security
250
- session_key "_<%= table_prefix %>_session"
249
+ # Session security: namespace EVERY session key this configuration touches -
250
+ # the account id plus `authenticated_by`, `login_redirect`,
251
+ # `two_factor_auth_setup` and friends. Without this, configurations share those
252
+ # keys and clobber each other's state, and `session_isolation` cannot tell which
253
+ # entries belong to which configuration.
254
+ #
255
+ # Do not also set `session_key` here. An explicit `session_key` is NOT prefixed,
256
+ # so the account id key would rotate on a different schedule from every other
257
+ # key - and Rodauth crashes on a session that has an account id but no
258
+ # `authenticated_by` (`nil.include?` in `logged_in_via_remember_key?`).
259
+ session_key_prefix "<%= table_prefix %>_"
251
260
 
252
261
  # ==> Hooks
253
262
 
@@ -9,6 +9,13 @@ class RodauthPlugin < Rodauth::Rails::Auth
9
9
  # See the Rodauth documentation for the list of available config options:
10
10
  # http://rodauth.jeremyevans.net/documentation.html
11
11
 
12
+ # Keep each configuration's session state to itself, so a person can be signed
13
+ # into more than one portal at a time. Without this, Rodauth's session reset on
14
+ # login (and on the `remember` feature's autologin) wipes the whole Rails
15
+ # session, evicting every other configuration's login.
16
+ # Relies on each configuration setting `session_key_prefix`.
17
+ enable :session_isolation
18
+
12
19
  # List of authentication features that are loaded.
13
20
  # enable :create_account, :verify_account, :verify_account_grace_period,
14
21
  # :reset_password, :change_password, :change_password_notify,
@@ -13,8 +13,8 @@
13
13
  <%%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
14
14
  <%% end %>
15
15
  </head>
16
- <body class="antialiased min-h-screen bg-[var(--pu-body)]">
17
- <main class="p-4 min-h-screen flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0">
16
+ <body class="antialiased pu-min-h-viewport bg-[var(--pu-body)]">
17
+ <main class="p-4 pu-min-h-viewport flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0">
18
18
  <%% if flash.any? %>
19
19
  <%% flash.each do |type, message| %>
20
20
  <div class="fixed z-50 top-16 inset-x-0 mx-auto flex items-center w-full max-w-md p-4 rounded-[var(--pu-radius-lg)]"
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Wizards
7
+ # Turns the wizard subsystem on.
8
+ #
9
+ # App-wide, and takes no portal — unlike pu:async_interactions:install,
10
+ # which connects a RESOURCE and therefore needs somewhere to route it. A
11
+ # wizard mounts itself wherever it is declared (the `wizard` macro on a
12
+ # definition, or `register_wizard` in a portal or the main app), so there is
13
+ # nothing here to attach to a portal.
14
+ #
15
+ # What it does is the two steps every wizard app needs and neither of which
16
+ # a wizard can do for itself: flip the flag that registers the migration
17
+ # path, and schedule the sweep.
18
+ class InstallGenerator < Rails::Generators::Base
19
+ include PlutoniumGenerators::Generator
20
+ include PlutoniumGenerators::Concerns::ConfiguresRecurring
21
+
22
+ desc(
23
+ "Enable wizards and schedule the abandonment sweep\n\n" \
24
+ "e.g. rails g pu:wizards:install"
25
+ )
26
+
27
+ class_option :schedule, type: :string, default: "every 15 minutes",
28
+ desc: "Cron-style schedule for the SweepJob recurring task"
29
+
30
+ def start
31
+ enable_wizards
32
+ schedule_sweep_job
33
+ migration_reminder
34
+ rescue => e
35
+ exception "#{self.class} failed:", e
36
+ end
37
+
38
+ private
39
+
40
+ # The flag gates the MIGRATION as well as the behaviour: while it is off
41
+ # the wizard migration path is never registered, so
42
+ # plutonium_wizard_sessions does not exist and nothing works.
43
+ def enable_wizards
44
+ configure_plutonium "config.wizards.enabled = true"
45
+ end
46
+
47
+ # Idempotent via add_recurring_tasks, so re-running this is a no-op.
48
+ #
49
+ # Scheduling matters more than it looks. For an +execute+-only wizard an
50
+ # unscheduled sweep just leaves stale session rows, which is untidy; for an
51
+ # +on_submit+ wizard it is the only thing that cleans up the partial domain
52
+ # records an abandoned run left behind.
53
+ def schedule_sweep_job
54
+ if gem_in_bundle?("solid_queue")
55
+ unless add_recurring_tasks(sweep_job_task_yaml, marker: "sweep_abandoned_wizards")
56
+ log :skip, "SweepJob not scheduled (config/recurring.yml missing or already scheduled)"
57
+ end
58
+ else
59
+ log :info, "solid_queue not found — schedule Plutonium::Wizard::SweepJob yourself (see docs)"
60
+ end
61
+ end
62
+
63
+ def sweep_job_task_yaml
64
+ <<~YAML
65
+ sweep_abandoned_wizards:
66
+ class: Plutonium::Wizard::SweepJob
67
+ schedule: "#{options[:schedule]}"
68
+ YAML
69
+ end
70
+
71
+ # Not run for you: the migration path is registered from the initializer at
72
+ # BOOT, and this process booted before the flag was written.
73
+ def migration_reminder
74
+ log :info, "wizards enabled — run `rails db:migrate` to create plutonium_wizard_sessions"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -6,7 +6,7 @@ module Plutonium
6
6
  module Action
7
7
  # Base class for all actions in the Plutonium framework.
8
8
  class Base
9
- attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to, :condition
9
+ attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to, :condition, :link, :button
10
10
 
11
11
  def initialize(name, **options)
12
12
  @name = name.to_sym
@@ -23,12 +23,20 @@ module Plutonium
23
23
  @collection_record_action = options[:collection_record_action] || false
24
24
  @record_action = options[:record_action] || false
25
25
  @resource_action = options[:resource_action] || false
26
- @kanban_drop = options[:kanban_drop] || false
26
+ @hidden = options[:hidden] || false
27
27
  @category = ActiveSupport::StringInquirer.new((options[:category] || :secondary).to_s)
28
28
  @position = options[:position] || 50
29
29
  @modal_mode = options[:modal]
30
30
  @modal_size = options[:size]
31
31
  @condition = options[:condition]
32
+ # Author-supplied HTML attributes, keyed by the element they target:
33
+ # `link:` merges onto every anchor rendering (the GET link and dropdown
34
+ # items — which are anchors even for non-GET actions), `button:` onto
35
+ # the button_to <form> (the non-GET toolbar rendering). Both deep-merge
36
+ # over the framework's attributes at render time, with the author
37
+ # winning.
38
+ @link = normalize_html_attribute_bag(options[:link], :link)
39
+ @button = normalize_html_attribute_bag(options[:button], :button)
32
40
  validate_modal_mode!
33
41
  validate_modal_size!
34
42
 
@@ -69,11 +77,14 @@ module Plutonium
69
77
  def record_action? = @record_action
70
78
  def resource_action? = @resource_action
71
79
 
72
- # True when this action was auto-registered for a kanban column's
73
- # `enter_interaction`. Such actions exist only so their policy method,
74
- # form, and params machinery are wired up they are reachable by
75
- # dropping a card, never rendered as a normal toolbar/row button.
76
- def kanban_drop? = @kanban_drop
80
+ # True when this action must never render. It still has a live route,
81
+ # policy predicate, and (if interactive) form + params machinery — it is
82
+ # simply reachable only by something other than a button: a kanban drop,
83
+ # a drag-reorder, a custom Stimulus controller.
84
+ #
85
+ # Display-only, like #condition_met? — NOT an authorization boundary.
86
+ # Keep authorization in the policy.
87
+ def hidden? = @hidden
77
88
 
78
89
  def permitted_by?(policy)
79
90
  policy.allowed_to?(:"#{name}?")
@@ -94,6 +105,21 @@ module Plutonium
94
105
  ConditionContext.new(view_context, record).instance_exec(&@condition)
95
106
  end
96
107
 
108
+ # Merge points for rendering surfaces: deep-merge the author's bag over
109
+ # the framework-built attributes for the element being rendered, with
110
+ # the author winning on every key. `link_attributes` is for anchors
111
+ # (the GET toolbar link, dropdown items — anchors even for non-GET
112
+ # actions — bulk-action links, kanban column actions, card show links);
113
+ # `button_attributes` is for the button_to <form> wrapper. Surfaces
114
+ # call these instead of merging #link/#button themselves.
115
+ def link_attributes(attributes)
116
+ merge_author_bag(@link, attributes)
117
+ end
118
+
119
+ def button_attributes(attributes)
120
+ merge_author_bag(@button, attributes)
121
+ end
122
+
97
123
  # Returns a new Action with the given options merged over this one.
98
124
  def with(**overrides)
99
125
  self.class.new(name, **to_options.merge(overrides))
@@ -119,21 +145,57 @@ module Plutonium
119
145
  collection_record_action: @collection_record_action,
120
146
  record_action: @record_action,
121
147
  resource_action: @resource_action,
122
- kanban_drop: @kanban_drop,
148
+ hidden: @hidden,
123
149
  category: @category.to_sym,
124
150
  position: @position,
125
151
  modal: @modal_mode,
126
152
  size: @modal_size,
127
- condition: @condition
153
+ condition: @condition,
154
+ link: @link,
155
+ button: @button
128
156
  }
129
157
  end
130
158
 
131
159
  private
132
160
 
161
+ def merge_author_bag(bag, attributes)
162
+ bag.empty? ? attributes : attributes.deep_merge(bag)
163
+ end
164
+
133
165
  def targets_remote_modal?
134
166
  @turbo_frame == Plutonium::REMOTE_MODAL_FRAME
135
167
  end
136
168
 
169
+ # Stores the action's own symbol-keyed copy of an author attribute bag.
170
+ # Symbolizing is load-bearing, not cosmetic: the render-time deep_merge
171
+ # matches keys exactly, so a string-keyed bag would sit alongside the
172
+ # framework's symbol keys and emit duplicate attributes instead of
173
+ # overriding them. Copying keeps the caller's hash untouched (no
174
+ # freezing objects we don't own), and the deep structural freeze makes
175
+ # the bag as immutable as the action itself.
176
+ def normalize_html_attribute_bag(bag, name)
177
+ return {}.freeze if bag.nil?
178
+ unless bag.is_a?(Hash)
179
+ raise ArgumentError, "#{name} must be a Hash of HTML attributes, got #{bag.inspect}"
180
+ end
181
+ deep_freeze_structure(bag.deep_symbolize_keys)
182
+ end
183
+
184
+ # Freezes nested hashes/arrays (fresh copies from deep_symbolize_keys)
185
+ # but not leaf values, which are still shared with the caller.
186
+ def deep_freeze_structure(value)
187
+ case value
188
+ when Hash
189
+ value.each_value { |v| deep_freeze_structure(v) }
190
+ value.freeze
191
+ when Array
192
+ value.each { |v| deep_freeze_structure(v) }
193
+ value.freeze
194
+ else
195
+ value
196
+ end
197
+ end
198
+
137
199
  def validate_modal_mode!
138
200
  return if @modal_mode.nil?
139
201
  return if [:centered, :slideover].include?(@modal_mode)
@@ -35,6 +35,15 @@ module Plutonium
35
35
  @immediate ? "#{label}?" : nil
36
36
  end
37
37
 
38
+ protected
39
+
40
+ # Extends Base's round-trip contract with the keywords this class's
41
+ # initialize requires — without them, #with would raise missing
42
+ # keywords when reconstructing the action.
43
+ def to_options
44
+ super.merge(interaction: @interaction, immediate: @immediate)
45
+ end
46
+
38
47
  # Factory for creating Interactive actions
39
48
  class Factory
40
49
  # TODO: move these into Plutonium::Action::Interactive