plutonium 0.60.5 → 0.61.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  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/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -43,6 +43,8 @@ module Plutonium
43
43
  define_collection_interactive_actions
44
44
  define_collection_typeahead_actions
45
45
  define_collection_export_actions
46
+ define_member_wizard_actions
47
+ define_collection_wizard_actions
46
48
  end
47
49
  end
48
50
 
@@ -149,6 +151,7 @@ module Plutonium
149
151
  as: :interactive_record_action
150
152
  post "record_actions/:interactive_action", action: :commit_interactive_record_action,
151
153
  as: :commit_interactive_record_action
154
+ post "kanban_move", action: :kanban_move, as: :kanban_move
152
155
  end
153
156
  end
154
157
 
@@ -183,6 +186,47 @@ module Plutonium
183
186
  end
184
187
  end
185
188
 
189
+ # Defines member-level wizard launch actions (§5.1 / Fix A). Auto-mounted on
190
+ # every Plutonium resource alongside record_actions — the action 404s unless
191
+ # `:wizard_name` is a wizard registered (anchored → record) on the resource's
192
+ # definition, mirroring how `:interactive_action` gates record_actions. The
193
+ # anchor is the scoped, policy-gated `resource_record!` (IDOR-safe).
194
+ #
195
+ # @return [void]
196
+ def define_member_wizard_actions
197
+ return unless Plutonium.configuration.wizards.enabled
198
+
199
+ member do
200
+ # Bare launch (no :step): resolve/mint the run and redirect to its step.
201
+ get "wizards/:wizard_name", action: :launch_wizard_record_action,
202
+ as: :launch_wizard_record_action
203
+ get "wizards/:wizard_name(/:token)/:step", action: :wizard_record_action,
204
+ as: :wizard_record_action
205
+ post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_record_action,
206
+ as: :commit_wizard_record_action
207
+ end
208
+ end
209
+
210
+ # Defines collection-level wizard launch actions (§5.1 / Fix A) for
211
+ # non-anchored (create) wizards. Auto-mounted alongside resource_actions;
212
+ # the action 404s unless `:wizard_name` is a collection wizard registered on
213
+ # the resource's definition.
214
+ #
215
+ # @return [void]
216
+ def define_collection_wizard_actions
217
+ return unless Plutonium.configuration.wizards.enabled
218
+
219
+ collection do
220
+ # Bare launch (no :step): resolve/mint the run and redirect to its step.
221
+ get "wizards/:wizard_name", action: :launch_wizard_resource_action,
222
+ as: :launch_wizard_resource_action
223
+ get "wizards/:wizard_name(/:token)/:step", action: :wizard_resource_action,
224
+ as: :wizard_resource_action
225
+ post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_resource_action,
226
+ as: :commit_wizard_resource_action
227
+ end
228
+ end
229
+
186
230
  # Defines the collection-level CSV export action. Auto-mounted on
187
231
  # every Plutonium resource alongside typeahead and bulk actions.
