plutonium 0.65.0 → 0.67.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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +158 -141
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +161 -98
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +43 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +56 -3
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +32 -32
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/config/brakeman.ignore +1 -1
  23. data/config/locales/en/js.yml +4 -0
  24. data/config/locales/en/ui.yml +16 -0
  25. data/docs/.vitepress/sync-skills.mjs +6 -3
  26. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  27. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  28. data/docs/getting-started/installation.md +5 -5
  29. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  30. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  31. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  32. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  33. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  34. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  36. data/docs/getting-started/tutorial/index.md +1 -1
  37. data/docs/guides/adding-resources.md +10 -7
  38. data/docs/guides/authentication.md +25 -25
  39. data/docs/guides/authorization.md +24 -24
  40. data/docs/guides/creating-packages.md +17 -17
  41. data/docs/guides/custom-actions.md +32 -32
  42. data/docs/guides/customizing-ui.md +29 -26
  43. data/docs/guides/dashboards.md +1 -1
  44. data/docs/guides/index.md +3 -3
  45. data/docs/guides/kanban.md +55 -55
  46. data/docs/guides/multi-tenancy.md +35 -22
  47. data/docs/guides/nested-resources.md +21 -21
  48. data/docs/guides/performance.md +3 -3
  49. data/docs/guides/search-filtering.md +13 -13
  50. data/docs/guides/testing.md +16 -12
  51. data/docs/guides/theming.md +32 -17
  52. data/docs/guides/troubleshooting.md +2 -2
  53. data/docs/guides/user-invites.md +17 -17
  54. data/docs/guides/user-profile.md +51 -24
  55. data/docs/guides/wizards.md +55 -55
  56. data/docs/reference/app/generators.md +22 -22
  57. data/docs/reference/app/index.md +15 -18
  58. data/docs/reference/app/packages.md +8 -8
  59. data/docs/reference/app/portals.md +75 -29
  60. data/docs/reference/auth/accounts.md +15 -15
  61. data/docs/reference/auth/index.md +12 -12
  62. data/docs/reference/auth/profile.md +67 -29
  63. data/docs/reference/behavior/async-interactions.md +24 -24
  64. data/docs/reference/behavior/controllers.md +28 -28
  65. data/docs/reference/behavior/index.md +5 -5
  66. data/docs/reference/behavior/interactions.md +44 -44
  67. data/docs/reference/behavior/policies.md +48 -28
  68. data/docs/reference/configuration.md +6 -6
  69. data/docs/reference/dashboard/dsl.md +2 -2
  70. data/docs/reference/dashboard/index.md +1 -1
  71. data/docs/reference/generators/lite.md +7 -7
  72. data/docs/reference/i18n.md +23 -0
  73. data/docs/reference/index.md +1 -1
  74. data/docs/reference/kanban/authorization.md +9 -9
  75. data/docs/reference/kanban/dsl.md +32 -32
  76. data/docs/reference/kanban/index.md +1 -1
  77. data/docs/reference/kanban/positioning.md +17 -15
  78. data/docs/reference/resource/actions.md +51 -51
  79. data/docs/reference/resource/definition.md +85 -80
  80. data/docs/reference/resource/export.md +6 -6
  81. data/docs/reference/resource/index.md +16 -16
  82. data/docs/reference/resource/model.md +24 -24
  83. data/docs/reference/resource/positioning.md +78 -76
  84. data/docs/reference/resource/query.md +24 -14
  85. data/docs/reference/tenancy/entity-scoping.md +65 -35
  86. data/docs/reference/tenancy/index.md +11 -11
  87. data/docs/reference/tenancy/invites.md +20 -20
  88. data/docs/reference/tenancy/nested-resources.md +13 -13
  89. data/docs/reference/testing/index.md +116 -22
  90. data/docs/reference/ui/assets.md +57 -25
  91. data/docs/reference/ui/components.md +20 -20
  92. data/docs/reference/ui/displays.md +32 -15
  93. data/docs/reference/ui/forms.md +89 -39
  94. data/docs/reference/ui/index.md +17 -15
  95. data/docs/reference/ui/layouts.md +21 -21
  96. data/docs/reference/ui/pages.md +22 -22
  97. data/docs/reference/ui/tables.md +7 -7
  98. data/docs/reference/wizard/anchoring-resume.md +33 -32
  99. data/docs/reference/wizard/dsl.md +44 -44
  100. data/docs/reference/wizard/index.md +6 -6
  101. data/docs/reference/wizard/one-time.md +18 -18
  102. data/docs/reference/wizard/registration-launch.md +32 -32
  103. data/docs/reference/wizard/storage-config.md +23 -23
  104. data/gemfiles/rails_7.gemfile +1 -0
  105. data/gemfiles/rails_7.gemfile.lock +13 -3
  106. data/gemfiles/rails_8.0.gemfile +1 -0
  107. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  108. data/gemfiles/rails_8.1.gemfile +1 -0
  109. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  110. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  111. data/lib/plutonium/auth/rodauth.rb +7 -0
  112. data/lib/plutonium/core/controller.rb +0 -2
  113. data/lib/plutonium/helpers/content_helper.rb +0 -3
  114. data/lib/plutonium/query/filter.rb +2 -1
  115. data/lib/plutonium/query/filters/tags.rb +36 -0
  116. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  117. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  118. data/lib/plutonium/ui/display/base.rb +50 -2
  119. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  120. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  121. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  122. data/lib/plutonium/ui/display/components/code.rb +23 -0
  123. data/lib/plutonium/ui/display/components/color.rb +14 -6
  124. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  125. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  126. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  127. data/lib/plutonium/ui/display/components/list.rb +33 -0
  128. data/lib/plutonium/ui/display/components/number.rb +32 -0
  129. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  130. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  131. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  132. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  133. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  134. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  135. data/lib/plutonium/ui/display/theme.rb +24 -2
  136. data/lib/plutonium/ui/form/base.rb +30 -1
  137. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  138. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  139. data/lib/plutonium/ui/form/components/list.rb +46 -0
  140. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  141. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  142. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  143. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  144. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  145. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  146. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  147. data/lib/plutonium/ui/form/theme.rb +20 -4
  148. data/lib/plutonium/ui/grid/card.rb +13 -1
  149. data/lib/plutonium/ui/layout/base.rb +10 -1
  150. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  151. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  152. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  153. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  154. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  155. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  156. data/lib/plutonium/version.rb +1 -1
  157. data/package.json +1 -1
  158. data/src/css/components.css +53 -10
  159. data/src/css/flatpickr.css +91 -0
  160. data/src/js/controllers/easymde_controller.js +1 -0
  161. data/src/js/controllers/register_controllers.js +2 -0
  162. data/src/js/controllers/slim_select_controller.js +32 -3
  163. data/src/js/controllers/timeago_controller.js +40 -0
  164. metadata +25 -2
