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
@@ -8,15 +8,15 @@ Each tenant sees only their own records. Queries are filtered, forms inject the
8
8
 
9
9
  ## 🚨 Critical
10
10
 
11
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain — explicitly here, or via `super(relation)` (the framework's `Plutonium::Resource::Policy` base calls it for you).
11
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain: explicitly here, or via `super(relation)` (the framework's `Plutonium::Resource::Policy` base calls it for you).
12
12
  - **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
13
- - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
13
+ - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}`, without this, uniqueness leaks across tenants.
14
14
 
15
15
  After login, users with memberships in multiple entities land on a workspace selector:
16
16
 
17
17
  ![Workspace selector after login](/images/guides/multi-tenancy-welcome.png)
18
18
 
19
- Picking one lands them on the entity-scoped dashboard — note the entity slug in the URL:
19
+ Picking one lands them on the entity-scoped dashboard: note the entity slug in the URL:
20
20
 
21
21
  ![Tenant-scoped dashboard](/images/guides/multi-tenancy-dashboard.png)
22
22
 
@@ -62,14 +62,17 @@ end
62
62
 
63
63
  Or pass `--scope=Organization` to `pu:pkg:portal` and the engine wires this automatically.
64
64
 
65
- ### 4. Mount the portal
65
+ ### 4. Check the mount
66
+
67
+ `pu:pkg:portal` already mounted the engine in `packages/customer_portal/config/routes.rb`:
66
68
 
67
69
  ```ruby
68
- # config/routes.rb
69
70
  mount CustomerPortal::Engine, at: "/customer"
70
71
  ```
71
72
 
72
- URLs now include the entity id as the first path segment after the mount: `/customer/42/posts`. The underlying param name is `organization_scoped` (Plutonium suffixes `_scoped` to avoid a name collision with any `belongs_to :organization` on child models — `params[:organization_scoped]` vs `params[:organization]`). Pass `param_key:` to `scope_to_entity` if you want a different param name.
73
+ To change the path, edit `at:` there. Don't add a second `mount` to `config/routes.rb`; Rails raises `Invalid route name, already in use`.
74
+
75
+ URLs now include the entity id as the first path segment after the mount: `/customer/42/posts`. The underlying param name is `organization_scoped` (Plutonium suffixes `_scoped` to avoid a name collision with any `belongs_to :organization` on child models: `params[:organization_scoped]` vs `params[:organization]`). Pass `param_key:` to `scope_to_entity` if you want a different param name.
73
76
 
74
77
  ### 5. Compound uniqueness
75
78
 
@@ -82,6 +85,10 @@ end
82
85
 
83
86
  🚨 Without the `scope:`, the same slug in different orgs would collide.
84
87
 
88
+ ### 6. Leave the entity in the policy
89
+
90
+ `organization` can stay in `permitted_attributes_for_create`. In the scoped portal the controller removes it from the form and params and sets it from the current entity (a forged `organization_id` is overwritten), while an unscoped admin portal using the same policy still gets a normal select.
91
+
85
92
  ## Strategies
86
93
 
87
94
  ### Path strategy (default)
@@ -143,7 +150,7 @@ class Membership < ResourceRecord
143
150
  end
144
151
  ```
145
152
 
146
- ### 3. Grandchild — `has_one :through`
153
+ ### 3. Grandchild: `has_one :through`
147
154
 
148
155
  ```ruby
149
156
  class Post < ResourceRecord
@@ -187,9 +194,9 @@ relation_scope do |relation|
187
194
  end
188
195
  ```
189
196
 
190
- 🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise the runtime verification raises. `super(relation)` works when extending `Plutonium::Resource::Policy` directly (its block calls `default_relation_scope`); call `default_relation_scope` by name when you're not chaining via `super`.
197
+ 🚨 `default_relation_scope(relation)` must be called somewhere in the chain, otherwise the runtime verification raises. `super(relation)` works when extending `Plutonium::Resource::Policy` directly (its block calls `default_relation_scope`); call `default_relation_scope` by name when you're not chaining via `super`.
191
198
 
192
- ## Cross-tenant operations — super-admin portal
199
+ ## Cross-tenant operations: super-admin portal
193
200
 
194
201
  Create a separate portal **without** `scope_to_entity`:
195
202
 
@@ -197,7 +204,7 @@ Create a separate portal **without** `scope_to_entity`:
197
204
  module SuperAdminPortal
198
205
  class Engine < Rails::Engine
199
206
  include Plutonium::Portal::Engine
200
- # No scope_to_entity — sees all tenants
207
+ # No scope_to_entity: sees all tenants
201
208
  end
202
209
  end
203
210
  ```
@@ -206,14 +213,14 @@ This portal's policies see everything. Don't enable public signup here.
206
213
 
207
214
  ## Multiple associations to the same entity
208
215
 
209
- If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), Plutonium raises:
216
+ If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), the controller raises:
210
217
 
211
218
  ```
212
219
  Match has multiple associations to Competition::Team: home_team, away_team.
213
220
  Plutonium cannot auto-detect which one to use for entity scoping.
214
221
  ```
215
222
 
216
- Override on the controller:
223
+ Override on the portal controller:
217
224
 
218
225
  ```ruby
219
226
  class MatchesController < ::ResourceController
@@ -222,18 +229,24 @@ class MatchesController < ::ResourceController
222
229
  end
223
230
  ```
224
231
 
232
+ Query scoping (`associated_with`) raises `AmbiguousAssociationError` too, so add an explicit scope on the model:
233
+
234
+ ```ruby
235
+ scope :associated_with_competition_team, ->(team) { where(home_team: team) }
236
+ ```
237
+
225
238
  ## Common issues
226
239
 
227
- - **`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(...)`.
228
- - **`Could not resolve the association between 'Model' and 'Entity'`** — the model has no path to the entity. Fix on the **model** (declare `has_one :through` or a custom `associated_with_<entity>` scope). Never paper over with `where` in the policy.
229
- - **Records leak across tenants** — likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
230
- - **Forms show the entity field anyway** — check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
231
- - **Want to bypass scoping in one place** — use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
240
+ - **`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(...)`.
241
+ - **`Could not resolve the association between 'Model' and 'Entity'`**: the model has no path to the entity. Fix on the **model** (declare `has_one :through` or a custom `associated_with_<entity>` scope). Never paper over with `where` in the policy.
242
+ - **Records leak across tenants**: likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
243
+ - **Forms show the entity field anyway**: check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
244
+ - **Want to bypass scoping in one place**: use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
232
245
 
233
246
  ## Related
234
247
 
235
- - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — full surface
236
- - [Reference › Behavior › Policies](/reference/behavior/policies) — `relation_scope` syntax
237
- - [Reference › App › Portals](/reference/app/portals) — `scope_to_entity` engine config
238
- - [Nested resources](./nested-resources) — parent scoping (takes precedence over entity scoping)
239
- - [User invites](./user-invites) — invitation-based membership onboarding
248
+ - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping): full surface
249
+ - [Reference › Behavior › Policies](/reference/behavior/policies): `relation_scope` syntax
250
+ - [Reference › App › Portals](/reference/app/portals): `scope_to_entity` engine config
251
+ - [Nested resources](./nested-resources): parent scoping (takes precedence over entity scoping)
252
+ - [User invites](./user-invites): invitation-based membership onboarding
@@ -11,7 +11,7 @@ Set up parent/child relationships so `/companies/:id/nested_properties` works au
11
11
  - Forms that auto-fill the parent (no manual hidden field).
12
12
  - Queries scoped to the parent (sibling companies' properties invisible).
13
13
 
14
- All of this happens with no manual route wiring — Plutonium generates it from the association.
14
+ All of this happens with no manual route wiring; Plutonium generates it from the association.
15
15
 
16
16
  ## Steps
17
17
 
@@ -65,7 +65,7 @@ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-
65
65
  | `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
66
66
  | `/companies/:company_id/nested_company_profile/new` | `has_one` new |
67
67
 
68
- `has_one` associations get singular routes — index redirects to show (or new if no record exists).
68
+ `has_one` associations get singular routes: index redirects to show (or new if no record exists).
69
69
 
70
70
  Every routable association gets one by default. To draw only some of them:
71
71
 
@@ -149,7 +149,7 @@ end
149
149
  ```
150
150
 
151
151
  ::: warning Always pass `as:`
152
- Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails — no named route to look up.
152
+ Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails: no named route to look up.
153
153
  :::
154
154
 
155
155
  ## Policy authorization context
@@ -167,13 +167,13 @@ class PropertyPolicy < ResourcePolicy
167
167
  end
168
168
  ```
169
169
 
170
- The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
170
+ The parent is authorized for `:read?` before `current_parent` returns; children inherit the parent's access requirements.
171
171
 
172
172
  ## Parent scoping vs entity scoping
173
173
 
174
- When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization — double-scoping isn't needed.
174
+ When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization; double-scoping isn't needed.
175
175
 
176
- In the child policy, just call `default_relation_scope` — it handles both cases:
176
+ In the child policy, just call `default_relation_scope`: it handles both cases:
177
177
 
178
178
  ```ruby
179
179
  relation_scope do |relation|
@@ -194,7 +194,7 @@ For deeper hierarchies, use top-level routes plus association tabs on the show p
194
194
 
195
195
  ## Nested inputs (sub-records inside a parent form)
196
196
 
197
- A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form — a single submit creates/updates/deletes them in one go, backed by Rails' `accepts_nested_attributes_for`.
197
+ A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form: a single submit creates/updates/deletes them in one go, backed by Rails' `accepts_nested_attributes_for`.
198
198
 
199
199
  Use nested inputs when the children are conceptually part of the parent (line items on an order, variants on a product, contact methods on a person) and don't deserve their own page.
200
200
 
@@ -214,7 +214,7 @@ class PostDefinition < ResourceDefinition
214
214
  end
215
215
  end
216
216
 
217
- # Policy — list the association name (NOT `comments_attributes`)
217
+ # Policy: list the association name (NOT `comments_attributes`)
218
218
  class PostPolicy < ResourcePolicy
219
219
  def permitted_attributes_for_create
220
220
  [:title, :body, :comments]
@@ -223,7 +223,7 @@ end
223
223
  ```
224
224
 
225
225
  ::: warning Permit the association, not the strong-params shape
226
- List `:comments` in `permitted_attributes_for_*` — Plutonium translates it to `comments_attributes: [...]` for you. If you write the raw hash, the form renders the field name as a literal label instead of the nested editor.
226
+ List `:comments` in `permitted_attributes_for_*`: Plutonium translates it to `comments_attributes: [...]` for you. If you write the raw hash, the form renders the field name as a literal label instead of the nested editor.
227
227
  :::
228
228
 
229
229
  ### Result
@@ -251,13 +251,13 @@ nested_input :profile, macro: :has_one # singular sub-form, no Add button
251
251
 
252
252
  | | Nested inputs (`nested_input :comments`) | Nested resources (this guide's main topic) |
253
253
  |---|---|---|
254
- | URL | None — inline in parent form | `/posts/:id/nested_comments` |
255
- | Submit | One — saves parent + children together | Independent CRUD per child |
254
+ | URL | None, inline in parent form | `/posts/:id/nested_comments` |
255
+ | Submit | One, saves parent + children together | Independent CRUD per child |
256
256
  | Discoverability | Always visible in parent form | Tab on parent show page (with `permitted_associations`) |
257
257
  | Best for | Tightly-owned children (line items, variants) | Children users browse on their own (orders, posts) |
258
258
  | Backing | `accepts_nested_attributes_for` | Plutonium's nested controller routing |
259
259
 
260
- You can use both on the same association — they're not mutually exclusive.
260
+ You can use both on the same association; they're not mutually exclusive.
261
261
 
262
262
  ## Inline `+` add on the parent form
263
263
 
@@ -265,15 +265,15 @@ When a form has an association select (e.g. picking the company on a Property fo
265
265
 
266
266
  ## Common issues
267
267
 
268
- - **Nested route doesn't exist** — both parent AND child must be registered in the same portal (`pu:res:conn`).
269
- - **Parent shows up in the form anyway** — check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
270
- - **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`) — Plutonium raises. Override `scoped_entity_association` to specify. See [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping#multiple-associations-to-the-same-entity-class).
271
- - **`resource_url_for` returns wrong URL for a nested resource** — check that custom routes use `as:`.
268
+ - **Nested route doesn't exist**: both parent AND child must be registered in the same portal (`pu:res:conn`).
269
+ - **Parent shows up in the form anyway**: check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
270
+ - **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`): give the parent one `has_many` per side (`has_many :home_matches, class_name: "Match", foreign_key: :home_team_id`). Each becomes its own nested route, and Plutonium fills in the matching `belongs_to` through `inverse_of` or the foreign key, so nothing raises. `scoped_entity_association` only matters when the parent class is also the portal's entity; see [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping#multiple-associations-to-the-same-entity-class).
271
+ - **`resource_url_for` returns wrong URL for a nested resource**: check that custom routes use `as:`.
272
272
 
273
273
  ## Related
274
274
 
275
- - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources) — full surface
276
- - [Reference › Behavior › Controllers](/reference/behavior/controllers) — `current_parent`, presentation hooks
277
- - [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions) — `permitted_associations`
278
- - [Multi-tenancy](./multi-tenancy) — how entity scoping interacts with parent scoping
279
- - [Adding resources](./adding-resources) — basic resource setup
275
+ - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources): full surface
276
+ - [Reference › Behavior › Controllers](/reference/behavior/controllers): `current_parent`, presentation hooks
277
+ - [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions): `permitted_associations`
278
+ - [Multi-tenancy](./multi-tenancy): how entity scoping interacts with parent scoping
279
+ - [Adding resources](./adding-resources): basic resource setup
@@ -20,7 +20,7 @@ Index pages, kanban boards and CSV exports already eager-load the associations a
20
20
 
