plutonium 0.48.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 +38 -0
- data/app/assets/plutonium.js +73 -25
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +29 -29
- data/app/assets/plutonium.min.js.map +3 -3
- data/app/views/plutonium/_flash.html.erb +1 -1
- data/config/initializers/pagy.rb +1 -1
- data/docs/guides/user-invites.md +64 -0
- data/docs/public/templates/plutonium.rb +3 -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 +27 -1
- data/gemfiles/rails_8.0.gemfile.lock +27 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/actual_db_schema/actual_db_schema_generator.rb +24 -0
- 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/lib/plutonium_generators/concerns/configures_sqlite.rb +9 -3
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +6 -3
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +18 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
- data/lib/plutonium/core/controller.rb +10 -3
- data/lib/plutonium/engine.rb +1 -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/rodauth/controller_methods.rb +5 -1
- data/lib/plutonium/ui/color_mode_selector.rb +7 -18
- data/lib/plutonium/ui/layout/rodauth_layout.rb +6 -0
- data/lib/plutonium/ui/layout/sidebar.rb +1 -1
- data/lib/plutonium/ui/table/components/pagy_info.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +16 -0
- data/src/js/controllers/color_mode_controller.js +41 -34
- data/src/js/controllers/flatpickr_controller.js +23 -0
- data/src/js/controllers/sidebar_controller.js +28 -1
- metadata +19 -2
|
@@ -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">
|
|
@@ -55,7 +55,7 @@ module PlutoniumGenerators
|
|
|
55
55
|
end.compact!
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def new_database(name, migrations_paths: nil)
|
|
58
|
+
def new_database(name, migrations_paths: nil, schema_dump: nil)
|
|
59
59
|
migrations_paths ||= "db/#{name}_migrate"
|
|
60
60
|
db = Psych::Nodes::Mapping.new(name)
|
|
61
61
|
db.children.concat [
|
|
@@ -66,6 +66,12 @@ module PlutoniumGenerators
|
|
|
66
66
|
Psych::Nodes::Scalar.new("database"),
|
|
67
67
|
Psych::Nodes::Scalar.new("storage/<%= Rails.env %>-#{name}.sqlite3")
|
|
68
68
|
]
|
|
69
|
+
unless schema_dump.nil?
|
|
70
|
+
db.children.concat [
|
|
71
|
+
Psych::Nodes::Scalar.new("schema_dump"),
|
|
72
|
+
Psych::Nodes::Scalar.new(schema_dump.to_s)
|
|
73
|
+
]
|
|
74
|
+
end
|
|
69
75
|
"\n" + emit_pair(Psych::Nodes::Scalar.new(name), db)
|
|
70
76
|
end
|
|
71
77
|
|
|
@@ -91,10 +97,10 @@ module PlutoniumGenerators
|
|
|
91
97
|
@database_yaml ||= DatabaseYAML.new(path: File.expand_path("config/database.yml", destination_root))
|
|
92
98
|
end
|
|
93
99
|
|
|
94
|
-
def add_sqlite_database(name, migrations_paths: nil)
|
|
100
|
+
def add_sqlite_database(name, migrations_paths: nil, schema_dump: nil)
|
|
95
101
|
# Define the new database configuration
|
|
96
102
|
insert_into_file "config/database.yml",
|
|
97
|
-
database_yaml.new_database(name, migrations_paths: migrations_paths) + "\n",
|
|
103
|
+
database_yaml.new_database(name, migrations_paths: migrations_paths, schema_dump: schema_dump) + "\n",
|
|
98
104
|
after: database_yaml.database_def_regex("default"),
|
|
99
105
|
verbose: false,
|
|
100
106
|
force: false
|
|
@@ -51,10 +51,13 @@ module Pu
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Then add database config
|
|
54
|
-
add_sqlite_database(@db_name, migrations_paths: "db/rails_pulse_migrate")
|
|
54
|
+
add_sqlite_database(@db_name, migrations_paths: "db/rails_pulse_migrate", schema_dump: false)
|
|
55
55
|
|
|
56
|
-
#
|
|
57
|
-
|
|
56
|
+
# rails_pulse ships a callable schema lambda; load it idempotently
|
|
57
|
+
# against the rails_pulse connection.
|
|
58
|
+
Bundler.with_unbundled_env do
|
|
59
|
+
run "bin/rails db:schema:load_rails_pulse"
|
|
60
|
+
end
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def mount_rails_pulse_engine
|
|
@@ -6,6 +6,24 @@ RailsPulse.configure do |config|
|
|
|
6
6
|
|
|
7
7
|
# Asset tracking (disable to reduce noise)
|
|
8
8
|
config.track_assets = false
|
|
9
|
+
|
|
10
|
+
# Background job tracking (off by default in the gem; enabling mounts /jobs)
|
|
11
|
+
config.track_jobs = true
|
|
12
|
+
|
|
13
|
+
# Don't capture job arguments — they may contain sensitive data
|
|
14
|
+
config.capture_job_arguments = false
|
|
15
|
+
|
|
16
|
+
# Match the engine mount so Pulse doesn't self-track its own dashboard
|
|
17
|
+
config.mount_path = "<%= options[:route] %>"
|
|
18
|
+
|
|
19
|
+
# Auth is handled upstream by ManagementConstraint in routes.rb;
|
|
20
|
+
# disable the gem's built-in auth to avoid double prompts.
|
|
21
|
+
config.authentication_enabled = false
|
|
22
|
+
|
|
23
|
+
# Skip Rails' default health check and other mounted management engines
|
|
24
|
+
# (mission_control-jobs, solid_errors, litestream, etc.) so they don't
|
|
25
|
+
# pollute the application route list.
|
|
26
|
+
config.ignored_routes = ["/up", "/cable", %r{^/manage/}]
|
|
9
27
|
<%- if options[:database] -%>
|
|
10
28
|
|
|
11
29
|
# Use separate database for performance data
|
|
@@ -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">
|
|
@@ -18,7 +18,14 @@ module Plutonium
|
|
|
18
18
|
raise exception
|
|
19
19
|
end
|
|
20
20
|
format.any do
|
|
21
|
-
|
|
21
|
+
# ActionPolicy stores the policy *class* on the exception
|
|
22
|
+
# (see ActionPolicy::Unauthorized#initialize), so reach for the
|
|
23
|
+
# live policy instance instead. Its record may itself be a Class
|
|
24
|
+
# for collection actions where no record is loaded — instantiate
|
|
25
|
+
# so ActiveModel::Errors has a real model instance to work with.
|
|
26
|
+
record = current_policy.record
|
|
27
|
+
record = record.new if record.is_a?(Class)
|
|
28
|
+
@errors = ActiveModel::Errors.new(record)
|
|
22
29
|
@errors.add(:base, :unauthorized, message: exception.result.message)
|
|
23
30
|
render "errors", status: :forbidden
|
|
24
31
|
end
|
|
@@ -248,8 +255,8 @@ module Plutonium
|
|
|
248
255
|
end
|
|
249
256
|
|
|
250
257
|
# Build named route helper, mirroring the pattern used by build_nested_resource_url_args.
|
|
251
|
-
# e.g., "
|
|
252
|
-
# "
|
|
258
|
+
# e.g., "organization_scoped_widgets" (collection), "organization_scoped_widget" (member),
|
|
259
|
+
# "edit_organization_scoped_widget" (edit action)
|
|
253
260
|
is_collection_action = action == :index || action == :create || (no_record && action != :new)
|
|
254
261
|
helper_base = if is_singular || is_collection_action
|
|
255
262
|
model_class.model_name.plural
|
data/lib/plutonium/engine.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Plutonium
|
|
|
19
19
|
# time — they're stable strings and don't depend on the live class
|
|
20
20
|
# identity, so caching them is safe.
|
|
21
21
|
resolved = @scoped_entity_class_name.constantize
|
|
22
|
-
@scoped_entity_param_key = param_key || :"#{resolved.model_name.singular_route_key}
|
|
22
|
+
@scoped_entity_param_key = param_key || :"#{resolved.model_name.singular_route_key}_scoped"
|
|
23
23
|
@scoped_entity_route_key = route_key || resolved.model_name.singular.to_sym
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -125,9 +125,9 @@ module Plutonium
|
|
|
125
125
|
|
|
126
126
|
transaction do
|
|
127
127
|
update!(
|
|
128
|
-
state
|
|
129
|
-
accepted_at
|
|
130
|
-
|
|
128
|
+
:state => :accepted,
|
|
129
|
+
:accepted_at => Time.current,
|
|
130
|
+
user_attribute => user
|
|
131
131
|
)
|
|
132
132
|
|
|
133
133
|
create_membership_for(user)
|
|
@@ -135,6 +135,14 @@ module Plutonium
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# Override to specify the user association name on the invite model.
|
|
139
|
+
# Defaults to :user. Override when the invite model's `belongs_to`
|
|
140
|
+
# uses a different name (e.g., :spender_account, :staff_user).
|
|
141
|
+
# @return [Symbol]
|
|
142
|
+
def user_attribute
|
|
143
|
+
:user
|
|
144
|
+
end
|
|
145
|
+
|
|
138
146
|
# Override this method to specify the mailer class.
|
|
139
147
|
#
|
|
140
148
|
# @return [Class] the mailer class for sending invitation emails
|
|
@@ -37,12 +37,12 @@ module Plutonium
|
|
|
37
37
|
|
|
38
38
|
def execute
|
|
39
39
|
attrs = {
|
|
40
|
-
entity: entity,
|
|
41
40
|
email: email,
|
|
42
41
|
role: role,
|
|
43
42
|
invited_by: current_user,
|
|
44
43
|
**additional_invite_attributes
|
|
45
44
|
}
|
|
45
|
+
attrs[invite_entity_attribute] = entity
|
|
46
46
|
attrs[:invitable] = invitable if invitable.present?
|
|
47
47
|
|
|
48
48
|
invite_class.create!(attrs)
|
|
@@ -109,6 +109,15 @@ module Plutonium
|
|
|
109
109
|
entity.class.name.underscore.to_sym
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Override to specify the entity association name on the invite model.
|
|
113
|
+
# Defaults to :entity, matching the convention documented on InviteToken.
|
|
114
|
+
# When the invite model uses a concrete `belongs_to :<entity_name>`
|
|
115
|
+
# instead, override this to return that association name.
|
|
116
|
+
# @return [Symbol]
|
|
117
|
+
def invite_entity_attribute
|
|
118
|
+
:entity
|
|
119
|
+
end
|
|
120
|
+
|
|
112
121
|
def role_is_present
|
|
113
122
|
errors.add(:role, :blank) if role.blank?
|
|
114
123
|
end
|
|
@@ -130,9 +139,9 @@ module Plutonium
|
|
|
130
139
|
return unless email.present? && entity.present?
|
|
131
140
|
|
|
132
141
|
pending = invite_class.find_by(
|
|
133
|
-
|
|
134
|
-
email
|
|
135
|
-
state
|
|
142
|
+
invite_entity_attribute => entity,
|
|
143
|
+
:email => email,
|
|
144
|
+
:state => :pending
|
|
136
145
|
)
|
|
137
146
|
errors.add(:email, "already has a pending invitation") if pending
|
|
138
147
|
end
|
|
@@ -35,6 +35,7 @@ module Plutonium
|
|
|
35
35
|
extend ActiveSupport::Concern
|
|
36
36
|
|
|
37
37
|
included do
|
|
38
|
+
append_view_path File.expand_path("app/views", Plutonium.root)
|
|
38
39
|
helper_method :current_user if respond_to?(:helper_method)
|
|
39
40
|
end
|
|
40
41
|
|
|
@@ -72,7 +73,7 @@ module Plutonium
|
|
|
72
73
|
return unless (@invite = load_and_validate_invite(params[:token]))
|
|
73
74
|
|
|
74
75
|
unless current_user
|
|
75
|
-
redirect_to
|
|
76
|
+
redirect_to invitation_path_for(params[:token]),
|
|
76
77
|
alert: "Please sign in to accept this invitation"
|
|
77
78
|
return
|
|
78
79
|
end
|
|
@@ -174,6 +175,18 @@ module Plutonium
|
|
|
174
175
|
raise NotImplementedError, "#{self.class}#invite_class must return the invite model class"
|
|
175
176
|
end
|
|
176
177
|
|
|
178
|
+
# Override to customize the invitation URL helper.
|
|
179
|
+
# Default uses Rails' `invitation_path(token:)` helper, which is what
|
|
180
|
+
# `pu:invites:install` generates for single-flow apps. Multi-flow apps
|
|
181
|
+
# whose generator scoped the route as `<prefix>_invitation_path` should
|
|
182
|
+
# override this.
|
|
183
|
+
#
|
|
184
|
+
# @param token [String] the invitation token
|
|
185
|
+
# @return [String] the URL path
|
|
186
|
+
def invitation_path_for(token)
|
|
187
|
+
invitation_path(token: token)
|
|
188
|
+
end
|
|
189
|
+
|
|
177
190
|
# Override to specify the user model class.
|
|
178
191
|
#
|
|
179
192
|
# @return [Class] the user model class
|
|
@@ -8,39 +8,34 @@ module Plutonium
|
|
|
8
8
|
# (e.g., WelcomeController, DashboardController) to check for
|
|
9
9
|
# pending invitations stored in cookies.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# Hosts may override either `invite_classes` (preferred — returns
|
|
12
|
+
# an Array of invite classes to check, in priority order) or
|
|
13
|
+
# `invite_class` (single class, kept for backward compatibility).
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# # Normal post-login flow...
|
|
19
|
-
# redirect_to dashboard_path
|
|
20
|
-
# end
|
|
21
|
-
#
|
|
22
|
-
# private
|
|
23
|
-
#
|
|
24
|
-
# def invite_class
|
|
25
|
-
# Invites::UserInvite
|
|
26
|
-
# end
|
|
15
|
+
# @example Single invite class
|
|
16
|
+
# def invite_class
|
|
17
|
+
# ::Invites::UserInvite
|
|
27
18
|
# end
|
|
28
19
|
#
|
|
20
|
+
# @example Multiple invite classes
|
|
21
|
+
# def invite_classes
|
|
22
|
+
# [::Invites::FunderInvite, ::Invites::SpenderInvite]
|
|
23
|
+
# end
|
|
29
24
|
module PendingInviteCheck
|
|
30
25
|
extend ActiveSupport::Concern
|
|
31
26
|
|
|
27
|
+
included do
|
|
28
|
+
append_view_path File.expand_path("app/views", Plutonium.root)
|
|
29
|
+
end
|
|
30
|
+
|
|
32
31
|
private
|
|
33
32
|
|
|
34
33
|
# Check for a pending invitation and redirect if found.
|
|
35
|
-
#
|
|
36
|
-
# @return [Boolean] true if redirected, false otherwise
|
|
37
34
|
def redirect_to_pending_invite!
|
|
38
35
|
token = cookies.encrypted[:pending_invitation]
|
|
39
36
|
return false unless token
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if invite
|
|
38
|
+
if find_pending_invite(token)
|
|
44
39
|
redirect_to invitation_path(token: token)
|
|
45
40
|
true
|
|
46
41
|
else
|
|
@@ -49,14 +44,12 @@ module Plutonium
|
|
|
49
44
|
end
|
|
50
45
|
end
|
|
51
46
|
|
|
52
|
-
# Returns the pending invite if one exists.
|
|
53
|
-
#
|
|
54
|
-
# @return [Object, nil] the pending invite or nil
|
|
47
|
+
# Returns the pending invite if one exists across any invite_classes.
|
|
55
48
|
def pending_invite
|
|
56
49
|
token = cookies.encrypted[:pending_invitation]
|
|
57
50
|
return nil unless token
|
|
58
51
|
|
|
59
|
-
invite =
|
|
52
|
+
invite = find_pending_invite(token)
|
|
60
53
|
unless invite
|
|
61
54
|
cookies.delete(:pending_invitation)
|
|
62
55
|
return nil
|
|
@@ -65,11 +58,27 @@ module Plutonium
|
|
|
65
58
|
invite
|
|
66
59
|
end
|
|
67
60
|
|
|
68
|
-
# Override to specify
|
|
69
|
-
#
|
|
70
|
-
# @return [Class]
|
|
61
|
+
# Override to specify multiple invite model classes (preferred).
|
|
62
|
+
# Defaults to `[invite_class]` for backward compatibility.
|
|
63
|
+
# @return [Array<Class>]
|
|
64
|
+
def invite_classes
|
|
65
|
+
[invite_class]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Override to specify a single invite model class. Maintained for
|
|
69
|
+
# backward compatibility; prefer `invite_classes` for multi-flow apps.
|
|
70
|
+
# @return [Class]
|
|
71
71
|
def invite_class
|
|
72
|
-
raise NotImplementedError,
|
|
72
|
+
raise NotImplementedError,
|
|
73
|
+
"#{self.class}#invite_class or #invite_classes must return the invite model class(es)"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def find_pending_invite(token)
|
|
77
|
+
invite_classes.each do |klass|
|
|
78
|
+
invite = klass.find_for_acceptance(token)
|
|
79
|
+
return invite if invite
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
73
82
|
end
|
|
74
83
|
end
|
|
75
84
|
end
|
|
@@ -29,7 +29,7 @@ module Plutonium
|
|
|
29
29
|
# GET /resources/1/record_actions/:interactive_action
|
|
30
30
|
def interactive_record_action
|
|
31
31
|
build_interactive_record_action_interaction
|
|
32
|
-
render :interactive_record_action,
|
|
32
|
+
render :interactive_record_action, formats: [:html], **modal_render_options
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# POST /resources/1/record_actions/:interactive_action
|
|
@@ -62,7 +62,7 @@ module Plutonium
|
|
|
62
62
|
end
|
|
63
63
|
else
|
|
64
64
|
format.any(:html, :turbo_stream) do
|
|
65
|
-
render :interactive_record_action,
|
|
65
|
+
render :interactive_record_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
|
|
66
66
|
end
|
|
67
67
|
format.any do
|
|
68
68
|
@errors = @interaction.errors
|
|
@@ -77,7 +77,7 @@ module Plutonium
|
|
|
77
77
|
def interactive_resource_action
|
|
78
78
|
skip_verify_current_authorized_scope!
|
|
79
79
|
build_interactive_resource_action_interaction
|
|
80
|
-
render :interactive_resource_action,
|
|
80
|
+
render :interactive_resource_action, formats: [:html], **modal_render_options
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
# POST /resources/resource_actions/:interactive_action
|
|
@@ -111,7 +111,7 @@ module Plutonium
|
|
|
111
111
|
end
|
|
112
112
|
else
|
|
113
113
|
format.any(:html, :turbo_stream) do
|
|
114
|
-
render :interactive_resource_action,
|
|
114
|
+
render :interactive_resource_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
|
|
115
115
|
end
|
|
116
116
|
format.any do
|
|
117
117
|
@errors = @interaction.errors
|
|
@@ -125,7 +125,7 @@ module Plutonium
|
|
|
125
125
|
# GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
|
|
126
126
|
def interactive_bulk_action
|
|
127
127
|
build_interactive_bulk_action_interaction
|
|
128
|
-
render :interactive_bulk_action,
|
|
128
|
+
render :interactive_bulk_action, formats: [:html], **modal_render_options
|
|
129
129
|
end
|
|
130
130
|
|
|
131
131
|
# POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
|
|
@@ -158,7 +158,7 @@ module Plutonium
|
|
|
158
158
|
end
|
|
159
159
|
else
|
|
160
160
|
format.any(:html, :turbo_stream) do
|
|
161
|
-
render :interactive_bulk_action,
|
|
161
|
+
render :interactive_bulk_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
|
|
162
162
|
end
|
|
163
163
|
format.any do
|
|
164
164
|
@errors = @interaction.errors
|
|
@@ -171,9 +171,13 @@ module Plutonium
|
|
|
171
171
|
|
|
172
172
|
private
|
|
173
173
|
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
# Render options for modal-aware actions. Returns `{ layout: false }` for
|
|
175
|
+
# turbo-frame requests so the bare frame is rendered, and an empty hash
|
|
176
|
+
# for top-level requests so the controller's default layout proc applies.
|
|
177
|
+
# (Passing `layout: nil` explicitly is treated as "no layout" by Rails,
|
|
178
|
+
# which is why we omit the key entirely on the default path.)
|
|
179
|
+
def modal_render_options
|
|
180
|
+
helpers.current_turbo_frame.present? ? {layout: false} : {}
|
|
177
181
|
end
|
|
178
182
|
|
|
179
183
|
def current_interactive_action
|