188
232
  # The action itself is gated by the `export_csv?` policy (default
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Routing
5
+ # Adds `register_wizard` to the routing mapper, mirroring `register_resource`
6
+ # (see {MapperExtensions}). A wizard is **portal-hosted** (§5.2): the routes are
7
+ # drawn inside the portal engine's `routes.draw` block, so they inherit the
8
+ # portal's scope/auth/layout, and they dispatch to a portal-namespaced wizard
9
+ # controller that includes {Plutonium::Wizard::Controller} + the portal's own
10
+ # controller concern.
11
+ #
12
+ # @example inside a portal engine's routes
13
+ # AdminPortal::Engine.routes.draw do
14
+ # register_wizard OnboardingWizard, at: "onboarding"
15
+ # register_resource ::User
16
+ # end
17
+ #
18
+ # Draws (portal-relative):
19
+ # GET /onboarding(/:token)/:step → <Portal>::WizardsController#show
20
+ # POST /onboarding(/:token)/:step → <Portal>::WizardsController#update
21
+ #
22
+ # with `onboarding_wizard_path` / `_url` helpers.
23
+ module WizardRegistration
24
+ WIZARD_CONTROLLER_NAME = "wizards"
25
+
26
+ # Tracks public wizard mounts already appended to the main app route set, so
27
+ # re-draws (boot, reload, multiple portals) don't stack duplicate named
28
+ # routes. Keyed by the wizard CLASS NAME (not the helper name) so two distinct
29
+ # anonymous wizards never collapse into one entry — a helper-name collision
30
+ # between different wizards is a hard error (see {#register_public_wizard}),
31
+ # not a silent drop. Maps `wizard_class.name => helper_name`.
32
+ class << self
33
+ attr_accessor :appended_public_wizards
34
+ end
35
+
36
+ # @param wizard_class [Class] a Plutonium::Wizard::Base subclass
37
+ # @param at [String] the portal-relative base path for the wizard's steps
38
+ # @param as [String, Symbol, nil] override the route helper name prefix
39
+ # @param public [Boolean, nil] mount on a PUBLIC (unauthenticated) route
40
+ # outside the portal's auth constraint, for an `anonymous` (guest) wizard.
41
+ # Defaults to the wizard's own `anonymous?` flag. A non-`anonymous` wizard
42
+ # may not be mounted public; an `anonymous` wizard may not be mounted
43
+ # authenticated (its whole point is pre-login access). See §4.5.
44
+ # @param layout [Symbol, String, nil] the Rails layout this mount renders in —
45
+ # a layout NAME, exactly like the controller `layout` macro: `:basic` (the
46
+ # bare `BasicLayout`, e.g. an onboarding screen), `:resource` (the standard
47
+ # shell), or any app layout. Only meaningful for `register_wizard` mounts;
48
+ # resource-defined (`wizard` macro) wizards are always embedded. Defaults by
49
+ # context (portal → the resource shell, main-app → `:basic`); turbo-frame
50
+ # requests are always layout-less regardless.
51
+ def register_wizard(wizard_class, at:, as: nil, public: nil, layout: nil)
52
+ # The wizard subsystem is opt-in (`config.wizards.enabled`). When disabled,
53
+ # draw no routes — its tables/migrations are skipped too, so a mounted route
54
+ # couldn't work anyway. Warn rather than fail silently, so a
55
+ # registered-but-disabled wizard is discoverable instead of a mystery 404.
56
+ unless Plutonium.configuration.wizards.enabled
57
+ Rails.logger.warn { "[Plutonium::Wizard] not registering routes for #{wizard_class} — config.wizards.enabled is false" }
58
+ return
59
+ end
60
+
61
+ # A CONTEXT anchor (`anchored via: :method`) is portal-level: the anchor is
62
+ # resolved by calling a controller method, needs no URL `:id`, and is
63
+ # IDOR-safe (trusted context) — so it CAN mount here. Only a TYPE anchor
64
+ # (`with:`-only, resolved from the URL `:id`) is rejected, because it needs
65
+ # the resource controller's scoped, policy-gated `resource_record!`.
66
+ if wizard_class.anchored? && !wizard_class.anchored_via?
67
+ raise ArgumentError,
68
+ "register_wizard #{wizard_class.name} — `with:`-anchored wizards are not " \
69
+ "mounted portal-level. Register them on the anchored resource's definition " \
70
+ "with the `wizard` macro, which auto-mounts a record action whose anchor is " \
71
+ "resolved through the resource controller's scoped, policy-gated " \
72
+ "`resource_record!`. (A `via:`-anchored wizard mounts here fine.)"
73
+ end
74
+
75
+ # Default the mount kind to the wizard's `anonymous?` flag, and reject
76
+ # contradictions (§4.5): an `anonymous` wizard NEEDS a public route
77
+ # (pre-login); a non-`anonymous` wizard MUST stay behind portal auth.
78
+ is_public = public.nil? ? wizard_class.anonymous? : !!public
79
+ if is_public && !wizard_class.anonymous?
80
+ raise ArgumentError,
81
+ "register_wizard #{wizard_class.name}, public: true — only an `anonymous` " \
82
+ "wizard may be mounted public. Add the `anonymous` macro to the wizard, or " \
83
+ "drop `public:`."
84
+ end
85
+ if !is_public && wizard_class.anonymous?
86
+ raise ArgumentError,
87
+ "register_wizard #{wizard_class.name} — an `anonymous` wizard must be mounted " \
88
+ "public (it runs pre-login). Pass `public: true` (it is the default for " \
89
+ "`anonymous` wizards)."
90
+ end
91
+
92
+ return register_public_wizard(wizard_class, at:, as:, layout:) if is_public
93
+
94
+ ensure_wizard_controller!(wizard_class)
95
+
96
+ # The helper name defaults to the mount path (`at:`), so
97
+ # `register_wizard W, at: "onboarding"` yields `onboarding_wizard_path`.
98
+ # `as:` overrides it; the wizard's own route name is the final fallback.
99
+ helper_name = (as || at.presence || wizard_route_name(wizard_class)).to_s.tr("/", "_")
100
+ defaults = wizard_route_defaults(wizard_class, layout)
101
+
102
+ scope path: at do
103
+ # Canonical launch: GET the bare mount → resolve/mint the run and PRG to
104
+ # its first (or resumed) step, with the token already in the URL. This is
105
+ # the shareable entry point; `wizard_step_url` builds the stepped URLs.
106
+ get "/", to: "#{WIZARD_CONTROLLER_NAME}#launch",
107
+ as: :"#{helper_name}_wizard_launch", defaults: defaults
108
+ get "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#show",
109
+ as: :"#{helper_name}_wizard", defaults: defaults
110
+ post "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#update",
111
+ defaults: defaults
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ # Route defaults carried on every wizard route: the wizard class (resolves the
118
+ # wizard at request time) and, when explicitly set, the `layout`
119
+ # (a layout name). An unset layout is omitted — the driving layer then
120
+ # defaults it by context (portal → the resource shell, main-app → `:basic`). The
121
+ # layout rides the route (not a controller-class setting) because one
122
+ # synthesized controller serves many mounts, so the per-mount value can't live
123
+ # on the controller.
124
+ def wizard_route_defaults(wizard_class, layout)
125
+ defaults = {wizard_class: wizard_class.name}
126
+ defaults[:wizard_layout] = layout.to_s if layout
127
+ defaults
128
+ end
129
+
130
+ # Mount an `anonymous` wizard on a PUBLIC (unauthenticated) route (§4.5).
131
+ #
132
+ # Portal engines are mounted INSIDE the host's auth constraint
133
+ # (`constraints Rodauth::Rails.authenticate(:user) { mount ... }`), so a
134
+ # route drawn in the engine is unreachable pre-login. A guest wizard must
135
+ # therefore be drawn on the MAIN application's route set, OUTSIDE that
136
+ # constraint. We append to `Rails.application.routes` so the public route is
137
+ # added after (and independent of) the engine mount.
138
+ #
139
+ # The route dispatches to a synthesized top-level `WizardsController` that
140
+ # includes the full Plutonium controller stack + `Plutonium::Auth::Public`
141
+ # (so `current_user` is the guest sentinel) + {Plutonium::Wizard::Controller}.
142
+ def register_public_wizard(wizard_class, at:, as:, layout: nil)
143
+ ensure_public_wizard_controller!
144
+
145
+ helper_name = (as || at.presence || wizard_route_name(wizard_class)).to_s.tr("/", "_")
146
+ defaults = wizard_route_defaults(wizard_class, layout)
147
+ mount_path = at.to_s.sub(%r{\A/}, "")
148
+
149
+ # `Rails.application.routes.append` blocks are RETAINED and re-run on every
150
+ # route reload — so append a given wizard's block at most once. Key by the
151
+ # wizard CLASS (not the helper name) so re-drawing the SAME wizard is a no-op
152
+ # while two DIFFERENT wizards are never silently collapsed.
153
+ registered = (Plutonium::Routing::WizardRegistration.appended_public_wizards ||= {})
154
+
155
+ # Two distinct public wizards sharing a helper name (same `at:`/`as:`) would
156
+ # draw the same route name → Rails "route name already in use", or worse, a
157
+ # silent drop. Fail loudly with a fix instead.
158
+ clash = registered.find { |klass_name, helper| helper == helper_name && klass_name != wizard_class.name }
159
+ if clash
160
+ raise ArgumentError,
161
+ "register_wizard #{wizard_class.name}, at: #{at.inspect} — the route helper " \
162
+ "`#{helper_name}_wizard` is already used by #{clash.first}. Give one of them a " \
163
+ "distinct `at:` or `as:`."
164
+ end
165
+
166
+ return unless registered[wizard_class.name].nil?
167
+ registered[wizard_class.name] = helper_name
168
+
169
+ Rails.application.routes.append do
170
+ scope path: mount_path do
171
+ get "/", to: "public_wizards#launch",
172
+ as: :"#{helper_name}_wizard_launch", defaults: defaults
173
+ get "(/:token)/:step", to: "public_wizards#show",
174
+ as: :"#{helper_name}_wizard", defaults: defaults
175
+ post "(/:token)/:step", to: "public_wizards#update",
176
+ defaults: defaults
177
+ end
178
+ end
179
+ end
180
+
181
+ # Synthesize the top-level public `PublicWizardsController` once. Unlike the
182
+ # portal controller (built on the portal's authenticated `PlutoniumController`),
183
+ # the public one is built directly on the Plutonium controller stack with
184
+ # `Plutonium::Auth::Public`, so it has the rendering/scoping infra a wizard
185
+ # needs WITHOUT requiring a login.
186
+ #
187
+ # It is a DISTINCT const from the authenticated main-app `::WizardsController`
188
+ # (see {#ensure_wizard_controller!}): the two must not share a controller, or a
189
+ # public (guest) and an authenticated main-app wizard in the same app would
190
+ # collapse onto whichever was synthesized first — an authenticated main-app
191
+ # wizard would then run through `Auth::Public` and reject every logged-in user.
192
+ def ensure_public_wizard_controller!
193
+ return if Object.const_defined?(:PublicWizardsController, false)
194
+
195
+ # Build on a BARE base, decoupled from the app's `::PlutoniumController`
196
+ # (which portals inherit and may carry auth). `Plutonium::Wizard::Controller`
197
+ # brings the full rendering stack — including `Core::Controller`'s gem
198
+ # view-path, which resolves the shared partials (`plutonium/_flash`, etc.) —
199
+ # so no PlutoniumController inheritance is needed for that.
200
+ base = "ApplicationController".safe_constantize || ActionController::Base
201
+ klass = Class.new(base) do
202
+ # `Auth::Public` provides the guest `current_user`; an `anonymous` wizard
203
+ # ignores it for identity (session-token keyed) but the host still needs a
204
+ # `current_user` defined.
205
+ include Plutonium::Auth::Public
206
+ include Plutonium::Wizard::Controller
207
+ end
208
+ Object.const_set(:PublicWizardsController, klass)
209
+ end
210
+
211
+ def wizard_route_name(wizard_class)
212
+ wizard_class.name.demodulize.underscore.sub(/_wizard\z/, "")
213
+ end
214
+
215
+ # Resolve (creating if needed) the portal-namespaced wizard controller the
216
+ # routes dispatch to. The portal engine isolates its namespace, so a route
217
+ # `controller: "wizards"` resolves to `<PortalModule>::WizardsController`.
218
+ # There is no hand-written file for it (unlike resource controllers, which
219
+ # are scaffolded), so we synthesize it here — the same idea as
220
+ # {Plutonium::Portal::DynamicControllers}, but triggered explicitly at route
221
+ # draw time rather than via `const_missing`.
222
+ def ensure_wizard_controller!(wizard_class)
223
+ engine = wizard_route_engine
224
+ return if engine.nil?
225
+
226
+ portal_module = wizard_portal_module(engine)
227
+ if portal_module.nil?
228
+ # Main-app / non-namespaced mount. Synthesize a BARE top-level
229
+ # WizardsController (ApplicationController + the wizard module) — it is NOT
230
+ # rooted in the app's `::PlutoniumController` (portals inherit that, so auth
231
+ # there would leak). A bare synthesized controller has NO auth, so a
232
+ # public/`anonymous` wizard works as-is; an AUTHENTICATED main-app wizard
233
+ # requires the app to define its own `::WizardsController` (with its auth
234
+ # concern), which the const-check below picks up instead of synthesizing.
235
+ #
236
+ # This rhymes with `register_resource` ("the app owns the controller") but
237
+ # isn't identical: `register_resource` never synthesizes a fallback, so a
238
+ # missing controller is a loud routing/constant error; here the fallback is
239
+ # auth-less, so a missing override for an authenticated wizard fails QUIETER
240
+ # — every user is bounced to login rather than erroring. Covered by the
241
+ # skill docs + main_app_wizard_test so it's an explicit, tested edge.
242
+ define_wizard_controller(Object, "WizardsController", "ApplicationController", nil)
243
+ return
244
+ end
245
+
246
+ define_wizard_controller(
247
+ portal_module,
248
+ "WizardsController",
249
+ "#{portal_module.name}::PlutoniumController",
250
+ "#{portal_module.name}::Concerns::Controller"
251
+ )
252
+ end
253
+
254
+ # Synthesize a wizard controller unless one is already defined (the app's
255
+ # override wins — define `<Portal>::WizardsController` / `::WizardsController`
256
+ # to take over). `Plutonium::Wizard::Controller` brings the full rendering
257
+ # stack, so the parent only needs to supply auth/scope (a portal's
258
+ # PlutoniumController) or nothing (a bare main-app base).
259
+ def define_wizard_controller(namespace, const_name, parent_name, concern_name)
260
+ return if namespace.const_defined?(const_name, false)
261
+
262
+ parent = parent_name.safe_constantize || ActionController::Base
263
+ klass = Class.new(parent) do
264
+ include Plutonium::Wizard::Controller
265
+ end
266
+ namespace.const_set(const_name, klass)
267
+
268
+ if concern_name && (concern = concern_name.safe_constantize)
269
+ klass.include concern
270
+ end
271
+ klass
272
+ end
273
+
274
+ # The Plutonium engine owning this route set (mirrors RouteSetExtensions#engine).
275
+ def wizard_route_engine
276
+ rs = respond_to?(:route_set) ? route_set : @set
277
+ rs.respond_to?(:engine) ? rs.engine : nil
278
+ end
279
+
280
+ # The portal module (e.g. AdminPortal) for a `SomePortal::Engine`, or nil for
281
+ # the main application.
282
+ def wizard_portal_module(engine)
283
+ return nil if engine == Rails.application.class
284
+
285
+ engine.module_parent
286
+ end
287
+ end
288
+ end
289
+ end
@@ -74,18 +74,23 @@ module Plutonium
74
74
  def render_tablist_with_details
