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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- 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
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 :
|
|
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
|
|
169
|
-
#
|
|
170
|
-
#
|
|
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
|
-
|
|
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,
|
|
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.
|