plutonium 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +574 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +167 -302
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +674 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +9 -6
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +44 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1010 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +38 -29
  18. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  19. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  20. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  21. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  22. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  23. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  24. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  25. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  26. data/docs/.vitepress/theme/custom.css +144 -0
  27. data/docs/.vitepress/theme/index.ts +58 -1
  28. data/docs/getting-started/index.md +33 -57
  29. data/docs/getting-started/installation.md +37 -80
  30. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  31. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  32. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  33. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  34. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  36. data/docs/getting-started/tutorial/index.md +4 -5
  37. data/docs/guides/adding-resources.md +66 -377
  38. data/docs/guides/authentication.md +98 -462
  39. data/docs/guides/authorization.md +124 -370
  40. data/docs/guides/creating-packages.md +93 -298
  41. data/docs/guides/custom-actions.md +126 -441
  42. data/docs/guides/customizing-ui.md +258 -0
  43. data/docs/guides/index.md +49 -52
  44. data/docs/guides/multi-tenancy.md +123 -186
  45. data/docs/guides/nested-resources.md +137 -396
  46. data/docs/guides/search-filtering.md +127 -238
  47. data/docs/guides/testing.md +10 -5
  48. data/docs/guides/theming.md +168 -405
  49. data/docs/guides/troubleshooting.md +5 -3
  50. data/docs/guides/user-invites.md +112 -425
  51. data/docs/guides/user-profile.md +82 -241
  52. data/docs/index.md +10 -219
  53. data/docs/public/asciinema/home-scaffold.cast +305 -0
  54. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  55. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  56. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  57. data/docs/public/images/guides/nested-inputs.png +0 -0
  58. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  59. data/docs/public/images/guides/search-filtering-index.png +0 -0
  60. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  61. data/docs/public/images/guides/theming-after.png +0 -0
  62. data/docs/public/images/guides/theming-before.png +0 -0
  63. data/docs/public/images/guides/user-invites-landing.png +0 -0
  64. data/docs/public/images/guides/user-profile-edit.png +0 -0
  65. data/docs/public/images/guides/user-profile-show.png +0 -0
  66. data/docs/public/images/home-index.png +0 -0
  67. data/docs/public/images/home-new.png +0 -0
  68. data/docs/public/images/home-show.png +0 -0
  69. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  70. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  71. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  72. data/docs/public/images/tutorial/02-new-form.png +0 -0
  73. data/docs/public/images/tutorial/03-create-account.png +0 -0
  74. data/docs/public/images/tutorial/03-login.png +0 -0
  75. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  76. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  77. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  78. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  79. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  80. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  81. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  82. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  83. data/docs/reference/app/generators.md +517 -0
  84. data/docs/reference/app/index.md +158 -0
  85. data/docs/reference/app/packages.md +146 -0
  86. data/docs/reference/app/portals.md +377 -0
  87. data/docs/reference/auth/accounts.md +229 -0
  88. data/docs/reference/auth/index.md +88 -0
  89. data/docs/reference/auth/profile.md +185 -0
  90. data/docs/reference/behavior/controllers.md +395 -0
  91. data/docs/reference/behavior/index.md +22 -0
  92. data/docs/reference/behavior/interactions.md +341 -0
  93. data/docs/reference/behavior/policies.md +417 -0
  94. data/docs/reference/index.md +67 -48
  95. data/docs/reference/resource/actions.md +423 -0
  96. data/docs/reference/resource/definition.md +508 -0
  97. data/docs/reference/resource/index.md +50 -0
  98. data/docs/reference/resource/model.md +348 -0
  99. data/docs/reference/resource/query.md +305 -0
  100. data/docs/reference/tenancy/entity-scoping.md +368 -0
  101. data/docs/reference/tenancy/index.md +36 -0
  102. data/docs/reference/tenancy/invites.md +400 -0
  103. data/docs/reference/tenancy/nested-resources.md +267 -0
  104. data/docs/reference/testing/index.md +287 -0
  105. data/docs/reference/ui/assets.md +400 -0
  106. data/docs/reference/ui/components.md +165 -0
  107. data/docs/reference/ui/displays.md +104 -0
  108. data/docs/reference/ui/forms.md +284 -0
  109. data/docs/reference/ui/index.md +30 -0
  110. data/docs/reference/ui/layouts.md +106 -0
  111. data/docs/reference/ui/pages.md +189 -0
  112. data/docs/reference/ui/tables.md +121 -0
  113. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  114. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  115. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  116. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  117. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  118. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  119. data/gemfiles/rails_7.gemfile.lock +1 -1
  120. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  121. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  122. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  123. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  124. data/lib/generators/pu/invites/install_generator.rb +45 -0
  125. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  126. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  127. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  128. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  129. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  130. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  131. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  132. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  133. data/lib/generators/pu/saas/membership/USAGE +4 -1
  134. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  135. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  136. data/lib/plutonium/definition/base.rb +1 -1
  137. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  138. data/lib/plutonium/helpers/turbo_helper.rb +30 -0
  139. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  140. data/lib/plutonium/resource/controller.rb +1 -0
  141. data/lib/plutonium/resource/controllers/crud_actions.rb +23 -5
  142. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  143. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  144. data/lib/plutonium/resource/policy.rb +7 -0
  145. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  146. data/lib/plutonium/ui/component/methods.rb +5 -0
  147. data/lib/plutonium/ui/form/base.rb +23 -3
  148. data/lib/plutonium/ui/form/components/json.rb +58 -0
  149. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  150. data/lib/plutonium/ui/form/components/secure_association.rb +103 -22
  151. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  152. data/lib/plutonium/ui/form/interaction.rb +1 -1
  153. data/lib/plutonium/ui/form/resource.rb +0 -4
  154. data/lib/plutonium/ui/form/theme.rb +1 -1
  155. data/lib/plutonium/ui/grid/resource.rb +1 -1
  156. data/lib/plutonium/ui/layout/base.rb +1 -0
  157. data/lib/plutonium/ui/page/base.rb +0 -7
  158. data/lib/plutonium/ui/page/edit.rb +1 -1
  159. data/lib/plutonium/ui/page/index.rb +4 -4
  160. data/lib/plutonium/ui/page/new.rb +1 -1
  161. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  162. data/lib/plutonium/ui/table/resource.rb +1 -1
  163. data/lib/plutonium/version.rb +1 -1
  164. data/lib/plutonium.rb +8 -0
  165. data/lib/tasks/release.rake +15 -1
  166. data/package.json +13 -10
  167. data/src/css/slim_select.css +4 -0
  168. data/src/js/controllers/form_controller.js +5 -4
  169. data/src/js/controllers/slim_select_controller.js +61 -0
  170. data/src/js/turbo/turbo_actions.js +33 -0
  171. data/yarn.lock +661 -544
  172. metadata +86 -33
  173. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  174. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  175. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  176. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  177. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  178. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  179. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  180. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  181. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  182. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  183. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  184. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  185. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  186. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  187. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  188. data/docs/reference/assets/index.md +0 -496
  189. data/docs/reference/controller/index.md +0 -412
  190. data/docs/reference/definition/actions.md +0 -462
  191. data/docs/reference/definition/fields.md +0 -383
  192. data/docs/reference/definition/index.md +0 -326
  193. data/docs/reference/definition/query.md +0 -351
  194. data/docs/reference/generators/index.md +0 -648
  195. data/docs/reference/interaction/index.md +0 -449
  196. data/docs/reference/model/features.md +0 -248
  197. data/docs/reference/model/index.md +0 -218
  198. data/docs/reference/policy/index.md +0 -456
  199. data/docs/reference/portal/index.md +0 -379
  200. data/docs/reference/views/forms.md +0 -411
  201. data/docs/reference/views/index.md +0 -544
