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 |
--roles | member,admin | Comma-separated roles |
--rodauth=NAME | user | Rodauth configuration for signup |
--enforce-domain | false | Require invited email domain to match entity domain |
Example with custom models:
rails g pu:invites:install \
--entity-model=Organization \
--user-model=Customer \
--membership-model=OrganizationMember \
--roles=member,manager,adminAfter install:
rails db:migrateWhat 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
rails g pu:invites:install --roles=viewer,editor,admin,ownerCustom 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
