plutonium 0.65.0 → 0.66.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 (104) 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 +144 -133
  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 +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -6,7 +6,7 @@ Add Rodauth-based authentication to your Plutonium app.
6
6
 
7
7
  Authenticated users can sign up, log in, change passwords, and reset forgotten passwords. Pages in protected portals are gated.
8
8
 
9
- ## Quick path — basic user auth
9
+ ## Quick path: basic user auth
10
10
 
11
11
  ```bash
12
12
  # 1. Install Rodauth
@@ -22,7 +22,7 @@ rails db:prepare
22
22
  # (when you run `pu:pkg:portal admin --auth=user`, this happens automatically)
23
23
  ```
24
24
 
25
- If you generated the portal with `--auth=user`, the engine is already mounted with the `Rodauth::Rails.authenticate(:user)` constraint — open `packages/admin_portal/config/routes.rb` to see it. The wiring looks like:
25
+ If you generated the portal with `--auth=user`, the engine is already mounted with the `Rodauth::Rails.authenticate(:user)` constraint: open `packages/admin_portal/config/routes.rb` to see it. The wiring looks like:
26
26
 
27
27
  ```ruby
28
28
  # packages/admin_portal/config/routes.rb (generated)
@@ -64,13 +64,13 @@ EMAIL=admin@example.com rails rodauth:admin
64
64
 
65
65
  The task creates the account and triggers a verification email; the admin sets their own password through that flow. No password is passed on the command line.
66
66
 
67
- ### Multi-tenant SaaS — user + entity + membership in one shot
67
+ ### Multi-tenant SaaS: user + entity + membership in one shot
68
68
 
69
69
  ```bash
70
70
  rails generate pu:saas:setup --user Customer --entity Organization
71
71
  ```
72
72
 
73
- ⚠️ This is a **meta-generator** — it also runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install`. Don't re-run those manually. See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup).
73
+ ⚠️ This is a **meta-generator**: it also runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install`. Don't re-run those manually. See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup).
74
74
 
75
75
  ### API-only (JWT)
76
76
 
@@ -103,7 +103,7 @@ module CustomerPortal::Concerns::Controller
103
103
  end
104
104
  ```
105
105
 
106
- Multiple account types — different portals use different Rodauth instances:
106
+ Multiple account types (different portals use different Rodauth instances):
107
107
 
108
108
  ```ruby
109
109
  # Admin portal
@@ -117,40 +117,40 @@ See [Reference › App › Portals](/reference/app/portals#controller-concern-au
117
117
 
118
118
  ## Multiple portals in one browser
119
119
 
120
- A person can hold a session in several portals at once — signed into the admin portal and the customer portal in the same browser, with neither evicting the other. Two settings make that work, and the generators emit both:
120
+ A person can hold a session in several portals at once (signed into the admin portal and the customer portal in the same browser, with neither evicting the other). Two settings make that work, and the generators emit both:
121
121
 
122
122
  ```ruby
123
- # app/rodauth/rodauth_plugin.rb — the shared base
123
+ # app/rodauth/rodauth_plugin.rb: the shared base
124
124
  configure do
125
125
  enable :session_isolation
126
126
  end
127
127
  ```
128
128
 
129
129
  ```ruby
130
- # app/rodauth/admin_rodauth_plugin.rb — once per account type
130
+ # app/rodauth/admin_rodauth_plugin.rb: once per account type
131
131
  configure do
132
132
  session_key_prefix "admin_"
133
133
  remember_cookie_key "_admin_remember"
134
134
  end