75
75
  tablist = BuildTabList()
76
76
 
77
- # Build an inner display component for the Details tab.
78
- # It must be a standalone Phlex component so that TabList can call
79
- # `render(details_display)` from within its own context. Phlex propagates
80
- # @_state through render calls, so the inner component writes to the same
81
- # buffer as the outer Resource display even though self changes.
82
- details_display = build_details_display
83
-
84
- tablist.with_tab(
85
- identifier: "details",
86
- title: -> { plain "Details" }
87
- ) do
88
- render details_display
77
+ # Only render the Details tab when the user is permitted to see at
78
+ # least one field. With no permitted fields the tab would be empty,
79
+ # so we drop it and let the first association tab lead instead.
80
+ if resource_fields.present?
81
+ # Build an inner display component for the Details tab.
82
+ # It must be a standalone Phlex component so that TabList can call
83
+ # `render(details_display)` from within its own context. Phlex propagates
84
+ # @_state through render calls, so the inner component writes to the same
85
+ # buffer as the outer Resource display even though self changes.
86
+ details_display = build_details_display
87
+
88
+ tablist.with_tab(
89
+ identifier: "details",
90
+ title: -> { plain "Details" }
91
+ ) do
92
+ render details_display
93
+ end
89
94
  end
90
95
 
91
96
  resource_associations.each do |name|
