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,400 @@
1
+ # Invites
2
+
3
+ Token-based email invitations for multi-tenant onboarding. Integrates with Rodauth signup, creates entity memberships on acceptance, and supports "invitable" hooks for app-specific behavior.
4
+
5
+ ## 🚨 Critical
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`).
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`.
11
+
12
+ ## Prerequisites
13
+
14
+ Before installing invites, you need:
15
+
16
+ 1. A Rodauth user model
17
+ 2. An entity model (Organization, Company, Team, …)
18
+ 3. A membership model linking users to entities
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:
21
+
22
+ ```bash
23
+ rails g pu:saas:setup --user Customer --entity Organization
24
+ ```
25
+
26
+ ## Install (standalone)
27
+
28
+ ```bash
29
+ rails generate pu:invites:install
30
+ ```
31
+
32
+ ### Options
33
+
34
+ | Option | Default | Description |
35
+ |---|---|---|
36
+ | `--entity-model=NAME` | `Entity` | Entity model name |
37
+ | `--user-model=NAME` | `User` | User model name |
38
+ | `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
39
+ | `--membership-model=NAME` | `EntityUser` | Membership join model (must already exist) |
40
+ | `--rodauth=NAME` | `user` | Rodauth configuration for signup |
41
+ | `--enforce-domain` | `false` | Require invited email domain to match entity domain |
42
+
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.
45
+ :::
46
+
47
+ Example with custom models:
48
+
49
+ ```bash
50
+ rails g pu:invites:install \
51
+ --entity-model=Organization \
52
+ --user-model=Customer \
53
+ --membership-model=OrganizationMember
54
+ ```
55
+
56
+ After install:
57
+
58
+ ```bash
59
+ rails db:migrate
60
+ ```
61
+
62
+ ## What gets created
63
+
64
+ ```
65
+ packages/invites/
66
+ ├── app/
67
+ │ ├── controllers/invites/
68
+ │ │ ├── user_invitations_controller.rb
69
+ │ │ └── welcome_controller.rb
70
+ │ ├── definitions/invites/user_invite_definition.rb
71
+ │ ├── interactions/invites/
72
+ │ │ ├── cancel_invite_interaction.rb
73
+ │ │ └── resend_invite_interaction.rb
74
+ │ ├── mailers/invites/user_invite_mailer.rb
75
+ │ ├── models/invites/user_invite.rb
76
+ │ ├── policies/invites/user_invite_policy.rb
77
+ │ └── views/invites/...
78
+
79
+ app/interactions/{entity,user}/invite_user_interaction.rb
80
+ db/migrate/TIMESTAMP_create_user_invites.rb
81
+ ```
82
+
83
+ Routes added:
84
+
85
+ ```ruby
86
+ get "welcome", to: "invites/welcome#index"
87
+ get "invitations/:token", to: "invites/user_invitations#show"
88
+ post "invitations/:token/accept", to: "invites/user_invitations#accept"
89
+ get "invitations/:token/signup", to: "invites/user_invitations#signup"
90
+ post "invitations/:token/signup", to: "invites/user_invitations#signup"
91
+ ```
92
+
93
+ ## Connect to a portal
94
+
95
+ ```ruby
96
+ # packages/customer_portal/lib/engine.rb
97
+ module CustomerPortal
98
+ class Engine < Rails::Engine
99
+ include Plutonium::Portal::Engine
100
+
101
+ register_package Invites::Engine
102
+ end
103
+ end
104
+ ```
105
+
106
+ Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity` → `associated_with` resolves directly → admins only see invites for their org.
107
+
108
+ ## The flow
109
+
110
+ ### 1. Admin sends the invite
111
+
112
+ ```ruby
113
+ # From entity context
114
+ entity.invite_user(email: "user@example.com", role: :member)
115
+
116
+ # From invitable context
117
+ tenant.invite_user(email: "user@example.com")
118
+ ```
119
+
120
+ ### 2. Email goes out
121
+
122
+ Token-based URL:
123
+
124
+ ```
125
+ Subject: You've been invited to join Acme Corp
126
+
127
+ Click here: https://app.example.com/invitations/abc123...
128
+ ```
129
+
130
+ ### 3. User accepts
131
+
132
+ **Existing user:**
133
+
134
+ 1. Clicks the invite link.
135
+ 2. Logs in (or is already logged in).
136
+ 3. System validates email matches.
137
+ 4. Membership created; invitable notified via `on_invite_accepted`.
138
+
139
+ **New user:**
140
+
141
+ 1. Clicks the invite link.
142
+ 2. Clicks "Create Account".
143
+ 3. Signs up with the invited email.
144
+ 4. System validates email matches.
145
+ 5. Membership created; invitable notified.
146
+
147
+ ### 4. Pending invite check
148
+
149
+ After login, users land on `/welcome` where pending invites are shown:
150
+
151
+ ```ruby
152
+ include Plutonium::Invites::PendingInviteCheck
153
+ ```
154
+
155
+ Rodauth wiring (required for the redirect):
156
+
157
+ ```ruby
158
+ # app/rodauth/user_rodauth_plugin.rb
159
+ configure do
160
+ login_return_to_requested_location? true
161
+ login_redirect "/welcome"
162
+
163
+ after_login do
164
+ session[:after_welcome_redirect] = session.delete(:login_redirect)
165
+ end
166
+ end
167
+ ```
168
+
169
+ ## Invitables — app models notified on accept
170
+
171
+ An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
172
+
173
+ ```bash
174
+ rails g pu:invites:invitable Tenant
175
+ rails g pu:invites:invitable TeamMember --role=member
176
+ rails g pu:invites:invitable Tenant --dest=my_package
177
+ ```
178
+
179
+ | Option | Default | Description |
180
+ |---|---|---|
181
+ | `--role=ROLE` | `member` | Role to assign on acceptance |
182
+ | `--user-model=NAME` | `User` | User model |
183
+ | `--membership-model=NAME` | `EntityUser` | Membership join model |
184
+ | `--dest=PACKAGE` | `main_app` | Destination package |
185
+ | `--[no-]email-templates` | `true` | Generate custom email templates |
186
+
187
+ Implement the callback on the invitable:
188
+
189
+ ```ruby
190
+ class Tenant < ApplicationRecord
191
+ include Plutonium::Invites::Concerns::Invitable
192
+
193
+ belongs_to :entity
194
+ belongs_to :user, optional: true
195
+
196
+ def on_invite_accepted(user)
197
+ update!(user: user, status: :active)
198
+ end
199
+ end
200
+ ```
201
+
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.
204
+ :::
205
+
206
+ ## Multiple invite flows
207
+
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
+
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
+
212
+ ```bash
213
+ rails g pu:invites:install \
214
+ --entity-model=FunderOrganization \
215
+ --user-model=SpenderAccount \
216
+ --invite-model=FunderInvite
217
+
218
+ rails g pu:invites:install \
219
+ --entity-model=Project \
220
+ --user-model=Member \
221
+ --invite-model=ProjectInvite
222
+ ```
223
+
224
+ Each invocation creates an independent flow: model `Invites::FunderInvite` on `funder_invites`, controller `Invites::FunderInvitationsController` on `/funder_invitations/:token`, helper `funder_invitation_path`, etc.
225
+
226
+ The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array, so `pending_invite` checks all flows in priority order (first-match wins).
227
+
228
+ ### Model-level overrides for non-default associations
229
+
230
+ ```ruby
231
+ def user_attribute = :spender_account # belongs_to :spender_account instead of :user
232
+ def invite_entity_attribute = :funder_organization # belongs_to :funder_organization instead of :entity
233
+ ```
234
+
235
+ ### Controller-level overrides (auto-generated)
236
+
237
+ ```ruby
238
+ # packages/invites/app/controllers/invites/welcome_controller.rb
239
+ def invite_classes
240
+ [::Invites::FunderInvite, ::Invites::ProjectInvite]
241
+ end
242
+
243
+ # packages/invites/app/controllers/invites/funder_invitations_controller.rb
244
+ def invitation_path_for(token)
245
+ funder_invitation_path(token: token)
246
+ end
247
+ ```
248
+
249
+ ## The UserInvite model
250
+
251
+ Generated as `Invites::<InviteModelName>`:
252
+
253
+ ```ruby
254
+ class Invites::UserInvite < Invites::ResourceRecord
255
+ include Plutonium::Invites::Concerns::InviteToken
256
+
257
+ belongs_to :entity
258
+ belongs_to :invited_by, polymorphic: true
259
+ belongs_to :user, optional: true
260
+ belongs_to :invitable, polymorphic: true, optional: true
261
+
262
+ enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
263
+ enum :role, member: 0, admin: 1
264
+ end
265
+ ```
266
+
267
+ Key methods:
268
+
269
+ ```ruby
270
+ invite = Invites::UserInvite.find_for_acceptance(token)
271
+ invite.accept_for_user!(current_user)
272
+ invite.resend!
273
+ invite.cancel!
274
+ ```
275
+
276
+ ## Customization
277
+
278
+ ### Custom email templates
279
+
280
+ Override views in your package:
281
+
282
+ ```erb
283
+ <%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
284
+ <h1>Welcome to <%= @invite.entity.name %>!</h1>
285
+ <p><%= @invite.invited_by.email %> has invited you.</p>
286
+ <p><%= link_to "Accept", @invitation_url %></p>
287
+ ```
288
+
289
+ ### Per-invitable templates
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".
292
+
293
+ ### Custom validation
294
+
295
+ Extend the invite model:
296
+
297
+ ```ruby
298
+ class Invites::UserInvite < Invites::ResourceRecord
299
+ validate :email_not_already_member
300
+
301
+ private
302
+
303
+ def email_not_already_member
304
+ existing = membership_model.joins(:user)
305
+ .where(entity: entity, users: {email: email})
306
+ .exists?
307
+ errors.add(:email, "is already a member") if existing
308
+ end
309
+ end
310
+ ```
311
+
312
+ ### Domain enforcement
313
+
314
+ ```bash
315
+ rails g pu:invites:install --enforce-domain
316
+ ```
317
+
318
+ Requires the invited email's domain to match the entity's domain.
319
+
320
+ ### Custom roles
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`):
323
+
324
+ ```bash
325
+ rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
326
+ # → enum :role, { owner: 0, admin: 1, editor: 2, viewer: 3 } (owner is auto-prepended)
327
+ ```
328
+
329
+ Or edit `enum :role` on the existing membership model directly. Then run `pu:invites:install`.
330
+
331
+ ### Custom expiration
332
+
333
+ Override on the model:
334
+
335
+ ```ruby
336
+ class Invites::UserInvite < Invites::ResourceRecord
337
+ TOKEN_EXPIRATION = 30.days # default is 1 week
338
+
339
+ def expired?
340
+ created_at < TOKEN_EXPIRATION.ago
341
+ end
342
+ end
343
+ ```
344
+
345
+ ## Managing invitations
346
+
347
+ ### Resend
348
+
349
+ ```ruby
350
+ invite.resend! # generates new token + sends email
351
+ ```
352
+
353
+ ### Cancel
354
+
355
+ ```ruby
356
+ invite.cancel! # transitions to :cancelled state
357
+ ```
358
+
359
+ ### View pending
360
+
361
+ ```ruby
362
+ entity.user_invites.pending
363
+ ```
364
+
365
+ ## Security
366
+
367
+ ### Token security
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).
370
+
371
+ ### Email validation
372
+
373
+ `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
374
+
375
+ To allow any email (NOT recommended):
376
+
377
+ ```ruby
378
+ def enforce_email? = false
379
+ ```
380
+
381
+ ### Rate limiting
382
+
383
+ Use Rack::Attack or similar to throttle:
384
+
385
+ - Invite creation per admin
386
+ - Invitation acceptance attempts per IP
387
+
388
+ ## Common issues
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`.
394
+
395
+ ## Related
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
@@ -0,0 +1,267 @@
1
+ # Nested Resources
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.
4
+
5
+ ## 🚨 Critical
6
+
7
+ - **One level only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
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.
10
+ - **The parent is authorized for `:read?`** before `current_parent` returns. The child policy receives the parent in its context.
11
+
12
+ ## Setup
13
+
14
+ ```bash
15
+ rails g pu:res:scaffold Company name:string --dest=main_app
16
+ rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
17
+ rails g pu:res:conn Company Property --dest=admin_portal
18
+ ```
19
+
20
+ Then register both in the portal:
21
+
22
+ ```ruby
23
+ # packages/admin_portal/config/routes.rb
24
+ register_resource ::Company
25
+ register_resource ::Property # has belongs_to :company
26
+ register_resource ::CompanyProfile # has_one :company_profile on Company
27
+ ```
28
+
29
+ ## Generated routes
30
+
31
+ Plutonium prefixes nested routes with `nested_` so they don't conflict with the top-level routes:
32
+
33
+ | Route | Purpose |
34
+ |---|---|
35
+ | `/companies/:company_id/nested_properties` | `has_many` index |
36
+ | `/companies/:company_id/nested_properties/new` | new |
37
+ | `/companies/:company_id/nested_properties/:id` | show |
38
+ | `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
39
+ | `/companies/:company_id/nested_company_profile/new` | `has_one` new |
40
+
41
+ For `has_one`:
42
+
43
+ - Routes are singular (no `:id` param).
44
+ - Index redirects to show (or new if no record exists).
45
+ - Only one record can exist per parent.
46
+ - Forms don't show the parent field (determined by URL).
47
+
48
+ ## Automatic behavior on nested routes
49
+
50
+ When the controller is hit via a nested route, Plutonium automatically:
51
+
52
+ 1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
53
+ 2. **Scopes queries** via the parent association:
54
+ - `has_many` → `parent.send(parent_association)` (e.g. `company.properties`)
55
+ - `has_one` → `relation.where(foreign_key => parent.id)` with limit
56
+ 3. **Assigns the parent** on create (injected into `resource_params`).
57
+ 4. **Hides the parent field** in forms and displays (already determined by URL).
58
+
59
+ You don't add hidden parent fields or filter queries manually.
60
+
61
+ ## Controller methods
62
+
63
+ ```ruby
64
+ current_parent # parent record (e.g. Company instance)
65
+ current_nested_association # association name (e.g. :properties)
66
+ parent_route_param # URL param (e.g. :company_id)
67
+ parent_input_param # form param / association name (e.g. :company)
68
+ ```
69
+
70
+ ## Parent vs entity scoping
71
+
72
+ 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.
73
+
74
+ In the child's policy, just call `default_relation_scope` — it handles both cases:
75
+
76
+ ```ruby
77
+ class PropertyPolicy < ResourcePolicy
78
+ relation_scope do |relation|
79
+ default_relation_scope(relation) # parent when present, entity_scope otherwise
80
+ end
81
+ end
82
+ ```
83
+
84
+ For composite filtering on top of the default:
85
+
86
+ ```ruby
87
+ relation_scope do |relation|
88
+ default_relation_scope(relation).where(archived: false)
89
+ end
90
+ ```
91
+
92
+ ## URL generation
93
+
94
+ `resource_url_for(...)` with the `parent:` option:
95
+
96
+ ```ruby
97
+ # Child collection (has_many)
98
+ resource_url_for(Property, parent: company)
99
+ # => /companies/123/nested_properties
100
+
101
+ # Child record
102
+ resource_url_for(property, parent: company)
103
+ # => /companies/123/nested_properties/456
104
+
105
+ # New child
106
+ resource_url_for(Property, action: :new, parent: company)
107
+ # => /companies/123/nested_properties/new
108
+
109
+ # Edit child
110
+ resource_url_for(property, action: :edit, parent: company)
111
+ # => /companies/123/nested_properties/456/edit
112
+
113
+ # Singular (has_one)
114
+ resource_url_for(company_profile, parent: company)
115
+ # => /companies/123/nested_company_profile
116
+
117
+ resource_url_for(CompanyProfile, action: :new, parent: company)
118
+ # => /companies/123/nested_company_profile/new
119
+
120
+ # Interactions compose with parent
121
+ resource_url_for(property, parent: company, interaction: :archive)
122
+ resource_url_for(Property, parent: company, interaction: :import)
123
+ resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])
124
+ ```
125
+
126
+ ### Cross-package URLs
127
+
128
+ ```ruby
129
+ # From AdminPortal, generate URL to a CustomerPortal resource
130
+ resource_url_for(property, parent: company, package: CustomerPortal)
131
+ ```
132
+
133
+ ## Authorization context
134
+
135
+ The child policy receives the parent automatically:
136
+
137
+ ```ruby
138
+ class PropertyPolicy < ResourcePolicy
139
+ # parent => the Company instance
140
+ # parent_association => :properties
141
+
142
+ def create?
143
+ parent.present? && user.member_of?(parent)
144
+ end
145
+
146
+ def read?
147
+ parent.present? && record.company == parent
148
+ end
149
+ end
150
+ ```
151
+
152
+ The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
153
+
154
+ ## Parameter handling
155
+
156
+ The parent is injected into `resource_params` automatically:
157
+
158
+ ```ruby
159
+ # When creating a property under /companies/123/nested_properties
160
+ resource_params
161
+ # => { name: "...", company: <Company:123>, company_id: 123 }
162
+ ```
163
+
164
+ No hidden parent fields needed in forms.
165
+
166
+ ## Presentation hooks
167
+
168
+ Control whether the parent field appears in views/forms:
169
+
170
+ ```ruby
171
+ class PropertiesController < ::ResourceController
172
+ private
173
+
174
+ def present_parent? = true # show on displays (default: false)
175
+ def submit_parent? = false # include in forms (defaults to present_parent?)
176
+ end
177
+ ```
178
+
179
+ Conditional — show parent only when accessed standalone:
180
+
181
+ ```ruby
182
+ def present_parent?
183
+ current_parent.nil?
184
+ end
185
+ ```
186
+
187
+ ## Custom parent resolution
188
+
189
+ Override `current_parent` for non-default lookup:
190
+
191
+ ```ruby
192
+ class PropertiesController < ::ResourceController
193
+ private
194
+
195
+ def current_parent
196
+ @current_parent ||= Company.friendly.find(params[:company_id])
197
+ end
198
+ end
199
+ ```
200
+
201
+ ## Custom routes on nested resources
202
+
203
+ ```ruby
204
+ register_resource ::Property do
205
+ member do
206
+ get :analytics, as: :analytics
207
+ post :archive, as: :archive
208
+ end
209
+ collection do
210
+ get :report, as: :report
211
+ end
212
+ end
213
+ ```
214
+
215
+ Generates `/companies/:company_id/nested_properties/:id/analytics`, etc.
216
+
217
+ ::: warning Always pass `as:`
218
+ Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails — there's no named route to look up.
219
+ :::
220
+
221
+ ## Compound uniqueness
222
+
223
+ Scope uniqueness to the parent FK:
224
+
225
+ ```ruby
226
+ class Property < ResourceRecord
227
+ belongs_to :company
228
+ validates :code, uniqueness: {scope: :company_id}
229
+ end
230
+ ```
231
+
232
+ Without the scope, the same code in different companies would collide.
233
+
234
+ ## Custom association scope (for complex relationships)
235
+
236
+ When the parent path isn't a direct `belongs_to`, define a custom scope on the child:
237
+
238
+ ```ruby
239
+ class Property < ResourceRecord
240
+ scope :associated_with_organization, ->(org) {
241
+ joins(:company).where(companies: {organization_id: org.id})
242
+ }
243
+ end
244
+ ```
245
+
246
+ Useful when the child is nested under a grandparent-style entity. See [Entity scoping › Three model shapes](./entity-scoping#three-model-shapes).
247
+
248
+ ## Breadcrumbs
249
+
250
+ Auto-include the parent: `Companies > Acme Corp > Properties > Property #123`.
251
+
252
+ ## Nesting limitations
253
+
254
+ Plutonium supports **one level of nesting**:
255
+
256
+ - ✅ `/companies/:company_id/nested_properties` (parent → child)
257
+ - ❌ `/companies/:company_id/nested_properties/:property_id/nested_units` (grandparent → parent → child)
258
+
259
+ For deeper hierarchies, use top-level routes plus association tabs on the show page (see [Behavior › Policy › Association permissions](/reference/behavior/policies#association-permissions) and [Resource › Definition › Custom page classes](/reference/resource/definition#custom-page-classes)).
260
+
261
+ ## Related
262
+
263
+ - [Entity scoping](./entity-scoping) — what happens when no parent is present
264
+ - [Invites](./invites) — membership-based onboarding
265
+ - [Behavior › Policy](/reference/behavior/policies) — `relation_scope`, parent context
266
+ - [Behavior › Controllers](/reference/behavior/controllers) — `current_parent`, presentation hooks
267
+ - [App › Portals](/reference/app/portals) — `register_resource` and custom member/collection routes