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
@@ -1,13 +1,14 @@
1
1
  # Entity Scoping
2
2
 
3
- Multi-tenant data isolation. Built on three cooperating pieces — portal, policy, model — that together ensure queries never leak across tenants.
3
+ Multi-tenant data isolation. Built on three cooperating pieces (portal, policy, model) that together ensure queries never leak across tenants.
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!` — make sure the chain ends up calling `default_relation_scope(relation)` somewhere (explicitly, or via `super` to a parent that calls it — `Plutonium::Resource::Policy` does).
7
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`; make sure the chain ends up calling `default_relation_scope(relation)` somewhere (explicitly, or via `super` to a parent that calls it, as `Plutonium::Resource::Policy` does).
8
8
  - **Fix the MODEL, not the policy.** If `associated_with` can't resolve, declare an association path (`belongs_to`, `has_one :through`) OR a custom `associated_with_<entity>` scope on the model. Never paper over it with a `where` in the policy.
9
- - **Compound uniqueness scoped to the tenant FK** — `validates :code, uniqueness: {scope: :organization_id}`.
10
- - **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller.
9
+ - **Compound uniqueness scoped to the tenant FK**: `validates :code, uniqueness: {scope: :organization_id}`.
10
+ - **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller, plus a custom `associated_with_<entity>` scope on the model.
11
+ - **The entity field can stay in `permitted_attributes_for_*`.** Entity-scoped portals strip it from the form and params and set it from the current entity.
11
12
 
12
13
  ## The three pieces
13
14
 
@@ -17,16 +18,16 @@ Multi-tenant data isolation. Built on three cooperating pieces — portal, polic
17
18
  | **Policy** | Applies the scope to every collection query | `default_relation_scope(relation)` (auto-called) |
18
19
  | **Model** | Resolves the scope path | Direct `belongs_to`, `has_one :through`, or custom scope |
19
20
 
20
- `default_relation_scope` is enforced — if you override `relation_scope` without calling it, `verify_default_relation_scope_applied!` raises at runtime.
21
+ `default_relation_scope` is enforced: if you override `relation_scope` without calling it, `verify_default_relation_scope_applied!` raises at runtime.
21
22
 
22
23
  ## `associated_with` resolution
23
24
 
24
25
  `Model.associated_with(entity)` resolves in this order:
25
26
 
26
- 1. **Custom scope** `associated_with_<entity_name>` (e.g. `associated_with_organization`) — highest priority, full SQL control.
27
- 2. **Direct `belongs_to` to the entity class** — `WHERE <entity>_id = ?`, most efficient.
28
- 3. **`has_one` / `has_one :through` to the entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
29
- 4. **Reverse `has_many` from the entity** — JOIN required, logs a warning (less efficient).
27
+ 1. **Custom scope** `associated_with_<entity_name>` (e.g. `associated_with_organization`): highest priority, full SQL control.
28
+ 2. **Direct `belongs_to` to the entity class**: `WHERE <entity>_id = ?`, most efficient.
29
+ 3. **`has_one` / `has_one :through` to the entity class**: JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
30
+ 4. **Reverse `has_many` from the entity**: JOIN required, logs a warning (less efficient).
30
31
 
31
32
  If none apply:
32
33
 
@@ -34,7 +35,7 @@ If none apply:
34
35
  Could not resolve the association between 'Model' and 'Entity'
35
36
  ```
36
37
 
37
- Fix on the **model** — either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
38
+ Fix on the **model**: either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
38
39
 
39
40
  ## Three model shapes
40
41
 
@@ -52,7 +53,7 @@ class Project < ResourceRecord
52
53
  end
53
54
 
54
55
  Project.associated_with(org)
55
- # => Project.where(organization: org) — simple WHERE, most efficient
56
+ # => Project.where(organization: org) # simple WHERE, most efficient
56
57
  ```
57
58
 
58
59
  Auto-detected. Use this when the model naturally has a direct FK to the entity.
