plutonium 0.50.0 → 0.52.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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +574 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +167 -302
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +674 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +9 -6
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +44 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1010 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +38 -29
  18. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  19. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  20. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  21. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  22. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  23. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  24. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  25. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  26. data/docs/.vitepress/theme/custom.css +144 -0
  27. data/docs/.vitepress/theme/index.ts +58 -1
  28. data/docs/getting-started/index.md +33 -57
  29. data/docs/getting-started/installation.md +37 -80
  30. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  31. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  32. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  33. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  34. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  36. data/docs/getting-started/tutorial/index.md +4 -5
  37. data/docs/guides/adding-resources.md +66 -377
  38. data/docs/guides/authentication.md +98 -462
  39. data/docs/guides/authorization.md +124 -370
  40. data/docs/guides/creating-packages.md +93 -298
  41. data/docs/guides/custom-actions.md +126 -441
  42. data/docs/guides/customizing-ui.md +258 -0
  43. data/docs/guides/index.md +49 -52
  44. data/docs/guides/multi-tenancy.md +123 -186
  45. data/docs/guides/nested-resources.md +137 -396
  46. data/docs/guides/search-filtering.md +127 -238
  47. data/docs/guides/testing.md +10 -5
  48. data/docs/guides/theming.md +168 -405
  49. data/docs/guides/troubleshooting.md +5 -3
  50. data/docs/guides/user-invites.md +112 -425
  51. data/docs/guides/user-profile.md +82 -241
  52. data/docs/index.md +10 -219
  53. data/docs/public/asciinema/home-scaffold.cast +305 -0
  54. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  55. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  56. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  57. data/docs/public/images/guides/nested-inputs.png +0 -0
  58. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  59. data/docs/public/images/guides/search-filtering-index.png +0 -0
  60. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  61. data/docs/public/images/guides/theming-after.png +0 -0
  62. data/docs/public/images/guides/theming-before.png +0 -0
  63. data/docs/public/images/guides/user-invites-landing.png +0 -0
  64. data/docs/public/images/guides/user-profile-edit.png +0 -0
  65. data/docs/public/images/guides/user-profile-show.png +0 -0
  66. data/docs/public/images/home-index.png +0 -0
  67. data/docs/public/images/home-new.png +0 -0
  68. data/docs/public/images/home-show.png +0 -0
  69. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  70. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  71. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  72. data/docs/public/images/tutorial/02-new-form.png +0 -0
  73. data/docs/public/images/tutorial/03-create-account.png +0 -0
  74. data/docs/public/images/tutorial/03-login.png +0 -0
  75. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  76. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  77. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  78. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  79. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  80. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  81. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  82. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  83. data/docs/reference/app/generators.md +517 -0
  84. data/docs/reference/app/index.md +158 -0
  85. data/docs/reference/app/packages.md +146 -0
  86. data/docs/reference/app/portals.md +377 -0
  87. data/docs/reference/auth/accounts.md +229 -0
  88. data/docs/reference/auth/index.md +88 -0
  89. data/docs/reference/auth/profile.md +185 -0
  90. data/docs/reference/behavior/controllers.md +395 -0
  91. data/docs/reference/behavior/index.md +22 -0
  92. data/docs/reference/behavior/interactions.md +341 -0
  93. data/docs/reference/behavior/policies.md +417 -0
  94. data/docs/reference/index.md +67 -48
  95. data/docs/reference/resource/actions.md +423 -0
  96. data/docs/reference/resource/definition.md +508 -0
  97. data/docs/reference/resource/index.md +50 -0
  98. data/docs/reference/resource/model.md +348 -0
  99. data/docs/reference/resource/query.md +305 -0
  100. data/docs/reference/tenancy/entity-scoping.md +368 -0
  101. data/docs/reference/tenancy/index.md +36 -0
  102. data/docs/reference/tenancy/invites.md +400 -0
  103. data/docs/reference/tenancy/nested-resources.md +267 -0
  104. data/docs/reference/testing/index.md +287 -0
  105. data/docs/reference/ui/assets.md +400 -0
  106. data/docs/reference/ui/components.md +165 -0
  107. data/docs/reference/ui/displays.md +104 -0
  108. data/docs/reference/ui/forms.md +284 -0
  109. data/docs/reference/ui/index.md +30 -0
  110. data/docs/reference/ui/layouts.md +106 -0
  111. data/docs/reference/ui/pages.md +189 -0
  112. data/docs/reference/ui/tables.md +121 -0
  113. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  114. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  115. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  116. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  117. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  118. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  119. data/gemfiles/rails_7.gemfile.lock +1 -1
  120. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  121. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  122. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  123. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  124. data/lib/generators/pu/invites/install_generator.rb +45 -0
  125. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  126. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  127. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  128. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  129. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  130. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  131. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  132. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  133. data/lib/generators/pu/saas/membership/USAGE +4 -1
  134. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  135. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  136. data/lib/plutonium/definition/base.rb +1 -1
  137. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  138. data/lib/plutonium/helpers/turbo_helper.rb +30 -0
  139. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  140. data/lib/plutonium/resource/controller.rb +1 -0
  141. data/lib/plutonium/resource/controllers/crud_actions.rb +23 -5
  142. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  143. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  144. data/lib/plutonium/resource/policy.rb +7 -0
  145. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  146. data/lib/plutonium/ui/component/methods.rb +5 -0
  147. data/lib/plutonium/ui/form/base.rb +23 -3
  148. data/lib/plutonium/ui/form/components/json.rb +58 -0
  149. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  150. data/lib/plutonium/ui/form/components/secure_association.rb +103 -22
  151. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  152. data/lib/plutonium/ui/form/interaction.rb +1 -1
  153. data/lib/plutonium/ui/form/resource.rb +0 -4
  154. data/lib/plutonium/ui/form/theme.rb +1 -1
  155. data/lib/plutonium/ui/grid/resource.rb +1 -1
  156. data/lib/plutonium/ui/layout/base.rb +1 -0
  157. data/lib/plutonium/ui/page/base.rb +0 -7
  158. data/lib/plutonium/ui/page/edit.rb +1 -1
  159. data/lib/plutonium/ui/page/index.rb +4 -4
  160. data/lib/plutonium/ui/page/new.rb +1 -1
  161. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  162. data/lib/plutonium/ui/table/resource.rb +1 -1
  163. data/lib/plutonium/version.rb +1 -1
  164. data/lib/plutonium.rb +8 -0
  165. data/lib/tasks/release.rake +15 -1
  166. data/package.json +13 -10
  167. data/src/css/slim_select.css +4 -0
  168. data/src/js/controllers/form_controller.js +5 -4
  169. data/src/js/controllers/slim_select_controller.js +61 -0
  170. data/src/js/turbo/turbo_actions.js +33 -0
  171. data/yarn.lock +661 -544
  172. metadata +86 -33
  173. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  174. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  175. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  176. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  177. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  178. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  179. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  180. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  181. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  182. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  183. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  184. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  185. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  186. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  187. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  188. data/docs/reference/assets/index.md +0 -496
  189. data/docs/reference/controller/index.md +0 -412
  190. data/docs/reference/definition/actions.md +0 -462
  191. data/docs/reference/definition/fields.md +0 -383
  192. data/docs/reference/definition/index.md +0 -326
  193. data/docs/reference/definition/query.md +0 -351
  194. data/docs/reference/generators/index.md +0 -648
  195. data/docs/reference/interaction/index.md +0 -449
  196. data/docs/reference/model/features.md +0 -248
  197. data/docs/reference/model/index.md +0 -218
  198. data/docs/reference/policy/index.md +0 -456
  199. data/docs/reference/portal/index.md +0 -379
  200. data/docs/reference/views/forms.md +0 -411
  201. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,674 @@
