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
data/docs/guides/user-invites.md
CHANGED
|
@@ -1,561 +1,242 @@
|
|
|
1
1
|
# User Invites
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Set up token-based email invitations so admins can invite users into a tenant's membership.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Goal
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Email Invitations**: Send secure invitation links to new or existing users
|
|
9
|
-
- **Token Validation**: Time-limited tokens with automatic expiration
|
|
10
|
-
- **Rodauth Integration**: Seamless signup and login flows
|
|
11
|
-
- **Entity Memberships**: Automatic membership creation on acceptance
|
|
12
|
-
- **Invitable Models**: Notify models when their invitations are accepted
|
|
7
|
+
An admin enters an email, the user gets an invite link, clicks it, signs up (or logs in if they already have an account) with that email, and is added to the org as a member.
|
|
13
8
|
|
|
14
9
|
## Prerequisites
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup` — it creates all three and runs `pu:invites:install` automatically:
|
|
17
12
|
|
|
18
|
-
1. **User Authentication**: A Rodauth user account
|
|
19
|
-
2. **Entity Model**: An organization/company/team model
|
|
20
|
-
3. **Membership Model**: A join model linking users to entities
|
|
21
|
-
|
|
22
|
-
The easiest way to set this up is with the SaaS generator:
|
|
23
13
|
```bash
|
|
24
|
-
rails g pu:saas:setup --user
|
|
14
|
+
rails g pu:saas:setup --user Customer --entity Organization
|
|
25
15
|
```
|
|
26
16
|
|
|
27
|
-
|
|
17
|
+
For manual setup, ensure all three exist before running `pu:invites:install`.
|
|
18
|
+
|
|
19
|
+
## Manual install
|
|
28
20
|
|
|
29
|
-
###
|
|
21
|
+
### 1. Run the generator
|
|
30
22
|
|
|
31
23
|
```bash
|
|
32
24
|
rails generate pu:invites:install
|
|
33
25
|
```
|
|
34
26
|
|
|
35
|
-
|
|
27
|
+
Or with custom models:
|
|
36
28
|
|
|
37
29
|
```bash
|
|
38
30
|
rails g pu:invites:install \
|
|
39
31
|
--entity-model=Organization \
|
|
40
|
-
--user-model=
|
|
41
|
-
--membership-model=
|
|
32
|
+
--user-model=Customer \
|
|
33
|
+
--membership-model=OrganizationCustomer \
|
|
42
34
|
--roles=member,manager,admin
|
|
43
35
|
```
|
|
44
36
|
|
|
45
|
-
### Options
|
|
46
|
-
|
|
47
37
|
| Option | Default | Description |
|
|
48
|
-
|
|
49
|
-
| `--entity-model` | Entity | Entity model
|
|
50
|
-
| `--user-model` | User | User
|
|
51
|
-
| `--invite-model` | `<EntityModel><UserModel>Invite` | Invite class name
|
|
52
|
-
| `--membership-model` | EntityUser |
|
|
53
|
-
| `--roles` | member,admin |
|
|
54
|
-
| `--rodauth` | user | Rodauth configuration
|
|
55
|
-
| `--enforce-domain` | false | Require email domain
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `--entity-model=NAME` | `Entity` | Entity model |
|
|
40
|
+
| `--user-model=NAME` | `User` | User model |
|
|
41
|
+
| `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name |
|
|
42
|
+
| `--membership-model=NAME` | `EntityUser` | Membership join model |
|
|
43
|
+
| `--roles` | `member,admin` | Comma-separated roles |
|
|
44
|
+
| `--rodauth=NAME` | `user` | Rodauth configuration for signup |
|
|
45
|
+
| `--enforce-domain` | `false` | Require email domain to match entity |
|
|
56
46
|
|
|
57
|
-
###
|
|
47
|
+
### 2. Migrate
|
|
58
48
|
|
|
59
49
|
```bash
|
|
60
50
|
rails db:migrate
|
|
61
51
|
```
|
|
62
52
|
|
|
63
|
-
###
|
|
64
|
-
|
|
65
|
-
Register the invites package in your portal:
|
|
53
|
+
### 3. Connect to your portal
|
|
66
54
|
|
|
67
55
|
```ruby
|
|
68
56
|
# packages/customer_portal/lib/engine.rb
|
|
69
57
|
module CustomerPortal
|
|
70
58
|
class Engine < Rails::Engine
|
|
71
59
|
include Plutonium::Portal::Engine
|
|
72
|
-
|
|
73
60
|
register_package Invites::Engine
|
|
74
61
|
end
|
|
75
62
|
end
|
|
76
63
|
```
|
|
77
64
|
|
|
78
|
-
|
|
65
|
+
### 4. Wire the post-login redirect
|
|
79
66
|
|
|
80
|
-
|
|
67
|
+
```ruby
|
|
68
|
+
# app/rodauth/user_rodauth_plugin.rb
|
|
69
|
+
configure do
|
|
70
|
+
login_return_to_requested_location? true
|
|
71
|
+
login_redirect "/welcome"
|
|
81
72
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
│ │ ├── user_invitations_controller.rb # Invitation acceptance
|
|
87
|
-
│ │ └── welcome_controller.rb # Post-login landing
|
|
88
|
-
│ ├── definitions/invites/
|
|
89
|
-
│ │ └── user_invite_definition.rb # UI configuration
|
|
90
|
-
│ ├── interactions/invites/
|
|
91
|
-
│ │ ├── cancel_invite_interaction.rb # Cancel action
|
|
92
|
-
│ │ └── resend_invite_interaction.rb # Resend action
|
|
93
|
-
│ ├── mailers/invites/
|
|
94
|
-
│ │ └── user_invite_mailer.rb # Invitation emails
|
|
95
|
-
│ ├── models/invites/
|
|
96
|
-
│ │ └── user_invite.rb # Invite model
|
|
97
|
-
│ ├── policies/invites/
|
|
98
|
-
│ │ └── user_invite_policy.rb # Authorization
|
|
99
|
-
│ └── views/invites/
|
|
100
|
-
│ ├── user_invitations/ # Acceptance views
|
|
101
|
-
│ ├── user_invite_mailer/ # Email templates
|
|
102
|
-
│ └── welcome/ # Welcome page
|
|
103
|
-
└── lib/
|
|
104
|
-
└── engine.rb # Package engine
|
|
73
|
+
after_login do
|
|
74
|
+
session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
105
77
|
```
|
|
106
78
|
|
|
107
|
-
|
|
79
|
+
Now users are redirected to `/welcome` after login, where pending invites are shown.
|
|
108
80
|
|
|
109
|
-
|
|
81
|
+
## The flow
|
|
110
82
|
|
|
111
|
-
|
|
83
|
+
### 1. Admin sends the invite
|
|
112
84
|
|
|
113
85
|
```ruby
|
|
114
|
-
|
|
115
|
-
action :invite_user,
|
|
116
|
-
interaction: Organization::InviteUserInteraction,
|
|
117
|
-
category: :secondary
|
|
86
|
+
entity.invite_user(email: "user@example.com", role: :member)
|
|
118
87
|
```
|
|
119
88
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
```ruby
|
|
123
|
-
# Generated interaction
|
|
124
|
-
class Organization::InviteUserInteraction < Plutonium::Interaction::Base
|
|
125
|
-
attribute :email, :string
|
|
126
|
-
attribute :role, :string, default: "member"
|
|
127
|
-
|
|
128
|
-
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
129
|
-
|
|
130
|
-
def execute
|
|
131
|
-
invite = Invites::UserInvite.create!(
|
|
132
|
-
entity: resource,
|
|
133
|
-
email: email,
|
|
134
|
-
role: role,
|
|
135
|
-
invited_by: current_user
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
succeed(invite)
|
|
139
|
-
.with_message("Invitation sent to #{email}")
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
```
|
|
89
|
+
Or via the auto-generated "Invite User" action on the entity's show page.
|
|
143
90
|
|
|
144
|
-
###
|
|
91
|
+
### 2. Email goes out
|
|
145
92
|
|
|
146
|
-
|
|
93
|
+
Token-based URL: `https://app.example.com/invitations/abc123...`
|
|
147
94
|
|
|
148
|
-
|
|
149
|
-
2. Clicks link, sees invitation details
|
|
150
|
-
3. If logged in with matching email, accepts directly
|
|
151
|
-
4. If not logged in, redirected to login
|
|
152
|
-
5. After login, redirected back to accept
|
|
95
|
+
### 3. User accepts
|
|
153
96
|
|
|
154
|
-
|
|
97
|
+
**Existing user:** clicks link → logs in (or already logged in) → email validated → membership created.
|
|
155
98
|
|
|
156
|
-
|
|
157
|
-
2. Clicks link, sees invitation details
|
|
158
|
-
3. Clicks "Create Account"
|
|
159
|
-
4. Signs up with the invited email address
|
|
160
|
-
5. After signup, automatically accepts invitation
|
|
99
|
+
**New user:** clicks link → "Create Account" → signs up with the invited email → membership created.
|
|
161
100
|
|
|
162
|
-
###
|
|
101
|
+
### 4. After login
|
|
163
102
|
|
|
164
|
-
|
|
103
|
+
Users land on `/welcome` where pending invites are shown. Including `Plutonium::Invites::PendingInviteCheck`:
|
|
165
104
|
|
|
166
105
|
```ruby
|
|
167
|
-
|
|
168
|
-
class Invites::WelcomeController < ApplicationController
|
|
169
|
-
def index
|
|
170
|
-
@pending_invites = Invites::UserInvite
|
|
171
|
-
.pending
|
|
172
|
-
.where(email: current_user.email)
|
|
173
|
-
|
|
174
|
-
if @pending_invites.any?
|
|
175
|
-
render :pending_invitation
|
|
176
|
-
else
|
|
177
|
-
redirect_to session.delete(:after_welcome_redirect) || root_path
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
end
|
|
106
|
+
include Plutonium::Invites::PendingInviteCheck
|
|
181
107
|
```
|
|
182
108
|
|
|
183
|
-
## Invitables
|
|
184
|
-
|
|
185
|
-
Invitables are models that trigger invitations and receive callbacks when accepted. Use this when you need to:
|
|
186
|
-
- Create a record that requires a user to be assigned
|
|
187
|
-
- Notify specific models when their invitation is accepted
|
|
188
|
-
- Customize invitation behavior per model type
|
|
109
|
+
## Invitables — app models notified on acceptance
|
|
189
110
|
|
|
190
|
-
|
|
111
|
+
An invitable is a model that gets notified when its invitation is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
|
|
191
112
|
|
|
192
113
|
```bash
|
|
193
114
|
rails g pu:invites:invitable Tenant
|
|
194
115
|
rails g pu:invites:invitable TeamMember --role=member
|
|
195
116
|
```
|
|
196
117
|
|
|
197
|
-
|
|
118
|
+
Then implement the callback:
|
|
198
119
|
|
|
199
120
|
```ruby
|
|
200
|
-
# app/models/tenant.rb
|
|
201
121
|
class Tenant < ApplicationRecord
|
|
202
122
|
include Plutonium::Invites::Concerns::Invitable
|
|
203
123
|
|
|
204
|
-
belongs_to :
|
|
124
|
+
belongs_to :entity
|
|
205
125
|
belongs_to :user, optional: true
|
|
206
126
|
|
|
207
|
-
# Called when the invitation is accepted
|
|
208
127
|
def on_invite_accepted(user)
|
|
209
|
-
update!(
|
|
210
|
-
user: user,
|
|
211
|
-
status: :active,
|
|
212
|
-
activated_at: Time.current
|
|
213
|
-
)
|
|
128
|
+
update!(user: user, status: :active)
|
|
214
129
|
end
|
|
215
130
|
end
|
|
216
131
|
```
|
|
217
132
|
|
|
218
|
-
|
|
133
|
+
::: warning Without `on_invite_accepted`
|
|
134
|
+
The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
|
|
135
|
+
:::
|
|
219
136
|
|
|
220
|
-
|
|
137
|
+
## Multiple invite flows in one app
|
|
221
138
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
139
|
+
Run `pu:invites:install` once per flow with different `--entity-model` / `--user-model` / `--invite-model`:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
rails g pu:invites:install \
|
|
143
|
+
--entity-model=FunderOrganization \
|
|
144
|
+
--user-model=SpenderAccount \
|
|
145
|
+
--invite-model=FunderInvite
|
|
225
146
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
147
|
+
rails g pu:invites:install \
|
|
148
|
+
--entity-model=Project \
|
|
149
|
+
--user-model=Member \
|
|
150
|
+
--invite-model=ProjectInvite
|
|
229
151
|
```
|
|
230
152
|
|
|
231
|
-
|
|
153
|
+
Each invocation creates an independent flow: model, controller, route, helper all named for the invite-model.
|
|
232
154
|
|
|
233
|
-
|
|
234
|
-
# System calls:
|
|
235
|
-
invite.accept_for_user!(user)
|
|
155
|
+
The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array — `pending_invite` checks all flows in priority order (first-match wins).
|
|
236
156
|
|
|
237
|
-
|
|
238
|
-
# 1. Creates entity membership
|
|
239
|
-
# 2. Calls tenant.on_invite_accepted(user)
|
|
240
|
-
```
|
|
157
|
+
See [Reference › Tenancy › Invites › Multiple invite flows](/reference/tenancy/invites#multiple-invite-flows).
|
|
241
158
|
|
|
242
159
|
## Customization
|
|
243
160
|
|
|
244
|
-
###
|
|
161
|
+
### Email templates
|
|
245
162
|
|
|
246
|
-
Override
|
|
163
|
+
Override views in your package:
|
|
247
164
|
|
|
248
165
|
```erb
|
|
249
166
|
<%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
|
|
250
|
-
|
|
251
|
-
<
|
|
252
|
-
<
|
|
253
|
-
<h1>Welcome to <%= @invite.entity.name %>!</h1>
|
|
254
|
-
|
|
255
|
-
<p>
|
|
256
|
-
<%= @invite.invited_by.email %> has invited you to join
|
|
257
|
-
as a <%= @invite.role %>.
|
|
258
|
-
</p>
|
|
259
|
-
|
|
260
|
-
<p>
|
|
261
|
-
<%= link_to "Accept Invitation", @invitation_url,
|
|
262
|
-
style: "background: #4F46E5; color: white; padding: 12px 24px;" %>
|
|
263
|
-
</p>
|
|
264
|
-
|
|
265
|
-
<p>This invitation expires in 7 days.</p>
|
|
266
|
-
</body>
|
|
267
|
-
</html>
|
|
167
|
+
<h1>Welcome to <%= @invite.entity.name %>!</h1>
|
|
168
|
+
<p><%= @invite.invited_by.email %> has invited you.</p>
|
|
169
|
+
<p><%= link_to "Accept", @invitation_url %></p>
|
|
268
170
|
```
|
|
269
171
|
|
|
270
|
-
###
|
|
271
|
-
|
|
272
|
-
Create model-specific email templates:
|
|
273
|
-
|
|
274
|
-
```erb
|
|
275
|
-
<%# packages/invites/app/views/invites/user_invite_mailer/invitation_tenant.html.erb %>
|
|
276
|
-
<h1>You've been assigned as a tenant!</h1>
|
|
277
|
-
<p>Accept to access your tenant dashboard.</p>
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
### Custom Validation
|
|
281
|
-
|
|
282
|
-
Add validation to the invite model:
|
|
172
|
+
### Custom validation
|
|
283
173
|
|
|
284
174
|
```ruby
|
|
285
|
-
# packages/invites/app/models/invites/user_invite.rb
|
|
286
175
|
class Invites::UserInvite < Invites::ResourceRecord
|
|
287
176
|
validate :email_not_already_member
|
|
288
|
-
validate :within_invite_limit
|
|
289
177
|
|
|
290
178
|
private
|
|
291
179
|
|
|
292
180
|
def email_not_already_member
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def within_invite_limit
|
|
299
|
-
pending_count = entity.user_invites.pending.count
|
|
300
|
-
if pending_count >= 100
|
|
301
|
-
errors.add(:base, "Maximum pending invitations reached")
|
|
302
|
-
end
|
|
181
|
+
existing = membership_model.joins(:user)
|
|
182
|
+
.where(entity: entity, users: {email: email}).exists?
|
|
183
|
+
errors.add(:email, "is already a member") if existing
|
|
303
184
|
end
|
|
304
185
|
end
|
|
305
186
|
```
|
|
306
187
|
|
|
307
|
-
### Domain
|
|
308
|
-
|
|
309
|
-
Require invited emails to match the entity's domain:
|
|
188
|
+
### Domain enforcement
|
|
310
189
|
|
|
311
190
|
```bash
|
|
312
191
|
rails g pu:invites:install --enforce-domain
|
|
313
192
|
```
|
|
314
193
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
```ruby
|
|
318
|
-
# packages/invites/app/models/invites/user_invite.rb
|
|
319
|
-
def enforce_domain
|
|
320
|
-
entity.domain # e.g., "acme.com"
|
|
321
|
-
end
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
### Custom Expiration
|
|
325
|
-
|
|
326
|
-
Change the default expiration time:
|
|
327
|
-
|
|
328
|
-
```ruby
|
|
329
|
-
# packages/invites/app/models/invites/user_invite.rb
|
|
330
|
-
private
|
|
331
|
-
|
|
332
|
-
def set_token_defaults
|
|
333
|
-
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
334
|
-
self.expires_at ||= 3.days.from_now # Override default 1 week
|
|
335
|
-
end
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
## Managing Invitations
|
|
339
|
-
|
|
340
|
-
### Resend Invitation
|
|
341
|
-
|
|
342
|
-
The generated `ResendInviteInteraction` allows resending:
|
|
343
|
-
|
|
344
|
-
```ruby
|
|
345
|
-
# Resets expiration and sends new email
|
|
346
|
-
invite.resend!
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
### Cancel Invitation
|
|
350
|
-
|
|
351
|
-
```ruby
|
|
352
|
-
invite.cancel!
|
|
353
|
-
# Sets state to :cancelled
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
### View Pending Invitations
|
|
357
|
-
|
|
358
|
-
In your admin portal:
|
|
359
|
-
|
|
360
|
-
```ruby
|
|
361
|
-
# Invites are scoped to the current entity
|
|
362
|
-
# Admins see all pending invites for their organization
|
|
363
|
-
Invites::UserInvite.pending.where(entity: current_scoped_entity)
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
## Security Considerations
|
|
367
|
-
|
|
368
|
-
### Token Security
|
|
369
|
-
|
|
370
|
-
- Tokens are 32-byte URL-safe base64 strings
|
|
371
|
-
- Tokens expire after 1 week by default
|
|
372
|
-
- Each invite has a unique token
|
|
373
|
-
|
|
374
|
-
### Email Validation
|
|
194
|
+
Requires the invited email domain to match the entity's domain.
|
|
375
195
|
|
|
376
|
-
|
|
196
|
+
### Custom expiration
|
|
377
197
|
|
|
378
198
|
```ruby
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
end
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
### Rate Limiting
|
|
385
|
-
|
|
386
|
-
Consider adding rate limiting to prevent abuse:
|
|
387
|
-
|
|
388
|
-
```ruby
|
|
389
|
-
# In your interaction
|
|
390
|
-
validate :rate_limit_invites
|
|
391
|
-
|
|
392
|
-
def rate_limit_invites
|
|
393
|
-
recent = Invites::UserInvite
|
|
394
|
-
.where(invited_by: current_user)
|
|
395
|
-
.where("created_at > ?", 1.hour.ago)
|
|
396
|
-
.count
|
|
397
|
-
|
|
398
|
-
if recent >= 50
|
|
399
|
-
errors.add(:base, "Too many invitations sent. Please wait.")
|
|
400
|
-
end
|
|
401
|
-
end
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
## Troubleshooting
|
|
405
|
-
|
|
406
|
-
### "Invitation not found or expired"
|
|
407
|
-
|
|
408
|
-
- Check that the token hasn't expired (default: 1 week)
|
|
409
|
-
- Verify the invite is still `pending` (not cancelled or accepted)
|
|
410
|
-
- Ensure the URL is complete and not truncated
|
|
411
|
-
|
|
412
|
-
### "Email mismatch" Error
|
|
413
|
-
|
|
414
|
-
The system requires the accepting user's email to match:
|
|
415
|
-
|
|
416
|
-
```
|
|
417
|
-
This invitation is for user@example.com.
|
|
418
|
-
You must use an account with that email address.
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
If you need to allow any email:
|
|
422
|
-
|
|
423
|
-
```ruby
|
|
424
|
-
def enforce_email?
|
|
425
|
-
false # Not recommended for security
|
|
426
|
-
end
|
|
427
|
-
```
|
|
428
|
-
|
|
429
|
-
### Rodauth Not Redirecting Properly
|
|
430
|
-
|
|
431
|
-
Ensure your Rodauth plugin is configured:
|
|
432
|
-
|
|
433
|
-
```ruby
|
|
434
|
-
# app/rodauth/user_rodauth_plugin.rb
|
|
435
|
-
configure do
|
|
436
|
-
login_return_to_requested_location? true
|
|
437
|
-
login_redirect "/welcome"
|
|
438
|
-
|
|
439
|
-
after_login do
|
|
440
|
-
session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
441
|
-
end
|
|
442
|
-
end
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
### Invitable Callback Not Called
|
|
446
|
-
|
|
447
|
-
Ensure your model includes the concern and implements the callback:
|
|
448
|
-
|
|
449
|
-
```ruby
|
|
450
|
-
class Tenant < ApplicationRecord
|
|
451
|
-
include Plutonium::Invites::Concerns::Invitable
|
|
199
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
200
|
+
TOKEN_EXPIRATION = 30.days # default: 1 week
|
|
452
201
|
|
|
453
|
-
def
|
|
454
|
-
|
|
455
|
-
update!(user: user)
|
|
202
|
+
def expired?
|
|
203
|
+
created_at < TOKEN_EXPIRATION.ago
|
|
456
204
|
end
|
|
457
205
|
end
|
|
458
206
|
```
|
|
459
207
|
|
|
460
|
-
##
|
|
461
|
-
|
|
462
|
-
### UserInvite States
|
|
463
|
-
|
|
464
|
-
| State | Description |
|
|
465
|
-
|-------|-------------|
|
|
466
|
-
| `pending` | Awaiting acceptance |
|
|
467
|
-
| `accepted` | Successfully accepted |
|
|
468
|
-
| `expired` | Past expiration date |
|
|
469
|
-
| `cancelled` | Manually cancelled |
|
|
470
|
-
|
|
471
|
-
### Key Methods
|
|
208
|
+
## Managing invitations
|
|
472
209
|
|
|
473
210
|
```ruby
|
|
474
|
-
#
|
|
475
|
-
invite
|
|
476
|
-
|
|
477
|
-
# Accept invitation
|
|
478
|
-
invite.accept_for_user!(user)
|
|
479
|
-
|
|
480
|
-
# Resend email
|
|
481
|
-
invite.resend!
|
|
482
|
-
|
|
483
|
-
# Cancel
|
|
484
|
-
invite.cancel!
|
|
485
|
-
|
|
486
|
-
# Check state
|
|
487
|
-
invite.pending?
|
|
488
|
-
invite.accepted?
|
|
489
|
-
invite.expired?
|
|
490
|
-
invite.cancelled?
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
## Multiple invite flows in one app
|
|
494
|
-
|
|
495
|
-
Some apps invite users to several distinct kinds of entity — for example, customers joining organizations and funders joining projects. Run `pu:invites:install` once per flow; each invocation produces independent migrations, models, policies, definitions, mailers, controllers, view templates, and route helpers.
|
|
496
|
-
|
|
497
|
-
### Default-derivation rule
|
|
498
|
-
|
|
499
|
-
When you omit `--invite-model`, the generator derives the class name as `<EntityModel><UserModel>Invite`. With the defaults (`--entity-model=Organization --user-model=User`) the generated class is `Invites::OrganizationUserInvite` — there is no literal `UserInvite` default. Single-flow apps never need to pass `--invite-model`.
|
|
500
|
-
|
|
501
|
-
Multi-flow apps either:
|
|
502
|
-
|
|
503
|
-
- Run the generator more than once with the **same** entity/user but different `--invite-model` values (rare), or
|
|
504
|
-
- Run with **different** `--entity-model` / `--user-model` pairs (common). Different derivations make `--invite-model` unnecessary; pass it only when you want a custom class name.
|
|
505
|
-
|
|
506
|
-
```bash
|
|
507
|
-
rails g pu:invites:install \
|
|
508
|
-
--entity-model=FunderOrganization \
|
|
509
|
-
--user-model=SpenderAccount \
|
|
510
|
-
--invite-model=FunderInvite
|
|
211
|
+
invite.resend! # generates new token + sends email
|
|
212
|
+
invite.cancel! # transitions to :cancelled state
|
|
511
213
|
|
|
512
|
-
|
|
513
|
-
--entity-model=Project \
|
|
514
|
-
--user-model=Member \
|
|
515
|
-
--invite-model=ProjectInvite
|
|
214
|
+
entity.user_invites.pending # list pending
|
|
516
215
|
```
|
|
517
216
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
### How the welcome flow finds pending invites
|
|
521
|
-
|
|
522
|
-
The shared `Invites::WelcomeController` keeps a running list of invite classes. Each install run injects its class into the array, preserving order:
|
|
523
|
-
|
|
524
|
-
```ruby
|
|
525
|
-
# packages/invites/app/controllers/invites/welcome_controller.rb
|
|
526
|
-
def invite_classes
|
|
527
|
-
[::Invites::FunderInvite, ::Invites::ProjectInvite]
|
|
528
|
-
end
|
|
529
|
-
```
|
|
217
|
+
## Security
|
|
530
218
|
|
|
531
|
-
|
|
219
|
+
- **Token security** — `SecureRandom.urlsafe_base64(32)` — 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
|
|
220
|
+
- **Email validation** — `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
|
|
221
|
+
- **Rate limiting** — use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
|
|
532
222
|
|
|
223
|
+
::: danger Don't disable enforce_email?
|
|
533
224
|
```ruby
|
|
534
|
-
|
|
535
|
-
include Plutonium::Invites::PendingInviteCheck
|
|
536
|
-
|
|
537
|
-
def invite_classes
|
|
538
|
-
[::Invites::FunderInvite, ::Invites::ProjectInvite, ::Foreign::ApiInvite]
|
|
539
|
-
end
|
|
540
|
-
end
|
|
225
|
+
def enforce_email? = false # ← only if you fully understand the trade-off
|
|
541
226
|
```
|
|
227
|
+
Without this, anyone with the token can sign up — defeats the purpose of an invitation system.
|
|
228
|
+
:::
|
|
542
229
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
The install generator emits an `invitation_path_for` override on each invitations controller so post-login redirects target the correct prefixed route:
|
|
546
|
-
|
|
547
|
-
```ruby
|
|
548
|
-
# packages/invites/app/controllers/invites/funder_invitations_controller.rb
|
|
549
|
-
def invitation_path_for(token)
|
|
550
|
-
funder_invitation_path(token: token)
|
|
551
|
-
end
|
|
552
|
-
```
|
|
230
|
+
## Common issues
|
|
553
231
|
|
|
554
|
-
|
|
232
|
+
- **"Invitation not found or expired"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
|
|
233
|
+
- **Email mismatch error** — the accepting user's email doesn't match the invited email. This is by design (security).
|
|
234
|
+
- **Rodauth redirect after login doesn't go to `/welcome`** — check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
|
|
235
|
+
- **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
|
|
555
236
|
|
|
556
|
-
##
|
|
237
|
+
## Related
|
|
557
238
|
|
|
558
|
-
- [
|
|
559
|
-
- [
|
|
560
|
-
- [
|
|
561
|
-
- [
|
|
239
|
+
- [Reference › Tenancy › Invites](/reference/tenancy/invites) — full surface, multi-flow apps, customization
|
|
240
|
+
- [Multi-tenancy](./multi-tenancy) — entity scoping (invites are entity-scoped automatically)
|
|
241
|
+
- [Authentication](./authentication) — Rodauth setup
|
|
242
|
+
- [User profile](./user-profile) — account-settings page
|