@@ -121,7 +122,7 @@ end
121
122
  `Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.
122
123
 
123
124
  ::: tip Declaring `has_one :through` is the lightest fix
124
- For grandchildren, the `has_one :through` on the model is all you need — `associated_with` finds it automatically. No policy override needed.
125
+ For grandchildren, the `has_one :through` on the model is all you need; `associated_with` finds it automatically. No policy override needed.
125
126
  :::
126
127
 
127
128
  ### When to fall back to a custom scope
@@ -142,7 +143,7 @@ end
142
143
 
143
144
  Plutonium picks this up **before** trying association detection.
144
145
 
145
- ## `relation_scope` — safe override patterns
146
+ ## `relation_scope`: safe override patterns
146
147
 
147
148
  `default_relation_scope(relation)` does two things:
148
149
 
@@ -152,7 +153,7 @@ Plutonium picks this up **before** trying association detection.
152
153
  ### Correct
153
154
 
154
155
  ```ruby
155
- # ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
156
+ # ✅ Best: don't override at all. The inherited scope already calls default_relation_scope.
156
157
 
157
158
  # ✅ Extra filters on top
158
159
  relation_scope do |relation|
@@ -169,18 +170,18 @@ end
169
170
  ### Wrong
170
171
 
171
172
  ```ruby
172
- # ❌ Manually filtering by entity — bypasses default_relation_scope
173
+ # ❌ Manually filtering by entity: bypasses default_relation_scope
173
174
  relation_scope { |r| r.where(organization: current_scoped_entity) }
174
175
 
175
- # ❌ Manual joins — same problem
176
+ # ❌ Manual joins: same problem
176
177
  relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
177
178
 
178
- # ❌ Missing default_relation_scope entirely — raises at runtime
179
+ # ❌ Missing default_relation_scope entirely: raises at runtime
179
180
  relation_scope { |r| r.where(published: true) }
180
181
  ```
181
182
 
182
183
  ::: tip `super` works too
183
- `Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope(relation)`, so `super(relation)` from a subclass picks it up and the runtime check passes. Use whichever reads more clearly — `super(relation).where(archived: false)` and `default_relation_scope(relation).where(archived: false)` are equivalent when extending the framework base. Call `default_relation_scope` explicitly when you're not chaining via `super` (e.g. replacing the scope entirely).
184
+ `Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope(relation)`, so `super(relation)` from a subclass picks it up and the runtime check passes. Use whichever reads more clearly: `super(relation).where(archived: false)` and `default_relation_scope(relation).where(archived: false)` are equivalent when extending the framework base. Call `default_relation_scope` explicitly when you're not chaining via `super` (e.g. replacing the scope entirely).
184
185
  :::
185
186
 
186
187
  ### Intentionally skipping the scope
@@ -214,7 +215,7 @@ module CustomerPortal
214
215
  end
215
216
  ```
216
217
 
217
- Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time — the entity id is the first path segment after the mount). The portal extracts `params[:organization_scoped]` and loads the entity automatically.
218
+ Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time; the entity id is the first path segment after the mount). The portal extracts `params[:organization_scoped]` and loads the entity automatically.
218
219
 
219
220
  ### Custom strategy (subdomain, session, etc.)
220
221
 
@@ -238,7 +239,7 @@ The strategy symbol must match a method name on the controller concern.
238
239
 
239
240
  ### Custom param key
240
241
 
241
- The default `param_key` derives from the entity class — `<singular_route_key>_scoped` (e.g. `:organization_scoped`) — to avoid colliding with a `belongs_to :organization` on child models when reading `params[:organization]`. The URL itself just uses the entity id as the first segment after the mount:
242
+ The default `param_key` derives from the entity class, `<singular_route_key>_scoped` (e.g. `:organization_scoped`), to avoid colliding with a `belongs_to :organization` on child models when reading `params[:organization]`. The URL itself just uses the entity id as the first segment after the mount:
242
243
 
243
244
  ```
244
245
  mount CustomerPortal::Engine, at: "/customer"
@@ -266,7 +267,7 @@ entity_scope # => current Organization
266
267
 
267
268
  ## Cross-tenant operations
268
269
 
269
- ### Super-admin portal — no scoping
270
+ ### Super-admin portal: no scoping
270
271
 
