plutonium 0.50.0 → 0.51.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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -0,0 +1,393 @@
|
|
|
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 |
|
|
40
|
+
| `--roles` | `member,admin` | Comma-separated roles |
|
|
41
|
+
| `--rodauth=NAME` | `user` | Rodauth configuration for signup |
|
|
42
|
+
| `--enforce-domain` | `false` | Require invited email domain to match entity domain |
|
|
43
|
+
|
|
44
|
+
Example with custom models:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
rails g pu:invites:install \
|
|
48
|
+
--entity-model=Organization \
|
|
49
|
+
--user-model=Customer \
|
|
50
|
+
--membership-model=OrganizationMember \
|
|
51
|
+
--roles=member,manager,admin
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
After install:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
rails db:migrate
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What gets created
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
packages/invites/
|
|
64
|
+
├── app/
|
|
65
|
+
│ ├── controllers/invites/
|
|
66
|
+
│ │ ├── user_invitations_controller.rb
|
|
67
|
+
│ │ └── welcome_controller.rb
|
|
68
|
+
│ ├── definitions/invites/user_invite_definition.rb
|
|
69
|
+
│ ├── interactions/invites/
|
|
70
|
+
│ │ ├── cancel_invite_interaction.rb
|
|
71
|
+
│ │ └── resend_invite_interaction.rb
|
|
72
|
+
│ ├── mailers/invites/user_invite_mailer.rb
|
|
73
|
+
│ ├── models/invites/user_invite.rb
|
|
74
|
+
│ ├── policies/invites/user_invite_policy.rb
|
|
75
|
+
│ └── views/invites/...
|
|
76
|
+
|
|
77
|
+
app/interactions/{entity,user}/invite_user_interaction.rb
|
|
78
|
+
db/migrate/TIMESTAMP_create_user_invites.rb
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Routes added:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
get "welcome", to: "invites/welcome#index"
|
|
85
|
+
get "invitations/:token", to: "invites/user_invitations#show"
|
|
86
|
+
post "invitations/:token/accept", to: "invites/user_invitations#accept"
|
|
87
|
+
get "invitations/:token/signup", to: "invites/user_invitations#signup"
|
|
88
|
+
post "invitations/:token/signup", to: "invites/user_invitations#signup"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Connect to a portal
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# packages/customer_portal/lib/engine.rb
|
|
95
|
+
module CustomerPortal
|
|
96
|
+
class Engine < Rails::Engine
|
|
97
|
+
include Plutonium::Portal::Engine
|
|
98
|
+
|
|
99
|
+
register_package Invites::Engine
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity` → `associated_with` resolves directly → admins only see invites for their org.
|
|
105
|
+
|
|
106
|
+
## The flow
|
|
107
|
+
|
|
108
|
+
### 1. Admin sends the invite
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# From entity context
|
|
112
|
+
entity.invite_user(email: "user@example.com", role: :member)
|
|
113
|
+
|
|
114
|
+
# From invitable context
|
|
115
|
+
tenant.invite_user(email: "user@example.com")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 2. Email goes out
|
|
119
|
+
|
|
120
|
+
Token-based URL:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Subject: You've been invited to join Acme Corp
|
|
124
|
+
|
|
125
|
+
Click here: https://app.example.com/invitations/abc123...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 3. User accepts
|
|
129
|
+
|
|
130
|
+
**Existing user:**
|
|
131
|
+
|
|
132
|
+
1. Clicks the invite link.
|
|
133
|
+
2. Logs in (or is already logged in).
|
|
134
|
+
3. System validates email matches.
|
|
135
|
+
4. Membership created; invitable notified via `on_invite_accepted`.
|
|
136
|
+
|
|
137
|
+
**New user:**
|
|
138
|
+
|
|
139
|
+
1. Clicks the invite link.
|
|
140
|
+
2. Clicks "Create Account".
|
|
141
|
+
3. Signs up with the invited email.
|
|
142
|
+
4. System validates email matches.
|
|
143
|
+
5. Membership created; invitable notified.
|
|
144
|
+
|
|
145
|
+
### 4. Pending invite check
|
|
146
|
+
|
|
147
|
+
After login, users land on `/welcome` where pending invites are shown:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
include Plutonium::Invites::PendingInviteCheck
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Rodauth wiring (required for the redirect):
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# app/rodauth/user_rodauth_plugin.rb
|
|
157
|
+
configure do
|
|
158
|
+
login_return_to_requested_location? true
|
|
159
|
+
login_redirect "/welcome"
|
|
160
|
+
|
|
161
|
+
after_login do
|
|
162
|
+
session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Invitables — app models notified on accept
|
|
168
|
+
|
|
169
|
+
An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
rails g pu:invites:invitable Tenant
|
|
173
|
+
rails g pu:invites:invitable TeamMember --role=member
|
|
174
|
+
rails g pu:invites:invitable Tenant --dest=my_package
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| Option | Default | Description |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `--role=ROLE` | `member` | Role to assign on acceptance |
|
|
180
|
+
| `--user-model=NAME` | `User` | User model |
|
|
181
|
+
| `--membership-model=NAME` | `EntityUser` | Membership join model |
|
|
182
|
+
| `--dest=PACKAGE` | `main_app` | Destination package |
|
|
183
|
+
| `--[no-]email-templates` | `true` | Generate custom email templates |
|
|
184
|
+
|
|
185
|
+
Implement the callback on the invitable:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class Tenant < ApplicationRecord
|
|
189
|
+
include Plutonium::Invites::Concerns::Invitable
|
|
190
|
+
|
|
191
|
+
belongs_to :entity
|
|
192
|
+
belongs_to :user, optional: true
|
|
193
|
+
|
|
194
|
+
def on_invite_accepted(user)
|
|
195
|
+
update!(user: user, status: :active)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
::: warning Without `on_invite_accepted`
|
|
201
|
+
The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
|
|
202
|
+
:::
|
|
203
|
+
|
|
204
|
+
## Multiple invite flows
|
|
205
|
+
|
|
206
|
+
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.
|
|
207
|
+
|
|
208
|
+
**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`.
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
rails g pu:invites:install \
|
|
212
|
+
--entity-model=FunderOrganization \
|
|
213
|
+
--user-model=SpenderAccount \
|
|
214
|
+
--invite-model=FunderInvite
|
|
215
|
+
|
|
216
|
+
rails g pu:invites:install \
|
|
217
|
+
--entity-model=Project \
|
|
218
|
+
--user-model=Member \
|
|
219
|
+
--invite-model=ProjectInvite
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Each invocation creates an independent flow: model `Invites::FunderInvite` on `funder_invites`, controller `Invites::FunderInvitationsController` on `/funder_invitations/:token`, helper `funder_invitation_path`, etc.
|
|
223
|
+
|
|
224
|
+
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).
|
|
225
|
+
|
|
226
|
+
### Model-level overrides for non-default associations
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
def user_attribute = :spender_account # belongs_to :spender_account instead of :user
|
|
230
|
+
def invite_entity_attribute = :funder_organization # belongs_to :funder_organization instead of :entity
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Controller-level overrides (auto-generated)
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# packages/invites/app/controllers/invites/welcome_controller.rb
|
|
237
|
+
def invite_classes
|
|
238
|
+
[::Invites::FunderInvite, ::Invites::ProjectInvite]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# packages/invites/app/controllers/invites/funder_invitations_controller.rb
|
|
242
|
+
def invitation_path_for(token)
|
|
243
|
+
funder_invitation_path(token: token)
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## The UserInvite model
|
|
248
|
+
|
|
249
|
+
Generated as `Invites::<InviteModelName>`:
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
253
|
+
include Plutonium::Invites::Concerns::InviteToken
|
|
254
|
+
|
|
255
|
+
belongs_to :entity
|
|
256
|
+
belongs_to :invited_by, polymorphic: true
|
|
257
|
+
belongs_to :user, optional: true
|
|
258
|
+
belongs_to :invitable, polymorphic: true, optional: true
|
|
259
|
+
|
|
260
|
+
enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
|
|
261
|
+
enum :role, member: 0, admin: 1
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Key methods:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
invite = Invites::UserInvite.find_for_acceptance(token)
|
|
269
|
+
invite.accept_for_user!(current_user)
|
|
270
|
+
invite.resend!
|
|
271
|
+
invite.cancel!
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Customization
|
|
275
|
+
|
|
276
|
+
### Custom email templates
|
|
277
|
+
|
|
278
|
+
Override views in your package:
|
|
279
|
+
|
|
280
|
+
```erb
|
|
281
|
+
<%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
|
|
282
|
+
<h1>Welcome to <%= @invite.entity.name %>!</h1>
|
|
283
|
+
<p><%= @invite.invited_by.email %> has invited you.</p>
|
|
284
|
+
<p><%= link_to "Accept", @invitation_url %></p>
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Per-invitable templates
|
|
288
|
+
|
|
289
|
+
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".
|
|
290
|
+
|
|
291
|
+
### Custom validation
|
|
292
|
+
|
|
293
|
+
Extend the invite model:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
297
|
+
validate :email_not_already_member
|
|
298
|
+
|
|
299
|
+
private
|
|
300
|
+
|
|
301
|
+
def email_not_already_member
|
|
302
|
+
existing = membership_model.joins(:user)
|
|
303
|
+
.where(entity: entity, users: {email: email})
|
|
304
|
+
.exists?
|
|
305
|
+
errors.add(:email, "is already a member") if existing
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Domain enforcement
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
rails g pu:invites:install --enforce-domain
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Requires the invited email's domain to match the entity's domain.
|
|
317
|
+
|
|
318
|
+
### Custom roles
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
rails g pu:invites:install --roles=viewer,editor,admin,owner
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Custom expiration
|
|
325
|
+
|
|
326
|
+
Override on the model:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
330
|
+
TOKEN_EXPIRATION = 30.days # default is 1 week
|
|
331
|
+
|
|
332
|
+
def expired?
|
|
333
|
+
created_at < TOKEN_EXPIRATION.ago
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Managing invitations
|
|
339
|
+
|
|
340
|
+
### Resend
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
invite.resend! # generates new token + sends email
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Cancel
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
invite.cancel! # transitions to :cancelled state
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### View pending
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
entity.user_invites.pending
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Security
|
|
359
|
+
|
|
360
|
+
### Token security
|
|
361
|
+
|
|
362
|
+
Tokens use `SecureRandom.urlsafe_base64(32)` — 256 bits, URL-safe. Stored hashed in the DB; raw token shown only at creation (in the email).
|
|
363
|
+
|
|
364
|
+
### Email validation
|
|
365
|
+
|
|
366
|
+
`enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
|
|
367
|
+
|
|
368
|
+
To allow any email (NOT recommended):
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
def enforce_email? = false
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Rate limiting
|
|
375
|
+
|
|
376
|
+
Use Rack::Attack or similar to throttle:
|
|
377
|
+
|
|
378
|
+
- Invite creation per admin
|
|
379
|
+
- Invitation acceptance attempts per IP
|
|
380
|
+
|
|
381
|
+
## Common issues
|
|
382
|
+
|
|
383
|
+
- **"Invitation not found or expired"** — token expired (default 1 week), invite cancelled, or no longer in `pending` state.
|
|
384
|
+
- **Email mismatch error** — the accepting user's email doesn't match the invited email. `enforce_email?` is enforcing the match (this is intentional security).
|
|
385
|
+
- **Rodauth redirect after login doesn't go to `/welcome`** — check the `login_redirect "/welcome"` line in the rodauth plugin's `configure` block.
|
|
386
|
+
- **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
|
|
387
|
+
|
|
388
|
+
## Related
|
|
389
|
+
|
|
390
|
+
- [Entity scoping](./entity-scoping) — how invites are filtered to the current entity
|
|
391
|
+
- [Auth](/reference/auth/) — Rodauth account configuration
|
|
392
|
+
- [Behavior › Interactions](/reference/behavior/interactions) — `cancel_invite_interaction`, `resend_invite_interaction`
|
|
393
|
+
- [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
|