Invites
Token-based email invitations for multi-tenant onboarding. Integrates with Rodauth signup, creates entity memberships on acceptance, and supports "invitable" hooks for app-specific behavior.
🚨 Critical
- Invite email must match the accepting user's email. Security feature — don't disable
enforce_email?lightly. - Entity scoping applies to invites — invites are automatically filtered to the current entity (their model has
belongs_to :entity). - Invitables must implement
on_invite_accepted. Without it, the invitable never learns about the new user. - A single app can have multiple invite flows — run
pu:invites:installonce per flow with different--entity-model/--user-model/--invite-model.
Prerequisites
Before installing invites, you need:
- A Rodauth user model
- An entity model (Organization, Company, Team, …)
- A membership model linking users to entities
The fastest path is pu:saas:setup — it creates all three plus the SaaS portal, profile, welcome flow, and invites in one shot:
rails g pu:saas:setup --user Customer --entity OrganizationInstall (standalone)
rails generate pu:invites:installOptions
| Option | Default | Description |
|---|---|---|
--entity-model=NAME | Entity | Entity model name |
--user-model=NAME | User | User model name |
--invite-model=NAME | <EntityModel><UserModel>Invite | Invite class name (omit for single-flow apps) |
--membership-model=NAME | EntityUser | Membership join model (must already exist) |
--rodauth=NAME | user | Rodauth configuration for signup |
--enforce-domain | false | Require invited email domain to match entity domain |
Roles come from the membership model
The role list is read from the membership model's enum :role — there is no --roles= flag on pu:invites:install. Set roles when generating the membership model (pu:saas:membership --roles=...) or edit its enum directly. Index 0 is the most privileged (typically owner, which the invite UI excludes from selectable choices); new invitees default to the second role.
Example with custom models:
rails g pu:invites:install \
--entity-model=Organization \
--user-model=Customer \
--membership-model=OrganizationMemberAfter install:
rails db:prepareWhat gets created
packages/invites/
├── app/
│ ├── controllers/invites/
│ │ ├── user_invitations_controller.rb
│ │ └── welcome_controller.rb
│ ├── definitions/invites/user_invite_definition.rb
│ ├── interactions/invites/
│ │ ├── cancel_invite_interaction.rb
│ │ └── resend_invite_interaction.rb
│ ├── mailers/invites/user_invite_mailer.rb
│ ├── models/invites/user_invite.rb
│ ├── policies/invites/user_invite_policy.rb
│ └── views/invites/...
app/interactions/{entity,user}/invite_user_interaction.rb
db/migrate/TIMESTAMP_create_user_invites.rbRoutes added:
get "welcome", to: "invites/welcome#index"
get "invitations/:token", to: "invites/user_invitations#show"
post "invitations/:token/accept", to: "invites/user_invitations#accept"
get "invitations/:token/signup", to: "invites/user_invitations#signup"
post "invitations/:token/signup", to: "invites/user_invitations#signup"Connect to a portal
# packages/customer_portal/lib/engine.rb
module CustomerPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
register_package Invites::Engine
end
endInvites are entity-scoped automatically: Invites::UserInvite belongs_to :entity → associated_with resolves directly → admins only see invites for their org.
The flow
1. Admin sends the invite
# From entity context
entity.invite_user(email: "user@example.com", role: :member)
# From invitable context
tenant.invite_user(email: "user@example.com")2. Email goes out
Token-based URL:
Subject: You've been invited to join Acme Corp
Click here: https://app.example.com/invitations/abc123...3. User accepts
Existing user:
- Clicks the invite link.
- Logs in (or is already logged in).
- System validates email matches.
- Membership created; invitable notified via
on_invite_accepted.
New user:
- Clicks the invite link.
- Clicks "Create Account".
- Signs up with the invited email.
- System validates email matches.
- Membership created; invitable notified.
4. Pending invite check
After login, users land on /welcome where pending invites are shown:
include Plutonium::Invites::PendingInviteCheckRodauth wiring (required for the redirect):
# app/rodauth/user_rodauth_plugin.rb
configure do
login_return_to_requested_location? true
login_redirect "/welcome"
after_login do
session[:after_welcome_redirect] = session.delete(:login_redirect)
end
endInvitables — app models notified on accept
An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: Tenant, TeamMember, ProjectCollaborator.
rails g pu:invites:invitable Tenant
rails g pu:invites:invitable TeamMember --role=member
rails g pu:invites:invitable Tenant --dest=my_package| Option | Default | Description |
|---|---|---|
--role=ROLE | member | Role to assign on acceptance |
--user-model=NAME | User | User model |
--membership-model=NAME | EntityUser | Membership join model |
--dest=PACKAGE | main_app | Destination package |
--[no-]email-templates | true | Generate custom email templates |
Implement the callback on the invitable:
class Tenant < ApplicationRecord
include Plutonium::Invites::Concerns::Invitable
belongs_to :entity
belongs_to :user, optional: true
def on_invite_accepted(user)
update!(user: user, status: :active)
end
endWithout on_invite_accepted
The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
Multiple invite flows
A single app can have several independent invite flows side-by-side (e.g. one for inviting customers to organizations, another for inviting funders to projects). Run pu:invites:install once per flow.
Default name derivation: when --invite-model is omitted, the class is <EntityModel><UserModel>Invite. So with the defaults (--entity-model=Organization --user-model=User) the generated class is Invites::OrganizationUserInvite — there is no literal UserInvite default. Single-flow apps don't need --invite-model.
rails g pu:invites:install \
--entity-model=FunderOrganization \
--user-model=SpenderAccount \
--invite-model=FunderInvite
rails g pu:invites:install \
--entity-model=Project \
--user-model=Member \
--invite-model=ProjectInviteEach invocation creates an independent flow: model Invites::FunderInvite on funder_invites, controller Invites::FunderInvitationsController on /funder_invitations/:token, helper funder_invitation_path, etc.
The shared Invites::WelcomeController accumulates each new class into its invite_classes array, so pending_invite checks all flows in priority order (first-match wins).
Model-level overrides for non-default associations
def user_attribute = :spender_account # belongs_to :spender_account instead of :user
def invite_entity_attribute = :funder_organization # belongs_to :funder_organization instead of :entityController-level overrides (auto-generated)
# packages/invites/app/controllers/invites/welcome_controller.rb
def invite_classes
[::Invites::FunderInvite, ::Invites::ProjectInvite]
end
# packages/invites/app/controllers/invites/funder_invitations_controller.rb
def invitation_path_for(token)
funder_invitation_path(token: token)
endThe UserInvite model
Generated as Invites::<InviteModelName>:
class Invites::UserInvite < Invites::ResourceRecord
include Plutonium::Invites::Concerns::InviteToken
belongs_to :entity
belongs_to :invited_by, polymorphic: true
belongs_to :user, optional: true
belongs_to :invitable, polymorphic: true, optional: true
enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
enum :role, member: 0, admin: 1
endKey methods:
invite = Invites::UserInvite.find_for_acceptance(token)
invite.accept_for_user!(current_user)
invite.resend!
invite.cancel!Customization
Custom email templates
Override views in your package:
<%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
<h1>Welcome to <%= @invite.entity.name %>!</h1>
<p><%= @invite.invited_by.email %> has invited you.</p>
<p><%= link_to "Accept", @invitation_url %></p>Per-invitable templates
When you generate an invitable with --email-templates, you get per-invitable mailer views — useful for differentiating "Join as a team member" from "Join as a project collaborator".
Custom validation
Extend the invite model:
class Invites::UserInvite < Invites::ResourceRecord
validate :email_not_already_member
private
def email_not_already_member
existing = membership_model.joins(:user)
.where(entity: entity, users: {email: email})
.exists?
errors.add(:email, "is already a member") if existing
end
endDomain enforcement
rails g pu:invites:install --enforce-domainRequires the invited email's domain to match the entity's domain.
Custom roles
Roles are defined on the membership model, not on the invites generator. Set them at membership generation time (ordering matters — index 0 is the most privileged, typically owner):
rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
# → enum :role, { owner: 0, admin: 1, editor: 2, viewer: 3 } (owner is auto-prepended)Or edit enum :role on the existing membership model directly. Then run pu:invites:install.
Custom expiration
Override on the model:
class Invites::UserInvite < Invites::ResourceRecord
TOKEN_EXPIRATION = 30.days # default is 1 week
def expired?
created_at < TOKEN_EXPIRATION.ago
end
endManaging invitations
Resend
invite.resend! # generates new token + sends emailCancel
invite.cancel! # transitions to :cancelled stateView pending
entity.user_invites.pendingSecurity
Token security
Tokens use SecureRandom.urlsafe_base64(32) — 256 bits, URL-safe. Stored hashed in the DB; raw token shown only at creation (in the email).
Email validation
enforce_email? is true by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
To allow any email (NOT recommended):
def enforce_email? = falseRate limiting
Use Rack::Attack or similar to throttle:
- Invite creation per admin
- Invitation acceptance attempts per IP
Common issues
- "Invitation not found or expired" — token expired (default 1 week), invite cancelled, or no longer in
pendingstate. - Email mismatch error — the accepting user's email doesn't match the invited email.
enforce_email?is enforcing the match (this is intentional security). - Rodauth redirect after login doesn't go to
/welcome— check thelogin_redirect "/welcome"line in the rodauth plugin'sconfigureblock. on_invite_acceptednot called — ensure the invitable modelinclude Plutonium::Invites::Concerns::Invitableand defineson_invite_accepted.
Related
- Entity scoping — how invites are filtered to the current entity
- Auth — Rodauth account configuration
- Behavior › Interactions —
cancel_invite_interaction,resend_invite_interaction - Guides › User invites — task-oriented walkthrough