@@ -47,6 +47,13 @@ module Plutonium
47
47
  end
48
48
  alias_method :switch_tag, :toggle_tag
49
49
 
50
+ # Password / secret input that never renders the stored value.
51
+ # Routed to here for both explicit `as: :password` and every field
52
+ # inferred as a password (see Options::InferredTypes#infer_field_component).
53
+ def password_tag(**, &)
54
+ create_component(Components::Password, :password, **, &)
55
+ end
56
+
50
57
  def slim_select_tag(**attributes, &)
51
58
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
52
59
  select_tag(**attributes, required: false, class!: "", &)
@@ -61,11 +68,16 @@ module Plutonium
61
68
  end
62
69
  alias_method :phone_tag, :int_tel_input_tag
63
70
 
71
+ # The `as:` values that render through the Uppy file-upload component —
72
+ # the single source of truth, also consulted by
73
+ # {Plutonium::Wizard::Attachments.field?} so a wizard can detect an
74
+ # attachment field model-free without re-listing the aliases.
75
+ FILE_INPUT_TYPES = %i[uppy file attachment].freeze
76
+
64
77
  def uppy_tag(**, &)
65
78
  create_component(Components::Uppy, :uppy, **, &)
66
79
  end
67
- alias_method :file_tag, :uppy_tag
68
- alias_method :attachment_tag, :uppy_tag
80
+ (FILE_INPUT_TYPES - [:uppy]).each { |name| alias_method :"#{name}_tag", :uppy_tag }
69
81
 