271
272
  Create a separate portal without `scope_to_entity`:
272
273
 
@@ -275,7 +276,7 @@ module SuperAdminPortal
275
276
  class Engine < Rails::Engine
276
277
  include Plutonium::Portal::Engine
277
278
 
278
- # No scope_to_entity — sees all tenants
279
+ # No scope_to_entity: sees all tenants
279
280
  end
280
281
  end
281
282
  ```
@@ -293,9 +294,25 @@ class PostPolicy < ResourcePolicy
293
294
  end
294
295
  ```
295
296
 
297
+ ## The entity field in permitted attributes
298
+
299
+ Listing the entity association in the policy is fine, and usually what you want:
300
+
301
+ ```ruby
302
+ def permitted_attributes_for_create
303
+ [:organization, :name]
304
+ end
305
+ ```
306
+
307
+ In an entity-scoped portal the controller drops the entity field from both the rendered form/display and the accepted params, then sets it from `current_scoped_entity` on create and update. The same policy then works unchanged in an unscoped portal (e.g. admin), where `organization` stays a normal, selectable input. So you don't need a portal-specific policy just to remove the entity field, and a forged `organization_id` in the request is overwritten.
308
+
309
+ What gets removed is the portal's param key plus the scoping association and its `_id` (`organization_scoped`, `organization_scoped_id`, `organization`, `organization_id`). Other associations to the same class are untouched. To show the entity field anyway, override `present_scoped_entity?` on the controller (and `submit_scoped_entity?` to let users change it; it defaults to `present_scoped_entity?`).
310
+
296
311
  ## Multiple associations to the same entity class
297
312
 
298
- Example: `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises:
313
+ Example: `Match belongs_to :home_team, :away_team` both pointing at `Team`. Two pieces of code pick "the" entity association, and both refuse to guess. Configure each one.
314
+
315
+ The **controller** raises on any scoped page that renders fields:
299
316
 
300
317
  ```
301
318
  Match has multiple associations to Competition::Team: home_team, away_team.
@@ -303,7 +320,7 @@ Plutonium cannot auto-detect which one to use for entity scoping.
303
320
  Override `scoped_entity_association` in your controller to specify the association.
304
321
  ```
305
322
 
306
- Override on the controller:
323
+ Override on the portal controller:
307
324
 
308
325
  ```ruby
309
326
  class MatchesController < ::ResourceController
@@ -312,6 +329,19 @@ class MatchesController < ::ResourceController
312
329
  end
313
330
  ```
314
331
 
332
+ The **model** (`associated_with`) raises `Plutonium::Resource::Record::AssociatedWith::AmbiguousAssociationError` for the same reason, so define the scope it asks for:
333
+
334
+ ```ruby
335
+ class Match < ResourceRecord
336
+ belongs_to :home_team, class_name: "Competition::Team"
337
+ belongs_to :away_team, class_name: "Competition::Team"
338
+
339
+ scope :associated_with_competition_team, ->(team) { where(home_team: team) }
340
+ end
341
+ ```
342
+
343
+ With both in place, the policy can permit `[:home_team, :away_team, ...]`: in the scoped portal `home_team` is stripped and forced to the current entity, while `away_team` stays a select the user fills in.
344
+
315
345
  ## `param_key` differs from association name
316
346
 
317
347
  Plutonium matches by **class**, not param key:
@@ -320,7 +350,7 @@ Plutonium matches by **class**, not param key:
320
350
  # Portal config
321
351
  scope_to_entity Competition::Team, param_key: :team
322
352
 
323
- # Model — association name differs from param_key, but Plutonium finds by class
353
+ # Model: association name differs from param_key, but Plutonium finds by class
324
354
  class Match < ApplicationRecord
325
355
  belongs_to :competition_team # ← Plutonium auto-detects this
326
356
  end
@@ -348,20 +378,20 @@ class Property < ResourceRecord
348
378
  end
349
379
  ```
350
380
 
351
- Without the scope, uniqueness leaks across tenants — Org A and Org B could collide on the same code.
381
+ Without the scope, uniqueness leaks across tenants: Org A and Org B could collide on the same code.
352
382
 