1
+ ---
2
+ name: plutonium-tenancy
3
+ description: Use BEFORE any multi-tenant work — scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.
4
+ ---
5
+
6
+ # Plutonium Tenancy — Entity Scoping, Nested Resources, Invites
7
+
8
+ Three closely-coupled concerns:
9
+
10
+ 1. **Entity scoping** — every record belongs to a tenant; queries are filtered automatically.
11
+ 2. **Nested resources** — parent/child URLs; parent scoping takes precedence over entity scoping.
12
+ 3. **Invites** — onboarding users into a tenant's membership.
13
+
14
+ Cross-references back to [[plutonium-resource]] (models, definitions) and [[plutonium-behavior]] (policies, controllers).
15
+
16
+ ## 🚨 Critical (read first)
17
+
18
+ - **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 `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.
19
+ - **Always declare an association path from model to entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, Plutonium raises. Fix the **model**, not the policy.
20
+ - **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.
21
+ - **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
22
+ - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
23
+ - **Invite email must match the accepting user's email.** Security feature. Don't disable `enforce_email?` lightly.
24
+ - **Use generators.** `pu:saas:setup`, `pu:pkg:portal --scope=Entity`, `pu:res:scaffold`, `pu:invites:install`, `pu:invites:invitable`. Hand-wiring is how leaks happen.
25
+
26
+ ---
27
+
28
+ # Part 1 — Entity Scoping
29
+
30
+ Built on three cooperating pieces:
31
+
32
+ | Piece | Role |
33
+ |---|---|
34
+ | **Portal** | Declares the entity class and how to resolve it (`scope_to_entity Organization, strategy: :path`). |
35
+ | **Policy** | `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)` on every collection query. Enforced via `verify_default_relation_scope_applied!`. |
36
+ | **Model** | `associated_with(entity)` resolves via custom scope, direct association, or `has_one :through`. |
37
+
38
+ ## `associated_with` resolution order
39
+
40
+ `Model.associated_with(entity)` tries, in order:
41
+
42
+ 1. **Custom scope** `associated_with_<entity_name>` — highest priority, full SQL control.
43
+ 2. **Direct `belongs_to` to entity class** — `WHERE <entity>_id = ?`, most efficient.
44
+ 3. **`has_one` / `has_one :through` to entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
45
+ 4. **Reverse `has_many` from entity** — JOIN required, logs a warning (less efficient).
46
+
47
+ If none apply: `Could not resolve the association between 'Model' and 'Entity'`. 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.
48
+
49
+ ## Three model shapes
50
+
51
+ Pick the lightest that fits.
52
+
53
+ ### Shape 1: Direct child (`belongs_to` the entity)
54
+
55
+ ```ruby
56
+ class Organization < ResourceRecord
57
+ has_many :projects
58
+ end
59
+
60
+ class Project < ResourceRecord
61
+ belongs_to :organization
62
+ end
63
+
64
+ Project.associated_with(org)
65
+ # => Project.where(organization: org)
66
+ ```
67
+
68
+ Auto-detected. No extra work.
69
+
70
+ ### Shape 2: Join table (membership)
71
+
72
+ ```ruby
73
+ class User < ResourceRecord
74
+ has_many :memberships
75
+ has_many :organizations, through: :memberships
76
+ end
77
+
78
+ class Membership < ResourceRecord
79
+ belongs_to :user
80
+ belongs_to :organization # auto-detected
81
+ end
82
+
83
+ Membership.associated_with(org)
84
+ # => Membership.where(organization: org)
85
+ ```
86
+
87
+ If `Membership` is itself a parent and the scoped target is two hops away, add `has_one :through`:
88
+
89
+ ```ruby
90
+ class ProjectMember < ResourceRecord
91
+ belongs_to :project
92
+ belongs_to :user
93
+ has_one :organization, through: :project # enables auto-scoping
94
+ end
95
+ ```
96
+
97
+ ### Shape 3: Grandchild (multi-hop via `has_one :through`)
98
+
99
+ ```ruby
100
+ class Project < ResourceRecord
101
+ belongs_to :organization
102
+ has_many :tasks
103
+ end
104
+
105
+ class Task < ResourceRecord
106
+ belongs_to :project
107
+ has_one :organization, through: :project # critical
108
+ end
109
+
110
+ class Comment < ResourceRecord
111
+ belongs_to :task
112
+ has_one :project, through: :task
113
+ has_one :organization, through: :project # multi-hop chain
114
+ end
115
+ ```
116
+
117
+ `Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.
118
+
119
+ ### When to fall back to a custom scope
120
+
121
+ ```ruby
122
+ class Comment < ResourceRecord
123
+ scope :associated_with_organization, ->(org) do
124
+ joins(task: :project).where(projects: {organization_id: org.id})
125
+ end
126
+ end
127
+ ```
128
+
129
+ Use when:
130
+ - The path is polymorphic.
131
+ - Conditional logic is needed.
132
+ - You want explicit SQL for performance.
133
+
134
+ Picked up BEFORE association detection.
135
+
136
+ ## `relation_scope` — safe overrides
137
+
138
+ `default_relation_scope(relation)` does two things:
139
+
140
+ 1. If a **parent** is present (nested resource), scopes via the parent association.
141
+ 2. Otherwise, applies `relation.associated_with(entity_scope)`.
142
+
143
+ ### Correct
144
+
145
+ ```ruby
146
+ # ✅ Best: don't override — the inherited scope already does it.
147
+
148
+ # ✅ Extra filters on top
149
+ relation_scope do |relation|
150
+ default_relation_scope(relation).where(archived: false)
151
+ end
152
+
153
+ # ✅ Role-based
154
+ relation_scope do |relation|
155
+ relation = default_relation_scope(relation)
156
+ user.admin? ? relation : relation.where(author: user)
157
+ end
158
+ ```
159
+
160
+ ### Wrong
161
+
162
+ ```ruby
163
+ # ❌ Manually filtering by entity — bypasses default_relation_scope
164
+ relation_scope { |r| r.where(organization: current_scoped_entity) }
165
+
166
+ # ❌ Manual joins — same problem
167
+ relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
168
+
169
+ # ❌ Missing default_relation_scope entirely — raises at runtime
170
+ relation_scope { |r| r.where(published: true) }
171
+ ```
172
+
173
+ **Prefer calling `default_relation_scope(relation)` explicitly** instead of relying on `super`. `super` works when you're extending a parent policy (e.g., a package-level base) that itself calls `default_relation_scope` — but it's brittle against `Plutonium::Resource::Policy` directly because `super`'s semantics depend on how ActionPolicy's DSL registered the scope. The runtime verification checks `default_relation_scope` was hit somewhere — not that you wrote it in this class.
174
+
175
+ ### Intentionally skipping
176
+
177
+ Rare. Before reaching for this, consider a separate, unscoped portal.
178
+
179
+ ```ruby
180
+ relation_scope do |relation|
181
+ skip_default_relation_scope!
182
+ relation
183
+ end
184
+ ```
185
+
186
+ ## Portal entity strategies
187
+
188
+ ### Path strategy (most common)
189
+
190
+ ```ruby
191
+ module AdminPortal
192
+ class Engine < Rails::Engine
193
+ include Plutonium::Portal::Engine
194
+
195
+ config.after_initialize do
196
+ scope_to_entity Organization, strategy: :path
197
+ end
198
+ end
199
+ end
200
+ ```
201
+
202
+ Routes become `/organizations/:organization_id/posts`. Portal extracts `params[:organization_id]` and loads the entity automatically.
203
+
204
+ ### Custom strategy (subdomain, session, etc.)
205
+
206
+ ```ruby
207
+ scope_to_entity Organization, strategy: :current_organization
208
+
209
+ module AdminPortal::Concerns::Controller
210
+ extend ActiveSupport::Concern
211
+ include Plutonium::Portal::Controller
212
+
213
+ private
214
+
215
+ def current_organization
216
+ @current_organization ||= Organization.find_by!(subdomain: request.subdomain)
217
+ end
218
+ end
219
+ ```
220
+
221
+ The strategy symbol must match a method name on the controller.
222
+
223
+ ### Accessing the scoped entity
224
+
225
+ ```ruby
226
+ # Controller
227
+ current_scoped_entity
228
+ scoped_to_entity?
229
+
230
+ # Policy
231
+ entity_scope
232
+ ```
233
+
234
+ ## Gotchas
235
+
236
+ - **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one (`def scoped_entity_association = :home_team`).
237
+ - **`param_key` differs from association name.** Fine — Plutonium matches by **class**, not param key. `scope_to_entity Competition::Team, param_key: :team` works with `belongs_to :competition_team`.
238
+ - **Default `param_key` includes `_scoped` suffix.** `scope_to_entity Organization` produces routes like `/organization_scoped/:organization_scoped_id/posts` to avoid colliding with a `belongs_to :organization` on child models. Pass `param_key:` (and optionally `route_key:`) to override for cleaner URLs.
239
+ - **Forgetting compound uniqueness.** `validates :code, uniqueness: true` leaks across tenants. Use `uniqueness: {scope: :organization_id}`.
240
+ - **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly. Never leave a `where` bypass in code.
241
+
242
+ ---
243
+
244
+ # Part 2 — Nested Resources
245
+
246
+ Plutonium auto-generates nested routes from `has_many` / `has_one` associations on a registered parent. **One level only** — no grandparent → parent → child chains.
247
+
248
+ ## Setup
249
+
250
+ ```bash
251
+ rails g pu:res:scaffold Company name:string --dest=main_app
252
+ rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
253
+ rails g pu:res:conn Company Property --dest=admin_portal
254
+ ```
255
+
256
+ Then register both in the portal routes:
257
+
258
+ ```ruby
259
+ register_resource ::Company
260
+ register_resource ::Property # has belongs_to :company
261
+ register_resource ::CompanyProfile # has_one :company_profile on Company
262
+ ```
263
+
264
+ ## Generated routes
265
+
266
+ Plutonium prefixes nested routes with `nested_` to avoid conflicts with the top-level routes:
267
+
268
+ | Route | Purpose |
269
+ |---|---|
270
+ | `/companies/:company_id/nested_properties` | has_many index |
271
+ | `/companies/:company_id/nested_properties/new` | new |
272
+ | `/companies/:company_id/nested_properties/:id` | show |
273
+ | `/companies/:company_id/nested_company_profile` | has_one show (no `:id`) |
274
+ | `/companies/:company_id/nested_company_profile/new` | has_one new |
275
+
276
+ For `has_one`: index redirects to show (or new if no record exists); only one record per parent.
277
+
278
+ ## Automatic behavior in nested routes
279
+
280
+ When the controller is hit through a nested route:
281
+
282
+ 1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
283
+ 2. **Scopes queries** via parent association (e.g. `parent.properties` for `has_many`, `where(foreign_key => parent.id)` for `has_one`).
284
+ 3. **Assigns parent** on create (injected into `resource_params`).
285
+ 4. **Hides parent field** in forms (already determined by URL).
286
+
287
+ You don't need to add hidden parent fields in forms or filter queries manually.
288
+
289
+ ## Controller methods
290
+
291
+ ```ruby
292
+ current_parent # Parent record
293
+ current_nested_association # :properties
294
+ parent_route_param # :company_id
295
+ parent_input_param # :company
296
+ ```
297
+
298
+ ## Parent vs entity scoping
299
+
300
+ 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 isn't needed.
301
+
302
+ ```ruby
303
+ # In the child policy — just call default_relation_scope, it handles both cases
304
+ relation_scope do |relation|
305
+ default_relation_scope(relation) # uses parent when present, entity_scope otherwise
306
+ end
307
+ ```
308
+
309
+ ## URL generation
310
+
311
+ ```ruby
312
+ # Collection
313
+ resource_url_for(Property, parent: company)
314
+ # => /companies/123/nested_properties
315
+
316
+ # Record
317
+ resource_url_for(property, parent: company)
318
+ # => /companies/123/nested_properties/456
319
+
320
+ # Form
321
+ resource_url_for(Property, action: :new, parent: company)
322
+ resource_url_for(property, action: :edit, parent: company)
323
+
324
+ # has_one
325
+ resource_url_for(CompanyProfile, action: :new, parent: company)
326
+ # => /companies/123/nested_company_profile/new
327
+
328
+ # Interactions
329
+ resource_url_for(property, parent: company, interaction: :archive)
330
+ resource_url_for(Property, parent: company, interaction: :import)
331
+ resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])
332
+
333
+ # Cross-package
334
+ resource_url_for(property, parent: company, package: CustomerPortal)
335
+ ```
336
+
337
+ ## Authorization context
338
+
339
+ The child policy receives the parent:
340
+
341
+ ```ruby
342
+ class PropertyPolicy < ResourcePolicy
343
+ # parent => the Company instance
344
+ # parent_association => :properties
345
+
346
+ def create?
347
+ parent.present? && user.member_of?(parent)
348
+ end
349
+ end
350
+ ```
351
+
352
+ ## Presentation hooks
353
+
354
+ ```ruby
355
+ class PropertiesController < ResourceController
356
+ private
357
+
358
+ def present_parent? = true # show parent in displays (default: false)
359
+ def submit_parent? = false # allow changing in forms (defaults to present_parent?)
360
+ end
361
+ ```
362
+
363
+ Conditional pattern — show parent only when accessed standalone:
364
+
365
+ ```ruby
366
+ def present_parent?
367
+ current_parent.nil?
368
+ end
369
+ ```
370
+
371
+ ## Custom parent resolution
372
+
373
+ ```ruby
374
+ def current_parent
375
+ @current_parent ||= Company.friendly.find(params[:company_id])
376
+ end
377
+ ```
378
+
379
+ ## Custom nested routes
380
+
381
+ ```ruby
382
+ register_resource ::Property do
383
+ member do
384
+ get :analytics, as: :analytics # `as:` is REQUIRED for resource_url_for to work
385
+ post :archive, as: :archive
386
+ end
387
+ end
388
+ ```
389
+
390
+ Generates `/companies/:company_id/nested_properties/:id/analytics` etc.
391
+
392
+ ## Breadcrumbs
393
+
394
+ Auto-include parent: `Companies > Acme Corp > Properties > Property #123`.
395
+
396
+ ---
397
+
398
+ # Part 3 — Invites
399
+
400
+ A complete user-invitation system: token-based emails, secure acceptance, Rodauth integration, entity membership creation, and "invitable" hooks for app-specific behavior.
401
+
402
+ ## Prerequisites
403
+
404
+ User model + entity model + membership model. The fastest path:
405
+
406
+ ```bash
407
+ rails g pu:saas:setup --user Customer --entity Organization
408
+ ```
409
+
410
+ This creates all three plus the join table.
411
+
412
+ ## Install
413
+
414
+ ```bash
415
+ rails generate pu:invites:install
416
+ ```
417
+
418
+ ### Options
419
+
420
+ | Option | Default | Description |
421
+ |---|---|---|
422
+ | `--entity-model=NAME` | `Entity` | Entity model name |
423
+ | `--user-model=NAME` | `User` | User model name |
424
+ | `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
425
+ | `--membership-model=NAME` | `EntityUser` | Membership join model (must already exist; roles are read from its `enum :role`) |
426
+ | `--rodauth=NAME` | `user` | Rodauth configuration for signup |
427
+ | `--enforce-domain` | `false` | Require invited email domain to match entity |
428
+ | `--dest=PACKAGE` | `main_app` | Package where the entity model lives (controls where `invite_user_interaction.rb` is generated) |
429
+
430
+ ::: 🚨 No `--roles` flag here
431
+ Role list is derived from the membership model's `enum :role`. Set roles via `pu:saas:membership --roles=...` (or edit the 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 (`roles[1]`).
432
+ :::
433
+
434
+ ::: 🚨 ActiveRecord encryption keys required
435
+ The invite model uses `encrypts :token, deterministic: true`. Without configured AR encryption keys, creating or accepting an invite raises `ActiveRecord::Encryption::Errors::Configuration`. The generator detects this and warns at install time — generate keys with `bin/rails db:encryption:init`, then paste the printed `active_record_encryption:` block into `config/credentials.yml.enc` (or set the equivalent `ACTIVE_RECORD_ENCRYPTION_*` ENV vars in production).
436
+ :::
437
+
438
+ ### What gets created
439
+
440
+ ```
441
+ packages/invites/
442
+ ├── app/controllers/invites/
443
+ │ ├── user_invitations_controller.rb
444
+ │ └── welcome_controller.rb
445
+ ├── app/definitions/invites/user_invite_definition.rb
446
+ ├── app/interactions/invites/
447
+ │ ├── cancel_invite_interaction.rb
448
+ │ └── resend_invite_interaction.rb
449
+ ├── app/mailers/invites/user_invite_mailer.rb
450
+ ├── app/models/invites/user_invite.rb
451
+ ├── app/policies/invites/user_invite_policy.rb
452
+ └── app/views/invites/...
453
+
454
+ app/interactions/{entity,user}/invite_user_interaction.rb
455
+ db/migrate/TIMESTAMP_create_user_invites.rb
456
+ ```
457
+
458
+ Routes added:
459
+
460
+ ```ruby
461
+ get "welcome", to: "invites/welcome#index"
462
+ get "invitations/:token", to: "invites/user_invitations#show"
463
+ post "invitations/:token/accept", to: "invites/user_invitations#accept"
464
+ get "invitations/:token/signup", to: "invites/user_invitations#signup"
465
+ post "invitations/:token/signup", to: "invites/user_invitations#signup"
466
+ ```
467
+
468
+ ## Multiple invite flows in one app
469
+
470
+ Run `pu:invites:install` once per flow. Default class name derives as `<EntityModel><UserModel>Invite` — no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
471
+
472
+ ```bash
473
+ rails g pu:invites:install \
474
+ --entity-model=FunderOrganization --user-model=SpenderAccount \
475
+ --invite-model=FunderInvite
476
+
477
+ rails g pu:invites:install \
478
+ --entity-model=Project --user-model=Member \
479
+ --invite-model=ProjectInvite
480
+ ```
481
+
482
+ Each invocation creates an independent model (`Invites::FunderInvite`), controller (`Invites::FunderInvitationsController`), route (`/funder_invitations/:token`), and helper (`funder_invitation_path`). The shared `Invites::WelcomeController` accumulates each class into `invite_classes`; `pending_invite` checks all flows in priority order (first-match wins).
483
+
484
+ Model-level overrides for non-default association names:
485
+
486
+ ```ruby
487
+ def user_attribute = :spender_account # belongs_to :spender_account
488
+ def invite_entity_attribute = :funder_organization # belongs_to :funder_organization
489
+ ```
490
+
491
+ Controller-level (auto-generated, but shown for clarity):
492
+
493
+ ```ruby
494
+ # welcome_controller.rb
495
+ def invite_classes
496
+ [::Invites::FunderInvite, ::Invites::ProjectInvite]
497
+ end
498
+
499
+ # funder_invitations_controller.rb
500
+ def invitation_path_for(token)
501
+ funder_invitation_path(token: token)
502
+ end
503
+ ```
504
+
505
+ ## Invitables — app models notified on accept
506
+
507
+ An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
508
+
509
+ ```bash
510
+ rails generate pu:invites:invitable Tenant
511
+ rails generate pu:invites:invitable TeamMember --role=member
512
+ rails generate pu:invites:invitable Tenant --dest=my_package
513
+ ```
514
+
515
+ Then implement the callback:
516
+
517
+ ```ruby
518
+ class Tenant < ApplicationRecord
519
+ include Plutonium::Invites::Concerns::Invitable
520
+
521
+ belongs_to :entity
522
+ belongs_to :user, optional: true
523
+
524
+ def on_invite_accepted(user)
525
+ update!(user: user, status: :active)
526
+ end
527
+ end
528
+ ```
529
+
530
+ Without `on_invite_accepted`, the invitable never learns about the new user.
531
+
532
+ ## The flow
533
+
534
+ ### 1. Admin sends the invite
535
+
536
+ ```ruby
537
+ entity.invite_user(email: "user@example.com", role: :member)
538
+ tenant.invite_user(email: "user@example.com") # from invitable context
539
+ ```
540
+
541
+ ### 2. Email goes out
542
+
543
+ Token-based URL: `https://app.example.com/invitations/abc123...`
544
+
545
+ ### 3. User accepts
546
+
547
+ **Existing user:** clicks link → logs in (or already logged in) → email validated → membership created → invitable notified via `on_invite_accepted`.
548
+
549
+ **New user:** clicks link → "Create Account" → signs up with the invited email → membership created → invitable notified.
550
+
551
+ ### 4. Pending invite check
552
+
553
+ After login, users land on `/welcome` where pending invites are shown:
554
+
555
+ ```ruby
556
+ include Plutonium::Invites::PendingInviteCheck
557
+ ```
558
+
559
+ Rodauth wiring (required for redirect):
560
+
561
+ ```ruby
562
+ # app/rodauth/user_rodauth_plugin.rb
563
+ configure do
564
+ login_return_to_requested_location? true
565
+ login_redirect "/welcome"
566
+
567
+ after_login do
568
+ session[:after_welcome_redirect] = session.delete(:login_redirect)
569
+ end
570
+ end
571
+ ```
572
+
573
+ ## The UserInvite model
574
+
575
+ Generated as `Invites::<InviteModelName>`:
576
+
577
+ ```ruby
578
+ class Invites::UserInvite < Invites::ResourceRecord
579
+ include Plutonium::Invites::Concerns::InviteToken
580
+
581
+ belongs_to :entity
582
+ belongs_to :invited_by, polymorphic: true
583
+ belongs_to :user, optional: true
584
+ belongs_to :invitable, polymorphic: true, optional: true
585
+
586
+ enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
587
+ enum :role, member: 0, admin: 1
588
+ end
589
+ ```
590
+
591
+ Key methods:
592
+
593
+ ```ruby
594
+ invite = Invites::UserInvite.find_for_acceptance(token)
595
+ invite.accept_for_user!(current_user)
596
+ invite.resend!
597
+ invite.cancel!
598
+ ```
599
+
600
+ ## Customization
601
+
602
+ ### Custom email templates
603
+
604
+ Override views in your package:
605
+
606
+ ```erb
607
+ <%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
608
+ <h1>Welcome to <%= @invite.entity.name %>!</h1>
609
+ <p><%= @invite.invited_by.email %> has invited you.</p>
610
+ <p><%= link_to "Accept", @invitation_url %></p>
611
+ ```
612
+
613
+ ### Custom validation
614
+
615
+ Extend the model:
616
+
617
+ ```ruby
618
+ class Invites::UserInvite < Invites::ResourceRecord
619
+ validate :email_not_already_member
620
+
621
+ private
622
+
623
+ def email_not_already_member
624
+ existing = membership_model.joins(:user)
625
+ .where(entity: entity, users: {email: email}).exists?
626
+ errors.add(:email, "is already a member") if existing
627
+ end
628
+ end
629
+ ```
630
+
631
+ ### Domain enforcement
632
+
633
+ ```bash
634
+ rails g pu:invites:install --enforce-domain
635
+ ```
636
+
637
+ ### Custom roles
638
+
639
+ Set roles when generating the membership model (ordering: index 0 = most privileged):
640
+
641
+ ```bash
642
+ rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
643
+ # → enum :role, { owner: 0, admin: 1, editor: 2, viewer: 3 } (owner auto-prepended)
644
+ ```
645
+
646
+ Or edit `enum :role` on the existing membership model directly. Then run `pu:invites:install`.
647
+
648
+ ## Portal connection
649
+
650
+ ```ruby
651
+ module CustomerPortal
652
+ class Engine < Rails::Engine
653
+ include Plutonium::Portal::Engine
654
+ register_package Invites::Engine
655
+ end
656
+ end
657
+ ```
658
+
659
+ Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity` → `associated_with` resolves directly → admins see only invites for their org.
660
+
661
+ ## Common issues
662
+
663
+ - **"Invite not found"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
664
+ - **Email mismatch** — `enforce_email?` is on by default. The accepting user's email must match the invited email. Override `def enforce_email? = false` only if you fully understand the security trade-off.
665
+ - **Rodauth redirect after login** — make sure `login_redirect "/welcome"` is set in the rodauth plugin.
666
+
667
+ ---
668
+
669
+ ## Related skills
670
+
671
+ - [[plutonium-resource]] — model declarations (`belongs_to`, `has_one :through`, custom scopes), `permitted_associations` for show-page tabs.
672
+ - [[plutonium-behavior]] — `relation_scope` syntax, policy authorization context, controller presentation hooks.
673
+ - [[plutonium-app]] — portal setup, `scope_to_entity`, mounting engines.
674
+ - [[plutonium-auth]] — Rodauth signup flow for invite acceptance.
@@ -207,7 +207,9 @@ current_account(portal: :admin)
207
207
  with_portal(:org) { ... } # scoped portal switch
208
208
  ```
