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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Plutonium
6
+ module Wizard
7
+ # Author-facing base class for wizards (§2). A wizard declares ordered `step`s
8
+ # (with their own field surface, branching `condition:`, and optional per-step
9
+ # `on_submit`/`on_rollback`), an optional terminal `review` step, and commits
10
+ # at the end via `execute`.
11
+ #
12
+ # This class is pure object behaviour — declaring the DSL, exposing the
13
+ # ordered steps, the union `data` snapshot, and the `anchor`/`fail!`
14
+ # accessors. HTTP/runner/store wiring lives elsewhere.
15
+ #
16
+ # @example
17
+ # class CompanyOnboardingWizard < Plutonium::Wizard::Base
18
+ # step :company do
19
+ # attribute :name, :string
20
+ # input :name
21
+ # validates :name, presence: true
22
+ # end
23
+ # review label: "Review"
24
+ #
25
+ # def execute
26
+ # succeed(Company.create!(name: data.name))
27
+ # end
28
+ # end
29
+ class Base
30
+ include ActiveModel::Model
31
+ include Plutonium::Definition::Presentable
32
+ include DSL
33
+
34
+ attr_reader :data_attributes
35
+ attr_accessor :view_context
36
+ attr_writer :anchor, :scope, :token
37
+
38
+ # Identity/concurrency context (§4.5), supplied by the runner/driving layer
39
+ # so `concurrency_key` resolvers and the tenancy fold can reach them.
40
+ # `wizard_token` is the per-run id (the identity for guest/repeatable runs,
41
+ # available inside `concurrency_key`) — NOT a pre-auth principal that
42
+ # survives login.
43
+ attr_accessor :current_user, :current_scoped_entity, :wizard_token
44
+
45
+ # The runner reuses a single wizard instance across a request, reassigning
46
+ # `data_attributes` between reads, so invalidate the memoized `data` snapshot
47
+ # whenever the staged attributes change. Only rebuild when the value actually
48
+ # CHANGES: the runner calls this on every `visible_path` (via `sync_data`),
49
+ # and an unconditional reset would discard a `data` snapshot the view layer
50
+ # has since mutated — e.g. validation errors `seed_errors!` added before the
51
+ # form reads them back (they'd silently vanish).
52
+ def data_attributes=(attrs)
53
+ return if @data && attrs == @data_attributes
54
+ @data_attributes = attrs
55
+ @data = nil
56
+ end
57
+
58
+ def initialize(view_context: nil, **)
59
+ @view_context = view_context
60
+ @data_attributes = {}
61
+ super()
62
+ end
63
+
64
+ class << self
65
+ # Per-step data spec ({step_key => {schema:, options:, structured:}}), used
66
+ # to build the step-keyed `data` container (§2.6). Each step contributes its
67
+ # OWN schema/options/structured — no cross-step union, so two steps may share
68
+ # a field name without colliding. `using:` imports are already composed into
69
+ # each step's `attribute_schema`.
70
+ def data_steps_spec
71
+ steps.reject(&:review?).each_with_object({}) do |step, acc|
72
+ acc[step.key.to_sym] = {
73
+ schema: step.attribute_schema,
74
+ options: step.attribute_options,
75
+ structured: step_structured_schema(step),
76
+ validations: step.validations + step.imported_form_validators
77
+ }
78
+ end
79
+ end
80
+
81
+ # The per-step typed sub-object classes ({step_key => Class}), built once
82
+ # per wizard class from {data_steps_spec} and reused for every `data`
83
+ # snapshot (the container is cheap to instantiate; the classes aren't).
84
+ def data_step_classes
85
+ @data_step_classes ||= data_steps_spec.transform_values do |spec|
86
+ Data.class_for(spec[:schema], options: spec[:options], structured: spec[:structured], validations: spec[:validations])
87
+ end
88
+ end
89
+
90
+ # Per-step typed classes carrying ONLY the step's INLINE `validates` (no
91
+ # imported form validators — those are validated separately via the
92
+ # transient model, so folding them in here would double-report). Built once
93
+ # per wizard class and reused by the runner's inline validation, which
94
+ # otherwise recompiles an equivalent anonymous class on every `validate`
95
+ # call (and `validate` runs many times per render). `{step_key => Class}`,
96
+ # omitting steps with no inline validations.
97
+ def inline_validation_classes
98
+ @inline_validation_classes ||= steps.reject(&:review?).each_with_object({}) do |step, acc|
99
+ next if step.validations.blank?
100
+ acc[step.key.to_sym] = Data.class_for(step.attribute_schema, validations: step.validations)
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ # One step's structured collections, as {name => [sub-field names]}, so
107
+ # `data.<step>.<name>` exposes typed sub-objects (§2.6 / §7.2).
108
+ def step_structured_schema(step)
109
+ step.structured_inputs.each_with_object({}) do |(name, entry), acc|
110
+ acc[name.to_sym] = structured_sub_fields(entry)
111
+ end
112
+ end
113
+
114
+ # Resolve the declared sub-field names of a structured_input entry by
115
+ # evaluating its block (or `using:` holder) against a FieldsDefinition.
116
+ def structured_sub_fields(entry)
117
+ options = entry[:options] || {}
118
+ return Array(options[:fields]).map(&:to_sym) if options[:fields]
119
+
120
+ holder =
121
+ if options[:using]
122
+ options[:using].is_a?(Class) ? options[:using].new : options[:using]
123
+ else
124
+ h = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
125
+ entry[:block]&.call(h)
126
+ h
127
+ end
128
+ holder.defined_inputs.keys.map(&:to_sym)
129
+ end
130
+ end
131
+
132
+ # The step-keyed `data` snapshot (§2.6), reconstituted from the nested staged
133
+ # `data_attributes` ({step_key => {field => value}}). Addressed as
134
+ # `data.<step>.<field>`; `data[:step]` for dynamic access.
135
+ def data
136
+ @data ||= Data::Container.new(self.class.data_step_classes, data_attributes)
137
+ end
138
+
139
+ # The record this wizard was launched against (§3). Raises when the wizard
140
+ # was not declared `anchored` — never returns nil.
141
+ def anchor
142
+ unless self.class.anchored?
143
+ raise NotAnchoredError, "#{self.class} is not declared `anchored`"
144
+ end
145
+ @anchor
146
+ end
147
+
148
+ # Resolve this wizard's concurrency_key VALUE(S) in the wizard context
149
+ # (§4.2), with the tenant ALWAYS folded in (§4.4). Returns nil when no
150
+ # `concurrency_key` is declared (→ tokened identity). The returned value is
151
+ # an array `[*key_values, tenant_gid]`; {InstanceKey.concurrency} serializes
152
+ # it. The tenant is appended even when nil so the digest is stable.
153
+ def concurrency_key_value
154
+ resolver = self.class.concurrency_key_resolver
155
+ return nil unless resolver
156
+
157
+ key = instance_exec(&resolver)
158
+ [*Array.wrap(key), current_scoped_entity]
159
+ end
160
+
161
+ # The `{ "step_key" => [gids] }` source the runner injects from the stored
162
+ # state, backing the lazy `persisted` view. Reassigning it resets the memo.
163
+ def persisted_gid_source=(source)
164
+ @persisted = LazyPersisted.new(source)
165
+ end
166
+
167
+ # Records the per-step `on_submit`/`persist` macro registers (§2.2), as a
168
+ # LAZY view over the stored GIDs: a key is located on first read and
169
+ # memoized, so a request that never reads `persisted` issues zero locates
170
+ # (§4.5). Records set this request (the `persist` macro) are live already.
171
+ def persisted
172
+ @persisted ||= LazyPersisted.new
173
+ end
174
+
175
+ # The at-end commit hook (§2.3). Authors override it.
176
+ def execute
177
+ raise NotImplementedError, "#{self.class} must implement #execute"
178
+ end
179
+
180
+ # Entry authorization (§5.2/§6.5). Authors override it; false → 403. Default
181
+ # allow — resource-mounted surfaces additionally gate via the action policy.
182
+ def authorize? = true
183
+
184
+ private
185
+
186
+ # Raise a StepError from `on_submit`/`execute` (§6.1).
187
+ #
188
+ # fail!("message") → base (form-level) error
189
+ # fail!(:field, "message") → field-level error
190
+ def fail!(attribute_or_message, message = nil)
191
+ if message.nil?
192
+ raise StepError.new(attribute_or_message, attribute: :base)
193
+ else
194
+ raise StepError.new(message, attribute: attribute_or_message)
195
+ end
196
+ end
197
+
198
+ # @return [Plutonium::Interaction::Outcome::Success]
199
+ def succeed(value = nil)
200
+ Plutonium::Interaction::Outcome::Success.new(value)
201
+ end
202
+ alias_method :success, :succeed
203
+
204
+ # @return [Plutonium::Interaction::Outcome::Failure]
205
+ def failed(errors = nil, attribute = :base)
206
+ case errors
207
+ when Hash
208
+ errors.each { |attr, error| self.errors.add(attr, error) }
209
+ else
210
+ Array(errors).each { |error| self.errors.add(attribute, error) }
211
+ end
212
+ Plutonium::Interaction::Outcome::Failure.new
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Convenience base for a standalone wizard controller that needs NO custom auth
6
+ # base: a plain `ActionController::Base` plus the wizard module. Use it when you
7
+ # want to drop in your own controller without an auth concern:
8
+ #
9
+ # class WizardsController < Plutonium::Wizard::BaseController; end
10
+ #
11
+ # For an AUTHENTICATED standalone wizard, don't use this — inherit your own
12
+ # authenticated base and `include Plutonium::Wizard::Controller` instead, so the
13
+ # controller carries `current_user`:
14
+ #
15
+ # class WizardsController < ApplicationController
16
+ # include Plutonium::Wizard::Controller
17
+ # include Plutonium::Auth::Rodauth(:user)
18
+ # end
19
+ #
20
+ # The module is the mechanism; this class is only sugar.
21
+ class BaseController < ActionController::Base
22
+ # A bare `ActionController::Base` host normally inherits forgery protection
23
+ # from the app's `default_protect_from_forgery`, but make it explicit here so
24
+ # a standalone wizard mount is CSRF-protected regardless of app config (the
25
+ # wizard `update` is a state-changing POST).
26
+ protect_from_forgery with: :exception
27
+
28
+ include Plutonium::Wizard::Controller
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Configuration for the Plutonium wizard subsystem.
6
+ #
7
+ # Exposed via {Plutonium::Configuration#wizards}.
8
+ class Configuration
9
+ # @return [Boolean] whether the wizard subsystem (and its migrations) is enabled
10
+ attr_accessor :enabled
11
+
12
+ # @return [ActiveSupport::Duration] how long completed/abandoned sessions are kept
13
+ attr_accessor :cleanup_after
14
+
15
+ # @return [Symbol] which database wizard tables live in
16
+ attr_accessor :database
17
+
18
+ # @return [Boolean] encrypt every wizard's staged `data` at rest by default.
19
+ # Off by default because it needs ActiveRecord encryption keys configured;
20
+ # a wizard may still opt in (`encrypt_data`) or out (`encrypt_data false`)
21
+ # individually regardless of this default.
22
+ attr_accessor :encrypt_data
23
+
24
+ # @return [Symbol, nil] the storage backend used to SERVER-SIDE-stage a plain
25
+ # (non-direct-upload) wizard attachment field — `:active_storage` or
26
+ # `:shrine`. `nil` auto-detects (active_shrine loaded → `:shrine`, else
27
+ # `:active_storage`). A field may override with `input …, backend:`. Only
28
+ # relevant when a file rides the step POST; direct-upload fields already
29
+ # arrive as a token and ignore this.
30
+ attr_accessor :attachment_backend
31
+
32
+ # Initialize a new wizard Configuration instance with default values.
33
+ def initialize
34
+ @enabled = false
35
+ @cleanup_after = 14.days
36
+ @database = :primary
37
+ @encrypt_data = false
38
+ @attachment_backend = nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # The standalone portal-hosted controller concern for portal-level wizards
6
+ # registered with `register_wizard` (§5.2). It is mixed into a portal-
7
+ # namespaced controller (see {Plutonium::Routing::WizardRegistration}), so it
8
+ # inherits the portal's auth, tenant scoping entity, layout, and Phlex
9
+ # rendering exactly like a resource controller.
10
+ #
11
+ # All runner-driving logic lives in {Plutonium::Wizard::Driving} (shared with
12
+ # the resource-mounted {Plutonium::Resource::Controllers::WizardActions}). This
13
+ # concern only adapts that logic to the standalone surface: the `show`/`update`
14
+ # actions, the wizard class carried as a route default, no anchor (portal-level
15
+ # wizards are non-anchored — anchored wizards mount on the resource controller),
16
+ # and the per-step URL built from the named route helper `register_wizard` draws.
17
+ #
18
+ # Identity (§4): a guest (`anonymous`) run's per-run id lives in the Rails
19
+ # session, namespaced per wizard (no cookie, no TTL); it is cleared on
20
+ # completion and auto-cleared on login/logout (Rodauth `reset_session`). An
21
+ # authenticated repeatable run carries its per-run id in the URL `:token`
22
+ # segment, guarded by owner-scoping; neither crosses the auth boundary
23
+ # mid-flow (§4.5).
24
+ module Controller
25
+ extend ActiveSupport::Concern
26
+ # The complete include surface for a standalone wizard controller: the
27
+ # Plutonium rendering/scoping stack PLUS the wizard driving. Including this
28
+ # one module yields a fully renderable wizard controller, so the synthesizer
29
+ # and any app override (`class WizardsController < MyAuthBase; include
30
+ # Plutonium::Wizard::Controller; end`) both get everything. Re-including Core
31
+ # on a host that already has it (a portal controller) is a harmless no-op.
32
+ include Plutonium::Core::Controller
33
+ include Plutonium::Wizard::Driving
34
+
35
+ included do
36
+ helper_method :current_user
37
+ end
38
+
39
+ class_methods do
40
+ # The gem's shared partials (`plutonium/_flash`, …) are looked up by a
41
+ # "plutonium" view prefix, which normally comes from inheriting a controller
42
+ # whose `controller_path` is "plutonium" (the app's `PlutoniumController`). A
43
+ # bare host (a main-app / public wizard rooted in `ActionController::Base`)
44
+ # has no such ancestor, so contribute the prefix here — making the module
45
+ # self-sufficient and the "main-app can be bare" path actually work.
46
+ def _prefixes
47
+ @_wizard_view_prefixes ||= (super | ["plutonium"])
48
+ end
49
+ end
50
+
51
+ # GET the bare mount — resolve/mint the run and redirect to its step.
52
+ def launch
53
+ wizard_launch
54
+ end
55
+
56
+ # GET .../:step — render the current step.
57
+ def show
58
+ wizard_show
59
+ end
60
+
61
+ # POST .../:step — advance / back / cancel.
62
+ def update
63
+ wizard_update
64
+ end
65
+
66
+ private
67
+
68
+ # Identity for a standalone wizard host. Defers to the host's own auth
69
+ # concern when present — a portal controller's `Rodauth(:account)`, or an
70
+ # app-defined `::WizardsController`'s — and is `nil` on a bare host (a public
71
+ # mount, or a misconfigured authenticated main-app wizard with no auth
72
+ # controller). An `anonymous` wizard never consults this; a non-anonymous
73
+ # wizard on a bare host resolves `nil` and is rejected by
74
+ # `require_wizard_authentication!`.
75
+ def current_user
76
+ defined?(super) ? super : nil
77
+ end
78
+
79
+ # The wizard class is carried as a route default (see WizardRegistration),
80
+ # so it is a server-set path parameter. Resolve it through an ALLOWLIST of
81
+ # the loaded wizard classes rather than `constantize`-ing the raw value:
82
+ # every standalone wizard route is drawn by handing `register_wizard` the
83
+ # class object, so by the time its route matches the class is loaded and
84
+ # registered as a `Base` descendant. Matching by name can therefore only
85
+ # ever return an actual wizard — it never triggers resolution of an
86
+ # arbitrary constant (the `constantize`-on-params code-execution surface).
87
+ def current_wizard_class
88
+ @current_wizard_class ||= begin
89
+ name = params.fetch(:wizard_class).to_s
90
+ Plutonium::Wizard::Base.descendants.find { |klass| klass.name == name } ||
91
+ raise(Plutonium::Wizard::UnknownWizardError, "unknown wizard #{name.inspect}")
92
+ end
93
+ end
94
+
95
+ # Portal-level wizards are either non-anchored or CONTEXT-anchored
96
+ # (`anchored via: :method`, §3). A `with:`-only (TYPE) anchor mounts on the
97
+ # resource controller instead (see {WizardActions}), where the anchor is
98
+ # resolved through the scoped, policy-gated `resource_record!` — never an
99
+ # unscoped `find_by`, which would be a cross-tenant IDOR.
100
+ #
101
+ # For a CONTEXT anchor we call the declared method on this controller; a nil
102
+ # result is a programming error (the wizard declared itself anchored).
103
+ def resolved_wizard_anchor
104
+ klass = current_wizard_class
105
+ return nil unless klass.anchored_via?
106
+
107
+ record = send(klass.anchor_via)
108
+ if record.nil?
109
+ raise Plutonium::Wizard::NotAnchoredError,
110
+ "#{klass.name} resolves its anchor via `#{klass.anchor_via}`, which returned nil"
111
+ end
112
+ assert_anchor_type!(klass, record)
113
+ record
114
+ end
115
+
116
+ # When a CONTEXT anchor also declares `with:` types, type-assert the result.
117
+ def assert_anchor_type!(klass, record)
118
+ types = klass.anchor_types
119
+ return if types.nil?
120
+ return if types.any? { |t| record.is_a?(t) }
121
+
122
+ raise Plutonium::Wizard::NotAnchoredError,
123
+ "#{klass.name} anchor resolved to #{record.class} but expects one of " \
124
+ "#{types.join(", ")}"
125
+ end
126
+
127
+ # Build the GET URL for a given step of this wizard, preserving the
128
+ # `:token` segment. Built through the named route helper that
129
+ # `register_wizard` draws (resolved from the current engine's route set by
130
+ # the wizard class, so `at:`/`as:` overrides are honored) — never
131
+ # string-surgery on `request.path`, so the URL is always a same-host,
132
+ # route-validated path.
133
+ def wizard_step_url(step_key)
134
+ url_options = {step: step_key}
135
+ token = wizard_url_token
136
+ url_options[:token] = token if token.present?
137
+ # An entity-scoped portal's wizard routes carry the scope path segment
138
+ # (e.g. `:organization_scoped`); thread it through from the request so the
139
+ # generated URL stays inside the tenant.
140
+ if scoped_to_entity?
141
+ url_options[scoped_entity_param_key] = params[scoped_entity_param_key]
142
+ end
143
+ current_engine.routes.url_helpers.public_send(wizard_step_url_helper, **url_options)
144
+ end
145
+
146
+ # The `<name>_wizard_path` helper for this wizard's standalone mount. Found
147
+ # by the GET route `register_wizard` draws (named `:"#{helper}_wizard"`,
148
+ # carrying the `wizard_class` route default) so the lookup tracks the actual
149
+ # `at:`/`as:` used at registration rather than re-deriving a slug.
150
+ def wizard_step_url_helper
151
+ @wizard_step_url_helper ||= begin
152
+ name = Plutonium::Wizard::RouteResolution.route_name(
153
+ current_engine.routes, current_wizard_class, action: "show"
154
+ )
155
+ raise "no register_wizard route found for #{current_wizard_class.name}" unless name
156
+
157
+ :"#{name}_path"
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Builds the wizard's typed `data` snapshot (§2.6). `data` is **step-keyed**: a
6
+ # container exposing one typed sub-object per step, so fields are addressed as
7
+ # `data.<step>.<field>` (e.g. `data.identity.name`, `data.profile.tier`). Each
8
+ # step sub-object is backed by ActiveModel::Attributes — scalar values are cast
9
+ # to their declared types and uncollected fields read as `nil`. Step namespacing
10
+ # means two steps may declare the same field name without colliding.
11
+ #
12
+ # `structured_input ..., repeat:` collections (which declare no scalar types —
13
+ # their sub-fields come from `input` declarations) are exposed on their step's
14
+ # sub-object as arrays of typed sub-objects responding to the declared sub-field
15
+ # names (`data.members.invites.first.email`).
16
+ module Data
17
+ # A read-only row inside a structured collection. Responds to each declared
18
+ # sub-field; values are exposed as-is (string-typed, since structured inputs
19
+ # carry no scalar type declarations).
20
+ class StructuredRow
21
+ def initialize(fields, values)
22
+ @values = values
23
+ fields.each do |field|
24
+ define_singleton_method(field) { @values[field.to_s] }
25
+ end
26
+ end
27
+
28
+ def [](key) = @values[key.to_s]
29
+
30
+ def to_h = @values.dup
31
+ end
32
+
33
+ # @param schema [Hash{Symbol=>Symbol}] scalar attribute name => type
34
+ # @param options [Hash{Symbol=>Hash}] scalar attribute name => options (default:, etc.)
35
+ # @param structured [Hash{Symbol=>Array<Symbol>}] structured name => sub-field names
36
+ # @param validations [Array<[Array, Hash]>] inline `validates` declarations
37
+ # ([args, options]) replayed onto the class so the form pipeline can infer
38
+ # required markers via `validators_on` (§7). The class is never `.valid?`'d
39
+ # in the form path — validation runs through the runner — so this only
40
+ # feeds introspection.
41
+ def self.class_for(schema, options: {}, structured: {}, validations: [])
42
+ Class.new do
43
+ include ActiveModel::Model
44
+ include ActiveModel::Attributes
45
+
46
+ # Anonymous classes have no name, which breaks label/error translation
47
+ # lookups (`human_attribute_name` / `errors.full_messages` call
48
+ # `model_name`). Supply a stable one so the form/display pipelines can
49
+ # humanize attribute labels.
50
+ def self.model_name = ActiveModel::Name.new(self, nil, "Wizard")
51
+
52
+ schema.each do |name, type|
53
+ attribute(name, Plutonium::Wizard.safe_attribute_type(type), **(options[name] || {}))
54
+ end
55
+
56
+ validations.each { |args, opts| validates(*args, **opts) }
57
+
58
+ structured.each do |name, fields|
59
+ # Backed by a plain accessor (not an ActiveModel attribute) so the raw
60
+ # array survives without coercion, then wrapped on read.
61
+ attr_writer name
62
+ define_method(name) do
63
+ rows = Array(instance_variable_get(:"@#{name}"))
64
+ rows.map do |row|
65
+ values = row.respond_to?(:to_h) ? row.to_h.transform_keys(&:to_s) : {}
66
+ StructuredRow.new(fields, values)
67
+ end
68
+ end
69
+ end
70
+
71
+ # Accept the union of scalar + structured keys, ignoring unknown keys.
72
+ define_method(:initialize) do |attrs = {}|
73
+ attrs = (attrs || {}).symbolize_keys
74
+ scalar = attrs.slice(*schema.keys)
75
+ super(scalar)
76
+ structured.each_key do |name|
77
+ instance_variable_set(:"@#{name}", attrs[name] || [])
78
+ end
79
+ end
80
+
81
+ # Typed plain-hash view: cast scalars + structured rows as hashes.
82
+ define_method(:to_h) do
83
+ h = {}
84
+ schema.each_key { |name| h[name] = public_send(name) }
85
+ structured.each_key { |name| h[name] = public_send(name).map(&:to_h) }
86
+ h
87
+ end
88
+ end
89
+ end
90
+
91
+ # The step-keyed `data` snapshot — a thin dispatcher over the per-step typed
92
+ # sub-objects (§2.6). `data.identity` (via method_missing) or `data[:identity]`
93
+ # returns the step's typed sub-object, built lazily from its nested data slice
94
+ # and memoized; an unknown step key returns nil. `to_h` gives the nested
95
+ # `{step => {field => value}}` view.
96
+ #
97
+ # A plain object (not a generated class) so it isn't rebuilt every time the
98
+ # runner reassigns `data_attributes`; the per-step typed classes are built
99
+ # once per wizard class and passed in.
100
+ class Container
101
+ # @param step_classes [Hash{Symbol=>Class}] step key => typed sub-object class
102
+ # @param attrs [Hash] nested staged data ({step_key => {field => value}})
103
+ def initialize(step_classes, attrs = {})
104
+ @step_classes = step_classes
105
+ @attrs = (attrs || {}).transform_keys(&:to_sym)
106
+ @objects = {}
107
+ end
108
+
109
+ # The typed sub-object for a step (lazy + memoized); nil for an unknown step.
110
+ def [](key)
111
+ key = key.to_sym
112
+ return nil unless @step_classes.key?(key)
113
+ @objects[key] ||= @step_classes[key].new(@attrs[key] || {})
114
+ end
115
+
116
+ # The declared step keys, in order.
117
+ def step_keys = @step_classes.keys
118
+
119
+ # Nested typed hash: {step_key => {field => value}}.
120
+ def to_h = @step_classes.keys.index_with { |key| self[key].to_h }
121
+
122
+ def respond_to_missing?(name, include_private = false)
123
+ @step_classes.key?(name.to_sym) || super
124
+ end
125
+
126
+ # `data.identity` → the identity step's typed sub-object.
127
+ def method_missing(name, *args)
128
+ return self[name] if args.empty? && @step_classes.key?(name.to_sym)
129
+ super
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end