21
21
  Each rendering passes its own field set, because they differ: the index renders its permitted attributes, an export renders `permitted_attributes_for_export`, and a kanban card renders its `card_fields`.
22
22
 
23
- It covers every association kind — `belongs_to`, `has_one`, `has_many` — and attachments on both ActiveStorage and Shrine.
23
+ It covers every association kind (`belongs_to`, `has_one`, `has_many`) and attachments on both ActiveStorage and Shrine.
24
24
 
25
25
  Turn it off globally:
26
26
 
@@ -99,6 +99,6 @@ Those need to leave the request rather than be optimised inside it. An interacti
99
99
 
100
100
  ## Related
101
101
 
102
- - **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables — see [Resource › Query](/reference/resource/query#search).
102
+ - **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables; see [Resource › Query](/reference/resource/query#search).
103
103
  - **Page size.** Query cost scales with rows per page.
104
- - [Async Interactions](/reference/behavior/async-interactions) — moving slow work out of the request
104
+ - [Async Interactions](/reference/behavior/async-interactions): moving slow work out of the request
@@ -28,7 +28,7 @@ All declared in the definition.
28
28
 
29
29
  ```ruby
30
30
  class PostDefinition < ResourceDefinition
31
- # Search box — searches title and body
31
+ # Search box: searches title and body
32
32
  search do |scope, query|
33
33
  scope.where("title ILIKE :q OR body ILIKE :q", q: "%#{query}%")
34
34
  end
@@ -81,7 +81,7 @@ end
81
81
 
82
82
  When an association input targets this resource, the dropdown's autocomplete calls the resource's `search` block. Same code, two surfaces.
83
83
 
84
- ### Without a `search` block — typeahead fallback
84
+ ### Without a `search` block: typeahead fallback
85
85
 
86
86
  The framework falls back to a case-insensitive `LIKE` on the first column it finds, in priority order:
87
87
 
@@ -89,7 +89,7 @@ The framework falls back to a case-insensitive `LIKE` on the first column it fin
89
89
  2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
90
90
  3. Otherwise the relation is returned unfiltered (capped).
91
91
 
92
- For large tables, write an explicit `search` block — the leading-wildcard `LIKE` can't use a b-tree index. See [Reference › Resource › Query › Search](/reference/resource/query#search).
92
+ For large tables, write an explicit `search` block: the leading-wildcard `LIKE` can't use a b-tree index. See [Reference › Resource › Query › Search](/reference/resource/query#search).
93
93
 
94
94
  ## Filters
95
95
 
@@ -154,7 +154,7 @@ class PostDefinition < ResourceDefinition
154
154
  scope :published # uses Post.published
155
155
  scope :draft # uses Post.draft
156
156
 
157
- # Inline scope — block runs with scope as argument
157
+ # Inline scope: block runs with scope as argument
158
158
  scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
159
159
 
160
160
  # Scope with controller context
@@ -206,9 +206,9 @@ Query params are namespaced under `q`:
206
206
  ## Performance tips
207
207
 
208
208
  - **Add indexes** for filtered and sorted columns.
209
- - **Use `.distinct`** when joining associations in search — duplicate rows otherwise.
209
+ - **Use `.distinct`** when joining associations in search: duplicate rows otherwise.
210
210
  - **Prefer scopes over filters** for queries used often (no input parsing).
211
- - **`LIKE '%q%'` can't use a b-tree index** — for large tables, use `pg_search` or a trigram/GIN/full-text index.
211
+ - **`LIKE '%q%'` can't use a b-tree index**: for large tables, use `pg_search` or a trigram/GIN/full-text index.
212
212
 
213
213
  ## Full-text search with `pg_search`
214
214
 
@@ -227,13 +227,13 @@ end
227
227
 
228
228
  ## Common issues
229
229
 
230
- - **Filter not showing up** — make sure the attribute is in `permitted_attributes_for_index` on the policy.
231
- - **Slow search on large tables** — `LIKE '%q%'` can't be indexed by a b-tree. Switch to FTS or trigram.
232
- - **Duplicate rows in results** — add `.distinct` when joining associations.
233
- - **Typeahead works on small dev tables but slows in production** — same b-tree issue. Write an explicit `search` block backed by a proper index.
230
+ - **Filter not showing up**: make sure the attribute is in `permitted_attributes_for_index` on the policy.
231
+ - **Slow search on large tables**: `LIKE '%q%'` can't be indexed by a b-tree. Switch to FTS or trigram.
232
+ - **Duplicate rows in results**: add `.distinct` when joining associations.
233
+ - **Typeahead works on small dev tables but slows in production**: same b-tree issue. Write an explicit `search` block backed by a proper index.
234
234
 
235
235
  ## Related
236
236
 
237
- - [Reference › Resource › Query](/reference/resource/query) — full surface
238
- - [Adding resources](./adding-resources) — basic resource setup
239
- - [Authorization](./authorization) — `permitted_attributes_for_index` gates which fields can be filtered
237
+ - [Reference › Resource › Query](/reference/resource/query): full surface
238
+ - [Adding resources](./adding-resources): basic resource setup
239
+ - [Authorization](./authorization): `permitted_attributes_for_index` gates which fields can be filtered
@@ -1,6 +1,6 @@
1
1
  # Testing
2
2
 
3
- Plutonium ships `Plutonium::Testing` — opt-in Minitest concerns that give your app default test coverage for resources, policies, definitions, interactions, models, nested scoping, portal access, and authentication.
3
+ Plutonium ships `Plutonium::Testing`, opt-in Minitest concerns that give your app default test coverage for resources, policies, definitions, interactions, models, nested scoping, portal access, and authentication.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -75,7 +75,7 @@ resource_tests_for ResourceClass,
75
75
  has_cents: %i[price] # ResourceModel only
76
76
  ```
77
77
 
78
- The **portal symbol** drives path prefix, default auth strategy, and scoping expectations. The resolver walks `Rails.application.routes.routes` for the engine mount — no manual configuration.
78
+ The **portal symbol** drives path prefix, default auth strategy, and scoping expectations. The resolver walks `Rails.application.routes.routes` for the engine mount, with no manual configuration.
79
79
 
80
80
  ## Concerns
81
81
 
@@ -84,15 +84,17 @@ The **portal symbol** drives path prefix, default auth strategy, and scoping exp
84
84
  | `ResourceCrud` | index/show/new/create/edit/update/destroy | `create_resource!`, `valid_create_params`, `valid_update_params` |
85
85
  | `ResourcePolicy` | permit? × role × action matrix + relation_scope smoke | `policy_roles`, `policy_record`, `policy_matrix` |
86
86
  | `ResourceDefinition` | definition class + defineable prop smoke | none |
87
- | `ResourceInteraction` | `assert_interaction_success/failure` helpers | `interaction_class`, `valid_interaction_input` |
87
+ | `ResourceInteraction` | `assert_interaction_success/failure` helpers | none |
88
88
  | `ResourceModel` | `associated_with`, SGID, `has_cents` | `model_test_record` |
89
89
  | `NestedResource` | nested CRUD + sibling-tenant boundaries | `parent_record!`, `other_parent_record!`, `create_resource!(parent:)` |
90
90
  | `PortalAccess` | cross-portal access matrix | `login_as_role`, `portal_root_path` |
91
91
 
92
- Mix and match — `include` only what you want.
92
+ Mix and match: `include` only what you want.
93
93
 
94
94
  ## Auth helpers
95
95
 
96
+ `login_as` and friends come from `Plutonium::Testing::AuthHelpers`. Only `ResourceCrud`, `NestedResource` and `PortalAccess` include it; the policy, definition, model and interaction concerns don't. A bare `login_as(account)` takes its portal from `resource_tests_for`, so in a `PortalAccess` class (or a hand-written test that adds `include Plutonium::Testing::AuthHelpers` itself) pass `portal:` every time.
97
+
96
98
  ```ruby
97
99
  login_as(account) # uses portal from DSL
98
100
  login_as(account, portal: :admin) # explicit override
@@ -130,7 +132,7 @@ Idempotent. Adds the require line and creates the override stub.
130
132
  |---|---|---|
131
133
  | `--portals=admin,org` | required | Emit one file per portal |
132
134
  | `--concerns=...` | `crud,policy,definition` | Subset of concerns to include |
133
- | `--parent=organization` | none | Wires `NestedResource` parent |
135
+ | `--parent=organization` | none | Adds `parent:` to `resource_tests_for`; the `NestedResource` include and stubs also need `nested` in `--concerns` (`--concerns=crud,nested --parent=organization`) |
134
136
  | `--dest=main_app\|<package>` | `main_app` | Output destination |
135
137
 
136
138
  Output: `test/integration/<portal>_portal/<resource>_test.rb`.
@@ -144,16 +146,18 @@ Output: `test/integration/<portal>_portal/<resource>_test.rb`.
144
146
 
145
147
  ## Common pitfalls
146
148
 
147
- - **Forgotten stubs raise `NotImplementedError`** with the stub name — look for the missing method.
149
+ - **Forgotten stubs raise `NotImplementedError`** with the stub name: look for the missing method.
148
150
  - **Portal mismatch:** `:admin` expects `AdminPortal::Engine`. Pass `path_prefix:` if your engine is named differently.
149
151
  - **Tenant leakage in stubs:** for an org portal, `create_resource!` must return a record bound to the test's `@org`.
150
- - **`policy_record` for tenant-scoped resources** must belong to a tenant the role can access — otherwise even allowed roles see `false`.
151
- - **Nested resources need `parent:` in the DSL AND a parent record** from `parent_record!`. Both are required for path interpolation.
152
+ - **`policy_record` for tenant-scoped resources** must belong to a tenant the role can access, otherwise even allowed roles see `false`.
153
+ - **Nested paths come from `parent_record!.id`**, so it must return the same persisted tenant on every call (e.g. `@org`). `parent:` in the DSL documents the relationship; the concern doesn't read it.
154
+ - **Entity-scoped (`:path`) portals need two classes** for CRUD and tenant isolation, since `ResourceCrud` needs the tenant in `current_path_prefix` and `NestedResource` adds it itself. See [Reference › Testing](/reference/testing/#entity-scoped-portals-crud-tenant-isolation).
155
+ - **`valid_update_params` is compared literally** after the PATCH: use strings for enums and leave association SGIDs out.
152
156
  - **`PortalAccess` uses `portal_access_for`**, not `resource_tests_for`. Don't mix them on the same class.
153
157
 
154
158
  ## Related
155
159
 
156
- - [Reference › Testing](/reference/testing/) — full DSL reference, all concern stubs, override hooks
157
- - [Authorization](./authorization) — write the policy this concern verifies
158
- - [Multi-tenancy](./multi-tenancy) — entity scoping that drives nested-resource tests
159
- - [Authentication](./authentication) — Rodauth setup behind the default login flow
160
+ - [Reference › Testing](/reference/testing/): full DSL reference, all concern stubs, override hooks
161
+ - [Authorization](./authorization): write the policy this concern verifies
162
+ - [Multi-tenancy](./multi-tenancy): entity scoping that drives nested-resource tests
163
+ - [Authentication](./authentication): Rodauth setup behind the default login flow
@@ -18,11 +18,12 @@ Adapt Plutonium's defaults to match your brand: primary color, fonts, logo, dark
18
18
 
19
19
  ## 🚨 Critical
20
20
 
21
- - **Always register Stimulus controllers** — `registerControllers(application)`. Without it, the entire interactive layer is dead.
22
- - **Use `plutoniumTailwindConfig.merge`** when overriding Tailwind theme — plain object spread drops Plutonium's defaults.
23
- - **Tokens are CSS variables, not Tailwind keys** — `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
24
- - **Dark mode is `selector`, not `class`** — toggle by adding/removing `dark` on `<html>`.
25
- - **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
21
+ - **Run `pu:core:assets` before any CSS or brand-color change.** Out of the box the app serves the gem's prebuilt `plutonium.css` / `plutonium.min.js`, so app-side Tailwind classes, palette changes and token overrides have nowhere to compile. Don't hand-write the Tailwind/PostCSS pipeline.
22
+ - **Once the app owns its JS bundle, register Stimulus controllers** with `registerControllers(application)` (`pu:core:assets` adds it). Your bundle replaces the gem's, so without it the entire interactive layer is dead.
23
+ - **Use `plutoniumTailwindConfig.merge`** when overriding Tailwind theme: plain object spread drops Plutonium's defaults.
24
+ - **Tokens are CSS variables, not Tailwind keys**: `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
25
+ - **Dark mode is `selector`, not `class`**: toggle by adding/removing `dark` on `<html>`.
26
+ - **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** Banners, badges, cards and buttons all have a `.pu-*` class that carries its own `.dark` rule; a hand-written `bg-warning-50 dark:bg-warning-950/30` pair duplicates that and drifts from the theme.
26
27
 
27
28
  ## Step 1: Run the assets generator
28
29
 
@@ -30,7 +31,9 @@ Adapt Plutonium's defaults to match your brand: primary color, fonts, logo, dark
30
31
  rails generate pu:core:assets
31
32
  ```
32
33
 
33
- This installs npm packages, creates `tailwind.config.js`, imports Plutonium CSS, registers Stimulus controllers, and points `Plutonium.configure` at your asset files. Run once per app.
34
+ This installs npm packages, creates `tailwind.config.js` and `postcss.config.js`, imports Plutonium CSS, registers Stimulus controllers, and points `Plutonium.configure` at your asset files. Run once per app.
35
+
36
+ It aborts unless `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` exist (an app created with `-j esbuild -c tailwind` plus Stimulus). For an app without them, run `bin/rails javascript:install:esbuild`, `css:install:tailwind` and `stimulus:install` first. See [Reference › UI › Assets › Generator](/reference/ui/assets#generator).
34
37
 
35
38
  ## Step 2: Asset configuration
36
39
 
@@ -75,6 +78,8 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
75
78
  })
76
79
  ```
77
80
 
81
+ These palettes are compiled into the CSS at build time (`.pu-btn-primary` is `@apply bg-primary-600 ...`), so a brand color change needs this `merge` plus a rebuild (the `build:css` script, or the running `bin/dev` watcher). A `--pu-*` override won't recolor `primary`.
82
+
78
83
  ### Default palette
79
84
 
80
85
  | Color | Use |
@@ -105,7 +110,15 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
105
110
  }
106
111
  ```
107
112
 
108
- Tokens auto-switch when the user toggles dark mode. See [Reference › UI › Assets › Design tokens](/reference/ui/assets#design-tokens) for the full token catalog.
113
+ Tokens auto-switch when the user toggles dark mode.
114
+
115
+ ::: warning Mirror every `:root` override in `.dark`
116
+ Your stylesheet loads after Plutonium's and `:root` / `.dark` have equal specificity, so a token overridden only in `:root` wins in dark mode too and ships your light value there. Re-assert every customized color token in `.dark`, shadows included: `--pu-shadow-sm/md/lg` have their own dark values in `src/css/tokens.css`.
117
+
118
+ Put dark values in a `.dark { ... }` block, not `@media (prefers-color-scheme: dark)`. Dark mode is the `dark` class on `<html>`, so a media query ignores the user's toggle.
119
+ :::
120
+
121
+ See [Reference › UI › Assets › Design tokens](/reference/ui/assets#design-tokens) for the full token catalog.
109
122
 
110
123
  ## Using tokens in your code
111
124
 
@@ -145,6 +158,8 @@ Pre-styled ready-to-use components:
145
158
  | Buttons | `.pu-btn`, `.pu-btn-md/-sm/-xs`, `.pu-btn-primary/-secondary/-danger/-success/-warning/-info/-accent`, `.pu-btn-ghost/-outline`, `.pu-btn-soft-*` |
146
159
  | Inputs | `.pu-input/-invalid/-valid`, `.pu-label/-required`, `.pu-hint`, `.pu-error`, `.pu-checkbox` |
147
160
  | Cards | `.pu-card`, `.pu-card-body`, `.pu-panel-header`, `.pu-panel-title`, `.pu-panel-description` |
161
+ | Badges | `.pu-badge`, `.pu-badge-neutral/-primary/-secondary/-success/-danger/-warning/-info/-accent` |
162
+ | Alerts (inline banners) | `.pu-alert`, `.pu-alert-success/-warning/-danger/-info`, `.pu-alert-message`, `.pu-alert-close` |
148
163
  | Tables | `.pu-table-wrapper`, `.pu-table`, `-header`, `-header-cell`, `-body-row`, `-body-row-selected`, `-body-cell`, `.pu-selection-cell` |
149
164
  | Toolbars / empty states | `.pu-toolbar`, `-text`, `-actions`; `.pu-empty-state`, `-icon`, `-title`, `-description` |
150
165
 
@@ -184,7 +199,7 @@ end
184
199
  ```
185
200
 
186
201
  ::: warning Always `super.merge(...)`
187
- Don't replace the theme wholesale — Plutonium's defaults handle invalid states, focus rings, and dark mode. `super.merge` keeps them.
202
+ Don't replace the theme wholesale (Plutonium's defaults handle invalid states, focus rings, and dark mode). `super.merge` keeps them.
188
203
  :::
189
204
 
190
205
  Full theme key catalog: [Reference › UI › Assets › Phlexi component themes](/reference/ui/assets#phlexi-component-themes).
@@ -225,7 +240,7 @@ document.documentElement.classList.toggle('dark')
225
240
 
226
241
  If you've overridden tokens via `:root` and `.dark`, both modes Just Work.
227
242
 
228
- ## Per-portal chrome — eject the shell
243
+ ## Per-portal chrome: eject the shell
229
244
 
230
245
  For per-portal headers/sidebars:
231
246
 
@@ -245,7 +260,7 @@ Copies `layouts/resource.html.erb` for layout-level edits.
245
260
 
246
261
  ```ruby
247
262
  Plutonium.configure do |config|
248
- config.shell = :modern # default — topbar + icon rail
263
+ config.shell = :modern # default: topbar + icon rail
249
264
  # config.shell = :classic # legacy header + sidebar (only when upgrading)
250
265
  end
251
266
  ```
@@ -268,13 +283,13 @@ Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fi
268
283
 
269
284
  ## Common issues
270
285
 
271
- - **Stimulus controllers silently fail** — if `registerControllers(application)` isn't called, the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error — just no behavior.
272
- - **`plutoniumTailwindConfig.merge` is mandatory** — plain spread drops defaults silently.
273
- - **Tokens not switching in dark mode** — you used `bg-pu-surface` instead of `bg-[var(--pu-surface)]`. Tokens are CSS variables, not Tailwind keys.
274
- - **`.pu-btn` styles not applying** — check that Plutonium CSS is imported BEFORE Tailwind: `@import "gem:plutonium/src/css/plutonium.css";` then `@import "tailwindcss";`.
286
+ - **Stimulus controllers silently fail**: once the app serves its own JS bundle, if `registerControllers(application)` isn't called, the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error: just no behavior.
287
+ - **`plutoniumTailwindConfig.merge` is mandatory**: plain spread drops defaults silently.
288
+ - **Tokens not switching in dark mode**: you used `bg-pu-surface` instead of `bg-[var(--pu-surface)]`. Tokens are CSS variables, not Tailwind keys.
289
+ - **`.pu-btn` styles not applying**: check that Plutonium CSS is imported BEFORE Tailwind: `@import "gem:plutonium/src/css/plutonium.css";` then `@import "tailwindcss";`.
275
290
 
276
291
  ## Related
277
292
 
278
- - [Reference › UI › Assets](/reference/ui/assets) — full Tailwind / Stimulus / design tokens / component classes surface
279
- - [Reference › UI › Layouts](/reference/ui/layouts) — shell, eject, ResourceLayout
280
- - [Reference › UI › Forms › Theming](/reference/ui/forms#theming) — Form theme keys
293
+ - [Reference › UI › Assets](/reference/ui/assets): full Tailwind / Stimulus / design tokens / component classes surface
294
+ - [Reference › UI › Layouts](/reference/ui/layouts): shell, eject, ResourceLayout
295
+ - [Reference › UI › Forms › Theming](/reference/ui/forms#theming): Form theme keys
@@ -79,6 +79,6 @@ If you encounter an issue not covered here, please [open an issue](https://githu
79
79
 
80
80
  - [Nested resources](./nested-resources)
81
81
  - [Adding resources](./adding-resources)
82
- - [Reference › Behavior › Controllers](/reference/behavior/controllers) — `controller_for`, `resource_url_for`, `current_parent`
83
- - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources) — nested URL generation
82
+ - [Reference › Behavior › Controllers](/reference/behavior/controllers): `controller_for`, `resource_url_for`, `current_parent`
83
+ - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources): nested URL generation
84
84
  - [Rails Inflections](https://api.rubyonrails.org/classes/ActiveSupport/Inflector/Inflections.html)
@@ -8,7 +8,7 @@ An admin enters an email, the user gets an invite link, clicks it, signs up (or
8
8
 
9
9
  ## Prerequisites
10
10
 
11
- You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup` — it creates all three and runs `pu:invites:install` automatically:
11
+ You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup`: it creates all three and runs `pu:invites:install` automatically:
12
12
 
13
13
  ```bash
14
14
  rails g pu:saas:setup --user Customer --entity Organization
@@ -43,7 +43,7 @@ rails g pu:invites:install \
43
43
  | `--enforce-domain` | `false` | Require email domain to match entity |
44
44
 
45
45
  ::: info Roles come from the membership model
46
- `pu:invites:install` reads the role list from the membership model's `enum :role` — it does not accept a `--roles=` flag. Define roles when you generate the membership model (`pu:saas:membership --roles=...`), or edit the enum directly. **Index 0 is the most privileged** (typically `owner`); the invite interaction excludes `owner` from selectable choices and defaults new invitees to the second role.
46
+ `pu:invites:install` reads the role list from the membership model's `enum :role`; it does not accept a `--roles=` flag. Define roles when you generate the membership model (`pu:saas:membership --roles=...`), or edit the enum directly. **Index 0 is the most privileged** (typically `owner`); the invite interaction excludes `owner` from selectable choices and defaults new invitees to the second role.
47
47
  :::
48
48
 
49
49
  ### 2. Migrate
@@ -112,7 +112,7 @@ Users land on `/welcome` where pending invites are shown. Including `Plutonium::
112
112
  include Plutonium::Invites::PendingInviteCheck
113
113
  ```
114
114
 
115
- ## Invitables — app models notified on acceptance
115
+ ## Invitables: app models notified on acceptance
116
116
 
117
117
  An invitable is a model that gets notified when its invitation is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
118
118
 
@@ -137,7 +137,7 @@ end
137
137
  ```
138
138
 
139
139
  ::: warning Without `on_invite_accepted`
140
- The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
140
+ The invitable never learns about the new user: the invite is consumed but your app doesn't update its state.
141
141
  :::
142
142
 
143
143
  ## Multiple invite flows in one app
@@ -158,7 +158,7 @@ rails g pu:invites:install \
158
158
 
159
159
  Each invocation creates an independent flow: model, controller, route, helper all named for the invite-model.
160
160
 
161
- The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array — `pending_invite` checks all flows in priority order (first-match wins).
161
+ The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array; `pending_invite` checks all flows in priority order (first-match wins).
162
162
 
163
163
  See [Reference › Tenancy › Invites › Multiple invite flows](/reference/tenancy/invites#multiple-invite-flows).
164
164
 
@@ -222,27 +222,27 @@ entity.user_invites.pending # list pending
222
222
 
223
223
  ## Security
224
224
 
225
- - **Token security** — `SecureRandom.urlsafe_base64(32)` — 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
226
- - **Email validation** — `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
227
- - **Rate limiting** — use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
225
+ - **Token security**: `SecureRandom.urlsafe_base64(32)`, 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
226
+ - **Email validation**: `enforce_email?` is `true` by default. The accepting user's email must match the invited email; this prevents account hijacking via invite forwarding.
227
+ - **Rate limiting**: use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
228
228
 
229
229
  ::: danger Don't disable enforce_email?
230
230
  ```ruby
231
231
  def enforce_email? = false # ← only if you fully understand the trade-off
232
232
  ```
233
- Without this, anyone with the token can sign up — defeats the purpose of an invitation system.
233
+ Without this, anyone with the token can sign up, which defeats the purpose of an invitation system.
234
234
  :::
235
235
 
236
236
  ## Common issues
237
237
 
238
- - **"Invitation not found or expired"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
239
- - **Email mismatch error** — the accepting user's email doesn't match the invited email. This is by design (security).
240
- - **Rodauth redirect after login doesn't go to `/welcome`** — check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
241
- - **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
238
+ - **"Invitation not found or expired"**: token expired (default 1 week), invite cancelled, or no longer `pending`.
239
+ - **Email mismatch error**: the accepting user's email doesn't match the invited email. This is by design (security).
240
+ - **Rodauth redirect after login doesn't go to `/welcome`**: check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
241
+ - **`on_invite_accepted` not called**: ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
242
242
 
243
243
  ## Related
244
244
 
245
- - [Reference › Tenancy › Invites](/reference/tenancy/invites) — full surface, multi-flow apps, customization
246
- - [Multi-tenancy](./multi-tenancy) — entity scoping (invites are entity-scoped automatically)
247
- - [Authentication](./authentication) — Rodauth setup
248
- - [User profile](./user-profile) — account-settings page
245
+ - [Reference › Tenancy › Invites](/reference/tenancy/invites): full surface, multi-flow apps, customization
246
+ - [Multi-tenancy](./multi-tenancy): entity scoping (invites are entity-scoped automatically)
247
+ - [Authentication](./authentication): Rodauth setup
248
+ - [User profile](./user-profile): account-settings page