209
209
 
210
- **Override hook for non-Rodauth apps:** define `sign_in_for_tests(account, portal:)` in your test class (or in `test/support/plutonium_testing.rb` for project-wide use). `AuthHelpers` will defer to it.
210
+ **Default Rodauth login expects `password: "password123"`** `login_as` POSTs to `/<account_table>/login` with that hardcoded password. Either seed test accounts with it (fixtures/factories) or override via `sign_in_for_tests` below.
211
+
212
+ **Override hook for non-Rodauth apps (or to bypass Rodauth in tests):** define `sign_in_for_tests(account, portal:)` in your test class (or in `test/support/plutonium_testing.rb` for project-wide use). `AuthHelpers` will defer to it.
211
213
 
212
214
  ```ruby
213
215
  def sign_in_for_tests(account, portal:)
@@ -260,9 +262,10 @@ Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
260
262
  - **Nested resources need `parent: :foo`** in the DSL AND a real parent record from `parent_record!`. Without both, path interpolation fails.
261
263
  - **`PortalAccess` doesn't use `resource_tests_for`** — use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
262
264
 
263
- ## See also
265
+ ## Related skills
264
266
 
265
- - `plutonium-policy`write the policy this concern verifies
266
- - `plutonium-definition` — definition props the smoke test introspects
267
- - `plutonium-portal`portal mounting and entity strategies that drive auth/scoping
268
- - `plutonium-auth`Rodauth setup behind the default login flow
267
+ - [[plutonium-behavior]]policies (verified by `ResourcePolicy`), interactions (asserted by `ResourceInteraction`)
268
+ - [[plutonium-resource]] — definition props the smoke test introspects (`field`, `input`, `display`, `column`, `scope`, `filter`, `sort`, `action`)
269
+ - [[plutonium-tenancy]]`relation_scope`, parent scoping, nested resources (matched by `NestedResource`)
270
+ - [[plutonium-app]]portal mounting and entity strategies that drive auth/scoping
271
+ - [[plutonium-auth]] — Rodauth setup behind the default login flow