353
383
  ## Gotchas
354
384
 
355
- - **Policy tries to filter by entity directly.** Wrong — bypasses `default_relation_scope`. Add the association path to the model instead.
356
- - **Multiple associations to the same entity class.** Override `scoped_entity_association`.
357
- - **`param_key` differs from association name.** Fine — Plutonium finds the association by class.
385
+ - **Policy tries to filter by entity directly.** Wrong, it bypasses `default_relation_scope`. Add the association path to the model instead.
386
+ - **Multiple associations to the same entity class.** Override `scoped_entity_association` on the controller and add an `associated_with_<entity>` scope on the model.
387
+ - **`param_key` differs from association name.** Fine: Plutonium finds the association by class.
358
388
  - **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks across tenants.
359
- - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly — never leave a `where` bypass in code.
389
+ - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly; never leave a `where` bypass in code.
360
390
 
361
391
  ## Related
362
392
 
363
- - [Nested resources](./nested-resources) — parent scoping takes precedence over entity scoping
364
- - [Invites](./invites) — membership-based onboarding
365
- - [Resource › Model](/reference/resource/model) — `associated_with`, model conventions
366
- - [Behavior › Policy](/reference/behavior/policies) — `relation_scope` syntax
367
- - [App › Portals](/reference/app/portals) — `scope_to_entity` engine config
393
+ - [Nested resources](./nested-resources): parent scoping takes precedence over entity scoping
394
+ - [Invites](./invites): membership-based onboarding
395
+ - [Resource › Model](/reference/resource/model): `associated_with`, model conventions
396
+ - [Behavior › Policy](/reference/behavior/policies): `relation_scope` syntax
397
+ - [App › Portals](/reference/app/portals): `scope_to_entity` engine config
@@ -2,9 +2,9 @@
2
2
 
3
3
  Three closely-coupled concerns:
4
4
 
5
- 1. **[Entity scoping](./entity-scoping)** — every record belongs to a tenant; queries filter automatically.
6
- 2. **[Nested resources](./nested-resources)** — parent/child URLs; parent scoping takes precedence over entity scoping.
7
- 3. **[Invites](./invites)** — onboarding users into a tenant's membership.
5
+ 1. **[Entity scoping](./entity-scoping)**: every record belongs to a tenant; queries filter automatically.
6
+ 2. **[Nested resources](./nested-resources)**: parent/child URLs; parent scoping takes precedence over entity scoping.
7
+ 3. **[Invites](./invites)**: onboarding users into a tenant's membership.
8
8
 
9
9
  ## How entity scoping fits together
10
10
 
@@ -20,17 +20,17 @@ Configure the portal once. The policy and model conventions then carry tenancy a
20
20
 
21
21
  ## 🚨 Critical (applies to all three sub-pages)
22
22
 
23
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`. Make sure `default_relation_scope(relation)` is called somewhere in the chain — explicitly here, or via `super` to a parent policy (e.g., a package base) that calls it.
23
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`. Make sure `default_relation_scope(relation)` is called somewhere in the chain, explicitly here or via `super` to a parent policy (e.g., a package base) that calls it.
24
24
  - **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.
25
25
  - **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, not via `entity_scope`. Don't double-scope.
26
26
  - **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