70
82
  def key_value_store_tag(**, &)
71
83
  create_component(Components::KeyValueStore, :key_value_store, **, &)
@@ -165,9 +177,11 @@ module Plutonium
165
177
  attributes["data-controller"] = form_data_controller
166
178
  end
167
179
 
168
- # `dirty-form-guard` is attached unconditionally — it self-disables
169
- # outside a <dialog>. Branching on `in_modal?` here would fail:
170
- # Phlex forbids view-context access before rendering begins.
180
+ # `dirty-form-guard` is attached unconditionally — it is inert until it has
181
+ # something to guard: a modal (Esc/close/cancel) or a control marked
182
+ # `data-dirty-form-guard-leave` posting without this form's fields. With
183
+ # neither it never prompts. Branching on `in_modal?` here would fail: Phlex
184
+ # forbids view-context access before rendering begins.
171
185
  def form_data_controller
172
186
  "form dirty-form-guard"
173
187
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Password / secret input that never emits the stored value into the DOM.
8
+ #
9
+ # The generic Phlexi input renders `value=field.dom.value`, leaking the
10
+ # server-side secret (and its length) into the page source. This component
11
+ # instead renders a fixed SENTINEL whenever an *untouched* secret is
12
+ # stored, masking both the secret and its length, and renders an empty
13
+ # field otherwise.
14
+ #
15
+ # On submit the sentinel maps back to `nil`, which Plutonium's param
16
+ # extraction (`submitted_resource_params`) compacts away — leaving the
17
+ # stored value untouched. An empty field passes through as "":
18
+ #
19
+ # untouched field → sentinel submitted → nil → keep existing
20
+ # emptied field → "" submitted → "" → explicit clear (clear-by-blank)
21
+ # typed value → value submitted → value → set new value
22
+ #
23
+ # On a failed re-render of an *edited* secret the field comes back blank
24
+ # (we never echo a submitted secret). When the edit set a new value it is
25
+ # also marked `required`, so the browser forces a re-type rather than a
26
+ # silent blank resubmit clearing the stored secret. When the edit *cleared*
27
+ # the value we leave it blank and not required — the clear may be intended
28
+ # (clear-by-blank). Either guard is client-side UX only.
29
+ #
30
+ # The rendered sentinel is guarded client-side by the `password-sentinel`
31
+ # Stimulus controller: the first edit (keystroke, paste, backspace) wipes
32
+ # the whole field, so a partial edit can't corrupt the sentinel into a
33
+ # literal new password.
34
+ #
35
+ # New records and interaction forms (set-password, reset-password) render
36
+ # an honest empty field that invites input and lets password managers
37
+ # offer to generate a strong password.
38
+ class Password < Phlexi::Form::Components::Input
39
+ # Rendered in place of an existing secret. Masked in the UI; only ever
40
+ # visible (as this constant) in page source — never the real value.
41
+ SENTINEL = "__plutonium_password_unchanged__"
42
+
43
+ protected
44
+
45
+ def build_input_attributes
46
+ super
47
+ attributes[:type] = :password
48
+ value = masked_value
49
+ attributes[:value] = value
50
+ attributes[:autocomplete] ||= "new-password"
51
+ # A stored secret edited (to a new value) on a failed submit comes
52
+ # back blank. Force re-entry so an untouched resubmit can't silently
53
+ # clear it via the clear-by-blank path. Client-side UX guard only.
54
+ attributes[:required] = true if reentry_required?
55
+
56
+ # When the field renders the sentinel, guard it so the first edit
57
+ # wipes the whole value — a partial edit would corrupt the sentinel
58
+ # into a literal new password.
59
+ if value == SENTINEL
60
+ attributes[:data_controller] = tokens(attributes[:data_controller], :"password-sentinel")
61
+ attributes[:data_action] = tokens(attributes[:data_action], "beforeinput->password-sentinel#beforeinput")
62
+ attributes[:data_password_sentinel_sentinel_value] = SENTINEL
63
+ end
64
+
65
+ apply_default_hint(value)
66
+ end
67
+
68
+ # The masked field is otherwise opaque — the user can't tell what the
69
+ # dots mean or what a blank submit does. Supply a default hint
70
+ # explaining it, unless the author already set one (theirs wins). The
71
+ # wrapper renders the hint after this input, so setting it here is in
72
+ # time. No hint for a plain empty field (new record) — it speaks for
73
+ # itself.
74
+ def apply_default_hint(value)
75
+ return if field.has_hint?
76
+ if value == SENTINEL
77
+ field.hint("Leave blank to keep the current value.")
78
+ elsif reentry_required?
79
+ field.hint("Re-enter the new value to save it.")
80
+ end
81
+ end
82
+
83
+ def masked_value
84
+ key = field.key.to_s
85
+ # New records and interaction forms (set-password, reset-password)
86
+ # have nothing stored — render an empty field that invites input.
87
+ return nil unless field.object.persisted?
88
+ # Write-only attributes (e.g. has_secure_password's `password`) are
89
+ # not real columns, so there is nothing stored to keep.
90
+ return nil unless field.object.has_attribute?(key)
91
+ # Nothing stored yet — render blank.
92
+ return nil unless field.object.attribute_in_database(key).present?
93
+ # A secret is stored. On a failed re-render of an edit it is dirty:
94
+ # render blank so the user re-enters it (we never echo a submitted
95
+ # secret). Otherwise mask it (and its length) behind the sentinel,
96
+ # which submits back as "leave unchanged".
97
+ field.object.attribute_changed?(key) ? nil : SENTINEL
98
+ end
99
+
100
+ # True only when a stored secret was edited *to a new value* and
101
+ # therefore renders blank — the state where an untouched resubmit would
102
+ # silently clear a secret the user meant to change. We deliberately do
103
+ # NOT force re-entry when the edit blanked the value: that user may
104
+ # have intended to clear it (clear-by-blank), so let the blank stand.
105
+ def reentry_required?
106
+ key = field.key.to_s
107
+ field.object.persisted? &&
108
+ field.object.has_attribute?(key) &&
109
+ field.object.attribute_in_database(key).present? &&
110
+ field.object.attribute_changed?(key) &&
111
+ field.object.read_attribute(key).present?
112
+ end
113
+
114
+ def normalize_input(input_value)
115
+ # The sentinel means "leave unchanged" → nil, which
116
+ # `submitted_resource_params` compacts away so the stored secret is
117
+ # kept. An empty field passes through as "" → an explicit clear
118
+ # (clear-by-blank); a typed value is set as-is.
119
+ return nil if input_value == SENTINEL
120
+ super
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -13,13 +13,16 @@ module Plutonium
13
13
  # Hidden field for ensuring removal of esp. has_one_attached attachments