135
135
  ```
136
136
 
137
- Both are required. `session_key_prefix` namespaces *every* session key a configuration touches — the account id plus `authenticated_by`, `login_redirect`, `two_factor_auth_setup` and the rest. `session_isolation` uses that prefix to decide which entries belong to whom, and stops one configuration's login from clearing another's.
137
+ Both are required. `session_key_prefix` namespaces *every* session key a configuration touches, the account id plus `authenticated_by`, `login_redirect`, `two_factor_auth_setup` and the rest. `session_isolation` uses that prefix to decide which entries belong to whom, and stops one configuration's login from clearing another's.
138
138
 
139
139
  ::: danger Do not also set `session_key`
140
- An explicit `session_key` is **not** prefixed (`convert_session_key`, `rodauth/features/base.rb:686` — only the default value passes through it). Setting both leaves the account id on a different name from every other key, so they stop rotating together. A session that holds an account id but no `authenticated_by` makes Rodauth raise on *every request* — `logged_in_via_remember_key?` calls `nil.include?` (`remember.rb:175`).
140
+ An explicit `session_key` is **not** prefixed (`convert_session_key`, `rodauth/features/base.rb:686`, only the default value passes through it). Setting both leaves the account id on a different name from every other key, so they stop rotating together. A session that holds an account id but no `authenticated_by` makes Rodauth raise on *every request*, `logged_in_via_remember_key?` calls `nil.include?` (`remember.rb:175`).
141
141
 
142
142
  A config without a prefix at all is also not isolated: its keys are Rodauth's unprefixed defaults, indistinguishable from any other unprefixed config's, so `session_isolation` carries nothing for it.
143
143
  :::
144
144
 
145
145
  ::: details Why this is needed
146
- Rodauth resets the session on every login — including the `remember` feature's `load_memory` autologin — to defend against session fixation, and rodauth-rails implements that reset as a full `reset_session`.
146
+ Rodauth resets the session on every login, including the `remember` feature's `load_memory` autologin, to defend against session fixation, and rodauth-rails implements that reset as a full `reset_session`.
147
147
 
148
148
  Without `session_isolation`, signing into one portal wipes every other portal's session. And because `RodauthApp#route` calls `load_memory` for *every* configuration on *every* request, the evicted configuration immediately autologins from its remember cookie and evicts the new one right back. The last `load_memory` call in the route block wins permanently, so the other portal can never hold a session at all.
149
149
 
150
150
  `session_isolation` carries only the *other configurations'* session entries across the reset. The session id is still rotated and application session data is still cleared, so session fixation is still defeated.
151
151
  :::
152
152
 
153
- Reading a raw Rodauth session key? Go through its accessor, never the literal — with a prefix set, the literal is the wrong key:
153
+ Reading a raw Rodauth session key? Go through its accessor, never the literal: with a prefix set, the literal is the wrong key:
154
154
 
155
155
  ```ruby
156
156
  after_login do
@@ -159,9 +159,9 @@ after_login do
159
159
  end
160
160
  ```
161
161
 
162
- **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin and `session_key_prefix` in each account plugin — and **delete the existing `session_key "_x_session"` line** while you're there, for the reason above.
162
+ **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin and `session_key_prefix` in each account plugin, and **delete the existing `session_key "_x_session"` line** while you're there, for the reason above.
163
163
 
164
- Every key name changes together, so old session cookies stop matching and are ignored — the safe outcome. Keeping the old `session_key` to "preserve" logins is what produces the half-migrated session that crashes.
164
+ Every key name changes together, so old session cookies stop matching and are ignored (the safe outcome). Keeping the old `session_key` to "preserve" logins is what produces the half-migrated session that crashes.
165
165
 
166
166
  In practice only *unremembered* sessions drop: `remember_cookie_key` is a cookie name and isn't touched by the prefix, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request, which writes the new prefixed keys. Expect logouts for users who never ticked "Remember Me", not a full sign-out.
167
167
 