27
- - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
28
- - **Invite email must match the accepting user's email.** Security feature — don't disable `enforce_email?` lightly.
27
+ - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}`. Without this, uniqueness leaks across tenants.
28
+ - **Invite email must match the accepting user's email.** Security feature; don't disable `enforce_email?` lightly.
29
29
 
30
30
  ## Related
31
31
 
32
- - [Behavior › Policy](/reference/behavior/policies) — `relation_scope` syntax
33
- - [Resource › Model](/reference/resource/model) — model layer (associations, `has_cents`, SGID)
34
- - [App › Portals](/reference/app/portals) — `scope_to_entity` engine config
35
- - [Guides › Multi-tenancy](/guides/multi-tenancy) — task-oriented walkthrough
36
- - [Guides › User invites](/guides/user-invites) — invitation setup recipe
32
+ - [Behavior › Policy](/reference/behavior/policies): `relation_scope` syntax
33
+ - [Resource › Model](/reference/resource/model): model layer (associations, `has_cents`, SGID)
34
+ - [App › Portals](/reference/app/portals): `scope_to_entity` engine config
35
+ - [Guides › Multi-tenancy](/guides/multi-tenancy): task-oriented walkthrough
36
+ - [Guides › User invites](/guides/user-invites): invitation setup recipe
@@ -4,10 +4,10 @@ Token-based email invitations for multi-tenant onboarding. Integrates with Rodau
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
- - **Invite email must match the accepting user's email.** Security feature — don't disable `enforce_email?` lightly.
8
- - **Entity scoping applies to invites** — invites are automatically filtered to the current entity (their model has `belongs_to :entity`).
7
+ - **Invite email must match the accepting user's email.** Security feature; don't disable `enforce_email?` lightly.
8
+ - **Entity scoping applies to invites.** Invites are automatically filtered to the current entity (their model has `belongs_to :entity`).
9
9
  - **Invitables must implement `on_invite_accepted`.** Without it, the invitable never learns about the new user.
10
- - **A single app can have multiple invite flows** — run `pu:invites:install` once per flow with different `--entity-model` / `--user-model` / `--invite-model`.
10
+ - **A single app can have multiple invite flows.** Run `pu:invites:install` once per flow with different `--entity-model` / `--user-model` / `--invite-model`.
11
11
 
12
12
  ## Prerequisites
13
13
 
@@ -17,7 +17,7 @@ Before installing invites, you need:
17
17
  2. An entity model (Organization, Company, Team, …)
18
18
  3. A membership model linking users to entities
19
19
 
20
- The fastest path is `pu:saas:setup` — it creates all three plus the SaaS portal, profile, welcome flow, and invites in one shot:
20
+ The fastest path is `pu:saas:setup`, which creates all three plus the SaaS portal, profile, welcome flow, and invites in one shot:
21
21
 
22
22
  ```bash
23
23
  rails g pu:saas:setup --user Customer --entity Organization
@@ -41,7 +41,7 @@ rails generate pu:invites:install
41
41
  | `--enforce-domain` | `false` | Require invited email domain to match entity domain |
42
42
 
43
43
  ::: info Roles come from the membership model
44
- The role list is read from the membership model's `enum :role` — there is no `--roles=` flag on `pu:invites:install`. Set roles when generating the membership model (`pu:saas:membership --roles=...`) or edit its enum directly. **Index 0 is the most privileged** (typically `owner`, which the invite UI excludes from selectable choices); new invitees default to the second role.
44
+ The role list is read from the membership model's `enum :role`; there is no `--roles=` flag on `pu:invites:install`. Set roles when generating the membership model (`pu:saas:membership --roles=...`) or edit its enum directly. **Index 0 is the most privileged** (typically `owner`, which the invite UI excludes from selectable choices); new invitees default to the second role.
45
45
  :::
46
46
 
47
47
  Example with custom models:
@@ -166,7 +166,7 @@ configure do
166
166
  end
167
167
  ```
168
168
 
169
- ## Invitables — app models notified on accept
169
+ ## Invitables: app models notified on accept
170
170
 
171
171
  An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
172
172
 
@@ -200,14 +200,14 @@ end
200
200
  ```
201
201
 
202
202
  ::: warning Without `on_invite_accepted`
203
- The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
203
+ The invitable never learns about the new user; the invite is consumed but your app doesn't update its state.
204
204
  :::
205
205
 
206
206
  ## Multiple invite flows
207
207
 
208
208
  A single app can have several independent invite flows side-by-side (e.g. one for inviting customers to organizations, another for inviting funders to projects). Run `pu:invites:install` once per flow.
209
209
 