14
14
  input(type: :hidden, name: attributes[:name], multiple: attributes[:multiple], value: nil, autocomplete: "off", hidden: true)
15
15
 
16
- next if field.value.nil?
17
-
16
+ # Always render the preview container — even when empty — so the
17
+ # `attachment-input` controller's `attachment-preview-container`
18
+ # OUTLET exists on a fresh (valueless) field. Without it the FIRST
19
+ # upload can't inject its preview: Stimulus raises on the missing
20
+ # outlet and the uploaded token never reaches the form.
18
21
  div(
19
22
  class: "attachment-preview-container grid grid-cols-[repeat(auto-fill,minmax(0,180px))] gap-4",
20
23
  data_controller: "attachment-preview-container"
21
24
  ) do
22
- render_existing_attachments
25
+ render_existing_attachments unless field.value.nil?
23
26
  end
24
27
  end
25
28
 
@@ -8,6 +8,14 @@ module Plutonium
8
8
  private
9
9
 
10
10
  def infer_field_component
11
+ # Password detection lives in the string-type inference, not the
12
+ # component-type inference (a `password` column infers as :string).
13
+ # Route every inferred password/secret field to the masking Password
14
+ # component so the stored value never reaches the DOM. We also widen
15
+ # the heuristic to secret-bearing names Phlexi misses (`*_secret`,
16
+ # `*_key`, `salt`, ...) — see #secret_field_name?.
17
+ return :password if inferred_string_field_type == :password || secret_field_name?
18
+
11
19
  case inferred_field_type
