plutonium 0.39.1 → 0.40.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-connect-resource/SKILL.md +19 -1
- data/.claude/skills/plutonium-controller/SKILL.md +5 -9
- data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
- data/.claude/skills/plutonium-installation/SKILL.md +9 -7
- data/.claude/skills/plutonium-invites/SKILL.md +363 -0
- data/.claude/skills/plutonium-package/SKILL.md +2 -1
- data/.claude/skills/plutonium-portal/SKILL.md +30 -16
- data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
- data/CHANGELOG.md +48 -0
- data/app/assets/plutonium.css +1 -1
- data/config/initializers/sqlite_alias.rb +8 -8
- data/docs/.vitepress/config.ts +1 -0
- data/docs/getting-started/tutorial/07-author-portal.md +1 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
- data/docs/guides/adding-resources.md +10 -0
- data/docs/guides/authentication.md +15 -8
- data/docs/guides/creating-packages.md +13 -8
- data/docs/guides/index.md +2 -0
- data/docs/guides/search-filtering.md +8 -3
- data/docs/guides/user-invites.md +497 -0
- data/docs/public/templates/base.rb +5 -1
- data/docs/public/templates/lite.rb +42 -0
- data/docs/public/templates/pluton8.rb +7 -2
- data/docs/reference/controller/index.md +12 -7
- data/docs/reference/definition/query.md +12 -3
- data/docs/reference/generators/index.md +70 -10
- data/docs/reference/portal/index.md +22 -11
- 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/gem/active_shrine/active_shrine_generator.rb +31 -0
- data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
- data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
- data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
- data/lib/generators/pu/invites/USAGE +27 -0
- data/lib/generators/pu/invites/install_generator.rb +364 -0
- data/lib/generators/pu/invites/invitable/USAGE +31 -0
- data/lib/generators/pu/invites/invitable_generator.rb +143 -0
- data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
- data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
- data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
- data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
- data/lib/generators/pu/pkg/portal/USAGE +8 -2
- data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
- data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
- data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
- data/lib/generators/pu/res/conn/USAGE +5 -0
- data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
- data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
- data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
- data/lib/generators/pu/rodauth/account_generator.rb +36 -11
- data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
- data/lib/generators/pu/rodauth/install_generator.rb +1 -8
- data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
- data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
- data/lib/generators/pu/saas/USAGE +22 -0
- data/lib/generators/pu/saas/entity/USAGE +19 -0
- data/lib/generators/pu/saas/entity_generator.rb +55 -0
- data/lib/generators/pu/saas/membership/USAGE +25 -0
- data/lib/generators/pu/saas/membership_generator.rb +165 -0
- data/lib/generators/pu/saas/setup/USAGE +27 -0
- data/lib/generators/pu/saas/setup_generator.rb +98 -0
- data/lib/generators/pu/saas/user/USAGE +21 -0
- data/lib/generators/pu/saas/user_generator.rb +66 -0
- data/lib/plutonium/core/controller.rb +9 -5
- data/lib/plutonium/definition/base.rb +3 -1
- data/lib/plutonium/definition/scoping.rb +20 -0
- data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
- data/lib/plutonium/invites/concerns/invitable.rb +98 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
- data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
- data/lib/plutonium/invites/controller.rb +226 -0
- data/lib/plutonium/invites/pending_invite_check.rb +76 -0
- data/lib/plutonium/invites.rb +6 -0
- data/lib/plutonium/resource/controllers/queryable.rb +4 -0
- data/lib/plutonium/resource/query_object.rb +3 -5
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +64 -7
- data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
- data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
- data/public/plutonium-assets/plutonium-logo-original.png +0 -0
- data/public/plutonium-assets/plutonium-logo-white.png +0 -0
- data/public/plutonium-assets/plutonium-logo.png +0 -0
|
@@ -254,9 +254,11 @@ Set a scope as default:
|
|
|
254
254
|
|
|
255
255
|
```ruby
|
|
256
256
|
class PostDefinition < ResourceDefinition
|
|
257
|
-
scope :published
|
|
257
|
+
scope :published
|
|
258
258
|
scope :draft
|
|
259
259
|
scope :archived
|
|
260
|
+
|
|
261
|
+
default_scope :published
|
|
260
262
|
end
|
|
261
263
|
```
|
|
262
264
|
|
|
@@ -327,14 +329,17 @@ class ProductDefinition < ResourceDefinition
|
|
|
327
329
|
filter :category, with: :association
|
|
328
330
|
|
|
329
331
|
# Quick scopes (reference model scopes)
|
|
330
|
-
scope :active
|
|
332
|
+
scope :active
|
|
331
333
|
scope :featured
|
|
332
334
|
scope(:recent) { |scope| scope.where("created_at > ?", 1.week.ago) }
|
|
333
335
|
|
|
336
|
+
# Default scope
|
|
337
|
+
default_scope :active
|
|
338
|
+
|
|
334
339
|
# Sortable columns
|
|
335
340
|
sorts :name, :price, :created_at
|
|
336
341
|
|
|
337
|
-
# Default: newest first
|
|
342
|
+
# Default sort: newest first
|
|
338
343
|
default_sort :created_at, :desc
|
|
339
344
|
end
|
|
340
345
|
```
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# User Invites
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
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
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
Before installing invites, ensure you have:
|
|
17
|
+
|
|
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
|
+
```bash
|
|
24
|
+
rails g pu:saas:setup --user User --entity Organization
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
### Step 1: Install the Invites Package
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
rails generate pu:invites:install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
With custom models:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
rails g pu:invites:install \
|
|
39
|
+
--entity-model=Organization \
|
|
40
|
+
--user-model=User \
|
|
41
|
+
--membership-model=OrganizationUser \
|
|
42
|
+
--roles=member,manager,admin
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Options
|
|
46
|
+
|
|
47
|
+
| Option | Default | Description |
|
|
48
|
+
|--------|---------|-------------|
|
|
49
|
+
| `--entity-model` | Entity | Entity model for scoping invites |
|
|
50
|
+
| `--user-model` | User | User account model |
|
|
51
|
+
| `--membership-model` | EntityUser | Join model for memberships |
|
|
52
|
+
| `--roles` | member,admin | Available invitation roles |
|
|
53
|
+
| `--rodauth` | user | Rodauth configuration name |
|
|
54
|
+
| `--enforce-domain` | false | Require email domain matching |
|
|
55
|
+
|
|
56
|
+
### Step 2: Run Migrations
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
rails db:migrate
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Step 3: Configure Your Portal
|
|
63
|
+
|
|
64
|
+
Register the invites package in your portal:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# packages/customer_portal/lib/engine.rb
|
|
68
|
+
module CustomerPortal
|
|
69
|
+
class Engine < Rails::Engine
|
|
70
|
+
include Plutonium::Portal::Engine
|
|
71
|
+
|
|
72
|
+
register_package Invites::Engine
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Generated Files
|
|
78
|
+
|
|
79
|
+
The generator creates a complete `packages/invites/` package:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
packages/invites/
|
|
83
|
+
├── app/
|
|
84
|
+
│ ├── controllers/invites/
|
|
85
|
+
│ │ ├── user_invitations_controller.rb # Invitation acceptance
|
|
86
|
+
│ │ └── welcome_controller.rb # Post-login landing
|
|
87
|
+
│ ├── definitions/invites/
|
|
88
|
+
│ │ └── user_invite_definition.rb # UI configuration
|
|
89
|
+
│ ├── interactions/invites/
|
|
90
|
+
│ │ ├── cancel_invite_interaction.rb # Cancel action
|
|
91
|
+
│ │ └── resend_invite_interaction.rb # Resend action
|
|
92
|
+
│ ├── mailers/invites/
|
|
93
|
+
│ │ └── user_invite_mailer.rb # Invitation emails
|
|
94
|
+
│ ├── models/invites/
|
|
95
|
+
│ │ └── user_invite.rb # Invite model
|
|
96
|
+
│ ├── policies/invites/
|
|
97
|
+
│ │ └── user_invite_policy.rb # Authorization
|
|
98
|
+
│ └── views/invites/
|
|
99
|
+
│ ├── user_invitations/ # Acceptance views
|
|
100
|
+
│ ├── user_invite_mailer/ # Email templates
|
|
101
|
+
│ └── welcome/ # Welcome page
|
|
102
|
+
└── lib/
|
|
103
|
+
└── engine.rb # Package engine
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Invitation Flow
|
|
107
|
+
|
|
108
|
+
### Sending Invitations
|
|
109
|
+
|
|
110
|
+
Admins can invite users from the entity detail page or user management:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# The generated action in your entity definition
|
|
114
|
+
action :invite_user,
|
|
115
|
+
interaction: Organization::InviteUserInteraction,
|
|
116
|
+
category: :secondary
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The interaction creates an `Invites::UserInvite` record and sends an email:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# Generated interaction
|
|
123
|
+
class Organization::InviteUserInteraction < Plutonium::Interaction::Base
|
|
124
|
+
attribute :email, :string
|
|
125
|
+
attribute :role, :string, default: "member"
|
|
126
|
+
|
|
127
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
128
|
+
|
|
129
|
+
def execute
|
|
130
|
+
invite = Invites::UserInvite.create!(
|
|
131
|
+
entity: resource,
|
|
132
|
+
email: email,
|
|
133
|
+
role: role,
|
|
134
|
+
invited_by: current_user
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
succeed(invite)
|
|
138
|
+
.with_message("Invitation sent to #{email}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Accepting Invitations
|
|
144
|
+
|
|
145
|
+
#### Existing Users
|
|
146
|
+
|
|
147
|
+
1. User receives email with invitation link
|
|
148
|
+
2. Clicks link, sees invitation details
|
|
149
|
+
3. If logged in with matching email, accepts directly
|
|
150
|
+
4. If not logged in, redirected to login
|
|
151
|
+
5. After login, redirected back to accept
|
|
152
|
+
|
|
153
|
+
#### New Users
|
|
154
|
+
|
|
155
|
+
1. User receives email with invitation link
|
|
156
|
+
2. Clicks link, sees invitation details
|
|
157
|
+
3. Clicks "Create Account"
|
|
158
|
+
4. Signs up with the invited email address
|
|
159
|
+
5. After signup, automatically accepts invitation
|
|
160
|
+
|
|
161
|
+
### Post-Login Welcome
|
|
162
|
+
|
|
163
|
+
After login, users land on `/welcome` where pending invitations are displayed:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
# The WelcomeController checks for pending invites
|
|
167
|
+
class Invites::WelcomeController < ApplicationController
|
|
168
|
+
def index
|
|
169
|
+
@pending_invites = Invites::UserInvite
|
|
170
|
+
.pending
|
|
171
|
+
.where(email: current_user.email)
|
|
172
|
+
|
|
173
|
+
if @pending_invites.any?
|
|
174
|
+
render :pending_invitation
|
|
175
|
+
else
|
|
176
|
+
redirect_to session.delete(:after_welcome_redirect) || root_path
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Invitables
|
|
183
|
+
|
|
184
|
+
Invitables are models that trigger invitations and receive callbacks when accepted. Use this when you need to:
|
|
185
|
+
- Create a record that requires a user to be assigned
|
|
186
|
+
- Notify specific models when their invitation is accepted
|
|
187
|
+
- Customize invitation behavior per model type
|
|
188
|
+
|
|
189
|
+
### Creating an Invitable
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
rails g pu:invites:invitable Tenant
|
|
193
|
+
rails g pu:invites:invitable TeamMember --role=member
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Implementing the Callback
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
# app/models/tenant.rb
|
|
200
|
+
class Tenant < ApplicationRecord
|
|
201
|
+
include Plutonium::Invites::Concerns::Invitable
|
|
202
|
+
|
|
203
|
+
belongs_to :organization
|
|
204
|
+
belongs_to :user, optional: true
|
|
205
|
+
|
|
206
|
+
# Called when the invitation is accepted
|
|
207
|
+
def on_invite_accepted(user)
|
|
208
|
+
update!(
|
|
209
|
+
user: user,
|
|
210
|
+
status: :active,
|
|
211
|
+
activated_at: Time.current
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### How Invitables Work
|
|
218
|
+
|
|
219
|
+
When creating an invite from an invitable:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# The invitable triggers the invitation
|
|
223
|
+
tenant.invite_user(email: "user@example.com")
|
|
224
|
+
|
|
225
|
+
# Creates UserInvite with:
|
|
226
|
+
# - invitable_type: "Tenant"
|
|
227
|
+
# - invitable_id: tenant.id
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
When the invite is accepted:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
# System calls:
|
|
234
|
+
invite.accept_for_user!(user)
|
|
235
|
+
|
|
236
|
+
# Which internally:
|
|
237
|
+
# 1. Creates entity membership
|
|
238
|
+
# 2. Calls tenant.on_invite_accepted(user)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Customization
|
|
242
|
+
|
|
243
|
+
### Custom Email Templates
|
|
244
|
+
|
|
245
|
+
Override the default templates:
|
|
246
|
+
|
|
247
|
+
```erb
|
|
248
|
+
<%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
|
|
249
|
+
<!DOCTYPE html>
|
|
250
|
+
<html>
|
|
251
|
+
<body>
|
|
252
|
+
<h1>Welcome to <%= @invite.entity.name %>!</h1>
|
|
253
|
+
|
|
254
|
+
<p>
|
|
255
|
+
<%= @invite.invited_by.email %> has invited you to join
|
|
256
|
+
as a <%= @invite.role %>.
|
|
257
|
+
</p>
|
|
258
|
+
|
|
259
|
+
<p>
|
|
260
|
+
<%= link_to "Accept Invitation", @invitation_url,
|
|
261
|
+
style: "background: #4F46E5; color: white; padding: 12px 24px;" %>
|
|
262
|
+
</p>
|
|
263
|
+
|
|
264
|
+
<p>This invitation expires in 7 days.</p>
|
|
265
|
+
</body>
|
|
266
|
+
</html>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Per-Invitable Templates
|
|
270
|
+
|
|
271
|
+
Create model-specific email templates:
|
|
272
|
+
|
|
273
|
+
```erb
|
|
274
|
+
<%# packages/invites/app/views/invites/user_invite_mailer/invitation_tenant.html.erb %>
|
|
275
|
+
<h1>You've been assigned as a tenant!</h1>
|
|
276
|
+
<p>Accept to access your tenant dashboard.</p>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Custom Validation
|
|
280
|
+
|
|
281
|
+
Add validation to the invite model:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
# packages/invites/app/models/invites/user_invite.rb
|
|
285
|
+
class Invites::UserInvite < Invites::ResourceRecord
|
|
286
|
+
validate :email_not_already_member
|
|
287
|
+
validate :within_invite_limit
|
|
288
|
+
|
|
289
|
+
private
|
|
290
|
+
|
|
291
|
+
def email_not_already_member
|
|
292
|
+
if entity.users.exists?(email: email)
|
|
293
|
+
errors.add(:email, "is already a member of this organization")
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def within_invite_limit
|
|
298
|
+
pending_count = entity.user_invites.pending.count
|
|
299
|
+
if pending_count >= 100
|
|
300
|
+
errors.add(:base, "Maximum pending invitations reached")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Domain Enforcement
|
|
307
|
+
|
|
308
|
+
Require invited emails to match the entity's domain:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
rails g pu:invites:install --enforce-domain
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Or implement custom domain logic:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
# packages/invites/app/models/invites/user_invite.rb
|
|
318
|
+
def enforce_domain
|
|
319
|
+
entity.domain # e.g., "acme.com"
|
|
320
|
+
end
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Custom Expiration
|
|
324
|
+
|
|
325
|
+
Change the default expiration time:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
# packages/invites/app/models/invites/user_invite.rb
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def set_token_defaults
|
|
332
|
+
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
333
|
+
self.expires_at ||= 3.days.from_now # Override default 1 week
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Managing Invitations
|
|
338
|
+
|
|
339
|
+
### Resend Invitation
|
|
340
|
+
|
|
341
|
+
The generated `ResendInviteInteraction` allows resending:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
# Resets expiration and sends new email
|
|
345
|
+
invite.resend!
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Cancel Invitation
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
invite.cancel!
|
|
352
|
+
# Sets state to :cancelled
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### View Pending Invitations
|
|
356
|
+
|
|
357
|
+
In your admin portal:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
# Invites are scoped to the current entity
|
|
361
|
+
# Admins see all pending invites for their organization
|
|
362
|
+
Invites::UserInvite.pending.where(entity: current_entity)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Security Considerations
|
|
366
|
+
|
|
367
|
+
### Token Security
|
|
368
|
+
|
|
369
|
+
- Tokens are 32-byte URL-safe base64 strings
|
|
370
|
+
- Tokens expire after 1 week by default
|
|
371
|
+
- Each invite has a unique token
|
|
372
|
+
|
|
373
|
+
### Email Validation
|
|
374
|
+
|
|
375
|
+
By default, the accepting user's email must match the invited email:
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
def enforce_email?
|
|
379
|
+
true # Default: require exact match
|
|
380
|
+
end
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Rate Limiting
|
|
384
|
+
|
|
385
|
+
Consider adding rate limiting to prevent abuse:
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
# In your interaction
|
|
389
|
+
validate :rate_limit_invites
|
|
390
|
+
|
|
391
|
+
def rate_limit_invites
|
|
392
|
+
recent = Invites::UserInvite
|
|
393
|
+
.where(invited_by: current_user)
|
|
394
|
+
.where("created_at > ?", 1.hour.ago)
|
|
395
|
+
.count
|
|
396
|
+
|
|
397
|
+
if recent >= 50
|
|
398
|
+
errors.add(:base, "Too many invitations sent. Please wait.")
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Troubleshooting
|
|
404
|
+
|
|
405
|
+
### "Invitation not found or expired"
|
|
406
|
+
|
|
407
|
+
- Check that the token hasn't expired (default: 1 week)
|
|
408
|
+
- Verify the invite is still `pending` (not cancelled or accepted)
|
|
409
|
+
- Ensure the URL is complete and not truncated
|
|
410
|
+
|
|
411
|
+
### "Email mismatch" Error
|
|
412
|
+
|
|
413
|
+
The system requires the accepting user's email to match:
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
This invitation is for user@example.com.
|
|
417
|
+
You must use an account with that email address.
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
If you need to allow any email:
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
def enforce_email?
|
|
424
|
+
false # Not recommended for security
|
|
425
|
+
end
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Rodauth Not Redirecting Properly
|
|
429
|
+
|
|
430
|
+
Ensure your Rodauth plugin is configured:
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
# app/rodauth/user_rodauth_plugin.rb
|
|
434
|
+
configure do
|
|
435
|
+
login_return_to_requested_location? true
|
|
436
|
+
login_redirect "/welcome"
|
|
437
|
+
|
|
438
|
+
after_login do
|
|
439
|
+
session[:after_welcome_redirect] = session.delete(:login_redirect)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Invitable Callback Not Called
|
|
445
|
+
|
|
446
|
+
Ensure your model includes the concern and implements the callback:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
class Tenant < ApplicationRecord
|
|
450
|
+
include Plutonium::Invites::Concerns::Invitable
|
|
451
|
+
|
|
452
|
+
def on_invite_accepted(user)
|
|
453
|
+
# This MUST be implemented
|
|
454
|
+
update!(user: user)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## API Reference
|
|
460
|
+
|
|
461
|
+
### UserInvite States
|
|
462
|
+
|
|
463
|
+
| State | Description |
|
|
464
|
+
|-------|-------------|
|
|
465
|
+
| `pending` | Awaiting acceptance |
|
|
466
|
+
| `accepted` | Successfully accepted |
|
|
467
|
+
| `expired` | Past expiration date |
|
|
468
|
+
| `cancelled` | Manually cancelled |
|
|
469
|
+
|
|
470
|
+
### Key Methods
|
|
471
|
+
|
|
472
|
+
```ruby
|
|
473
|
+
# Find valid invite
|
|
474
|
+
invite = Invites::UserInvite.find_for_acceptance(token)
|
|
475
|
+
|
|
476
|
+
# Accept invitation
|
|
477
|
+
invite.accept_for_user!(user)
|
|
478
|
+
|
|
479
|
+
# Resend email
|
|
480
|
+
invite.resend!
|
|
481
|
+
|
|
482
|
+
# Cancel
|
|
483
|
+
invite.cancel!
|
|
484
|
+
|
|
485
|
+
# Check state
|
|
486
|
+
invite.pending?
|
|
487
|
+
invite.accepted?
|
|
488
|
+
invite.expired?
|
|
489
|
+
invite.cancelled?
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
## Next Steps
|
|
493
|
+
|
|
494
|
+
- [Authentication](/guides/authentication) - Set up Rodauth
|
|
495
|
+
- [Authorization](/guides/authorization) - Configure policies
|
|
496
|
+
- [Custom Actions](/guides/custom-actions) - Add more invite actions
|
|
497
|
+
- [Multi-tenancy](/guides/multi-tenancy) - Entity scoping
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
after_bundle do
|
|
2
2
|
Bundler.with_unbundled_env do
|
|
3
|
-
|
|
3
|
+
if ENV["LOCAL"]
|
|
4
|
+
run %(bundle add plutonium --path="/Users/stefan/Documents/plutonium/plutonium-core")
|
|
5
|
+
else
|
|
6
|
+
run "bundle add plutonium"
|
|
7
|
+
end
|
|
4
8
|
end
|
|
5
9
|
|
|
6
10
|
generate "pu:core:install"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
after_bundle do
|
|
2
|
+
# SQLite infrastructure (replaces Redis/Postgres for simple deployments)
|
|
3
|
+
generate "pu:lite:setup"
|
|
4
|
+
git add: "."
|
|
5
|
+
git commit: %( -m 'setup sqlite') if `git status --porcelain`.present?
|
|
6
|
+
|
|
7
|
+
unless ENV["SKIP_SOLID_QUEUE"]
|
|
8
|
+
generate "pu:lite:solid_queue"
|
|
9
|
+
git add: "."
|
|
10
|
+
git commit: %( -m 'add solid_queue') if `git status --porcelain`.present?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
unless ENV["SKIP_SOLID_CACHE"]
|
|
14
|
+
generate "pu:lite:solid_cache"
|
|
15
|
+
git add: "."
|
|
16
|
+
git commit: %( -m 'add solid_cache') if `git status --porcelain`.present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
unless ENV["SKIP_SOLID_CABLE"]
|
|
20
|
+
generate "pu:lite:solid_cable"
|
|
21
|
+
git add: "."
|
|
22
|
+
git commit: %( -m 'add solid_cable') if `git status --porcelain`.present?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
unless ENV["SKIP_SOLID_ERRORS"]
|
|
26
|
+
generate "pu:lite:solid_errors"
|
|
27
|
+
git add: "."
|
|
28
|
+
git commit: %( -m 'add solid_errors') if `git status --porcelain`.present?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless ENV["SKIP_LITESTREAM"]
|
|
32
|
+
generate "pu:lite:litestream"
|
|
33
|
+
git add: "."
|
|
34
|
+
git commit: %( -m 'add litestream') if `git status --porcelain`.present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless ENV["SKIP_RAILS_PULSE"]
|
|
38
|
+
generate "pu:lite:rails_pulse"
|
|
39
|
+
git add: "."
|
|
40
|
+
git commit: %( -m 'add rails_pulse') if `git status --porcelain`.present?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -7,6 +7,11 @@ after_bundle do
|
|
|
7
7
|
end
|
|
8
8
|
rails_command "app:template LOCATION=#{template_location}"
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
10
|
+
# Run the lite stack setup (via rails_command so generators are available)
|
|
11
|
+
lite_location = if ENV["LOCAL"]
|
|
12
|
+
"/Users/stefan/Documents/plutonium/plutonium-core/docs/public/templates/lite.rb"
|
|
13
|
+
else
|
|
14
|
+
"https://radioactive-labs.github.io/plutonium-core/templates/lite.rb"
|
|
15
|
+
end
|
|
16
|
+
rails_command "app:template LOCATION=#{lite_location}"
|
|
12
17
|
end
|
|
@@ -20,19 +20,24 @@ class PostsController < ::ResourceController
|
|
|
20
20
|
end
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
For portals
|
|
23
|
+
For portals:
|
|
24
24
|
|
|
25
25
|
```ruby
|
|
26
|
-
# packages/admin_portal/app/controllers/admin_portal/
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
# packages/admin_portal/app/controllers/admin_portal/resource_controller.rb
|
|
27
|
+
module AdminPortal
|
|
28
|
+
class ResourceController < ::ResourceController
|
|
29
|
+
include AdminPortal::Concerns::Controller
|
|
30
|
+
end
|
|
31
|
+
end
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
# packages/admin_portal/app/controllers/admin_portal/posts_controller.rb
|
|
34
|
+
module AdminPortal
|
|
35
|
+
class PostsController < ResourceController
|
|
36
|
+
# Portal-specific customizations
|
|
37
|
+
end
|
|
31
38
|
end
|
|
32
39
|
```
|
|
33
40
|
|
|
34
|
-
Controllers are auto-created if not defined. When accessing a portal resource controller, Plutonium dynamically creates it by inheriting from the feature package's controller.
|
|
35
|
-
|
|
36
41
|
## Built-in Actions
|
|
37
42
|
|
|
38
43
|
| Action | HTTP Method | Path | Purpose |
|
|
@@ -18,6 +18,9 @@ class PostDefinition < Plutonium::Resource::Definition
|
|
|
18
18
|
scope :published
|
|
19
19
|
scope :draft
|
|
20
20
|
|
|
21
|
+
# Default scope
|
|
22
|
+
default_scope :published
|
|
23
|
+
|
|
21
24
|
# Sorting - sortable columns
|
|
22
25
|
sort :title
|
|
23
26
|
sort :created_at
|
|
@@ -149,13 +152,16 @@ end
|
|
|
149
152
|
|
|
150
153
|
### Default Scope
|
|
151
154
|
|
|
152
|
-
|
|
155
|
+
Set a scope as the default selection:
|
|
153
156
|
|
|
154
157
|
```ruby
|
|
155
|
-
scope :active
|
|
158
|
+
scope :active
|
|
156
159
|
scope :archived
|
|
160
|
+
|
|
161
|
+
default_scope :active
|
|
157
162
|
```
|
|
158
163
|
|
|
164
|
+
|
|
159
165
|
### Inline Scope (Block Syntax)
|
|
160
166
|
|
|
161
167
|
For scopes that don't exist on the model, use block syntax with the scope as an argument:
|
|
@@ -319,10 +325,13 @@ class PostDefinition < Plutonium::Resource::Definition
|
|
|
319
325
|
scope :featured
|
|
320
326
|
scope(:recent) { |scope| scope.where('created_at > ?', 1.week.ago) }
|
|
321
327
|
|
|
328
|
+
# Default scope
|
|
329
|
+
default_scope :published
|
|
330
|
+
|
|
322
331
|
# Sortable columns
|
|
323
332
|
sorts :title, :created_at, :view_count, :published_at
|
|
324
333
|
|
|
325
|
-
# Default: newest first
|
|
334
|
+
# Default sort: newest first
|
|
326
335
|
default_sort :created_at, :desc
|
|
327
336
|
end
|
|
328
337
|
```
|