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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +574 -0
- data/.claude/skills/plutonium-auth/SKILL.md +167 -302
- 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 +674 -0
- data/.claude/skills/plutonium-testing/SKILL.md +9 -6
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +44 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1010 -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 +38 -29
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -57
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +98 -462
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +93 -298
- data/docs/guides/custom-actions.md +126 -441
- data/docs/guides/customizing-ui.md +258 -0
- data/docs/guides/index.md +49 -52
- data/docs/guides/multi-tenancy.md +123 -186
- data/docs/guides/nested-resources.md +137 -396
- data/docs/guides/search-filtering.md +127 -238
- data/docs/guides/testing.md +10 -5
- data/docs/guides/theming.md +168 -405
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +112 -425
- data/docs/guides/user-profile.md +82 -241
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- 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 +229 -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 +67 -48
- 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 +368 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +400 -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 +121 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -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/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -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/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- 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 +30 -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 +23 -5
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- 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 +5 -0
- data/lib/plutonium/ui/form/base.rb +23 -3
- 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 +103 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/form/theme.rb +1 -1
- 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/edit.rb +1 -1
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/page/new.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -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 +13 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +661 -544
- metadata +86 -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
|
@@ -77,6 +77,8 @@ If you encounter an issue not covered here, please [open an issue](https://githu
|
|
|
77
77
|
|
|
78
78
|
## Related
|
|
79
79
|
|
|
80
|
-
- [Nested
|
|
81
|
-
- [Adding
|
|
82
|
-
- [
|
|
80
|
+
- [Nested resources](./nested-resources)
|
|
81
|
+
- [Adding resources](./adding-resources)
|
|
82
|
+
- [Reference › Behavior › Controllers](/reference/behavior/controllers) — `controller_for`, `resource_url_for`, `current_parent`
|
|
83
|
+
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources) — nested URL generation
|
|
84
|
+
- [Rails Inflections](https://api.rubyonrails.org/classes/ActiveSupport/Inflector/Inflections.html)
|
data/docs/guides/user-invites.md
CHANGED
|
@@ -1,561 +1,248 @@
|
|
|
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=
|
|
42
|
-
--roles=member,manager,admin
|
|
32
|
+
--user-model=Customer \
|
|
33
|
+
--membership-model=OrganizationCustomer
|
|
43
34
|
```
|
|
44
35
|
|
|
45
|
-
### Options
|
|
46
|
-
|
|
47
36
|
| 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
|
-
| `--
|
|
54
|
-
| `--
|
|
55
|
-
| `--enforce-domain` | false | Require email domain matching |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `--entity-model=NAME` | `Entity` | Entity model |
|
|
39
|
+
| `--user-model=NAME` | `User` | User model |
|
|
40
|
+
| `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name |
|
|
41
|
+
| `--membership-model=NAME` | `EntityUser` | Membership join model (must already exist) |
|
|
42
|
+
| `--rodauth=NAME` | `user` | Rodauth configuration for signup |
|
|
43
|
+
| `--enforce-domain` | `false` | Require email domain to match entity |
|
|
56
44
|
|
|
57
|
-
|
|
45
|
+
::: info Roles come from the membership model
|
|
46
|
+
`pu:invites:install` reads the role list from the membership model's `enum :role` — it does not accept a `--roles=` flag. Define roles when you generate the membership model (`pu:saas:membership --roles=...`), or edit the enum directly. **Index 0 is the most privileged** (typically `owner`); the invite interaction excludes `owner` from selectable choices and defaults new invitees to the second role.
|
|
47
|
+
:::
|
|
48
|
+
|
|
49
|
+
### 2. Migrate
|
|
58
50
|
|
|
59
51
|
```bash
|
|
60
52
|
rails db:migrate
|
|
61
53
|
```
|
|
62
54
|
|
|
63
|
-
###
|
|
64
|
-
|
|
65
|
-
Register the invites package in your portal:
|
|
55
|
+
### 3. Connect to your portal
|
|
66
56
|
|
|
67
57
|
```ruby
|
|
68
58
|
# packages/customer_portal/lib/engine.rb
|
|
69
59
|
module CustomerPortal
|
|
70
60
|
class Engine < Rails::Engine
|
|
71
61
|
include Plutonium::Portal::Engine
|
|
72
|
-
|
|
73
62
|
register_package Invites::Engine
|
|
74
63
|
end
|
|
75
64
|
end
|
|
76
65
|
```
|
|
77
66
|
|
|
78
|
-
|
|
67
|
+
### 4. Wire the post-login redirect
|
|
79
68
|
|
|
80
|
-
|
|
69
|
+
```ruby
|
|
70
|
+
# app/rodauth/user_rodauth_plugin.rb
|
|
71
|
+
configure do
|
|
72
|
+
login_return_to_requested_location? true
|
|
73
|
+
login_redirect "/welcome"
|
|
81
74
|
|
|
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
|
|
75
|
+
after_login do
|
|
76
|
+
session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
105
79
|
```
|
|
106
80
|
|
|
107
|
-
|
|
81
|
+
Now users are redirected to `/welcome` after login, where pending invites are shown.
|
|
108
82
|
|
|
109
|
-
|
|
83
|
+
## The flow
|
|
110
84
|
|
|
111
|
-
|
|
85
|
+
### 1. Admin sends the invite
|
|
112
86
|
|
|
113
87
|
```ruby
|
|
114
|
-
|
|
115
|
-
action :invite_user,
|
|
116
|
-
interaction: Organization::InviteUserInteraction,
|
|
117
|
-
category: :secondary
|
|
88
|
+
entity.invite_user(email: "user@example.com", role: :member)
|
|
118
89
|
```
|
|
119
90
|
|
|
120
|
-
|
|
91
|
+
Or via the auto-generated "Invite User" action on the entity's show page.
|
|
121
92
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
```
|
|
93
|
+
### 2. Email goes out
|
|
94
|
+
|
|
95
|
+
Token-based URL: `https://app.example.com/invitations/abc123...`
|
|
143
96
|
|
|
144
|
-
###
|
|
97
|
+
### 3. User accepts
|
|
145
98
|
|
|
146
|
-
|
|
99
|
+
Clicking the link lands on the invitation page:
|
|
147
100
|
|
|
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
|
|
101
|
+

|
|
153
102
|
|
|
154
|
-
|
|
103
|
+
**Existing user:** clicks link → logs in (or already logged in) → email validated → membership created.
|
|
155
104
|
|
|
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
|
|
105
|
+
**New user:** clicks link → "Create Account" → signs up with the invited email → membership created.
|
|
161
106
|
|
|
162
|
-
###
|
|
107
|
+
### 4. After login
|
|
163
108
|
|
|
164
|
-
|
|
109
|
+
Users land on `/welcome` where pending invites are shown. Including `Plutonium::Invites::PendingInviteCheck`:
|
|
165
110
|
|
|
166
111
|
```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
|
|
112
|
+
include Plutonium::Invites::PendingInviteCheck
|
|
181
113
|
```
|
|
182
114
|
|
|
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
|
|
115
|
+
## Invitables — app models notified on acceptance
|
|
189
116
|
|
|
190
|
-
|
|
117
|
+
An invitable is a model that gets notified when its invitation is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
|
|
191
118
|
|
|
192
119
|
```bash
|
|
193
120
|
rails g pu:invites:invitable Tenant
|
|
194
121
|
rails g pu:invites:invitable TeamMember --role=member
|
|
195
122
|
```
|
|
196
123
|
|
|
197
|
-
|
|
124
|
+
Then implement the callback:
|
|
198
125
|
|
|
199
126
|
```ruby
|
|
200
|
-
# app/models/tenant.rb
|
|
201
127
|
class Tenant < ApplicationRecord
|
|
202
128
|
include Plutonium::Invites::Concerns::Invitable
|
|
203
129
|
|
|
204
|
-
belongs_to :
|
|
130
|
+
belongs_to :entity
|
|
205
131
|
belongs_to :user, optional: true
|
|
206
132
|
|
|
207
|
-
# Called when the invitation is accepted
|
|
208
133
|
def on_invite_accepted(user)
|
|
209
|
-
update!(
|
|
210
|
-
user: user,
|
|
211
|
-
status: :active,
|
|
212
|
-
activated_at: Time.current
|
|
213
|
-
)
|
|
134
|
+
update!(user: user, status: :active)
|
|
214
135
|
end
|
|
215
136
|
end
|
|
216
137
|
```
|
|
217
138
|
|
|
218
|
-
|
|
139
|
+
::: warning Without `on_invite_accepted`
|
|
140
|
+
The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
|
|
141
|
+
:::
|
|
219
142
|
|
|
220
|
-
|
|
143
|
+
## Multiple invite flows in one app
|
|
221
144
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
145
|
+
Run `pu:invites:install` once per flow with different `--entity-model` / `--user-model` / `--invite-model`:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
rails g pu:invites:install \
|
|
149
|
+
--entity-model=FunderOrganization \
|
|
150
|
+
--user-model=SpenderAccount \
|
|
151
|
+
--invite-model=FunderInvite
|
|
225
152
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
153
|
+
rails g pu:invites:install \
|
|
154
|
+
--entity-model=Project \
|
|
155
|
+
--user-model=Member \
|
|
156
|
+
--invite-model=ProjectInvite
|
|
229
157
|
```
|
|
230
158
|
|
|
231
|
-
|
|
159
|
+
Each invocation creates an independent flow: model, controller, route, helper all named for the invite-model.
|
|
232
160
|
|
|
233
|
-
|
|
234
|
-
# System calls:
|
|
235
|
-
invite.accept_for_user!(user)
|
|
161
|
+
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
162
|
|
|
237
|
-
|
|
238
|
-
# 1. Creates entity membership
|
|
239
|
-
# 2. Calls tenant.on_invite_accepted(user)
|
|
240
|
-
```
|
|
163
|
+
See [Reference › Tenancy › Invites › Multiple invite flows](/reference/tenancy/invites#multiple-invite-flows).
|
|
241
164
|
|
|
242
165
|
## Customization
|
|
243
166
|
|
|
244
|
-
###
|
|
167
|
+
### Email templates
|
|
245
168
|
|
|
246
|
-
Override
|
|
169
|
+
Override views in your package:
|
|
247
170
|
|
|
248
171
|
```erb
|
|
249
172
|
<%# 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>
|
|
173
|
+
<h1>Welcome to <%= @invite.entity.name %>!</h1>
|
|
174
|
+
<p><%= @invite.invited_by.email %> has invited you.</p>
|
|
175
|
+
<p><%= link_to "Accept", @invitation_url %></p>
|
|
268
176
|
```
|
|
269
177
|
|
|
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:
|
|
178
|
+
### Custom validation
|
|
283
179
|
|
|
284
180
|
```ruby
|
|
285
|
-
# packages/invites/app/models/invites/user_invite.rb
|
|
286
181
|
class Invites::UserInvite < Invites::ResourceRecord
|
|
287
182
|
validate :email_not_already_member
|
|
288
|
-
validate :within_invite_limit
|
|
289
183
|
|
|
290
184
|
private
|
|
291
185
|
|
|
292
186
|
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
|
|
187
|
+
existing = membership_model.joins(:user)
|
|
188
|
+
.where(entity: entity, users: {email: email}).exists?
|
|
189
|
+
errors.add(:email, "is already a member") if existing
|
|
303
190
|
end
|
|
304
191
|
end
|
|
305
192
|
```
|
|
306
193
|
|
|
307
|
-
### Domain
|
|
308
|
-
|
|
309
|
-
Require invited emails to match the entity's domain:
|
|
194
|
+
### Domain enforcement
|
|
310
195
|
|
|
311
196
|
```bash
|
|
312
197
|
rails g pu:invites:install --enforce-domain
|
|
313
198
|
```
|
|
314
199
|
|
|
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
|
|
375
|
-
|
|
376
|
-
By default, the accepting user's email must match the invited email:
|
|
377
|
-
|
|
378
|
-
```ruby
|
|
379
|
-
def enforce_email?
|
|
380
|
-
true # Default: require exact match
|
|
381
|
-
end
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
### Rate Limiting
|
|
200
|
+
Requires the invited email domain to match the entity's domain.
|
|
385
201
|
|
|
386
|
-
|
|
202
|
+
### Custom expiration
|
|
387
203
|
|
|
388
204
|
```ruby
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
205
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
206
|
+
TOKEN_EXPIRATION = 30.days # default: 1 week
|
|
397
207
|
|
|
398
|
-
|
|
399
|
-
|
|
208
|
+
def expired?
|
|
209
|
+
created_at < TOKEN_EXPIRATION.ago
|
|
400
210
|
end
|
|
401
211
|
end
|
|
402
212
|
```
|
|
403
213
|
|
|
404
|
-
##
|
|
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:
|
|
214
|
+
## Managing invitations
|
|
422
215
|
|
|
423
216
|
```ruby
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
end
|
|
427
|
-
```
|
|
428
|
-
|
|
429
|
-
### Rodauth Not Redirecting Properly
|
|
430
|
-
|
|
431
|
-
Ensure your Rodauth plugin is configured:
|
|
217
|
+
invite.resend! # generates new token + sends email
|
|
218
|
+
invite.cancel! # transitions to :cancelled state
|
|
432
219
|
|
|
433
|
-
|
|
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
|
|
220
|
+
entity.user_invites.pending # list pending
|
|
443
221
|
```
|
|
444
222
|
|
|
445
|
-
|
|
223
|
+
## Security
|
|
446
224
|
|
|
447
|
-
|
|
225
|
+
- **Token security** — `SecureRandom.urlsafe_base64(32)` — 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
|
|
226
|
+
- **Email validation** — `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
|
|
227
|
+
- **Rate limiting** — use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
|
|
448
228
|
|
|
229
|
+
::: danger Don't disable enforce_email?
|
|
449
230
|
```ruby
|
|
450
|
-
|
|
451
|
-
include Plutonium::Invites::Concerns::Invitable
|
|
452
|
-
|
|
453
|
-
def on_invite_accepted(user)
|
|
454
|
-
# This MUST be implemented
|
|
455
|
-
update!(user: user)
|
|
456
|
-
end
|
|
457
|
-
end
|
|
231
|
+
def enforce_email? = false # ← only if you fully understand the trade-off
|
|
458
232
|
```
|
|
233
|
+
Without this, anyone with the token can sign up — defeats the purpose of an invitation system.
|
|
234
|
+
:::
|
|
459
235
|
|
|
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
|
|
472
|
-
|
|
473
|
-
```ruby
|
|
474
|
-
# Find valid invite
|
|
475
|
-
invite = Invites::UserInvite.find_for_acceptance(token)
|
|
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
|
|
511
|
-
|
|
512
|
-
rails g pu:invites:install \
|
|
513
|
-
--entity-model=Project \
|
|
514
|
-
--user-model=Member \
|
|
515
|
-
--invite-model=ProjectInvite
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
After the second invocation you'll have, for example, `Invites::FunderInvite` on table `funder_invites` with controller `Invites::FunderInvitationsController` mounted at `/funder_invitations/:token`, alongside `Invites::ProjectInvite` on `project_invites` mounted at `/project_invitations/:token`. Route helpers are prefixed (`funder_invitation_path`, `project_invitation_path`).
|
|
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
|
-
```
|
|
530
|
-
|
|
531
|
-
After login, `Plutonium::Invites::PendingInviteCheck#pending_invite` iterates `invite_classes` and returns the first valid pending invite for the cookie-stored token (first-match wins). Plug a third-party invite class in by overriding `invite_classes` directly:
|
|
532
|
-
|
|
533
|
-
```ruby
|
|
534
|
-
class WelcomeController < ApplicationController
|
|
535
|
-
include Plutonium::Invites::PendingInviteCheck
|
|
536
|
-
|
|
537
|
-
def invite_classes
|
|
538
|
-
[::Invites::FunderInvite, ::Invites::ProjectInvite, ::Foreign::ApiInvite]
|
|
539
|
-
end
|
|
540
|
-
end
|
|
541
|
-
```
|
|
542
|
-
|
|
543
|
-
### Per-flow controller overrides
|
|
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
|
-
```
|
|
236
|
+
## Common issues
|
|
553
237
|
|
|
554
|
-
|
|
238
|
+
- **"Invitation not found or expired"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
|
|
239
|
+
- **Email mismatch error** — the accepting user's email doesn't match the invited email. This is by design (security).
|
|
240
|
+
- **Rodauth redirect after login doesn't go to `/welcome`** — check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
|
|
241
|
+
- **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
|
|
555
242
|
|
|
556
|
-
##
|
|
243
|
+
## Related
|
|
557
244
|
|
|
558
|
-
- [
|
|
559
|
-
- [
|
|
560
|
-
- [
|
|
561
|
-
- [
|
|
245
|
+
- [Reference › Tenancy › Invites](/reference/tenancy/invites) — full surface, multi-flow apps, customization
|
|
246
|
+
- [Multi-tenancy](./multi-tenancy) — entity scoping (invites are entity-scoped automatically)
|
|
247
|
+
- [Authentication](./authentication) — Rodauth setup
|
|
248
|
+
- [User profile](./user-profile) — account-settings page
|