210
- **Default name derivation:** when `--invite-model` is omitted, the class is `<EntityModel><UserModel>Invite`. So with the defaults (`--entity-model=Organization --user-model=User`) the generated class is `Invites::OrganizationUserInvite` — there is no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
210
+ **Default name derivation:** when `--invite-model` is omitted, the class is `<EntityModel><UserModel>Invite`. So with the defaults (`--entity-model=Organization --user-model=User`) the generated class is `Invites::OrganizationUserInvite`; there is no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
211
211
 
212
212
  ```bash
213
213
  rails g pu:invites:install \
@@ -288,7 +288,7 @@ Override views in your package:
288
288
 
289
289
  ### Per-invitable templates
290
290
 
291
- When you generate an invitable with `--email-templates`, you get per-invitable mailer views — useful for differentiating "Join as a team member" from "Join as a project collaborator".
291
+ When you generate an invitable with `--email-templates`, you get per-invitable mailer views, useful for differentiating "Join as a team member" from "Join as a project collaborator".
292
292
 
293
293
  ### Custom validation
294
294
 
@@ -319,7 +319,7 @@ Requires the invited email's domain to match the entity's domain.
319
319
 
320
320
  ### Custom roles
321
321
 
322
- Roles are defined on the membership model, not on the invites generator. Set them at membership generation time (ordering matters — **index 0 is the most privileged**, typically `owner`):
322
+ Roles are defined on the membership model, not on the invites generator. Set them at membership generation time (ordering matters: **index 0 is the most privileged**, typically `owner`):
323
323
 
324
324
  ```bash
325
325
  rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
@@ -366,11 +366,11 @@ entity.user_invites.pending
366
366
 
367
367
  ### Token security
368
368
 
369
- Tokens use `SecureRandom.urlsafe_base64(32)` — 256 bits, URL-safe. Stored hashed in the DB; raw token shown only at creation (in the email).
369
+ Tokens use `SecureRandom.urlsafe_base64(32)`: 256 bits, URL-safe. Stored hashed in the DB; raw token shown only at creation (in the email).
370
370
 
371
371
  ### Email validation
372
372
 
373
- `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
373
+ `enforce_email?` is `true` by default. The accepting user's email must match the invited email, which prevents account hijacking via invite forwarding.
374
374
 
375
375
  To allow any email (NOT recommended):
376
376
 
@@ -387,14 +387,14 @@ Use Rack::Attack or similar to throttle:
387
387
 
388
388
  ## Common issues
389
389
 
390
- - **"Invitation not found or expired"** — token expired (default 1 week), invite cancelled, or no longer in `pending` state.
391
- - **Email mismatch error** — the accepting user's email doesn't match the invited email. `enforce_email?` is enforcing the match (this is intentional security).
392
- - **Rodauth redirect after login doesn't go to `/welcome`** — check the `login_redirect "/welcome"` line in the rodauth plugin's `configure` block.
393
- - **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
390
+ - **"Invitation not found or expired"**: token expired (default 1 week), invite cancelled, or no longer in `pending` state.
391
+ - **Email mismatch error**: the accepting user's email doesn't match the invited email. `enforce_email?` is enforcing the match (this is intentional security).
392
+ - **Rodauth redirect after login doesn't go to `/welcome`**: check the `login_redirect "/welcome"` line in the rodauth plugin's `configure` block.
393
+ - **`on_invite_accepted` not called**: ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
394
394
 
395
395
  ## Related
396
396
 
397
- - [Entity scoping](./entity-scoping) — how invites are filtered to the current entity
398
- - [Auth](/reference/auth/) — Rodauth account configuration
399
- - [Behavior › Interactions](/reference/behavior/interactions) — `cancel_invite_interaction`, `resend_invite_interaction`
400
- - [Guides › User invites](/guides/user-invites) — task-oriented walkthrough
397
+ - [Entity scoping](./entity-scoping): how invites are filtered to the current entity
398
+ - [Auth](/reference/auth/): Rodauth account configuration
399
+ - [Behavior › Interactions](/reference/behavior/interactions): `cancel_invite_interaction`, `resend_invite_interaction`
400
+ - [Guides › User invites](/guides/user-invites): task-oriented walkthrough
@@ -1,12 +1,12 @@
1
1
  # Nested Resources
2
2
 
3
- Plutonium auto-generates nested routes from `has_many` and `has_one` associations on a registered parent. No manual route wiring — `belongs_to` on the child plus `register_resource` for both is enough.
3
+ Plutonium auto-generates nested routes from `has_many` and `has_one` associations on a registered parent. No manual route wiring: `belongs_to` on the child plus `register_resource` for both is enough.
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
7
  - **One level only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
8
8
  - **Parent scoping beats entity scoping.** When a parent is present, `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
