Skip to content

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:install once per flow with different --entity-model / --user-model / --invite-model.

Prerequisites โ€‹

Before installing invites, you need:

  1. A Rodauth user model
  2. An entity model (Organization, Company, Team, โ€ฆ)
  3. 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:

bash
rails g pu:saas:setup --user Customer --entity Organization

Install (standalone) โ€‹

bash
rails generate pu:invites:install

Options โ€‹

OptionDefaultDescription
--entity-model=NAMEEntityEntity model name
--user-model=NAMEUserUser model name
--invite-model=NAME<EntityModel><UserModel>InviteInvite class name (omit for single-flow apps)
--membership-model=NAMEEntityUserMembership join model (must already exist)
--rodauth=NAMEuserRodauth configuration for signup
--enforce-domainfalseRequire 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:

bash
rails g pu:invites:install \
  --entity-model=Organization \
  --user-model=Customer \
  --membership-model=OrganizationMember

After install:

bash
rails db:prepare

What 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.rb

Routes added:

ruby
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 โ€‹

ruby
# packages/customer_portal/lib/engine.rb
module CustomerPortal
  class Engine < Rails::Engine
    include Plutonium::Portal::Engine

    register_package Invites::Engine
  end
end

Invites 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 โ€‹

ruby
# 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:

  1. Clicks the invite link.
  2. Logs in (or is already logged in).
  3. System validates email matches.
  4. Membership created; invitable notified via on_invite_accepted.

New user:

  1. Clicks the invite link.
  2. Clicks "Create Account".
  3. Signs up with the invited email.
  4. System validates email matches.
  5. Membership created; invitable notified.

4. Pending invite check โ€‹

After login, users land on /welcome where pending invites are shown:

ruby
include Plutonium::Invites::PendingInviteCheck

Rodauth wiring (required for the redirect):

ruby
# 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
end

Invitables โ€” 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.

bash
rails g pu:invites:invitable Tenant
rails g pu:invites:invitable TeamMember --role=member
rails g pu:invites:invitable Tenant --dest=my_package
OptionDefaultDescription
--role=ROLEmemberRole to assign on acceptance
--user-model=NAMEUserUser model
--membership-model=NAMEEntityUserMembership join model
--dest=PACKAGEmain_appDestination package
--[no-]email-templatestrueGenerate custom email templates

Implement the callback on the invitable:

ruby
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
end

Without 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.

bash
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=ProjectInvite

Each 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 โ€‹

ruby
def user_attribute         = :spender_account     # belongs_to :spender_account instead of :user
def invite_entity_attribute = :funder_organization # belongs_to :funder_organization instead of :entity

Controller-level overrides (auto-generated) โ€‹

ruby
# 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)
end

The UserInvite model โ€‹

Generated as Invites::<InviteModelName>:

ruby
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
end

Key methods:

ruby
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:

erb
<%# 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:

ruby
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
end

Domain enforcement โ€‹

bash
rails g pu:invites:install --enforce-domain

Requires 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):

bash
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:

ruby
class Invites::UserInvite < Invites::ResourceRecord
  TOKEN_EXPIRATION = 30.days   # default is 1 week

  def expired?
    created_at < TOKEN_EXPIRATION.ago
  end
end

Managing invitations โ€‹

Resend โ€‹

ruby
invite.resend!   # generates new token + sends email

Cancel โ€‹

ruby
invite.cancel!   # transitions to :cancelled state

View pending โ€‹

ruby
entity.user_invites.pending

Security โ€‹

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):

ruby
def enforce_email? = false

Rate 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 pending state.
  • 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 the login_redirect "/welcome" line in the rodauth plugin's configure block.
  • on_invite_accepted not called โ€” ensure the invitable model include Plutonium::Invites::Concerns::Invitable and defines on_invite_accepted.

Released under the MIT License.