plutonium 0.49.0 → 0.49.1
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-invites/SKILL.md +41 -0
- data/CHANGELOG.md +15 -0
- data/app/assets/plutonium.js +35 -0
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +24 -24
- data/app/assets/plutonium.min.js.map +3 -3
- data/app/views/plutonium/_flash.html.erb +1 -1
- data/docs/guides/user-invites.md +64 -0
- data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
- data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
- 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/invites/install_generator.rb +136 -35
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
- data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
- data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
- data/lib/plutonium/invites/controller.rb +14 -1
- data/lib/plutonium/invites/pending_invite_check.rb +37 -28
- data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
- data/lib/plutonium/resource/policy.rb +23 -8
- data/lib/plutonium/ui/layout/sidebar.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/js/controllers/flatpickr_controller.js +23 -0
- data/src/js/controllers/sidebar_controller.js +28 -1
- metadata +5 -3
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"planPath": "docs/superpowers/plans/2026-05-06-multi-invite-model-support.md",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{"id": 1, "subject": "Task 1: PendingInviteCheck — multi-class support", "status": "completed"},
|
|
5
|
+
{"id": 2, "subject": "Task 2: Auto append_view_path in invites concerns + qualify flash partial", "status": "completed"},
|
|
6
|
+
{"id": 3, "subject": "Task 3: Plutonium::Invites::Controller — overridable invitation_path", "status": "completed"},
|
|
7
|
+
{"id": 4, "subject": "Task 4: install_generator — --invite_model option + naming helpers", "status": "completed"},
|
|
8
|
+
{"id": 5, "subject": "Task 5: Parameterize all templates by invite model name", "status": "completed", "blockedBy": [4]},
|
|
9
|
+
{"id": 6, "subject": "Task 6: Scope routes per invite type with idempotent re-run", "status": "completed", "blockedBy": [4]},
|
|
10
|
+
{"id": 7, "subject": "Task 7: Welcome controller — multi-class integration on re-run", "status": "completed", "blockedBy": [1, 4]},
|
|
11
|
+
{"id": 8, "subject": "Task 8: End-to-end dual-invocation generator test", "status": "completed", "blockedBy": [5, 6, 7]},
|
|
12
|
+
{"id": 9, "subject": "Task 9: Update skill + guide docs", "status": "completed", "blockedBy": [8]}
|
|
13
|
+
],
|
|
14
|
+
"lastUpdated": "2026-05-06T14:00:00Z"
|
|
15
|
+
}
|
|
@@ -21,6 +21,9 @@ module Pu
|
|
|
21
21
|
class_option :user_model, type: :string, default: "User",
|
|
22
22
|
desc: "The user model name"
|
|
23
23
|
|
|
24
|
+
class_option :invite_model, type: :string, default: nil,
|
|
25
|
+
desc: "The invite model class name. Defaults to <EntityModel><UserModel>Invite (e.g., OrganizationUserInvite). Override for separate flows in multi-entity apps."
|
|
26
|
+
|
|
24
27
|
class_option :membership_model, type: :string,
|
|
25
28
|
desc: "The membership model name (defaults to <Entity><User>)"
|
|
26
29
|
|
|
@@ -72,44 +75,80 @@ module Pu
|
|
|
72
75
|
|
|
73
76
|
def create_user_invites_migration
|
|
74
77
|
migration_template "db/migrate/create_user_invites.rb",
|
|
75
|
-
"db/migrate/
|
|
78
|
+
"db/migrate/create_#{invite_table}.rb"
|
|
76
79
|
end
|
|
77
80
|
|
|
78
81
|
def create_model
|
|
79
82
|
template "packages/invites/app/models/invites/user_invite.rb",
|
|
80
|
-
"packages/invites/app/models/invites
|
|
83
|
+
"packages/invites/app/models/invites/#{invite_underscore}.rb"
|
|
81
84
|
end
|
|
82
85
|
|
|
83
86
|
def create_mailer
|
|
84
87
|
template "packages/invites/app/mailers/invites/user_invite_mailer.rb",
|
|
85
|
-
"packages/invites/app/mailers/invites
|
|
88
|
+
"packages/invites/app/mailers/invites/#{invite_underscore}_mailer.rb"
|
|
86
89
|
|
|
87
90
|
template "packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb",
|
|
88
|
-
"packages/invites/app/views/invites/
|
|
91
|
+
"packages/invites/app/views/invites/#{invite_underscore}_mailer/invitation.html.erb"
|
|
89
92
|
|
|
90
93
|
template "packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb",
|
|
91
|
-
"packages/invites/app/views/invites/
|
|
94
|
+
"packages/invites/app/views/invites/#{invite_underscore}_mailer/invitation.text.erb"
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def create_controllers
|
|
95
98
|
template "packages/invites/app/controllers/invites/user_invitations_controller.rb",
|
|
96
|
-
"packages/invites/app/controllers/invites
|
|
99
|
+
"packages/invites/app/controllers/invites/#{invitations_path}_controller.rb"
|
|
100
|
+
|
|
101
|
+
# Welcome controller is a one-shot — only generate if it doesn't exist yet.
|
|
102
|
+
welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
|
|
103
|
+
unless File.exist?(Rails.root.join(welcome_path))
|
|
104
|
+
template "packages/invites/app/controllers/invites/welcome_controller.rb",
|
|
105
|
+
welcome_path
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def add_welcome_invite_class
|
|
110
|
+
welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
|
|
111
|
+
return unless File.exist?(Rails.root.join(welcome_path))
|
|
97
112
|
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
content = File.read(Rails.root.join(welcome_path))
|
|
114
|
+
new_class = "::Invites::#{invite_model}"
|
|
115
|
+
|
|
116
|
+
# Already present? bail. Use a non-word lookahead so we don't match
|
|
117
|
+
# `::Invites::FunderInvite` when looking for `::Invites::Funder`.
|
|
118
|
+
return if content =~ /#{Regexp.escape(new_class)}(?!\w)/
|
|
119
|
+
|
|
120
|
+
# Find `def invite_classes` block; inject before the closing `]`.
|
|
121
|
+
injection = content.sub(/(\bdef invite_classes\b.*?\[)([^\]]*)(\])/m) do
|
|
122
|
+
before, list, after = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
|
|
123
|
+
existing = list.strip
|
|
124
|
+
new_list = existing.empty? ? new_class : "#{existing.chomp(",").strip}, #{new_class}"
|
|
125
|
+
"#{before}#{new_list}#{after}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if injection != content
|
|
129
|
+
File.write(Rails.root.join(welcome_path), injection)
|
|
130
|
+
say_status :inject, "Added #{new_class} to welcome controller's invite_classes", :green
|
|
131
|
+
end
|
|
100
132
|
end
|
|
101
133
|
|
|
102
134
|
def create_views
|
|
103
135
|
%w[landing show signup error].each do |view|
|
|
104
136
|
template "packages/invites/app/views/invites/user_invitations/#{view}.html.erb",
|
|
105
|
-
"packages/invites/app/views/invites
|
|
137
|
+
"packages/invites/app/views/invites/#{invitations_path}/#{view}.html.erb"
|
|
106
138
|
end
|
|
107
139
|
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
# Welcome view is a one-shot too.
|
|
141
|
+
pending_path = "packages/invites/app/views/invites/welcome/pending_invitation.html.erb"
|
|
142
|
+
unless File.exist?(Rails.root.join(pending_path))
|
|
143
|
+
template "packages/invites/app/views/invites/welcome/pending_invitation.html.erb",
|
|
144
|
+
pending_path
|
|
145
|
+
end
|
|
110
146
|
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
layout_path = "packages/invites/app/views/layouts/invites/invitation.html.erb"
|
|
148
|
+
unless File.exist?(Rails.root.join(layout_path))
|
|
149
|
+
template "packages/invites/app/views/layouts/invites/invitation.html.erb",
|
|
150
|
+
layout_path
|
|
151
|
+
end
|
|
113
152
|
end
|
|
114
153
|
|
|
115
154
|
def create_interactions
|
|
@@ -122,17 +161,17 @@ module Pu
|
|
|
122
161
|
|
|
123
162
|
def create_definition
|
|
124
163
|
template "packages/invites/app/definitions/invites/user_invite_definition.rb",
|
|
125
|
-
"packages/invites/app/definitions/invites
|
|
164
|
+
"packages/invites/app/definitions/invites/#{invite_underscore}_definition.rb"
|
|
126
165
|
end
|
|
127
166
|
|
|
128
167
|
def create_policy
|
|
129
168
|
template "packages/invites/app/policies/invites/user_invite_policy.rb",
|
|
130
|
-
"packages/invites/app/policies/invites
|
|
169
|
+
"packages/invites/app/policies/invites/#{invite_underscore}_policy.rb"
|
|
131
170
|
end
|
|
132
171
|
|
|
133
172
|
def add_entity_association
|
|
134
173
|
inject_into_file entity_model_path,
|
|
135
|
-
" has_many
|
|
174
|
+
" has_many :#{invite_table}, class_name: \"Invites::#{invite_model}\", dependent: :destroy\n",
|
|
136
175
|
before: /^\s*# add has_many associations above\.\n/
|
|
137
176
|
end
|
|
138
177
|
|
|
@@ -203,31 +242,47 @@ module Pu
|
|
|
203
242
|
|
|
204
243
|
def add_routes
|
|
205
244
|
routes_content = File.read(Rails.root.join("config/routes.rb"))
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
245
|
+
flow_marker = "# Invitation routes for #{invite_model}"
|
|
246
|
+
|
|
247
|
+
if routes_content.include?(flow_marker)
|
|
248
|
+
say_status :skip, "Invitation routes for #{invite_model} already present", :yellow
|
|
249
|
+
else
|
|
250
|
+
welcome_present = routes_content.include?("# Invitation welcome routes")
|
|
210
251
|
|
|
211
|
-
|
|
252
|
+
welcome_block = if welcome_present
|
|
253
|
+
""
|
|
254
|
+
else
|
|
255
|
+
<<-RUBY
|
|
212
256
|
|
|
213
|
-
#
|
|
257
|
+
# Invitation welcome routes (shared across all invite flows)
|
|
214
258
|
scope module: :invites do
|
|
215
259
|
get "invitations/welcome", to: "welcome#index", as: :invites_welcome_check
|
|
216
260
|
delete "invitations/welcome", to: "welcome#skip", as: :invites_welcome_skip
|
|
217
|
-
get "invitations/:token", to: "user_invitations#show", as: :invitation
|
|
218
|
-
post "invitations/:token/accept", to: "user_invitations#accept", as: :accept_invitation
|
|
219
|
-
get "invitations/:token/signup", to: "user_invitations#signup", as: :invitation_signup
|
|
220
|
-
post "invitations/:token/signup", to: "user_invitations#signup"
|
|
221
261
|
end
|
|
222
|
-
|
|
262
|
+
RUBY
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
flow_block = <<-RUBY
|
|
266
|
+
|
|
267
|
+
#{flow_marker}
|
|
268
|
+
scope module: :invites do
|
|
269
|
+
get "#{invitations_path}/:token", to: "#{invitations_path}#show", as: :#{invite_route_prefix}_invitation
|
|
270
|
+
post "#{invitations_path}/:token/accept", to: "#{invitations_path}#accept", as: :accept_#{invite_route_prefix}_invitation
|
|
271
|
+
get "#{invitations_path}/:token/signup", to: "#{invitations_path}#signup", as: :#{invite_route_prefix}_invitation_signup
|
|
272
|
+
post "#{invitations_path}/:token/signup", to: "#{invitations_path}#signup"
|
|
273
|
+
end
|
|
274
|
+
RUBY
|
|
223
275
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
276
|
+
inject_into_file "config/routes.rb",
|
|
277
|
+
welcome_block + flow_block,
|
|
278
|
+
before: /^end\s*\z/
|
|
279
|
+
end
|
|
227
280
|
|
|
228
281
|
# If no main WelcomeController exists, add /welcome route pointing to
|
|
229
282
|
# Invites::WelcomeController so Rodauth's login_redirect "/welcome" works.
|
|
230
|
-
|
|
283
|
+
routes_content = File.read(Rails.root.join("config/routes.rb"))
|
|
284
|
+
unless File.exist?(Rails.root.join("app/controllers/welcome_controller.rb")) ||
|
|
285
|
+
routes_content.include?(%(get "welcome", to: "invites/welcome#index"))
|
|
231
286
|
welcome_route = <<-RUBY
|
|
232
287
|
|
|
233
288
|
# Welcome route (handled by invites package — replace with pu:saas:welcome for full onboarding)
|
|
@@ -236,7 +291,7 @@ module Pu
|
|
|
236
291
|
|
|
237
292
|
inject_into_file "config/routes.rb",
|
|
238
293
|
welcome_route,
|
|
239
|
-
before: /^\s*#
|
|
294
|
+
before: /^\s*# Invitation welcome routes/
|
|
240
295
|
end
|
|
241
296
|
end
|
|
242
297
|
|
|
@@ -342,11 +397,23 @@ module Pu
|
|
|
342
397
|
" return redirect_to(invites_welcome_check_path) if pending_invite\n\n",
|
|
343
398
|
after: /def index\n/
|
|
344
399
|
|
|
345
|
-
# Add
|
|
346
|
-
|
|
400
|
+
# Add invite_classes method if neither it nor invite_class is present
|
|
401
|
+
if file_content !~ /def invite_classes\b/ && file_content !~ /def invite_class\b/
|
|
347
402
|
inject_into_file relative_path,
|
|
348
|
-
"\n def
|
|
403
|
+
"\n def invite_classes\n [::Invites::#{invite_model}]\n end\n",
|
|
349
404
|
before: /^end\s*\z/
|
|
405
|
+
else
|
|
406
|
+
# Inject this invite_model into the existing invite_classes array if missing.
|
|
407
|
+
host_content = File.read(Rails.root.join(relative_path))
|
|
408
|
+
if host_content =~ /def invite_classes\b/ && host_content !~ /::Invites::#{invite_model}\b/
|
|
409
|
+
updated = host_content.sub(/(\bdef invite_classes\b.*?\[)([^\]]*)(\])/m) do
|
|
410
|
+
before, list, after = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
|
|
411
|
+
existing = list.strip
|
|
412
|
+
new_list = existing.empty? ? "::Invites::#{invite_model}" : "#{existing.chomp(",").strip}, ::Invites::#{invite_model}"
|
|
413
|
+
"#{before}#{new_list}#{after}"
|
|
414
|
+
end
|
|
415
|
+
File.write(Rails.root.join(relative_path), updated)
|
|
416
|
+
end
|
|
350
417
|
end
|
|
351
418
|
|
|
352
419
|
# Update Invites::WelcomeController to redirect to /welcome (the main hub)
|
|
@@ -437,6 +504,40 @@ module Pu
|
|
|
437
504
|
"app/policies/#{user_table}_policy.rb"
|
|
438
505
|
end
|
|
439
506
|
|
|
507
|
+
def invite_model
|
|
508
|
+
return options[:invite_model].camelize if options[:invite_model].present?
|
|
509
|
+
|
|
510
|
+
# Flatten "::" so namespaced entities like Blogging::Post produce a
|
|
511
|
+
# valid (single-segment) class name: BloggingPostUserInvite.
|
|
512
|
+
entity_part = entity_model.delete(":")
|
|
513
|
+
user_part = user_model.delete(":")
|
|
514
|
+
"#{entity_part}#{user_part}Invite"
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def invite_underscore
|
|
518
|
+
invite_model.underscore
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def invite_table
|
|
522
|
+
invite_model.tableize
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# e.g. UserInvite -> UserInvitationsController, FunderInvite -> FunderInvitationsController.
|
|
526
|
+
# If the input ends in "Invite", swap to "Invitations"; else append "Invitations".
|
|
527
|
+
def invitations_controller_class
|
|
528
|
+
base = invite_model.sub(/Invite\z/, "")
|
|
529
|
+
"#{base}InvitationsController"
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def invitations_path
|
|
533
|
+
invitations_controller_class.sub(/Controller\z/, "").underscore
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Route helper prefix: "user" for UserInvite, "funder" for FunderInvite.
|
|
537
|
+
def invite_route_prefix
|
|
538
|
+
invite_model.sub(/Invite\z/, "").underscore.presence || "invite"
|
|
539
|
+
end
|
|
540
|
+
|
|
440
541
|
def membership_model
|
|
441
542
|
options[:membership_model] || "#{entity_model}#{user_model}"
|
|
442
543
|
end
|
|
@@ -6,11 +6,17 @@ class <%= entity_model %>::InviteUserInteraction < Plutonium::Resource::Interact
|
|
|
6
6
|
presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
|
|
7
7
|
|
|
8
8
|
attribute :role
|
|
9
|
-
input :role, as: :select, choices: Invites
|
|
10
|
-
<% if membership_model != "EntityUser" || user_model != "User" -%>
|
|
9
|
+
input :role, as: :select, choices: Invites::<%= invite_model %>.roles.keys.excluding("owner")
|
|
10
|
+
<% if membership_model != "EntityUser" || user_model != "User" || entity_association_name != "entity" -%>
|
|
11
11
|
|
|
12
12
|
private
|
|
13
13
|
<% end -%>
|
|
14
|
+
<% if entity_association_name != "entity" -%>
|
|
15
|
+
|
|
16
|
+
def invite_entity_attribute
|
|
17
|
+
:<%= entity_association_name %>
|
|
18
|
+
end
|
|
19
|
+
<% end -%>
|
|
14
20
|
<% if membership_model != "EntityUser" -%>
|
|
15
21
|
|
|
16
22
|
def membership_class
|
data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt
CHANGED
|
@@ -6,13 +6,19 @@ class <%= user_model %>::InviteUserInteraction < Plutonium::Resource::Interactio
|
|
|
6
6
|
presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
|
|
7
7
|
|
|
8
8
|
attribute :role
|
|
9
|
-
input :role, as: :select, choices: Invites
|
|
9
|
+
input :role, as: :select, choices: Invites::<%= invite_model %>.roles.keys.excluding("owner")
|
|
10
10
|
|
|
11
11
|
private
|
|
12
12
|
|
|
13
13
|
def entity
|
|
14
14
|
current_scoped_entity
|
|
15
15
|
end
|
|
16
|
+
<% if entity_association_name != "entity" -%>
|
|
17
|
+
|
|
18
|
+
def invite_entity_attribute
|
|
19
|
+
:<%= entity_association_name %>
|
|
20
|
+
end
|
|
21
|
+
<% end -%>
|
|
16
22
|
<% if membership_model != "EntityUser" -%>
|
|
17
23
|
|
|
18
24
|
def membership_class
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class
|
|
3
|
+
class Create<%= invite_table.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
4
|
def change
|
|
5
|
-
create_table
|
|
5
|
+
create_table :<%= invite_table %> do |t|
|
|
6
6
|
# Entity association
|
|
7
7
|
t.belongs_to :<%= entity_association_name %>, null: false, foreign_key: true
|
|
8
8
|
|
|
@@ -35,13 +35,13 @@ class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.cu
|
|
|
35
35
|
|
|
36
36
|
# Only one pending invite per email per entity
|
|
37
37
|
t.index [:<%= entity_association_name %>_id, :email], unique: true, where: "state = 0",
|
|
38
|
-
name: "
|
|
38
|
+
name: "index_<%= invite_table %>_on_entity_email_pending"
|
|
39
39
|
|
|
40
40
|
# Only one pending invite per invitable (when invitable is present)
|
|
41
41
|
t.index [:invitable_type, :invitable_id],
|
|
42
42
|
unique: true,
|
|
43
43
|
where: "state = 0 AND invitable_id IS NOT NULL",
|
|
44
|
-
name: "
|
|
44
|
+
name: "index_<%= invite_table %>_on_invitable_pending"
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Invites
|
|
4
|
-
class
|
|
4
|
+
class <%= invitations_controller_class %> < ApplicationController
|
|
5
5
|
<% if rodauth? -%>
|
|
6
6
|
include Plutonium::Auth::Rodauth(:<%= rodauth_config %>)
|
|
7
7
|
<% end -%>
|
|
8
8
|
include Plutonium::Invites::Controller
|
|
9
9
|
|
|
10
|
-
prepend_view_path Invites::Engine.root.join("app/views")
|
|
11
10
|
layout "invites/invitation"
|
|
12
11
|
helper_method :login_path
|
|
13
12
|
|
|
14
13
|
private
|
|
15
14
|
|
|
16
15
|
def invite_class
|
|
17
|
-
::Invites
|
|
16
|
+
::Invites::<%= invite_model %>
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def invitation_path_for(token)
|
|
20
|
+
<%= invite_route_prefix %>_invitation_path(token: token)
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def user_class
|
|
@@ -68,7 +71,9 @@ module Invites
|
|
|
68
71
|
|
|
69
72
|
def sign_in_user(user)
|
|
70
73
|
rodauth.account_from_login(user.email)
|
|
71
|
-
|
|
74
|
+
# login_session just persists the session; `login` would redirect to
|
|
75
|
+
# rodauth.login_redirect and short-circuit our post-accept redirect.
|
|
76
|
+
rodauth.login_session("signup")
|
|
72
77
|
end
|
|
73
78
|
<% else -%>
|
|
74
79
|
def create_user_for_signup(email, password)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Invites
|
|
4
|
-
class
|
|
4
|
+
class <%= invite_model %>Definition < Invites::ResourceDefinition
|
|
5
5
|
action :resend, interaction: Invites::ResendInviteInteraction, collection_record_action: false
|
|
6
6
|
action :cancel, interaction: Invites::CancelInviteInteraction, collection_record_action: false
|
|
7
7
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Invites
|
|
4
|
-
class
|
|
4
|
+
class <%= invite_model %>Mailer < ApplicationMailer
|
|
5
5
|
prepend_view_path Invites::Engine.root.join("app/views")
|
|
6
6
|
|
|
7
|
-
def invitation(
|
|
8
|
-
@
|
|
9
|
-
@invitation_url =
|
|
7
|
+
def invitation(invite)
|
|
8
|
+
@invite = invite
|
|
9
|
+
@invitation_url = <%= invite_route_prefix %>_invitation_url(token: invite.token)
|
|
10
10
|
|
|
11
11
|
mail(
|
|
12
|
-
to:
|
|
12
|
+
to: invite.email,
|
|
13
13
|
subject: invitation_subject,
|
|
14
14
|
template_name: invitation_template_name
|
|
15
15
|
)
|
|
@@ -18,14 +18,14 @@ module Invites
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
20
|
def invitation_subject
|
|
21
|
-
"You've been invited to join #{@
|
|
21
|
+
"You've been invited to join #{@invite.entity.to_label}"
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def invitation_template_name
|
|
25
|
-
return "invitation" unless @
|
|
25
|
+
return "invitation" unless @invite.invitable_type.present?
|
|
26
26
|
|
|
27
27
|
# e.g., "TenantProfile" -> "invitation_tenant_profile"
|
|
28
|
-
template = "invitation_#{@
|
|
28
|
+
template = "invitation_#{@invite.invitable_type.underscore}"
|
|
29
29
|
template_exists?(template) ? template : "invitation"
|
|
30
30
|
end
|
|
31
31
|
|
data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Invites
|
|
4
|
-
class
|
|
4
|
+
class <%= invite_model %> < Invites::ResourceRecord
|
|
5
5
|
include Plutonium::Invites::Concerns::InviteToken
|
|
6
6
|
|
|
7
7
|
enum :role, <%= membership_model %>.roles
|
|
@@ -18,7 +18,7 @@ module Invites
|
|
|
18
18
|
# Implement required methods from InviteToken concern
|
|
19
19
|
|
|
20
20
|
def invitation_mailer
|
|
21
|
-
Invites
|
|
21
|
+
Invites::<%= invite_model %>Mailer
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def enforce_domain
|
|
@@ -30,11 +30,20 @@ module Invites
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def create_membership_for(user)
|
|
33
|
-
<%= membership_model %>.create!(<%= entity_association_name %>: <%= entity_association_name %>,
|
|
33
|
+
<%= membership_model %>.create!(<%= entity_association_name %>: <%= entity_association_name %>, <%= user_association_name %>: user, role: role)
|
|
34
34
|
end
|
|
35
|
+
<% if user_table != "user" -%>
|
|
36
|
+
|
|
37
|
+
# InviteToken concern updates `user_attribute => user` when accepting.
|
|
38
|
+
# Our invite model uses `belongs_to :<%= user_table %>` instead.
|
|
39
|
+
def user_attribute
|
|
40
|
+
:<%= user_table %>
|
|
41
|
+
end
|
|
42
|
+
<% end -%>
|
|
35
43
|
<% if entity_association_name != "entity" -%>
|
|
36
44
|
|
|
37
|
-
# Alias for InviteToken concern compatibility
|
|
45
|
+
# Alias for InviteToken concern compatibility (used by views/mailers
|
|
46
|
+
# that read `@invite.entity.to_label`).
|
|
38
47
|
alias_method :entity, :<%= entity_association_name %>
|
|
39
48
|
<% end -%>
|
|
40
49
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Invites
|
|
4
|
-
class
|
|
4
|
+
class <%= invite_model %>Policy < Invites::ResourcePolicy
|
|
5
5
|
def create?
|
|
6
6
|
false # Created via InviteUserInteraction
|
|
7
7
|
end
|
|
@@ -19,11 +19,11 @@ module Invites
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def permitted_attributes_for_create
|
|
22
|
-
[
|
|
22
|
+
[:<%= entity_association_name %>, :email, :role, :invited_by, :expires_at]
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def permitted_attributes_for_read
|
|
26
|
-
[
|
|
26
|
+
[:<%= entity_association_name %>, :email, :role, :state, :invited_by, :expires_at, :accepted_at, :<%= user_table %>]
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def permitted_associations
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<p class="text-[var(--pu-text-muted)] mb-4">To accept this invitation, please:</p>
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
|
-
<%%= link_to "Create Account",
|
|
25
|
+
<%%= link_to "Create Account", <%= invite_route_prefix %>_invitation_signup_path(token: params[:token]),
|
|
26
26
|
class: "w-full block pu-btn pu-btn-md pu-btn-primary text-center" %>
|
|
27
27
|
|
|
28
28
|
<div class="text-center">
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<%% end %>
|
|
30
30
|
|
|
31
31
|
<div class="space-y-3 pt-4">
|
|
32
|
-
<%%= form_with url:
|
|
32
|
+
<%%= form_with url: accept_<%= invite_route_prefix %>_invitation_path(token: params[:token]), method: :post, local: true do |form| %>
|
|
33
33
|
<%%= form.submit "Accept Invitation",
|
|
34
34
|
class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
|
|
35
35
|
<%% end %>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
Create Your Account
|
|
11
11
|
</h1>
|
|
12
12
|
|
|
13
|
-
<%%= form_with url:
|
|
13
|
+
<%%= form_with url: <%= invite_route_prefix %>_invitation_signup_path(token: @invite.token), method: :post, local: true, class: "space-y-4", data: { turbo: false } do |form| %>
|
|
14
14
|
<div>
|
|
15
15
|
<label for="email" class="pu-label">Email</label>
|
|
16
16
|
<%% if @invite.enforce_email? %>
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
<div class="space-y-3 pt-4">
|
|
44
44
|
<%%= form.submit "Create Account",
|
|
45
45
|
class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
|
|
46
|
-
<%%= link_to "Back",
|
|
46
|
+
<%%= link_to "Back", <%= invite_route_prefix %>_invitation_path(token: @invite.token),
|
|
47
47
|
class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
|
|
48
48
|
</div>
|
|
49
49
|
<%% end %>
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
|
24
24
|
<div class="email-container">
|
|
25
|
-
<h2>You've been invited to <%%= @
|
|
25
|
+
<h2>You've been invited to <%%= @invite.entity.to_label %></h2>
|
|
26
26
|
|
|
27
27
|
<p>Hi there,</p>
|
|
28
28
|
|
|
29
|
-
<p><%%= @
|
|
29
|
+
<p><%%= @invite.invited_by.email %> has invited you to join <%%= @invite.entity.to_label %> as a <%%= @invite.role %>.</p>
|
|
30
30
|
|
|
31
31
|
<p>Click the link below to accept your invitation:</p>
|
|
32
32
|
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
<p>Or copy and paste this URL into your browser:</p>
|
|
36
36
|
<p><%%= @invitation_url %></p>
|
|
37
37
|
|
|
38
|
-
<%% if @
|
|
39
|
-
<p><small>This invitation will expire on <%%= @
|
|
38
|
+
<%% if @invite.expires_at.present? %>
|
|
39
|
+
<p><small>This invitation will expire on <%%= @invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %></small></p>
|
|
40
40
|
<%% end %>
|
|
41
41
|
|
|
42
42
|
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
You've been invited to <%%= @
|
|
1
|
+
You've been invited to <%%= @invite.entity.to_label %>
|
|
2
2
|
======================================================
|
|
3
3
|
|
|
4
4
|
Hi there,
|
|
5
5
|
|
|
6
|
-
<%%= @
|
|
6
|
+
<%%= @invite.invited_by.email %> has invited you to join <%%= @invite.entity.to_label %> as a <%%= @invite.role %>.
|
|
7
7
|
|
|
8
8
|
Accept your invitation by visiting this link:
|
|
9
9
|
<%%= @invitation_url %>
|
|
10
10
|
|
|
11
|
-
<%% if @
|
|
12
|
-
This invitation will expire on <%%= @
|
|
11
|
+
<%% if @invite.expires_at.present? %>
|
|
12
|
+
This invitation will expire on <%%= @invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
|
|
13
13
|
<%% end %>
|
|
14
14
|
|
|
15
15
|
If you didn't expect this invitation, you can safely ignore this email.
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
<meta name="csrf-param" content="authenticity_token" />
|
|
8
8
|
<meta name="csrf-token" content="<%%= form_authenticity_token %>" />
|
|
9
9
|
<%%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
10
|
-
|
|
10
|
+
<%% if defined?(Importmap) %>
|
|
11
|
+
<%%= javascript_importmap_tags %>
|
|
12
|
+
<%% else %>
|
|
13
|
+
<%%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
|
|
14
|
+
<%% end %>
|
|
11
15
|
</head>
|
|
12
16
|
<body class="antialiased min-h-screen bg-[var(--pu-body)]">
|
|
13
17
|
<main class="p-4 min-h-screen flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0">
|