9
- - **Named custom routes.** When adding member/collection routes on a nested resource, always pass `as:` — otherwise `resource_url_for` will fail.
9
+ - **Named custom routes.** When adding member/collection routes on a nested resource, always pass `as:`; otherwise `resource_url_for` will fail.
10
10
  - **The parent is authorized for `:read?`** before `current_parent` returns. The child policy receives the parent in its context.
11
11
 
12
12
  ## Setup
@@ -102,13 +102,13 @@ current_nested_association # association name (e.g. :properties)
102
102
  parent_input_param # form param / association name (e.g. :company)
103
103
  ```
104
104
 
105
- Each nested route carries the key of its own registration, so the parent class and association are **read from the route** rather than reconstructed from the URL. That is what lets a resource registered `singular: true` act as a parent at all — it contributes no id parameter, so there is nothing in the path to infer from.
105
+ Each nested route carries the key of its own registration, so the parent class and association are **read from the route** rather than reconstructed from the URL. That is what lets a resource registered `singular: true` act as a parent at all: it contributes no id parameter, so there is nothing in the path to infer from.
106
106
 
107
107
  ## Parent vs entity scoping
108
108
 
109
- When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already authorized and entity-scoped during its own authorization — double-scoping is redundant.
109
+ When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already authorized and entity-scoped during its own authorization; double-scoping is redundant.
110
110
 
111
- In the child's policy, just call `default_relation_scope` — it handles both cases:
111
+ In the child's policy, just call `default_relation_scope`, which handles both cases:
112
112
 
113
113
  ```ruby
114
114
  class PropertyPolicy < ResourcePolicy
@@ -186,7 +186,7 @@ class PropertyPolicy < ResourcePolicy
186
186
  end
187
187
  ```
188
188
 
189
- The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
189
+ The parent is authorized for `:read?` before `current_parent` returns; children inherit the parent's access requirements.
190
190
 
191
191
  ## Parameter handling
192
192
 
@@ -234,7 +234,7 @@ class PropertiesController < ::ResourceController
234
234
  end
235
235
  ```
236
236
 
237
- Conditional — show parent only when accessed standalone:
237
+ Conditional: show parent only when accessed standalone:
238
238
 
239
239
  ```ruby
240
240
  def present_parent?
@@ -273,7 +273,7 @@ end
273
273
  Generates `/companies/:company_id/nested_properties/:id/analytics`, etc.
274
274
 
275
275
  ::: warning Always pass `as:`
276
- Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails — there's no named route to look up.
276
+ Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails; there's no named route to look up.
277
277
  :::
278
278
 
279
279
  ## Compound uniqueness
@@ -318,8 +318,8 @@ For deeper hierarchies, use top-level routes plus association tabs on the show p
318
318
 
319
319
  ## Related
320
320
 
321
- - [Entity scoping](./entity-scoping) — what happens when no parent is present
322
- - [Invites](./invites) — membership-based onboarding
323
- - [Behavior › Policy](/reference/behavior/policies) — `relation_scope`, parent context
324
- - [Behavior › Controllers](/reference/behavior/controllers) — `current_parent`, presentation hooks
325
- - [App › Portals](/reference/app/portals) — `register_resource` and custom member/collection routes
321
+ - [Entity scoping](./entity-scoping): what happens when no parent is present
322
+ - [Invites](./invites): membership-based onboarding
323
+ - [Behavior › Policy](/reference/behavior/policies): `relation_scope`, parent context
324
+ - [Behavior › Controllers](/reference/behavior/controllers): `current_parent`, presentation hooks
325
+ - [App › Portals](/reference/app/portals): `register_resource` and custom member/collection routes