@@ -180,9 +180,9 @@ after_login { remember_login }
180
180
  ```
181
181
 
182
182
  ::: warning Compare the value, don't just check presence
183
- `param_or_nil(remember_param)` on its own is a truthiness check, and `remember_param` is shared with Rodauth's `/remember` settings page — which submits `forget` and `disable` as well as `remember`. A bare presence check means `remember=disable` would remember you. Compare against `remember_remember_param_value`, as above.
183
+ `param_or_nil(remember_param)` on its own is a truthiness check, and `remember_param` is shared with Rodauth's `/remember` settings page, which submits `forget` and `disable` as well as `remember`. A bare presence check means `remember=disable` would remember you. Compare against `remember_remember_param_value`, as above.
184
184
 
185
- Plutonium's login form also passes `include_hidden: false` to `check_box`, so an unticked box sends nothing at all rather than Rails' default `"0"`. With the value comparison that's belt-and-braces rather than load-bearing (`"0" == "remember"` is already false) — but it keeps a junk param off the wire and keeps the form correct for anyone who reverts the hook to a bare truthiness check.
185
+ Plutonium's login form also passes `include_hidden: false` to `check_box`, so an unticked box sends nothing at all rather than Rails' default `"0"`. With the value comparison that's belt-and-braces rather than load-bearing (`"0" == "remember"` is already false), but it keeps a junk param off the wire and keeps the form correct for anyone who reverts the hook to a bare truthiness check.
186
186
  :::
187
187
 
188
188
  Logged-in users can change the setting later on Rodauth's `/remember` page (Remember / Forget / Disable).
@@ -254,15 +254,15 @@ user
254
254
 
255
255
  ## Common issues
256
256
 
257
- - **"You need to set up Rodauth"** — run `pu:rodauth:install` first.
258
- - **Portal redirects to login even though you're authenticated** — the portal mount constraint references a different Rodauth account than the portal's controller concern uses. Match them up.
259
- - **Email confirmation never arrives in development** — Plutonium sets ActionMailer to `:test` by default. Check `tmp/letter_opener/` or your mail interceptor. In production, configure SMTP (see above).
260
- - **Signing into one portal signs you out of another** — or one portal can never stay signed in at all. Missing `enable :session_isolation` and/or `session_key_prefix`; see [Multiple portals in one browser](#multiple-portals-in-one-browser).
257
+ - **"You need to set up Rodauth"**: run `pu:rodauth:install` first.
258
+ - **Portal redirects to login even though you're authenticated**: the portal mount constraint references a different Rodauth account than the portal's controller concern uses. Match them up.
259
+ - **Email confirmation never arrives in development**: Plutonium sets ActionMailer to `:test` by default. Check `tmp/letter_opener/` or your mail interceptor. In production, configure SMTP (see above).
260
+ - **Signing into one portal signs you out of another**: or one portal can never stay signed in at all. Missing `enable :session_isolation` and/or `session_key_prefix`; see [Multiple portals in one browser](#multiple-portals-in-one-browser).
261
261
 
262
262
  ## Related
263
263
 
264
- - [Reference › Auth](/reference/auth/) — full auth surface
265
- - [Authorization](./authorization) — controlling who can do what AFTER login
266
- - [Multi-tenancy](./multi-tenancy) — entity scoping for SaaS apps
267
- - [User invites](./user-invites) — invitation-based onboarding
268
- - [User profile](./user-profile) — account-settings page
264
+ - [Reference › Auth](/reference/auth/): full auth surface
265
+ - [Authorization](./authorization): controlling who can do what AFTER login
266
+ - [Multi-tenancy](./multi-tenancy): entity scoping for SaaS apps
267
+ - [User invites](./user-invites): invitation-based onboarding
268
+ - [User profile](./user-profile): account-settings page
@@ -10,9 +10,9 @@ For each resource, decide who can create / read / update / destroy / run custom
10
10
 
11
11
  Every policy controls three things:
12
12
 
13
- 1. **Action permissions** — `create?`, `read?`, `update?`, `destroy?`, plus your custom action methods.
14
- 2. **Attribute permissions** — `permitted_attributes_for_create`, `_for_read`, etc.
15
- 3. **Collection scope** — `relation_scope` (which records show up in lists).
13
+ 1. **Action permissions**: `create?`, `read?`, `update?`, `destroy?`, plus your custom action methods.
14
+ 2. **Attribute permissions**: `permitted_attributes_for_create`, `_for_read`, etc.
15
+ 3. **Collection scope**: `relation_scope` (which records show up in lists).
16
16
 
17
17
  ## 🚨 Critical
18
18
 
@@ -39,7 +39,7 @@ class PostPolicy < ResourcePolicy
39
39
  end
40
40
  ```
41
41
 
42
- These default to `false` — without an explicit override, nobody can create or read records.
42
+ These default to `false`, without an explicit override, nobody can create or read records.
43
43
 
44
44
  ### 3. Override derived methods only when rules differ
45
45
 
@@ -55,7 +55,7 @@ def destroy?
55
55
  end
56
56
  ```
57
57
 
58
- 🚨 **`record` is the resource CLASS on collection routes.** `read?` backs both `show?` (called with the record) and `index?` (called with the class — there is no single record to pass). The same goes for `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates. So `def read? = record.published?` raises `NoMethodError` the moment the index renders. Keep record-state rules out of these methods: filter what the list shows in `relation_scope` (step 6 below), and gate individual records in `show?` (which always receives the record). Record-action methods like `publish?` are safe — they are always evaluated against an instance.
58
+ 🚨 **`record` is the resource CLASS on collection routes.** `read?` backs both `show?` (called with the record) and `index?` (called with the class: there is no single record to pass). The same goes for `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates. So `def read? = record.published?` raises `NoMethodError` the moment the index renders. Keep record-state rules out of these methods: filter what the list shows in `relation_scope` (step 6 below), and gate individual records in `show?` (which always receives the record). Record-action methods like `publish?` are safe; they are always evaluated against an instance.
59
59
 
60
60
  ### 4. Declare attribute permissions
61
61
 
@@ -69,8 +69,8 @@ def permitted_attributes_for_read
69
69
  end
70
70
  ```