@@ -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 Resources Guide](./nested-resources)
81
- - [Adding Resources Guide](./adding-resources)
82
- - [Rails Inflections Documentation](https://api.rubyonrails.org/classes/ActiveSupport/Inflector/Inflections.html)
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)
@@ -1,561 +1,248 @@
1
1
  # User Invites
2
2
 
3
- Plutonium provides a complete user invitation system for multi-tenant applications. This guide covers setting up invitations, customizing the flow, and integrating with your portals.
3
+ Set up token-based email invitations so admins can invite users into a tenant's membership.
4
4
 
5
- ## Overview
5
+ ## Goal
6
6
 
7
- The invitation system handles:
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
- Before installing invites, ensure you have:
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 User --entity Organization
14
+ rails g pu:saas:setup --user Customer --entity Organization
25
15
  ```
26
16
 
27
- ## Installation
17
+ For manual setup, ensure all three exist before running `pu:invites:install`.
18
+
19
+ ## Manual install
28
20
 
29
- ### Step 1: Install the Invites Package
21
+ ### 1. Run the generator
30
22
 
31
23
  ```bash
32
24
  rails generate pu:invites:install
33
25
  ```
34
26
 
35
- With custom models:
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=User \
41
- --membership-model=OrganizationUser \
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 for scoping invites |
50
- | `--user-model` | User | User account model |
51
- | `--invite-model` | `<EntityModel><UserModel>Invite` | Invite class name. Omit for single-flow apps; set per-invocation to run the generator more than once for distinct flows. |
52
- | `--membership-model` | EntityUser | Join model for memberships |
53
- | `--roles` | member,admin | Available invitation roles |
54
- | `--rodauth` | user | Rodauth configuration name |
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
- ### Step 2: Run Migrations
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
- ### Step 3: Configure Your Portal
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
- ## Generated Files
67
+ ### 4. Wire the post-login redirect
79
68
 
80
- The generator creates a complete `packages/invites/` package:
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
- packages/invites/
84
- ├── app/
85
- │ ├── controllers/invites/
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
- ## Invitation Flow
81
+ Now users are redirected to `/welcome` after login, where pending invites are shown.
108
82
 
109
- ### Sending Invitations
83
+ ## The flow
110
84
 
111
- Admins can invite users from the entity detail page or user management:
85
+ ### 1. Admin sends the invite
112
86
 
113
87
  ```ruby
