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
@@ -0,0 +1,639 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Surface-agnostic runner-driving logic shared by every wizard launch surface
6
+ # (§6). It is mixed into:
7
+ #
8
+ # - {Plutonium::Wizard::Controller} — the standalone portal-level
9
+ # `register_wizard` controller (non-anchored), and
10
+ # - {Plutonium::Resource::Controllers::WizardActions} — the resource-mounted
11
+ # member/collection actions (anchored anchor comes from the scoped, policy-
12
+ # gated `resource_record!`, exactly like interactive record actions).
13
+ #
14
+ # Both surfaces share the per-request flow: resolve the instance (URL token /
15
+ # anchor + `current_user` owner + portal `scoped_entity` scope) → build the
16
+ # {Runner} → check entry authorization → GET renders the current step / POST
17
+ # dispatches on `params[:_direction]` (`back` / `cancel` / advance+finalize).
18
+ #
19
+ # Surfaces differ only in a small set of hooks (the wizard class, the anchor,
20
+ # the per-step URL, completion/exit targets, and authorization), which the
21
+ # including controller overrides.
22
+ module Driving
23
+ extend ActiveSupport::Concern
24
+ include Plutonium::StructuredInputs::ParamsConcern
25
+
26
+ # The Rails-session bucket holding per-wizard guest run ids (§4.5). A guest
27
+ # (`anonymous`) run's token lives under `session[SESSION_TOKENS_KEY][wizard_key]`.
28
+ SESSION_TOKENS_KEY = "plutonium_wizards"
29
+
30
+ # The Rails-session bucket holding the per-wizard "return to" path captured at
31
+ # launch — the page the user came from. Cancel redirects there instead of the
32
+ # host root. Namespaced per wizard so two in-flight wizards don't clobber each
33
+ # other.
34
+ WIZARD_RETURN_TO_KEY = "plutonium_wizard_return_to"
35
+
36
+ # The per-wizard key under {SESSION_TOKENS_KEY} for a guest run's token.
37
+ def self.session_token_key(wizard_class)
38
+ wizard_class.name.underscore.tr("/", "_")
39
+ end
40
+
41
+ private
42
+
43
+ # GET the bare mount (no :step) — the canonical launch. Resolve the run (mint
44
+ # the per-run token for a tokened wizard, or resolve the keyed/guest identity),
45
+ # then PRG to its entry step — the resumed cursor for an in-progress keyed/guest
46
+ # run, else the first visible step. The redirect URL carries the token, so the
47
+ # address bar shows a stable, shareable run URL from the first paint (no more
48
+ # "token appears only after the first submit", and no fork-on-reload).
49
+ def wizard_launch
50
+ require_wizard_authentication!
51
+ stash_wizard_return_to!
52
+
53
+ # `on_relaunch :prompt` (§4.5): when the user already has pending runs of
54
+ # this (tokened) wizard, show a "resume or start new" chooser instead of
55
+ # forking a fresh run. Decided BEFORE `build_wizard_runner`, which would
56
+ # otherwise mint a token.
57
+ if wizard_relaunch_prompt?
58
+ return render_wizard_chooser
59
+ end
60
+
61
+ runner = build_wizard_runner
62
+ deny_wizard_resume_for_other_user!(runner)
63
+ authorize_wizard_entry!(runner)
64
+
65
+ if runner.completed_one_time?
66
+ return render_wizard_completed(runner)
67
+ end
68
+
69
+ redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
70
+ end
71
+
72
+ # Whether the bare launch should divert to the resume-or-new chooser: the
73
+ # wizard opted in (`on_relaunch :prompt`), it's an authenticated TOKENED run
74
+ # (keyed wizards already auto-resume; `anonymous` runs are session-keyed),
75
+ # the request isn't the explicit "start new" path, and a pending run exists.
76
+ def wizard_relaunch_prompt?
77
+ klass = current_wizard_class
78
+ return false unless klass.relaunch_prompt?
79
+ return false if klass.anonymous? || klass.concurrency_key?
80
+ return false if params[:new].present?
81
+
82
+ wizard_pending_entries.any?
83
+ end
84
+
85
+ # This wizard's in-progress runs for the current owner (tenant-scoped),
86
+ # enriched with resume URLs — via the shared {Resume} listing module. The
87
+ # `wizard:` filter narrows in the query, so only THIS wizard's rows are
88
+ # enriched (not every pending run of every wizard, then discarded).
89
+ def wizard_pending_entries
90
+ @wizard_pending_entries ||=
91
+ Plutonium::Wizard::Resume.entries_for(view_context, wizard: current_wizard_class)
92
+ end
93
+
94
+ # GET .../:step — render the current step (or bounce on a completeness gap).
95
+ def wizard_show
96
+ require_wizard_authentication!
97
+ runner = build_wizard_runner
98
+ deny_wizard_resume_for_other_user!(runner)
99
+ authorize_wizard_entry!(runner)
100
+
101
+ # Re-entering a finished one-time wizard (§4.3/§9): its key holds a
102
+ # retained `completed` row whose `data` was cleared on completion, so there
103
+ # is nothing to review — render the standalone "already completed" page.
104
+ if runner.completed_one_time?
105
+ return render_wizard_completed(runner)
106
+ end
107
+
108
+ if (target = wizard_redirect_step)
109
+ return redirect_to wizard_step_url(target), status: :see_other, allow_other_host: false
110
+ end
111
+
112
+ # Honor a direct GET to a specific (visited/visible) step — stepper jumps
113
+ # and resume-by-URL. Forward jumps to unvisited steps are ignored.
114
+ runner.go_to(params[:step])
115
+
116
+ @wizard_runner = runner
117
+ render_wizard_step(runner)
118
+ end
119
+
120
+ # POST .../:step — advance / back / cancel.
121
+ def wizard_update
122
+ require_wizard_authentication!
123
+ runner = build_wizard_runner
124
+ deny_wizard_resume_for_other_user!(runner)
125
+ authorize_wizard_entry!(runner)
126
+ @wizard_runner = runner
127
+
128
+ # A POST to a finished one-time wizard (stale form / double submit): nothing
129
+ # to run. PRG to the step URL so the follow-up GET renders the completed page.
130
+ if runner.completed_one_time?
131
+ return redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
132
+ end
133
+
134
+ # Align the in-memory cursor to the step being POSTed. A user can navigate
135
+ # BACK to an earlier step via a GET (stepper jump / direct URL), which by
136
+ # design does NOT persist the cursor — so the stored cursor still points at
137
+ # a LATER step. Without realigning, `wizard_params`/`wizard_step_form` would
138
+ # extract this submission through the wrong step's form: the edited fields
139
+ # are silently dropped and the stored-cursor step's fields leak in. The step
140
+ # carried in the URL is the one being submitted, so make it current.
141
+ #
142
+ # `go_to` returns false when that step is NOT reachable for this run — a
143
+ # branch-hidden step, or a forward jump to an unvisited step. A forged or
144
+ # stale POST to such a step must be REFUSED here: otherwise `advance` would
145
+ # look the step up across the whole declaration list and validate/stage/run
146
+ # its `on_submit` for a step the user can't see (the branch-prune only
147
+ # compensates `persist`ed records, never raw side effects). PRG back to the
148
+ # run's actual current step instead of processing the submission.
149
+ unless runner.go_to(params[:step])
150
+ return redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
151
+ end
152
+
153
+ if params[:pre_submit]
154
+ return render_wizard_pre_submit(runner)
155
+ end
156
+
157
+ result =
158
+ case params[:_direction].to_s
159
+ when "back"
160
+ runner.back
161
+ when "cancel"
162
+ runner.cancel
163
+ target = wizard_exit_url
164
+ clear_wizard_return_to
165
+ return redirect_to target, status: :see_other, allow_other_host: false
166
+ else
167
+ advance_or_finalize(runner)
168
+ end
169
+
170
+ respond_to_wizard_result(runner, result)
171
+ end
172
+
173
+ # Advance the current step; if the POSTed step is the last visible step,
174
+ # finalize. The last visible step is the terminal `review` (no fields), so
175
+ # finalize runs directly; otherwise validate + stage + move the cursor.
176
+ def advance_or_finalize(runner)
177
+ return runner.finalize if wizard_posting_last_step?(runner)
178
+
179
+ runner.advance(params[:step], wizard_params(runner), goto: params[:_goto].presence)
180
+ end
181
+
182
+ # Whether the step being POSTed is the last visible step (so Next → Finish).
183
+ # Computed BEFORE advancing, since advance moves the cursor past it.
184
+ def wizard_posting_last_step?(runner)
185
+ last = runner.visible_path.last
186
+ last && last.key.to_s == params[:step].to_s
187
+ end
188
+
189
+ def respond_to_wizard_result(runner, result)
190
+ if result.completed?
191
+ return complete_wizard!(result)
192
+ end
193
+
194
+ if (target = result.redirect_step)
195
+ return redirect_to wizard_step_url(target), status: :see_other, allow_other_host: false
196
+ end
197
+
198
+ if result.ok?
199
+ redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
200
+ else
201
+ @wizard_errors = result.errors
202
+ render_wizard_step(runner, status: :unprocessable_content)
203
+ end
204
+ end
205
+
206
+ # PRG out of a completed wizard: clear the guest run's session token and
207
+ # redirect. A gate (§9 {Plutonium::Wizard::Gate}) may have stashed the user's
208
+ # intended destination in `session[:return_to]` before bouncing them into a
209
+ # one-time wizard; prefer that bounce target over the outcome value's URL.
210
+ def complete_wizard!(result)
211
+ clear_wizard_session_token
212
+ # Completion lands on the RESULT (the created/updated resource) by default,
213
+ # not the launch origin — so drop the captured return-to. A gate's stashed
214
+ # `:return_to` (the page the user was bounced FROM into a one-time wizard)
215
+ # still wins, so they resume where they were headed.
216
+ clear_wizard_return_to
217
+ target = session.delete(:return_to).presence || wizard_completion_url(result.value)
218
+ redirect_to target, status: :see_other, allow_other_host: false
219
+ end
220
+
221
+ # Drop a guest run's token from the Rails session (on completion). A no-op
222
+ # for authenticated runs, whose token rides the URL, not the session.
223
+ def clear_wizard_session_token
224
+ bucket = session[Plutonium::Wizard::Driving::SESSION_TOKENS_KEY]
225
+ return unless bucket.is_a?(Hash)
226
+
227
+ bucket.delete(Plutonium::Wizard::Driving.session_token_key(current_wizard_class))
228
+ session.delete(Plutonium::Wizard::Driving::SESSION_TOKENS_KEY) if bucket.empty?
229
+ end
230
+
231
+ # --- rendering ---
232
+
233
+ # The "resume or start new" chooser (§4.5), rendered at the bare launch URL
234
+ # when `on_relaunch :prompt` and pending runs exist. "Start new" re-enters
235
+ # this launch with `?new=1`, which skips the chooser and mints a fresh run.
236
+ def render_wizard_chooser
237
+ render(
238
+ Plutonium::UI::Page::WizardChooser.new(
239
+ wizard_class: current_wizard_class,
240
+ entries: wizard_pending_entries,
241
+ start_new_url: "#{request.path}?new=1"
242
+ ),
243
+ **wizard_modal_render_options
244
+ )
245
+ end
246
+
247
+ # The standalone "already completed" page for a re-opened one-time wizard
248
+ # (§9). Its `data` was cleared on completion, so this never shows the review —
249
+ # just a confirmation (or the wizard's `completed` block).
250
+ def render_wizard_completed(runner)
251
+ render(
252
+ Plutonium::UI::Page::WizardCompleted.new(
253
+ runner:,
254
+ exit_url: wizard_exit_url
255
+ ),
256
+ **wizard_modal_render_options
257
+ )
258
+ end
259
+
260
+ def render_wizard_step(runner, status: :ok)
261
+ render(
262
+ Plutonium::UI::Page::Wizard.new(
263
+ runner:,
264
+ step_url: wizard_step_url(runner.current_step&.key),
265
+ errors: @wizard_errors,
266
+ description: wizard_page_description
267
+ ),
268
+ status:,
269
+ **wizard_modal_render_options
270
+ )
271
+ end
272
+
273
+ # A `change->form#preSubmit` re-render: in a turbo frame (modal), replace just
274
+ # the step form; otherwise re-render the whole page. Mirrors interactive
275
+ # actions, where conditional inputs depend on sibling values.
276
+ def render_wizard_pre_submit(runner)
277
+ form = wizard_step_form(runner)
278
+ respond_to do |format|
279
+ format.turbo_stream do
280
+ render turbo_stream: turbo_stream.replace(
281
+ helpers.turbo_scoped_dom_id("wizard-form"),
282
+ view_context.render(form)
283
+ )
284
+ end
285
+ format.html { render_wizard_step(runner, status: :unprocessable_content) }
286
+ end
287
+ end
288
+
289
+ def wizard_page_description = nil
290
+
291
+ # The render options (chiefly the layout) for a wizard page:
292
+ # - turbo-frame request → no layout (the embedded/modal case);
293
+ # - a resolved layout NAME → render in it (e.g. `basic` for an onboarding
294
+ # screen);
295
+ # - nil → inherit the controller's layout (the resource shell), so a custom
296
+ # controller layout still wins.
297
+ def wizard_modal_render_options
298
+ return {layout: false} if helpers.current_turbo_frame.present?
299
+
300
+ layout = wizard_layout
301
+ layout ? {layout:} : {}
302
+ end
303
+
304
+ # The Rails layout this run renders in — a layout NAME, exactly like the
305
+ # controller `layout` macro: `basic` (the bare BasicLayout, e.g. onboarding),
306
+ # `resource` (the standard shell), or any app layout. An explicit `layout:`
307
+ # from `register_wizard` rides as a route DEFAULT — one synthesized controller
308
+ # serves many mounts, so the per-mount value travels on the route, not the
309
+ # controller. Absent, it defaults by host: main-app → `"basic"` (no shell to
310
+ # embed in), portal → nil (inherit the controller's resource shell). Resource-
311
+ # defined wizards carry no `layout:` and render embedded (turbo frame → no
312
+ # layout, above).
313
+ def wizard_layout
314
+ explicit = params[:wizard_layout]
315
+ return explicit if explicit.present?
316
+
317
+ (current_engine == Rails.application.class) ? "basic" : nil
318
+ end
319
+
320
+ # --- runner construction ---
321
+
322
+ def build_wizard_runner
323
+ Plutonium::Wizard::Runner.new(
324
+ wizard_class: current_wizard_class,
325
+ store: wizard_store,
326
+ instance_key: resolved_wizard_instance_key,
327
+ view_context:,
328
+ owner: resolved_wizard_owner,
329
+ anchor: resolved_wizard_anchor,
330
+ scope: resolved_wizard_scope,
331
+ token: wizard_token,
332
+ # The portal this run is launched in — recorded so the resume listing
333
+ # only ever surfaces it from THIS portal (§4.5).
334
+ engine: current_engine.name,
335
+ current_user: resolved_wizard_owner,
336
+ current_scoped_entity: resolved_wizard_scope
337
+ )
338
+ end
339
+
340
+ # The authenticated user driving this run, or nil for a guest (`anonymous`)
341
+ # run (§4.5). The public surface stubs `current_user` to the "Guest"
342
+ # sentinel; a guest run has NO owner — its identity is the unguessable
343
+ # `wizard_token`, not a principal. Normalizing to nil keeps the runner's
344
+ # owner-scoping and the wizard's `current_user` honest.
345
+ def resolved_wizard_owner
346
+ return nil if current_wizard_class.anonymous?
347
+
348
+ current_user_present_for_wizard? ? current_user : nil
349
+ end
350
+
351
+ def wizard_store
352
+ Plutonium::Wizard::Store::ActiveRecord.new
353
+ end
354
+
355
+ # The instance_key for this run (§4.1). A wizard with a `concurrency_key`
356
+ # gets a stable digest over its resolved key value(s) (tenant folded in);
357
+ # otherwise the digest is over the per-launch `wizard_token` (fresh per
358
+ # launch → repeatable). This MUST stay byte-identical to the gate's
359
+ # recomputation (§9), so both go through {InstanceKey}.
360
+ def resolved_wizard_instance_key
361
+ Plutonium::Wizard.compute_instance_key(
362
+ wizard_class: current_wizard_class,
363
+ current_user: resolved_wizard_owner,
364
+ current_scoped_entity: resolved_wizard_scope,
365
+ anchor: resolved_wizard_anchor,
366
+ wizard_token: wizard_token
367
+ )
368
+ end
369
+
370
+ # The portal scoping entity (tenant) when the portal is entity-scoped (§4.4 /
371
+ # §8 multi-tenancy); nil otherwise. Folded into the key automatically.
372
+ def resolved_wizard_scope
373
+ return unless scoped_to_entity?
374
+
375
+ current_scoped_entity
376
+ end
377
+
378
+ # The per-run id (§4.5). It is the identity principal for runs without a
379
+ # `concurrency_key` — guest (`anonymous`) runs AND authenticated repeatable
380
+ # runs — and is folded into `concurrency_key` resolution. It is NOT a
381
+ # pre-auth principal that survives login: authenticated runs are guarded by
382
+ # owner-scoping, and the wizard never crosses the auth boundary mid-flow.
383
+ #
384
+ # Two sources, by run identity:
385
+ #
386
+ # - **Guest (`anonymous`) runs** key off the **Rails session**, namespaced
387
+ # per wizard (`session["plutonium_wizards"][<wizard_key>]`), minted with
388
+ # `SecureRandom.alphanumeric(32)` and stored if absent, read each request. We never
389
+ # read the token from the URL for a guest run — the session is the only
390
+ # source, so there is no URL-leak surface. There is no TTL: the row's
391
+ # `cleanup_after` → sweep is the authoritative lifetime; the session token
392
+ # is just a pointer (browser-close ephemeral, auto-cleared by Rodauth's
393
+ # `reset_session` on login/logout, and cleared on completion).
394
+ # - **Authenticated repeatable runs** carry their per-run id in the URL
395
+ # `:token` segment (owner-scoped on the row), minting one when absent so a
396
+ # fresh launch is a fresh run.
397
+ def wizard_token
398
+ return @wizard_token if defined?(@wizard_token)
399
+
400
+ @wizard_token =
401
+ if current_wizard_class.anonymous?
402
+ guest_session_token
403
+ else
404
+ params[:token].presence || SecureRandom.alphanumeric(32)
405
+ end
406
+ end
407
+
408
+ # Read (minting + storing if absent) the guest run's token from the Rails
409
+ # session bucket. Session storage gives browser-close ephemerality and
410
+ # auto-clear on login/logout (Rodauth's `clear_session` → `reset_session`).
411
+ def guest_session_token
412
+ bucket = (session[Plutonium::Wizard::Driving::SESSION_TOKENS_KEY] ||= {})
413
+ key = Plutonium::Wizard::Driving.session_token_key(current_wizard_class)
414
+ bucket[key] ||= SecureRandom.alphanumeric(32)
415
+ end
416
+
417
+ # The token to thread into a step URL, if any. An authenticated REPEATABLE
418
+ # run (no `concurrency_key` → tokened identity) carries its per-run id in the
419
+ # URL `:token` segment, so a fresh GET resumes rather than forks. A guest
420
+ # (`anonymous`) run keys off the Rails session, so its token MUST NOT appear
421
+ # in the URL (no leak surface); a keyed run's identity is its
422
+ # `concurrency_key`, so the token is irrelevant there. `nil` keeps it off.
423
+ def wizard_url_token
424
+ return nil if current_wizard_class.anonymous?
425
+ return nil if current_wizard_class.concurrency_key?
426
+
427
+ wizard_token
428
+ end
429
+
430
+ # --- authorization ---
431
+
432
+ # Authentication gate (§4.5). Wizards REQUIRE authentication by default —
433
+ # entry without a `current_user` is rejected. An `anonymous` wizard opts out
434
+ # (guest access; it may authenticate only at its terminal `execute`).
435
+ #
436
+ # When a `current_user` is missing for a non-`anonymous` wizard we reject the
437
+ # way the host already handles unauthenticated access: Rodauth's
438
+ # `require_authentication` (redirect to login) when the portal exposes it,
439
+ # else a plain 401. We deliberately do NOT lean on the portal's own auth
440
+ # before_action because the public mount (for `anonymous` wizards) runs
441
+ # OUTSIDE the portal's authenticated route constraint.
442
+ def require_wizard_authentication!
443
+ return if current_wizard_class.anonymous?
444
+ return if current_user_present_for_wizard?
445
+
446
+ if respond_to?(:rodauth, true)
447
+ rodauth.require_authentication
448
+ else
449
+ head :unauthorized
450
+ end
451
+ end
452
+
453
+ # `current_user` is truthy AND not the {Plutonium::Auth::Public} "Guest"
454
+ # sentinel (a public controller stubs `current_user` to the string "Guest").
455
+ def current_user_present_for_wizard?
456
+ user = current_user
457
+ user.present? && user != "Guest"
458
+ end
459
+
460
+ # Owner-scoped resume (§4.5): a non-`anonymous` wizard's row may only be
461
+ # resumed by its owner. The runner flags a mismatched row as forbidden; we
462
+ # 404 (rather than fork a fresh run) so a run id leaked in a URL can't be
463
+ # picked up by — or even probed by — another logged-in user.
464
+ def deny_wizard_resume_for_other_user!(runner)
465
+ return unless runner.forbidden?
466
+
467
+ raise ActiveRecord::RecordNotFound, "wizard run not found"
468
+ end
469
+
470
+ # Entry auth (§5.2 / §6.5). A wizard may define `authorize?` (default allow);
471
+ # false → 403 via the existing ActionPolicy::Unauthorized rescue. Resource-
472
+ # attached surfaces additionally gate via the action policy (see
473
+ # {WizardActions}); this base check covers the wizard-level hook common to
474
+ # both surfaces.
475
+ def authorize_wizard_entry!(runner)
476
+ wizard = runner.wizard
477
+ return if wizard.authorize?
478
+
479
+ raise ActionPolicy::Unauthorized.new(wizard.class, :authorize?)
480
+ end
481
+
482
+ # --- params ---
483
+
484
+ # Extract the current step's submitted params through the step form (like
485
+ # interactions), so typed inputs and structured/repeater inputs are parsed
486
+ # consistently with how they were rendered — then clean structured inputs
487
+ # (drop blank/template rows) and stringify keys for the data snapshot.
488
+ def wizard_params(runner)
489
+ return {} if params[:wizard].blank?
490
+
491
+ step = runner.current_step
492
+ form = wizard_step_form(runner)
493
+ extracted = form.extract_input(params, view_context:)[:wizard] || {}
494
+ cleaned = clean_structured_inputs(Plutonium::Wizard::StepAdapter.new(step), extracted.dup)
495
+ stage_wizard_uploads!(step, cleaned)
496
+ cleaned.stringify_keys
497
+ end
498
+
499
+ # Replace each attachment field's value with a staged TOKEN, minting one from
500
+ # an uploaded file for a plain (non-direct-upload) field. Reads the RAW param
501
+ # so a multipart `UploadedFile` isn't mangled by form extraction, then
502
+ # overrides the extracted value. A nil result (blank / no new file) drops the
503
+ # key, so the token already in `data` survives a Back/re-submit (`stage`
504
+ # merges, it doesn't replace). Direct-upload fields arrive as a token string
505
+ # and pass through unchanged.
506
+ def stage_wizard_uploads!(step, cleaned)
507
+ raw = params[:wizard]
508
+ step.inputs.each do |name, config|
509
+ next unless Plutonium::Wizard::Attachments.field?(config)
510
+
511
+ token = Plutonium::Wizard::Attachments.stage_upload(
512
+ raw[name],
513
+ backend: config.dig(:options, :backend),
514
+ uploader: config.dig(:options, :uploader)
515
+ )
516
+ if token.nil?
517
+ cleaned.delete(name)
518
+ cleaned.delete(name.to_s)
519
+ else
520
+ cleaned[name] = token
521
+ end
522
+ end
523
+ end
524
+
525
+ # The form for the current step, seeded from the wizard's typed data — used
526
+ # for both param extraction and the pre_submit turbo re-render.
527
+ def wizard_step_form(runner)
528
+ step = runner.current_step
529
+ Plutonium::UI::Form::Wizard.new(
530
+ step:,
531
+ data: Plutonium::Wizard::AttachmentData.wrap(runner.wizard.data[step.key], step),
532
+ action: wizard_step_url(step&.key),
533
+ fields: step.attribute_schema.keys.map(&:to_sym) + step.structured_inputs.keys.map(&:to_sym)
534
+ )
535
+ end
536
+
537
+ def wizard_redirect_step = nil
538
+
539
+ # Where a completed wizard redirects (§6). Prefer the outcome value's resource
540
+ # URL; fall back to the portal root.
541
+ def wizard_completion_url(value)
542
+ if value.is_a?(ActiveRecord::Base)
543
+ resource_url_for(value)
544
+ else
545
+ main_or_portal_root_url
546
+ end
547
+ rescue
548
+ main_or_portal_root_url
549
+ end
550
+
551
+ # Where Cancel redirects out to: the page the user launched from (captured at
552
+ # launch), falling back to the host root.
553
+ def wizard_exit_url
554
+ peek_wizard_return_to || main_or_portal_root_url
555
+ end
556
+
557
+ # Capture the launch origin so Cancel can return there. Called only at the bare
558
+ # launch (the step pages' referer is the wizard itself). Prefers an explicit
559
+ # `?return_to=` over the referer; both are sanitized to a same-host local path
560
+ # that isn't the wizard's own mount, so there's no open-redirect surface.
561
+ def stash_wizard_return_to!
562
+ candidate = wizard_return_to_candidate
563
+ return if candidate.blank?
564
+
565
+ bucket = (session[WIZARD_RETURN_TO_KEY] ||= {})
566
+ bucket[Plutonium::Wizard::Driving.session_token_key(current_wizard_class)] = candidate
567
+ end
568
+
569
+ def wizard_return_to_candidate
570
+ [params[:return_to].to_s, request.referer].each do |raw|
571
+ path = local_wizard_return_path(raw)
572
+ return path if path
573
+ end
574
+ nil
575
+ end
576
+
577
+ # Sanitize a candidate return-to into a same-host absolute path (with query),
578
+ # or nil. Rejects other hosts (open-redirect), protocol-relative `//host`
579
+ # paths, and the wizard's own pages (so Cancel never bounces back into the
580
+ # flow it's leaving).
581
+ def local_wizard_return_path(raw)
582
+ return nil if raw.blank?
583
+
584
+ uri = begin
585
+ URI.parse(raw)
586
+ rescue URI::InvalidURIError
587
+ nil
588
+ end
589
+ return nil if uri.nil?
590
+ return nil unless uri.host.nil? || uri.host == request.host
591
+
592
+ path = uri.path.presence
593
+ return nil if path.nil? || !path.start_with?("/") || path.start_with?("//")
594
+ return nil if path.start_with?(request.path)
595
+
596
+ uri.query.present? ? "#{path}?#{uri.query}" : path
597
+ end
598
+
599
+ def peek_wizard_return_to
600
+ bucket = session[WIZARD_RETURN_TO_KEY]
601
+ return nil unless bucket.is_a?(Hash)
602
+
603
+ bucket[Plutonium::Wizard::Driving.session_token_key(current_wizard_class)].presence
604
+ end
605
+
606
+ def clear_wizard_return_to
607
+ bucket = session[WIZARD_RETURN_TO_KEY]
608
+ return unless bucket.is_a?(Hash)
609
+
610
+ bucket.delete(Plutonium::Wizard::Driving.session_token_key(current_wizard_class))
611
+ session.delete(WIZARD_RETURN_TO_KEY) if bucket.empty?
612
+ end
613
+
614
+ def main_or_portal_root_url
615
+ current_engine.routes.url_helpers.root_path
616
+ rescue
617
+ "/"
618
+ end
619
+
620
+ # --- surface hooks (overridden per surface) ---
621
+
622
+ # @return [Class] the wizard class for this request.
623
+ def current_wizard_class
624
+ raise NotImplementedError
625
+ end
626
+
627
+ # @return [ActiveRecord::Base, nil] the anchor record (scoped/authorized for
628
+ # resource-mounted member actions; nil for non-anchored surfaces).
629
+ def resolved_wizard_anchor
630
+ nil
631
+ end
632
+
633
+ # @return [String] the GET URL for a given step of this wizard.
634
+ def wizard_step_url(step_key)
635
+ raise NotImplementedError
636
+ end
637
+ end
638
+ end
639
+ end