71
71
 
72
- ::: warning Index has no `record`
73
- `permitted_attributes_for_index` runs at collection level — `record` is `nil`. If you write a `record`-dependent `_for_read`, you MUST also declare an explicit `_for_index`. See [Reference › Behavior › Policies › Index has no record](/reference/behavior/policies#index-has-no-record).
72
+ ::: warning Index has no `record` instance
73
+ `permitted_attributes_for_index` runs at collection level, where `record` is the resource class. `_for_index` falls back to `_for_read` (and `_for_export` to `_for_index`), so if you write a `record`-dependent `_for_read`, you MUST also declare an explicit `_for_index` that never touches `record`. See [Reference › Behavior › Policies › Index has no record](/reference/behavior/policies#index-has-no-record).
74
74
  :::
75
75
 
76
76
  ### 5. Custom action methods
@@ -87,7 +87,7 @@ end
87
87
 
88
88
  The method name matches the action name plus `?`. Undefined methods return `false`.
89
89
 
90
- ### 6. Optionally filter the collection — `relation_scope`
90
+ ### 6. Optionally filter the collection: `relation_scope`
91
91
 
92
92
  ```ruby
93
93
  relation_scope do |relation|
@@ -95,7 +95,7 @@ relation_scope do |relation|
95
95
  end
96
96
  ```
97
97
 
98
- 🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise `verify_default_relation_scope_applied!` raises at runtime. Calling it explicitly here is safest. `super` works only when the parent policy also calls it.
98
+ 🚨 `default_relation_scope(relation)` must be called somewhere in the chain, otherwise `verify_default_relation_scope_applied!` raises at runtime. Calling it explicitly here is safest. `super` works only when the parent policy also calls it.
99
99
 
100
100
  ## Common patterns
101
101
 
@@ -143,7 +143,7 @@ def update?
143
143
  end
144
144
  ```
145
145
 
146
- ## Bulk action authorization — per record
146
+ ## Bulk action authorization: per record
147
147
 
148
148
  ```ruby
149
149
  def bulk_archive?
@@ -154,7 +154,7 @@ end
154
154
  - **Backend:** if any selected record fails, the entire request is rejected.
155
155
  - **UI:** only actions ALL selected records support are shown (intersection).
156
156
 
157
- Records come from `current_authorized_scope` — users can only select records they can access.
157
+ Records come from `current_authorized_scope`; users can only select records they can access.
158
158
 
159
159
  ## Portal-specific policies
160
160
 
@@ -163,7 +163,7 @@ class PostPolicy < ResourcePolicy
163
163
  def create? = user.present?
164
164
  end
165
165
 
166
- # Admin — more permissive
166
+ # Admin: more permissive
167
167
  class AdminPortal::PostPolicy < ::PostPolicy
168
168
  include AdminPortal::ResourcePolicy
169
169
 
@@ -171,7 +171,7 @@ class AdminPortal::PostPolicy < ::PostPolicy
171
171
  def permitted_attributes_for_create = %i[title content featured internal_notes]
172
172
  end
173
173
 
174
- # Public — read-only
174
+ # Public: read-only
175
175
  class PublicPortal::PostPolicy < ::PostPolicy
176
176
  include PublicPortal::ResourcePolicy
177
177
  def create? = false
@@ -186,7 +186,7 @@ def permitted_associations
186
186
  end
187
187
  ```
188
188
 
189
- Drives the show-page tablist. Each named association must exist on the model AND be a registered Plutonium resource. See [Reference › Behavior › Policies › Association permissions](/reference/behavior/policies#association-permissions).
189
+ Drives the show-page tablist. Each named association must exist on the model AND be a registered Plutonium resource in every portal that uses the policy; a portal that doesn't register the child raises `ArgumentError ... is not a registered resource` on its show page. To add a tab in one portal only, put it in a portal-specific policy (`rails g pu:res:conn <Resource> --dest=<portal> --policy`). See [Reference › Behavior › Policies › Association permissions](/reference/behavior/policies#association-permissions).
190
190
 
191
191
  ::: warning Not for nested forms
192
192
  `permitted_associations` is for show-page navigation tabs, NOT nested forms. Nested forms come from `nested_input :variants` in the definition. See [Reference › Resource › Definition › Nested inputs](/reference/resource/definition#nested-inputs).
@@ -194,7 +194,7 @@ Drives the show-page tablist. Each named association must exist on the model AND
194
194
 
195
195
  ## Multi-tenant scoping
196
196
 
197
- When the portal sets `scope_to_entity Organization`, the inherited `relation_scope` automatically filters everything to the current org — no work in the policy. To add filters on top:
197
+ When the portal sets `scope_to_entity Organization`, the inherited `relation_scope` automatically filters everything to the current org, no work in the policy. To add filters on top:
198
198
 
199
199
  ```ruby
200
200
  relation_scope do |relation|
@@ -243,15 +243,15 @@ end
243
243
 
244
244
  ## Common issues
245
245
 
246
- - **Undefined custom action policy method** — the button silently disappears (undefined returns `false`). Add `def my_action?` to the policy.
247
- - **`record.X` crashes during index** — `record` is `nil` on index. Add an explicit `permitted_attributes_for_index` that doesn't depend on `record`.
248
- - **`verify_default_relation_scope_applied!` raises** — your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
249
- - **`super` in `relation_scope`** — works when you're extending a parent policy that itself calls `default_relation_scope`. If you're not sure (or you're inheriting from `Plutonium::Resource::Policy` directly), call `default_relation_scope(relation)` explicitly. The runtime check verifies `default_relation_scope` was hit somewhere — not that you wrote it in this class.
246
+ - **Undefined custom action policy method**: the button silently disappears (undefined returns `false`). Add `def my_action?` to the policy.
247
+ - **`record.X` crashes during index**: `record` is the resource class on index, not an instance. Add an explicit `permitted_attributes_for_index` that doesn't depend on `record`.
248
+ - **`verify_default_relation_scope_applied!` raises**: your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
249
+ - **`super` in `relation_scope`**; works when you're extending a parent policy that itself calls `default_relation_scope`. If you're not sure (or you're inheriting from `Plutonium::Resource::Policy` directly), call `default_relation_scope(relation)` explicitly. The runtime check verifies `default_relation_scope` was hit somewhere: not that you wrote it in this class.
250
250
 
251
251
  ## Related
252
252
 
253
- - [Reference › Behavior › Policies](/reference/behavior/policies) — full policy surface
254
- - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — `default_relation_scope`, multi-tenant patterns
255
- - [Authentication](./authentication) — who's the user in the first place
256
- - [Multi-tenancy](./multi-tenancy) — entity scoping setup
257
- - [Custom actions](./custom-actions) — defining the actions that need policy methods
253
+ - [Reference › Behavior › Policies](/reference/behavior/policies): full policy surface
254
+ - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping): `default_relation_scope`, multi-tenant patterns
255
+ - [Authentication](./authentication): who's the user in the first place
256
+ - [Multi-tenancy](./multi-tenancy): entity scoping setup
257
+ - [Custom actions](./custom-actions): defining the actions that need policy methods
@@ -13,7 +13,7 @@ Domain code (models, policies, definitions, interactions) lives in **feature pac
13
13
  | **Feature** | Business logic | `pu:pkg:package NAME` | `blogging`, `billing`, `inventory` |
14
14
  | **Portal** | Web interface | `pu:pkg:portal NAME` | `admin_portal`, `customer_portal`, `public_portal` |
15
15
 
16
- 🚨 Don't mix the two. Feature packages own the **domain code** — models, interactions, policies/definitions for resources owned by that feature. Portal packages own the **web surface** — controllers, routes, auth, and portal-specific policy/definition *overrides* for resources they expose.
16
+ 🚨 Don't mix the two. Feature packages own the **domain code**: models, interactions, policies/definitions for resources owned by that feature. Portal packages own the **web surface**: controllers, routes, auth, and portal-specific policy/definition *overrides* for resources they expose.
17
17
 
18
18
  ## Feature package
19
19
 
@@ -59,12 +59,12 @@ rails g pu:pkg:portal admin --auth=user
59
59
 
60
60
  Options:
61
61
 
62
- - `--auth=NAME` — Rodauth account to authenticate with.
63
- - `--public` — public access, no auth.
64
- - `--byo` — bring your own auth.
65
- - `--scope=CLASS` — entity class for multi-tenancy.
62
+ - `--auth=NAME`: Rodauth account to authenticate with.
63
+ - `--public`: public access, no auth.
64
+ - `--byo`: bring your own auth.
65
+ - `--scope=CLASS`: entity class for multi-tenancy.
66
66
 
67
- The generator mounts the engine for you — at `/admin` in this case, wrapped in `constraints Rodauth::Rails.authenticate(:user)` because you passed `--auth=user`. Open `packages/admin_portal/config/routes.rb` to see the generated mount.
67
+ The generator mounts the engine for you: at `/admin` in this case, wrapped in `constraints Rodauth::Rails.authenticate(:user)` because you passed `--auth=user`. Open `packages/admin_portal/config/routes.rb` to see the generated mount.
68
68
 
69
69
  ### 2. Connect resources
70
70
 
@@ -87,7 +87,7 @@ Every file under `app/<kind>/blogging/` resolves to `Blogging::*`:
87
87
  - `app/models/blogging/post.rb` → `Blogging::Post`
88
88
  - `app/policies/blogging/post_policy.rb` → `Blogging::PostPolicy`
89
89
 
90
- Each feature package gets base classes — `Blogging::ApplicationRecord`, `Blogging::ResourceRecord`, `Blogging::ResourcePolicy`, `Blogging::ResourceDefinition`, `Blogging::ResourceInteraction` — that inherit from the main app's.
90
+ Each feature package gets base classes, `Blogging::ApplicationRecord`, `Blogging::ResourceRecord`, `Blogging::ResourcePolicy`, `Blogging::ResourceDefinition`, `Blogging::ResourceInteraction`, that inherit from the main app's.
91
91
 
92
92
  ## Cross-package references
93
93
 
@@ -129,7 +129,7 @@ packages/
129
129
  └── customer_portal/ # Portal: customer dashboard
130
130
  ```
131
131
 
132
- The portals expose the features. A single feature can be exposed by multiple portals — usually with different policies and definitions per portal.
132
+ The portals expose the features. A single feature can be exposed by multiple portals, usually with different policies and definitions per portal.
133
133
 
134
134
  ## Package loading
135
135
 
@@ -147,13 +147,13 @@ Loaded from `config/application.rb`. Migrations from all packages are picked up
147
147
  ## Per-portal overrides
148
148
 
149
149
  ```ruby
150
- # Definition — how fields render per portal
150
+ # Definition: how fields render per portal
151
151
  class AdminPortal::PostDefinition < ::PostDefinition
152
152
  scope :pending_review
153
153
  input :internal_notes, hint: "Not shown to the author"
154
154
  end
155
155
 
156
- # Policy — which fields exist, and who may act
156
+ # Policy: which fields exist, and who may act
157
157
  # `internal_notes` appears for admins because THIS permits it,
158
158
  # not because the definition above mentions it.
159
159
  class AdminPortal::PostPolicy < ::PostPolicy
@@ -166,13 +166,13 @@ end
166
166
 
167
167
  ## Common issues
168
168
 
169
- - **Class not loading** — namespace must match the directory: `app/models/blogging/post.rb` MUST be `Blogging::Post`.
170
- - **Migration not running** — package migrations are auto-included. If they aren't running, check `config/packages.rb` is loaded from `application.rb`.
171
- - **Cross-package association fails** — use `blogging/post:belongs_to` in `pu:res:scaffold`, OR manually set `class_name: "Blogging::Post"` on the `belongs_to`.
169
+ - **Class not loading**: namespace must match the directory: `app/models/blogging/post.rb` MUST be `Blogging::Post`.
170
+ - **Migration not running**: package migrations are auto-included. If they aren't running, check `config/packages.rb` is loaded from `application.rb`.
171
+ - **Cross-package association fails**: use `blogging/post:belongs_to` in `pu:res:scaffold`, OR manually set `class_name: "Blogging::Post"` on the `belongs_to`.
172
172
 
173
173
  ## Related
174
174
 
175
- - [Reference › App › Packages](/reference/app/packages) — full package surface
176
- - [Reference › App › Portals](/reference/app/portals) — portal-specific configuration
177
- - [Adding resources](./adding-resources) — `pu:res:scaffold` and `pu:res:conn`
178
- - [Authentication](./authentication) — portal auth setup
175
+ - [Reference › App › Packages](/reference/app/packages): full package surface
176
+ - [Reference › App › Portals](/reference/app/portals): portal-specific configuration
177
+ - [Adding resources](./adding-resources): `pu:res:scaffold` and `pu:res:conn`
178
+ - [Authentication](./authentication): portal auth setup
@@ -1,6 +1,6 @@
1
1
  # Custom Actions
2
2
 
3
- Add buttons beyond CRUD — Publish, Archive, Import, Send invitation, Bulk-update, etc.
3
+ Add buttons beyond CRUD: Publish, Archive, Import, Send invitation, Bulk-update, etc.
4
4
 
5
5
  ## Goal
6
6
 
@@ -10,17 +10,17 @@ A button appears in the right place (show page / table row / index header / bulk
10
10
 
11
11
  | Flavor | Use for |
12
12
  |---|---|
13
- | **Simple action** — navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
14
- | **Interactive action** — run an interaction class | Anything that *does* something (the common case) |
13
+ | **Simple action**: navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
14
+ | **Interactive action**: run an interaction class | Anything that *does* something (the common case) |
15
15
 
16
- Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects — all for free.
16
+ Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects, all for free.
17
17
 
18
- ## Quick recipe — interactive action
18
+ ## Quick recipe: interactive action
19
19
 
20
20
  ### 1. Write the interaction
21
21
 
22
22
  ```ruby
23
- # app/models/post.rb — what publishing actually means
23
+ # app/models/post.rb: what publishing actually means
24
24
  class Post < ApplicationRecord
25
25
  def publish!(on: Time.current)
26
26
  update!(published: true, published_at: on)
@@ -29,7 +29,7 @@ end
29
29
  ```
30
30
 
31
31
  ```ruby
32
- # app/interactions/publish_post_interaction.rb — the button in front of it
32
+ # app/interactions/publish_post_interaction.rb: the button in front of it
33
33
  class PublishPostInteraction < ResourceInteraction
34
34
  presents label: "Publish",
35
35
  icon: Phlex::TablerIcons::Send,
@@ -51,7 +51,7 @@ Plutonium doesn't rescue it automatically. Always rescue when using `create!` /
51
51
  :::
52
52
 
53
53
  ::: tip Why `publish!` is on the model
54
- An interaction can only be built with a `view_context` — it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
54
+ An interaction can only be built with a `view_context`; it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
55
55
  :::
56
56
 
57
57
  ### 2. Register it in the definition
@@ -62,7 +62,7 @@ class PostDefinition < ResourceDefinition
62
62
  end
63
63
  ```
64
64
 
65
- Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes — no need to declare `record_action: true`. See [Inferred visibility](#inferred-visibility) below.
65
+ Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes; no need to declare `record_action: true`. See [Inferred visibility](#inferred-visibility) below.
66
66
 
67
67
  ### 3. Add a policy method
68
68
 
@@ -88,7 +88,7 @@ For `interaction:`-based actions, visibility flags are inferred from the interac
88
88
  | `attribute :resources` (plural) | `bulk_action: true` → bulk toolbar |
89
89
  | neither | `resource_action: true` → index page header |
90
90
 
91
- User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden" — the interaction's attribute shape is semantic:
91
+ User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden"; the interaction's attribute shape is semantic:
92
92
 
93
93
  ```ruby
94
94
  # Hide from per-row menu, keep on show page
@@ -127,7 +127,7 @@ class Company::InviteUserInteraction < ResourceInteraction
127
127
  end
128
128
  ```
129
129
 
130
- `Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight — see the [full worked example](/reference/behavior/interactions#complete-example).
130
+ `Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight, see the [full worked example](/reference/behavior/interactions#complete-example).
131
131
 
132
132
  ## Bulk actions
133
133
 
@@ -146,7 +146,7 @@ class BulkArchiveInteraction < ResourceInteraction
146
146
  end
147
147
  ```
148
148
 
149
- Policy — checked **per record** (fails the whole request if any record is unauthorized):
149
+ Policy: checked **per record** (fails the whole request if any record is unauthorized):
150
150
 
151
151
  ```ruby
152
152
  def bulk_archive?
@@ -181,13 +181,13 @@ class BulkArchiveInteraction < ResourceInteraction
181
181
  end
182
182
  ```
183
183
 
184
- Nothing else about the action changes — the definition, the policy method and the form are the same. Only the work moves.
184
+ Nothing else about the action changes: the definition, the policy method and the form are the same. Only the work moves.
185
185
 
186
186
  Three things worth knowing:
187
187
 
188
188
  - **The block is the run's class body, not `execute`.** The work runs later, in a job with no controller, so it cannot close over anything in the interaction. Its inputs arrive through `options`.
189
189
  - **The user is sent back where they were**, and the index they land on shows a banner for the run with a link to its progress page.
190
- - **Permissions are re-checked per record, at perform time** — not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
190
+ - **Permissions are re-checked per record, at perform time**: not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
191
191
 
192
192
  Full detail, including file attributes, failure policies and resuming a crashed run: [Async Interactions](/reference/behavior/async-interactions).
193
193
 
@@ -212,8 +212,8 @@ end
212
212
 
213
213
  ## Immediate vs form
214
214
 
215
- - **Immediate** — interaction has only `:resource` / `:resources` (no extra inputs). Browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom message"` or `confirmation: false` on the action.
216
- - **Form** — interaction has additional `attribute` / `input`. Renders modal form first; no auto-confirmation (the form is the confirmation).
215
+ - **Immediate**: interaction has only `:resource` / `:resources` (no extra inputs). Browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom message"` or `confirmation: false` on the action.
216
+ - **Form**: interaction has additional `attribute` / `input`. Renders modal form first; no auto-confirmation (the form is the confirmation).
217
217
 
218
218
  ## Action options
219
219
 
@@ -231,10 +231,10 @@ action :name,
231
231
 
232
232
  # Behavior
233
233
  confirmation: "Are you sure?",
234
- modal: :slideover, # :slideover / :centered — overrides definition's modal mode
235
- size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
234
+ modal: :slideover, # :slideover / :centered, overrides definition's modal mode
235
+ size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
236
236
 
237
- # HTML attributes — author wins over the framework's on every key
237
+ # HTML attributes: author wins over the framework's on every key
238
238
  link: {target: "_blank", rel: "noopener"}, # every <a> rendering (toolbar GET link, dropdown items, bulk links, card show link)
239
239
  button: {data: {analytics: "archive"}} # the button_to <form> wrapper (non-GET toolbar rendering)
240
240
  ```
@@ -283,7 +283,7 @@ Every resource gets `:archive` automatically.
283
283
 
284
284
  ## Where the logic goes
285
285
 
286
- Interactions are presentation objects — they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
286
+ Interactions are presentation objects: they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
287
287
 
288
288
  ```ruby
289
289
  # 🚫 Three interactions, three view contexts, none of it callable from a job
@@ -300,22 +300,22 @@ def execute
300
300
  end
301
301
  ```
302
302
 
303
- Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does — and neither has a view context to hand.
303
+ Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does, and neither has a view context to hand.
304
304
 
305
- The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger** — and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
305
+ The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger**, and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
306
306
 
307
307
  ## Common issues
308
308
 
309
- - **Action button missing** — check the policy method (`def my_action?`). Undefined returns `false`.
310
- - **`ActiveRecord::RecordInvalid` crashes the action** — not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
311
- - **Bulk action fails on some records** — that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
312
- - **Confirmation prompt shows when you don't want one** — pass `confirmation: false` on the action.
313
- - **The action times out on a large selection** — the work is running inside the request. Move it to a background run with `async`, above.
309
+ - **Action button missing**: check the policy method (`def my_action?`). Undefined returns `false`.
310
+ - **`ActiveRecord::RecordInvalid` crashes the action**: not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
311
+ - **Bulk action fails on some records**: that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
312
+ - **Confirmation prompt shows when you don't want one**: pass `confirmation: false` on the action.
313
+ - **The action times out on a large selection**: the work is running inside the request. Move it to a background run with `async`, above.
314
314
 
315
315
  ## Related
316
316
 
317
- - [Reference › Resource › Actions](/reference/resource/actions) — full action options and bulk patterns
318
- - [Reference › Behavior › Interactions](/reference/behavior/interactions) — interaction class anatomy
319
- - [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions) — `async`, progress pages, resuming a crashed run
320
- - [Reference › Behavior › Policies](/reference/behavior/policies) — `def <action>?` methods
321
- - [Authorization](./authorization) — policy patterns
317
+ - [Reference › Resource › Actions](/reference/resource/actions): full action options and bulk patterns
318
+ - [Reference › Behavior › Interactions](/reference/behavior/interactions): interaction class anatomy
319
+ - [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions): `async`, progress pages, resuming a crashed run
320
+ - [Reference › Behavior › Policies](/reference/behavior/policies): `def <action>?` methods
321
+ - [Authorization](./authorization): policy patterns