114
- # The generated action in your entity definition
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
- The interaction creates an `Invites::UserInvite` record and sends an email:
91
+ Or via the auto-generated "Invite User" action on the entity's show page.
121
92
 
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
- ```
93
+ ### 2. Email goes out
94
+
95
+ Token-based URL: `https://app.example.com/invitations/abc123...`
143
96
 
144
- ### Accepting Invitations
97
+ ### 3. User accepts
145
98
 
146
- #### Existing Users
99
+ Clicking the link lands on the invitation page:
147
100
 
148
- 1. User receives email with invitation link
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
+ ![Invitation landing page](/images/guides/user-invites-landing.png)
153
102
 
154
- #### New Users
103
+ **Existing user:** clicks link → logs in (or already logged in) → email validated → membership created.
155
104
 
156
- 1. User receives email with invitation link
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
- ### Post-Login Welcome
107
+ ### 4. After login
163
108
 
164
- After login, users land on `/welcome` where pending invitations are displayed:
109
+ Users land on `/welcome` where pending invites are shown. Including `Plutonium::Invites::PendingInviteCheck`:
165
110
 
166
111
  ```ruby
167
- # The WelcomeController checks for pending invites
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
- ### Creating an Invitable
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
- ### Implementing the Callback
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 :organization
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
- ### How Invitables Work
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
- When creating an invite from an invitable:
143
+ ## Multiple invite flows in one app
221
144
 
222
- ```ruby
223
- # The invitable triggers the invitation
224
- tenant.invite_user(email: "user@example.com")
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
- # Creates UserInvite with:
227
- # - invitable_type: "Tenant"
228
- # - invitable_id: tenant.id
153
+ rails g pu:invites:install \
154
+ --entity-model=Project \
155
+ --user-model=Member \
156
+ --invite-model=ProjectInvite
229
157
  ```
230
158
 
231
- When the invite is accepted:
159
+ Each invocation creates an independent flow: model, controller, route, helper all named for the invite-model.
232
160
 
233
- ```ruby
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
- # Which internally:
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
- ### Custom Email Templates
167
+ ### Email templates
245
168
 
246
- Override the default templates:
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
- <!DOCTYPE html>
251
- <html>
252
- <body>
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
- ### Per-Invitable Templates
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
- if entity.users.exists?(email: email)
294
- errors.add(:email, "is already a member of this organization")
295
- end
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 Enforcement
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
- Or implement custom domain logic:
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
- Consider adding rate limiting to prevent abuse:
202
+ ### Custom expiration
387
203
 
388
204
  ```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
205
+ class Invites::UserInvite < Invites::ResourceRecord
206
+ TOKEN_EXPIRATION = 30.days # default: 1 week
397
207
 
398
- if recent >= 50
399
- errors.add(:base, "Too many invitations sent. Please wait.")
208
+ def expired?
209
+ created_at < TOKEN_EXPIRATION.ago
400
210
  end
401
211
  end
402
212
  ```
403
213
 
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:
214
+ ## Managing invitations
422
215
 
423
216
  ```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:
217
+ invite.resend! # generates new token + sends email
218
+ invite.cancel! # transitions to :cancelled state
432
219
 
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
220
+ entity.user_invites.pending # list pending
443
221
  ```
444
222
 
445
- ### Invitable Callback Not Called
223
+ ## Security
446
224
 
447
- Ensure your model includes the concern and implements the callback:
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
- class Tenant < ApplicationRecord
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
- ## API Reference
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
- You won't normally edit thisbut it's worth knowing the hook exists, because that's how multi-flow controllers stay independent from the shared `Plutonium::Invites::Controller` concern.
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
- ## Next Steps
243
+ ## Related
557
244
 
558
- - [Authentication](/guides/authentication) - Set up Rodauth
559
- - [Authorization](/guides/authorization) - Configure policies
560
- - [Custom Actions](/guides/custom-actions) - Add more invite actions
561
- - [Multi-tenancy](/guides/multi-tenancy) - Entity scoping
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