@@ -1,10 +1,10 @@
1
1
  # Registration & launch
2
2
 
3
- A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`) — inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth — see [Hosting & the controller override hook](#hosting-the-controller-override-hook)).
3
+ A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`), inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth); see [Hosting & the controller override hook](#hosting-the-controller-override-hook).
4
4
 
5
- ## On a resource — the `wizard` macro
5
+ ## On a resource: the `wizard` macro
6
6
 
7
- A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action — sugar over the Action system.
7
+ A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action, sugar over the Action system.
8
8
 
9
9
  ```ruby
10
10
  class CompanyDefinition < Plutonium::Resource::Definition
@@ -14,19 +14,19 @@ class CompanyDefinition < Plutonium::Resource::Definition
14
14
  end
15
15
  ```
16
16
 
17
- - **Placement is dictated by the wizard, not chosen** — an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
18
- - **Auto-mounted on the resource controller** — the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
19
- - **The anchor is IDOR-safe** — an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
20
- - **Bulk wizards are not supported** — wizards are inherently per-instance flows. Use a bulk interaction instead.
21
- - **Authorization mirrors actions** — a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
17
+ - **Placement is dictated by the wizard, not chosen**: an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
18
+ - **Auto-mounted on the resource controller**: the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
19
+ - **The anchor is IDOR-safe**: an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
20
+ - **Bulk wizards are not supported**: wizards are inherently per-instance flows. Use a bulk interaction instead.
21
+ - **Authorization mirrors actions**: a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
22
22
 
23
23
  | Option | Meaning |
24
24
  |---|---|
25
25
  | `record_action:` | **(Record wizards only)** show on the record's **show page**. Default `true`; `false` removes it from there. |
26
26
  | `collection_record_action:` | **(Record wizards only)** show on each **list row** (scoped to that row's record). Default `true`; `false` keeps it on the show page but off the list. |
27
- | `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome — any `action` option passes through. |
27
+ | `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome, any `action` option passes through. |
28
28
 
29
- Placement isn't an option — it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
29
+ Placement isn't an option; it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
30
30
 
31
31
  ### Synthesized routes (resource-mounted)
32
32
 
@@ -42,9 +42,9 @@ GET/POST /companies/wizards/:wizard_name(/:token)/:step
42
42
 
43
43
  The synthesized launch action points at the **bare** wizard URL (no step). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the **resumed cursor** for an in-progress keyed run, else the **first step**, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.
44
44
 
45
- ## Route-mounted — `register_wizard`
45
+ ## Route-mounted: `register_wizard`
46
46
 
47
- For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard` — **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
47
+ For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard`, **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
48
48
 
49
49
  ```ruby
50
50
  # packages/admin_portal/config/routes.rb
@@ -55,7 +55,7 @@ AdminPortal::Engine.routes.draw do
55
55
  register_resource ::Company
56
56
  end
57
57
 
58
- # config/routes.rb — a main-app (portal-less) wizard
58
+ # config/routes.rb: a main-app (portal-less) wizard
59
59
  Rails.application.routes.draw do
60
60
  register_wizard ::AppOnboardingWizard, at: "onboarding" # main-app default → :basic
61
61
  end
@@ -66,21 +66,21 @@ end
66
66
  | `at:` (required) | The host-relative base path for the wizard's steps. |
67
67
  | `as:` | Override the route helper name prefix (defaults to `at:`, then the wizard's name, e.g. `OnboardOrganizationWizard` → `onboarding`). |
68
68
  | `public:` | Mount on a **public (unauthenticated) route** for an [`anonymous`](#public-mount-for-anonymous-wizards) wizard. Defaults to the wizard's own `anonymous?` flag. |
69
- | `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host — **portal → the resource shell**, **main-app → `:basic`**. |
69
+ | `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host, **portal → the resource shell**, **main-app → `:basic`**. |
70
70
 
71
71
  This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides `<name>_wizard_path` / `_url` helpers.
72
72
 
73
73
  ### Hosting & the controller override hook
74
74
 
75
- `register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource` — there is no hand-written file unless you want to customize.
75
+ `register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource`, there is no hand-written file unless you want to customize.
76
76
 
77
77
  | Host | Controller | Base / auth |
78
78
  |---|---|---|
79
79
  | **Portal** | `<Portal>::WizardsController` if defined, else synthesized | the portal's `PlutoniumController` (inherits its auth/scope/layout) |
80
- | **Main app, authenticated** | `::WizardsController` — **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
80
+ | **Main app, authenticated** | `::WizardsController`, **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
81
81
  | **Main app, public** (`anonymous`) | `::PublicWizardsController` (synthesized) | a bare base + `Plutonium::Auth::Public` (guest) |
82
82
 
83
- The synthesized **main-app** controller is **bare** — rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
83
+ The synthesized **main-app** controller is **bare**, rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
84
84
 
85
85
  ```ruby
86
86
  # app/controllers/wizards_controller.rb
@@ -94,17 +94,17 @@ end
94
94
 
95
95
  ### Layout
96
96
 
97
- `layout:` is the **Rails layout** the wizard renders in — a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
97
+ `layout:` is the **Rails layout** the wizard renders in, a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
98
98
 
99
99
  | `layout:` | Appearance | Layout |
100
100
  |---|---|---|
101
- | `:basic` | no sidebar/topbar — e.g. "set up your organization" | `basic` (`BasicLayout`) |
101
+ | `:basic` | no sidebar/topbar, e.g. "set up your organization" | `basic` (`BasicLayout`) |
102
102
  | `:resource` | sidebar + topbar + wizard (in-app) | the `resource` shell layout |
103
103
  | *(any app layout)* | whatever that layout renders | passed straight to Rails |
104
- | *(omitted)* | host default — see below | — |
104
+ | *(omitted)* | host default, see below | - |
105
105
 
106
106
  - **Defaults by host:** portal → the `resource` shell; main-app → `:basic` (a bare main-app host has no shell to embed in). Pass `layout:` to override.
107
- - **Turbo-frame requests are always layout-less** regardless of the setting — that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
107
+ - **Turbo-frame requests are always layout-less** regardless of the setting; that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
108
108
  - `layout:` is a **`register_wizard` option only**; it travels with the mount, not the wizard class. Resource-defined wizards take no `layout:` (always embedded).
109
109
 
110
110
  ### Synthesized routes
@@ -123,7 +123,7 @@ The POST `_direction` param carries `next` / `back` / `cancel`. **Where Cancel s
123
123
 
124
124
  ## Entry authorization
125
125
 
126
- A portal-level wizard has no resource policy, so gate entry with an `authorize?` instance method on the wizard — checked before each request:
126
+ A `register_wizard` wizard (portal or main-app) has no resource policy, so gate entry with an `authorize?` instance method on the wizard, checked before each request:
127
127
 
128
128
  ```ruby
129
129
  class WelcomeWizard < Plutonium::Wizard::Base
@@ -135,12 +135,12 @@ end
135
135
 
136
136
  A falsy return → `ActionPolicy::Unauthorized` (403). Resource-attached wizards instead use their action's policy predicate (`def onboard?` etc.).
137
137
 
138
- ::: danger A portal-level wizard with no `authorize?` is open to ANY authenticated portal user
139
- A portal-level wizard (registered with `register_wizard`) has **no resource policy** and **defaults to allowed** — so if you omit `authorize?`, every authenticated user of that portal can run it. **Always define `def authorize?`** for anything privileged (admin-only flows, per-user gating, tenant checks). The default-allow is only safe for flows that are genuinely fine for any signed-in portal user (e.g. self-service onboarding).
138
+ ::: danger A `register_wizard` wizard with no `authorize?` is open to ANY authenticated user
139
+ A wizard registered with `register_wizard` (in a portal or on the main app) has **no resource policy**, and the base class defines `def authorize? = true`. If you omit it, every authenticated user of that portal or app can run it. **Define `def authorize?` on every `register_wizard` wizard.** A gated `one_time` wizard is no exception: the gate forces users *into* the wizard but doesn't decide who may run it. For a flow that is genuinely fine for any signed-in user (e.g. self-service onboarding), say so with `def authorize? = true`.
140
140
  :::
141
141
 
142
142
  ::: warning As-built: `authorize?` is an instance method
143
- Define `def authorize?` on the wizard. The controller checks it only when the wizard responds to it (default: allowed).
143
+ Define `def authorize?` on the wizard. The controller checks it before every request; the inherited default returns `true`.
144
144
  :::
145
145
 
146
146
  ## Public mount for `anonymous` wizards
@@ -154,12 +154,12 @@ register_wizard ::GuestSignupWizard, at: "signup", public: true
154
154
  ```
155
155
 
156
156
  - `public: true` is the **default for an `anonymous` wizard**; you can pass it explicitly for clarity.
157
- - A **non-`anonymous`** wizard may **not** be mounted public (raises) — and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
157
+ - A **non-`anonymous`** wizard may **not** be mounted public (raises), and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
158
158
  - The public route dispatches to a synthesized top-level `::PublicWizardsController` (a **distinct** const from an authenticated main-app `::WizardsController`, so the two never collapse onto each other) that renders **full-page** with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via `Plutonium::Auth::Public`.
159
159
 
160
160
  ## One-time gating
161
161
 
162
- To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time) — a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
162
+ To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time), a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
163
163
 