12
20
  when :rich_text
13
21
  return :markdown
@@ -25,6 +33,18 @@ module Plutonium
25
33
  inferred_field_component
26
34
  end
27
35
  end
36
+
37
+ # Secret-bearing names Phlexi's `is_password_field?` does not catch
38
+ # (it only handles `password`, `encrypted_*`, `*_password`, `*_digest`,
39
+ # `*_hash`, `*_token`). Mask these too so their value never reaches the
40
+ # DOM. Still a name heuristic, not a guarantee — opt in/out per field
41
+ # with `as: :password` / `as: :string`.
42
+ def secret_field_name?
43
+ name = key.to_s.downcase
44
+ name == "token" || name == "salt" ||
45
+ name.include?("secret") ||
46
+ name.end_with?("_key", "_salt")
47
+ end
28
48
  end
29
49
  end
30
50
  end
@@ -66,7 +66,7 @@ module Plutonium
66
66
  "w-full max-w-md p-0 " \
67
67
  "open:flex flex-col " \
68
68
  "opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
69
- "transition-[opacity,transform] duration-200 ease-out",
69
+ "transition-[opacity,scale] duration-200 ease-out",
70
70
  data: {"dirty-form-guard-target": "confirmDialog"},
71
71
  # Modern Chrome refuses user-agent close requests (Esc, backdrop);
72
72
  # older browsers fall back to the JS controller's interception.