164
164
  For a **one-time** wizard, the launch action this macro synthesizes also **hides itself once the current user has completed it** (via a render-time action `condition:`); a custom `condition:` composes with that check. See [The launch action hides itself once completed](/reference/wizard/one-time#the-launch-action-hides-itself-once-completed).
165
165
 
@@ -167,11 +167,11 @@ For a **one-time** wizard, the launch action this macro synthesizes also **hides
167
167
 
168
168
  - **`with:`-anchored wizards mount on the resource, not route-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro (it auto-mounts a member action whose anchor is the scoped `resource_record!`). Passing a `with:`-anchored wizard to **`register_wizard`** raises: a route-level mount has no resource record to anchor to. A **`via:`-anchored** (context-anchored) wizard *does* mount route-level; its anchor is resolved by a controller method, not a URL `:id`.
169
169
  - **An authenticated main-app wizard needs an app-defined controller.** The synthesized main-app controller is bare (no `current_user`); supply your own `::WizardsController` with an auth concern (see [Hosting & the controller override hook](#hosting-the-controller-override-hook)). Portal mounts and `anonymous` public mounts need nothing.
170
- - **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time — give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
170
+ - **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time, give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
171
171
 
172
172
  ## Related
173
173
 
174
- - [DSL reference](/reference/wizard/dsl) — `authorize?`, the wizard body.
175
- - [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys.
176
- - [One-time wizards](/reference/wizard/one-time) — completion + gating.
177
- - [Custom actions guide](/guides/custom-actions) — the Action system the `wizard` macro builds on.
174
+ - [DSL reference](/reference/wizard/dsl): `authorize?`, the wizard body.
175
+ - [Anchoring & resume](/reference/wizard/anchoring-resume): anchors, instance keys.
176
+ - [One-time wizards](/reference/wizard/one-time): completion + gating.
177
+ - [Custom actions guide](/guides/custom-actions): the Action system the `wizard` macro builds on.
@@ -23,25 +23,25 @@ rails db:migrate
23
23
 
24
24
  | Config | Default | Meaning |
25
25
  |---|---|---|
26
- | `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes — both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
26
+ | `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes, both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
27
27
  | `config.wizards.cleanup_after` | `14.days` | Global default idle TTL for the abandonment sweep; overridable per wizard via `cleanup_after`. |
28
- | `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only** — see below. |
28
+ | `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only**, see below. |
29
29
  | `config.wizards.encrypt_data` | `false` | Encrypt **every** wizard's staged `data` at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with `encrypt_data` / `encrypt_data false`. See [Encryption](#encryption). |
30
- | `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects — active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
30
+ | `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects, active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
31
31
 
32
32
  ## Gem-shipped migration
33
33
 
34
- The migration ships **in the gem** and Rails runs it **in place** — there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
34
+ The migration ships **in the gem** and Rails runs it **in place**; there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
35
35
 
36
36
  - Once run, the table is dumped into your `schema.rb` / `structure.sql` like any other, so `db:schema:load` on fresh/CI databases recreates it normally.
37
37
  - Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
38
- - `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed) — standard for gem-shipped migrations.
38
+ - `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed), standard for gem-shipped migrations.
39
39
 
40
40
  ::: warning v1 supports the primary database only
41
- The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use** — multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
41
+ The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use**, multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
42
42
  :::
43
43
 
44
- ## The table — `plutonium_wizard_sessions`
44
+ ## The table: `plutonium_wizard_sessions`
45
45
 
46
46
  One framework-owned table serves everything; **no changes to your models.**
47
47
 
@@ -51,7 +51,7 @@ One framework-owned table serves everything; **no changes to your models.**
51
51
  | `status` | `in_progress` \| `completing` \| `completed` (see the note below). |
52
52
  | `current_step` | The step cursor. |
53
53
  | `instance_key` (unique) | The deterministic identity digest (see [Anchoring & resume](/reference/wizard/anchoring-resume#instance-identity)). |
54
- | `owner_type` / `owner_id` | The user (nullable — `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
54
+ | `owner_type` / `owner_id` | The user (nullable, `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
55
55
  | `anchor_type` / `anchor_id` | The anchor record (nullable). |
56
56
  | `scope_type` / `scope_id` | The portal scoping entity / tenant (nullable). |
57
57
  | `engine` | The portal (engine class name) the run was launched in, e.g. `"OrgPortal::Engine"`. The "continue where you left off" listing only shows (and links) runs whose `engine` matches the portal being viewed (two portals can share an entity scope, so `scope` alone can't identify the portal). |
@@ -64,14 +64,14 @@ One framework-owned table serves everything; **no changes to your models.**
64
64
 
65
65
  What the single table powers:
66
66
 
67
- - **Resume** — look up the `in_progress` row by `instance_key`.
68
- - **One-time check** — does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
69
- - **In-progress listing** — by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
70
- - **Multi-tenancy** — the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
71
- - **Sweep** — idle `in_progress`/`completing` rows past `expires_at`.
67
+ - **Resume**: look up the `in_progress` row by `instance_key`.
68
+ - **One-time check**: does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
69
+ - **In-progress listing**: by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
70
+ - **Multi-tenancy**: the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
71
+ - **Sweep**: idle `in_progress`/`completing` rows past `expires_at`.
72
72
 
73
73
  ::: tip The `persisted` / `tracked_records` naming
74
- The column is `tracked_records`, not `persisted` — an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
74
+ The column is `tracked_records`, not `persisted`: an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
75
75
  :::
76
76
 
77
77
  ## Encryption
@@ -85,7 +85,7 @@ class CheckoutWizard < Plutonium::Wizard::Base
85
85
  end
86
86
  ```
87
87
 
88
- This encrypts the `data` column (the staged step values) — off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
88
+ This encrypts the `data` column (the staged step values), off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
89
89
 
90
90
  **Encrypt everything by default.** Once your app has ActiveRecord encryption keys, you can flip encryption on for *all* wizards with one global flag, then override per wizard:
91
91
 
@@ -99,7 +99,7 @@ class PublicSurveyWizard < Plutonium::Wizard::Base
99
99
  end
100
100
  ```
101
101
 
102
- Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys — see the warning below.
102
+ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys, see the warning below.
103
103
 
104
104
  **How it works.** Because `data` is one shared `jsonb` column across all wizards (some opting in, some not), a static model-level `encrypts :data` doesn't fit (it would encrypt every row, and fights the `jsonb` type). Instead, the store encrypts at write time using **ActiveRecord's configured encryptor** (`ActiveRecord::Encryption.encryptor`, the same keys as `encrypts`) and stores a self-describing envelope inside the column:
105
105
 
@@ -107,17 +107,17 @@ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard alwa
107
107
  { "_enc": "<ciphertext>" }
108
108
  ```
109
109
 
110
- A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?` — so toggling the flag never strands existing runs.
110
+ A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?`, so toggling the flag never strands existing runs.
111
111
 
112
112
  ::: warning Requires ActiveRecord encryption keys
113
- `encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard — rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
113
+ `encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard, rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
114
114
  :::
115
115
 
116
116
  ## Files
117
117
 
118
118
  A file can't sit in the JSON `data` column, so a wizard stages only the backend's **upload token** (an ActiveStorage `signed_id`, or Shrine cached-file data) and `execute` assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See [DSL › Attachment fields](/reference/wizard/dsl#attachment-fields) and the [guide](/guides/wizards#file-uploads-attachments) for the full surface (`backend:`, `multiple:`, direct upload).
119
119
 
120
- A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it — the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
120
+ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it, the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
121
121
 
122
122
  ## Cleanup & the SweepJob
123
123
 
@@ -126,7 +126,7 @@ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Eac
126
126
  `Plutonium::Wizard::SweepJob` reaps idle `in_progress` / `completing` rows past `expires_at`: for each it runs the wizard's cleanup (each step's `on_rollback` if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.
127
127
 
128
128
  ::: tip The `completing` state and its grace window
129
- A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window — long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
129
+ A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window, long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
130
130
  :::
131
131
 
132
132
  ### SweepJob is load-bearing for save-as-you-go
@@ -147,6 +147,6 @@ On completion of a one-time wizard, the row is kept as the durable marker but it
147
147
 
148
148
  ## Related
149
149
 
150
- - [Anchoring & resume](/reference/wizard/anchoring-resume) — `instance_key`, resume.
151
- - [DSL reference](/reference/wizard/dsl) — `cleanup_after`, `encrypt_data`, `persist`.
152
- - [One-time wizards](/reference/wizard/one-time) — durable completion markers.
150
+ - [Anchoring & resume](/reference/wizard/anchoring-resume): `instance_key`, resume.
151
+ - [DSL reference](/reference/wizard/dsl): `cleanup_after`, `encrypt_data`, `persist`.
152
+ - [One-time wizards](/reference/wizard/one-time): durable completion markers.
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rotp"
6
+ gem "no_fly_list", github: "radioactive-labs/no_fly_list", branch: "feat/scoped-tags"
6
7
  gem "rails", "~> 7.2"
7
8
  gem "sqlite3"
8
9
  gem "puma", ">= 5.0"
@@ -1,7 +1,15 @@
1
+ GIT
2
+ remote: https://github.com/radioactive-labs/no_fly_list.git
3
+ revision: e1e88d9c7d898ef269e3cabfa12da2fb327459cc
4
+ branch: feat/scoped-tags
5
+ specs:
6
+ no_fly_list (0.7.4)
7
+ activerecord (>= 7.2)
8
+
1
9
  PATH
2
10
  remote: ..
3
11
  specs:
4
- plutonium (0.64.0)
12
+ plutonium (0.66.0)
5
13
  action_policy (~> 0.7.0)
6
14
  csv
7
15
  listen (~> 3.8)
@@ -490,6 +498,7 @@ DEPENDENCIES
490
498
  importmap-rails
491
499
  minitest
492
500
  minitest-reporters
501
+ no_fly_list!
493
502
  plutonium!
494
503
  propshaft
495
504
  puma (>= 5.0)
@@ -521,7 +530,7 @@ CHECKSUMS
521
530
  activerecord (7.2.3.2) sha256=e62d24ba7e5f820d24ed122eb9225491b0aaa8398778783b400067c3467285c7
522
531
  activestorage (7.2.3.2) sha256=863cb4b21b83505555be2addb939fefb856ab05f6c6d24bab00d6f1a7e4edbfc
523
532
  activesupport (7.2.3.2) sha256=ddc90d11dd88086d78ace03407fe3c2a3f5c8853c3c3046313db5ed823ef3312
524
- acts_as_list (1.2.6)
533
+ acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8
525
534
  addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
526
535
  ansi (1.6.0) sha256=ac9ea0c0ea8d32fb4e271348e609963ac78882f34b73836c2a02b3622e666658
527
536
  appraisal (2.5.0) sha256=36989221be127913b0dba8d114da2001e6b2dceea7bd4951200eaba764eed3ce
@@ -580,6 +589,7 @@ CHECKSUMS
580
589
  net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
581
590
  net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
582
591
  nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
592
+ no_fly_list (0.7.4)
583
593
  nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
584
594
  nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
585
595
  nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca
@@ -601,7 +611,7 @@ CHECKSUMS
601
611
  phlexi-form (0.14.3) sha256=47d69c4a49ba22279ba541b9b110e81d62acc4602513f21bb5c3d29fa9c8d5ca
602
612
  phlexi-menu (0.4.1) sha256=f6eeb1eb9597caadeee33a53f1520b3613a6ac616cf1e395e9d734a412895fb2
603
613
  phlexi-table (0.2.0) sha256=73bee135adbe73fcdb6fdd2672444373556dd9e6bba61e7b5b20ca8b7a1f822c
604
- plutonium (0.64.0)
614
+ plutonium (0.66.0)
605
615
  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
606
616
  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
607
617
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rotp"
6
+ gem "no_fly_list", github: "radioactive-labs/no_fly_list", branch: "feat/scoped-tags"
6
7
  gem "rails", "~> 8.0.0"
7
8
  gem "sqlite3"
8
9
  gem "puma", ">= 5.0"
@@ -1,7 +1,15 @@
1
+ GIT
2
+ remote: https://github.com/radioactive-labs/no_fly_list.git
3
+ revision: e1e88d9c7d898ef269e3cabfa12da2fb327459cc
4
+ branch: feat/scoped-tags
5
+ specs:
6
+ no_fly_list (0.7.4)
7
+ activerecord (>= 7.2)
8
+
1
9
  PATH
2
10
  remote: ..
3
11
  specs:
4
- plutonium (0.62.2)
12
+ plutonium (0.66.0)
5
13
  action_policy (~> 0.7.0)
6
14
  csv
7
15
  listen (~> 3.8)
@@ -457,6 +465,7 @@ DEPENDENCIES
457
465
  importmap-rails
458
466
  minitest
459
467
  minitest-reporters
468
+ no_fly_list!
460
469
  plutonium!
461
470
  propshaft
462
471
  puma (>= 5.0)
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rotp"
6
+ gem "no_fly_list", github: "radioactive-labs/no_fly_list", branch: "feat/scoped-tags"
6
7
  gem "rails", "~> 8.1.0"
7
8
  gem "sqlite3"
8
9
  gem "puma", ">= 5.0"
@@ -1,7 +1,15 @@
1
+ GIT
2
+ remote: https://github.com/radioactive-labs/no_fly_list.git
3
+ revision: e1e88d9c7d898ef269e3cabfa12da2fb327459cc
4
+ branch: feat/scoped-tags
5
+ specs:
6
+ no_fly_list (0.7.4)
7
+ activerecord (>= 7.2)
8
+
1
9
  PATH
2
10
  remote: ..
3
11
  specs:
4
- plutonium (0.64.0)
12
+ plutonium (0.66.0)
5
13
  action_policy (~> 0.7.0)
6
14
  csv
7
15
  listen (~> 3.8)
@@ -459,6 +467,7 @@ DEPENDENCIES
459
467
  importmap-rails
460
468
  minitest
461
469
  minitest-reporters
470
+ no_fly_list!
462
471
  plutonium!
463
472
  propshaft
464
473
  puma (>= 5.0)
@@ -65,6 +65,12 @@ module Pu
65
65
  user.#{profile_association}.nil?
66
66
  end
67
67
 
68
+ # The base update? delegates to create?, which is false once the profile
69
+ # exists. relation_scope already limits this to the user's own profile.
70
+ def update?
71
+ true
72
+ end
73
+
68
74
  def destroy?
69
75
  false
70
76
  end
@@ -24,6 +24,13 @@ module Plutonium
24
24
  rodauth.rails_account
25
25
  end
26
26
 
27
+ # A request Rodauth authenticated with a valid JWT reads its session
28
+ # from the token, not the cookie, so there is nothing for a forged
29
+ # request to ride on. Everything else gets the CSRF token check.
30
+ def verified_request?
31
+ super || (rodauth.features.include?(:jwt) && rodauth.valid_jwt?)
32
+ end
33
+
27
34
  def logout_url
28
35
  rodauth.logout_path
29
36
  end
@@ -10,8 +10,6 @@ module Plutonium
10
10
  included do
11
11
  add_flash_types :success, :warning, :error
12
12
 
13
- protect_from_forgery with: :null_session, if: -> { request.headers["Authorization"].present? }
14
-
15
13
  rescue_from ::ActionPolicy::Unauthorized do |exception|
16
14
  respond_to do |format|
17
15
  format.any(:html, :turbo_stream) do
@@ -10,9 +10,6 @@ module Plutonium
10
10
  title: content,
11
11
  data: {
12
12
  controller: "timeago",
13
- timeago_refresh_interval_value: 1000,
14
- timeago_include_seconds_value: true,
15
- timeago_add_suffix_value: true,
16
13
  timeago_datetime_value: date.iso8601
17
14
  }
18
15
  )
@@ -10,7 +10,8 @@ module Plutonium
10
10
  def lookup(type)
11
11
  return type if type.is_a?(Class) && type < Filter
12
12
 
13
- class_name = "Plutonium::Query::Filters::#{type.to_s.classify}"
13
+ # camelize, not classify: classify singularizes (`:tags` -> `Tag`).
14
+ class_name = "Plutonium::Query::Filters::#{type.to_s.camelize}"
14
15
  class_name.constantize
15
16
  rescue NameError
16
17
  raise ArgumentError, "Unknown filter type: #{type}. Expected #{class_name} to exist."
@@ -0,0 +1,36 @@
1
+ module Plutonium
2
+ module Query
3
+ module Filters
4
+ # Filter by a no_fly_list `has_tags` context, through the gem's own
5
+ # query scopes.
6
+ #
7
+ # @example Records with any of the chosen tags
8
+ # filter :labels, with: :tags
9
+ #
10
+ # @example Records with every chosen tag
11
+ # filter :labels, with: :tags, match: :all
12
+ #
13
+ class Tags < Filter
14
+ def initialize(match: :any, **)
15
+ super(**)
16
+ @match = match
17
+ end
18
+
19
+ def apply(scope, value:)
20
+ tags = Array(value).compact_blank
21
+ return scope if tags.empty?
22
+
23
+ scope.public_send(:"with_#{@match}_#{key}", tags)
24
+ end
25
+
26
+ def humanize_value(value)
27
+ Array(value).compact_blank.join(", ")
28
+ end
29
+
30
+ def customize_inputs
31
+ input :value, as: Plutonium::UI::Form::Components::TagsFilter, context: key
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -6,6 +6,12 @@ module Plutonium
6
6
  module AssociatedWith
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ # Raised when more than one association links the two classes and no
10
+ # `associated_with_<record>` scope says which one to use. Picking the
11
+ # first would scope by declaration order, so a reordered model could
12
+ # silently widen what a tenant sees.
13
+ class AmbiguousAssociationError < StandardError; end
14
+
9
15
  included do
10
16
  scope :associated_with, ->(record) do
11
17
  # If scoping to same class, just match by ID (e.g., Team scoped to Team)
@@ -45,21 +51,27 @@ module Plutonium
45
51
 
46
52
  class_methods do
47
53
  def find_association_from_self_to_record(record)
48
- reflect_on_all_associations.find do |assoc|
54
+ matches = reflect_on_all_associations.select do |assoc|
49
55
  assoc.klass.name == record.class.name unless assoc.polymorphic?
50
56
  rescue
51
57
  assoc.check_validity!
52
58
  raise
53
59
  end
60
+ raise_ambiguous_association_error(self, record.class, matches, record) if matches.size > 1
61
+
62
+ matches.first
54
63
  end
55
64
 
56
65
  def find_association_to_self_from_record(record)
57
- record.class.reflect_on_all_associations.find do |assoc|
66
+ matches = record.class.reflect_on_all_associations.select do |assoc|
58
67
  assoc.klass.name == name
59
68
  rescue
60
69
  assoc.check_validity!
61
70
  raise
62
71
  end
72
+ raise_ambiguous_association_error(record.class, self, matches, record) if matches.size > 1
73
+
74
+ matches.first
63
75
  end
64
76
 
65
77
  def query_based_on_association(assoc, record)
@@ -75,6 +87,15 @@ module Plutonium
75
87
  end
76
88
  end
77
89
 
90
+ def raise_ambiguous_association_error(from, to, matches, record)
91
+ named_scope = :"associated_with_#{record.model_name.singular}"
92
+ raise AmbiguousAssociationError,
93
+ "#{from.name} has multiple associations to #{to.name}: #{matches.map(&:name).join(", ")}. " \
94
+ "Plutonium cannot tell which one scopes #{name} to a #{record.class.name}.\n\n" \
95
+ "Define a named scope on #{name} e.g.\n\n" \
96
+ "scope :#{named_scope}, ->(#{record.model_name.singular}) { do_something_here }"
97
+ end
98
+
78
99
  def raise_unresolvable_association_error(record, named_scope)
79
100
  raise "Could not resolve the association between '#{name}' and '#{record.class.name}'\n\n" \
80
101
  "Define\n" \
@@ -34,9 +34,22 @@ module Plutonium
34
34
  tag ||= inferred_field_component
35
35
  return create_component(tag, component_theme_key(tag), **attributes, &) if tag.is_a?(Class)
36
36
 
37
+ # `field :x, as:` reaches every surface, but some aliases exist on
38
+ # only one (`:toggle` is form-only, `:badge` display-only). Those
39
+ # surfaces render what they would have inferred instead of raising.
40
+ # A tag NO surface defines is a typo and still raises below.
41
+ tag = inferred_field_component if !respond_to?(:"#{tag}_tag") && ResolvesTags.surface_tag?(tag)
42
+
37
43
  send(:"#{tag}_tag", **attributes, &)
38
44
  end
39
45
 
46
+ # Whether any field surface (form or display; the table renders through
47
+ # a display builder) defines `tag`.
48
+ def self.surface_tag?(tag)
49
+ [Plutonium::UI::Form::Base::Builder, Plutonium::UI::Display::Base::Builder]
50
+ .any? { |builder| builder.method_defined?(:"#{tag}_tag") }
51
+ end
52
+
40
53
  private
41
54
 
42
55
  # Theme key for a directly-rendered component class: