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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
module Concerns
|
|
6
|
+
# Invitable allows any model to trigger invites and be notified on acceptance.
|
|
7
|
+
#
|
|
8
|
+
# This pattern is useful when you have a profile or record that needs to
|
|
9
|
+
# invite a user and then connect itself to that user after acceptance.
|
|
10
|
+
#
|
|
11
|
+
# @example TenantProfile that invites users
|
|
12
|
+
# class TenantProfile < ApplicationRecord
|
|
13
|
+
# include Plutonium::Resource::Record
|
|
14
|
+
# include Plutonium::Invites::Concerns::Invitable
|
|
15
|
+
#
|
|
16
|
+
# belongs_to :entity
|
|
17
|
+
# belongs_to :user, optional: true
|
|
18
|
+
#
|
|
19
|
+
# def on_invite_accepted(user)
|
|
20
|
+
# update!(user: user)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example Creating an invite from an invitable
|
|
25
|
+
# tenant_profile.create_invite!(
|
|
26
|
+
# email: tenant_profile.email,
|
|
27
|
+
# entity: tenant_profile.entity,
|
|
28
|
+
# invited_by: current_user,
|
|
29
|
+
# role: :member,
|
|
30
|
+
# email_template: "tenant"
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
module Invitable
|
|
34
|
+
extend ActiveSupport::Concern
|
|
35
|
+
|
|
36
|
+
included do
|
|
37
|
+
# Association to the pending user invite for this record.
|
|
38
|
+
# Scoped to pending only - cancelled/expired/accepted invites are kept for audit.
|
|
39
|
+
has_one :user_invite, -> { pending }, class_name: "Invites::UserInvite", as: :invitable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Create an invite for this invitable.
|
|
43
|
+
#
|
|
44
|
+
# If there's already a pending invite for this invitable, it will be
|
|
45
|
+
# destroyed and replaced with a new one.
|
|
46
|
+
#
|
|
47
|
+
# @param email [String] the email address to invite
|
|
48
|
+
# @param entity [Object] the entity to join
|
|
49
|
+
# @param invited_by [Object] the user creating the invite
|
|
50
|
+
# @param role [Symbol, String] the role to assign (default: nil, uses model default)
|
|
51
|
+
# @param email_template [String, nil] optional template type for email customization
|
|
52
|
+
# @return [Object] the created invite record
|
|
53
|
+
def create_invite!(email:, entity:, invited_by:, role: nil, email_template: nil)
|
|
54
|
+
# Cancel any existing pending invite first (association is already scoped to pending)
|
|
55
|
+
user_invite&.cancelled!
|
|
56
|
+
|
|
57
|
+
attrs = {
|
|
58
|
+
email: email,
|
|
59
|
+
entity: entity,
|
|
60
|
+
invited_by: invited_by,
|
|
61
|
+
email_template: email_template
|
|
62
|
+
}
|
|
63
|
+
attrs[:role] = role if role.present?
|
|
64
|
+
|
|
65
|
+
create_user_invite!(attrs)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if there's an active pending invite.
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean] true if there's a pending invite
|
|
71
|
+
def has_pending_invite?
|
|
72
|
+
user_invite.present?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if this invitable can receive an invitation.
|
|
76
|
+
#
|
|
77
|
+
# Override this method to customize the logic. The default implementation
|
|
78
|
+
# returns true if no user is attached and no pending invite exists.
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean] true if invitation can be sent
|
|
81
|
+
def can_invite_user?
|
|
82
|
+
!user.present? && !has_pending_invite?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Called when the invited user accepts and joins the entity.
|
|
86
|
+
#
|
|
87
|
+
# Override this method in your model to handle the acceptance,
|
|
88
|
+
# typically to connect the invitable to the user.
|
|
89
|
+
#
|
|
90
|
+
# @param user [Object] the user who accepted the invite
|
|
91
|
+
# @raise [NotImplementedError] if not overridden
|
|
92
|
+
def on_invite_accepted(user)
|
|
93
|
+
raise NotImplementedError, "#{self.class.name} must implement #on_invite_accepted(user)"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
module Concerns
|
|
6
|
+
# InviteToken provides core invite functionality for models.
|
|
7
|
+
#
|
|
8
|
+
# This concern handles:
|
|
9
|
+
# - Token generation and validation
|
|
10
|
+
# - State machine (pending, accepted, expired, cancelled)
|
|
11
|
+
# - Email constraint validation
|
|
12
|
+
# - Invite acceptance flow
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# class UserInvite < ApplicationRecord
|
|
16
|
+
# include Plutonium::Resource::Record
|
|
17
|
+
# include Plutonium::Invites::Concerns::InviteToken
|
|
18
|
+
#
|
|
19
|
+
# belongs_to :entity
|
|
20
|
+
# belongs_to :invited_by, polymorphic: true
|
|
21
|
+
# belongs_to :user, optional: true
|
|
22
|
+
# belongs_to :invitable, polymorphic: true, optional: true
|
|
23
|
+
#
|
|
24
|
+
# enum :role, member: 0, admin: 1
|
|
25
|
+
#
|
|
26
|
+
# def invitation_mailer
|
|
27
|
+
# UserInviteMailer
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# def create_membership_for(user)
|
|
31
|
+
# EntityMembership.create!(entity: entity, user: user, role: role)
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
module InviteToken
|
|
36
|
+
extend ActiveSupport::Concern
|
|
37
|
+
|
|
38
|
+
included do
|
|
39
|
+
# State machine for invite lifecycle
|
|
40
|
+
enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
|
|
41
|
+
|
|
42
|
+
# Callbacks
|
|
43
|
+
before_validation :set_token_defaults, on: :create
|
|
44
|
+
after_create :send_invitation_email
|
|
45
|
+
|
|
46
|
+
# Core validations
|
|
47
|
+
validates :email, presence: true
|
|
48
|
+
validates :token, presence: true
|
|
49
|
+
validates :state, presence: true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class_methods do
|
|
53
|
+
# Find a valid invitation for acceptance.
|
|
54
|
+
#
|
|
55
|
+
# Returns nil if the token is invalid, expired, or already accepted.
|
|
56
|
+
# Automatically marks expired invites as expired.
|
|
57
|
+
#
|
|
58
|
+
# @param token [String] the invitation token
|
|
59
|
+
# @return [Object, nil] the invite record or nil
|
|
60
|
+
def find_for_acceptance(token)
|
|
61
|
+
return nil if token.blank?
|
|
62
|
+
|
|
63
|
+
invite = find_by(token: token)
|
|
64
|
+
return nil unless invite
|
|
65
|
+
|
|
66
|
+
# Check if invitation is expired
|
|
67
|
+
if invite.expires_at && invite.expires_at < Time.current
|
|
68
|
+
invite.expired! if invite.pending?
|
|
69
|
+
return nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Only pending invites can be accepted
|
|
73
|
+
return nil unless invite.pending?
|
|
74
|
+
|
|
75
|
+
invite
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Override in subclass to enforce email domain matching.
|
|
80
|
+
#
|
|
81
|
+
# @return [String, nil] the domain to enforce, or nil to skip domain check
|
|
82
|
+
def enforce_domain
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Override in subclass to require exact email match.
|
|
87
|
+
#
|
|
88
|
+
# @return [Boolean] true to require exact email match
|
|
89
|
+
def enforce_email?
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Validate email constraints against the accepting user's email.
|
|
94
|
+
#
|
|
95
|
+
# @param user_email [String] the email of the user accepting the invite
|
|
96
|
+
# @raise [ActiveRecord::RecordInvalid] if constraints are violated
|
|
97
|
+
def validate_email_constraints!(user_email)
|
|
98
|
+
if enforce_email? && user_email.downcase != email.downcase
|
|
99
|
+
errors.add(:base, "This invitation is for #{email}. You must use an account with that email address.")
|
|
100
|
+
raise ActiveRecord::RecordInvalid.new(self)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if (required_domain = enforce_domain)
|
|
104
|
+
user_domain = extract_domain(user_email)
|
|
105
|
+
|
|
106
|
+
if user_domain != required_domain
|
|
107
|
+
errors.add(:base, "This invitation requires an email from the #{required_domain} domain.")
|
|
108
|
+
raise ActiveRecord::RecordInvalid.new(self)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Accept the invitation for a user.
|
|
114
|
+
#
|
|
115
|
+
# This method:
|
|
116
|
+
# 1. Validates email constraints
|
|
117
|
+
# 2. Marks the invite as accepted
|
|
118
|
+
# 3. Creates the entity membership
|
|
119
|
+
# 4. Notifies the invitable (if present)
|
|
120
|
+
#
|
|
121
|
+
# @param user [Object] the user accepting the invitation
|
|
122
|
+
# @raise [ActiveRecord::RecordInvalid] if acceptance fails
|
|
123
|
+
def accept_for_user!(user)
|
|
124
|
+
validate_email_constraints!(user.email)
|
|
125
|
+
|
|
126
|
+
transaction do
|
|
127
|
+
update!(
|
|
128
|
+
state: :accepted,
|
|
129
|
+
accepted_at: Time.current,
|
|
130
|
+
user: user
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
create_membership_for(user)
|
|
134
|
+
notify_invitable(user)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Override this method to specify the mailer class.
|
|
139
|
+
#
|
|
140
|
+
# @return [Class] the mailer class for sending invitation emails
|
|
141
|
+
# @raise [NotImplementedError] if not overridden
|
|
142
|
+
def invitation_mailer
|
|
143
|
+
raise NotImplementedError, "#{self.class}#invitation_mailer must be implemented to return the mailer class"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Override this method to create the entity membership.
|
|
147
|
+
#
|
|
148
|
+
# @param user [Object] the user who accepted the invitation
|
|
149
|
+
# @raise [NotImplementedError] if not overridden
|
|
150
|
+
def create_membership_for(user)
|
|
151
|
+
raise NotImplementedError, "#{self.class}#create_membership_for must be implemented to create the membership record"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Alias method for the entity association.
|
|
155
|
+
# Override if your entity association has a different name.
|
|
156
|
+
#
|
|
157
|
+
# @return [Object] the entity record
|
|
158
|
+
def entity
|
|
159
|
+
raise NotImplementedError, "#{self.class}#entity must be implemented or an entity association must exist"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def extract_domain(email)
|
|
165
|
+
return nil unless email&.include?("@")
|
|
166
|
+
email.split("@").last&.downcase
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def set_token_defaults
|
|
170
|
+
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
171
|
+
self.expires_at ||= 1.week.from_now
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def send_invitation_email
|
|
175
|
+
invitation_mailer.invitation(self).deliver_later
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def notify_invitable(user)
|
|
179
|
+
return unless invitable_id.present?
|
|
180
|
+
|
|
181
|
+
invitable.on_invite_accepted(user)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
module Concerns
|
|
6
|
+
# InviteUser provides the core logic for inviting users to an entity.
|
|
7
|
+
#
|
|
8
|
+
# Include this in your InviteUserInteraction and implement the required methods.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage with polymorphic entity
|
|
11
|
+
# class Organization::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
12
|
+
# include Plutonium::Invites::Concerns::InviteUser
|
|
13
|
+
#
|
|
14
|
+
# def invite_class
|
|
15
|
+
# Invites::UserInvite
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def membership_class
|
|
19
|
+
# OrganizationMembership
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
module InviteUser
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
|
|
26
|
+
included do
|
|
27
|
+
presents label: "Invite User", icon: Phlex::TablerIcons::Mail
|
|
28
|
+
|
|
29
|
+
attribute :resource
|
|
30
|
+
attribute :email
|
|
31
|
+
|
|
32
|
+
validates :email, presence: true
|
|
33
|
+
validate :role_is_present
|
|
34
|
+
validate :user_not_already_member
|
|
35
|
+
validate :no_pending_invitation
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def execute
|
|
39
|
+
attrs = {
|
|
40
|
+
entity: entity,
|
|
41
|
+
email: email,
|
|
42
|
+
role: role,
|
|
43
|
+
invited_by: current_user,
|
|
44
|
+
**additional_invite_attributes
|
|
45
|
+
}
|
|
46
|
+
attrs[:invitable] = invitable if invitable.present?
|
|
47
|
+
|
|
48
|
+
invite_class.create!(attrs)
|
|
49
|
+
|
|
50
|
+
succeed(resource).with_message(success_message)
|
|
51
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
52
|
+
failed(e.record.errors)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Override to specify the invite model class
|
|
58
|
+
# @return [Class]
|
|
59
|
+
def invite_class
|
|
60
|
+
Invites::UserInvite
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Override to specify the membership model class
|
|
64
|
+
# @return [Class]
|
|
65
|
+
def membership_class
|
|
66
|
+
EntityMembership
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Override to specify the user model class
|
|
70
|
+
# @return [Class]
|
|
71
|
+
def user_class
|
|
72
|
+
User
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Override to specify how to get the entity from the resource.
|
|
76
|
+
# By default assumes resource IS the entity.
|
|
77
|
+
# @return [Object]
|
|
78
|
+
def entity
|
|
79
|
+
resource
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Override to specify the invitable (model that triggered the invite).
|
|
83
|
+
# By default returns nil (no invitable).
|
|
84
|
+
# @return [Object, nil]
|
|
85
|
+
def invitable
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Override to specify the role to assign
|
|
90
|
+
# @return [Symbol, String]
|
|
91
|
+
def role
|
|
92
|
+
raise NotImplementedError, "#{self.class}#role must return the role to assign"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Override to add additional attributes when creating the invite
|
|
96
|
+
# @return [Hash]
|
|
97
|
+
def additional_invite_attributes
|
|
98
|
+
{}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Override to customize success message
|
|
102
|
+
def success_message
|
|
103
|
+
"Invitation sent to #{email}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Override to specify the entity association name on membership
|
|
107
|
+
# @return [Symbol]
|
|
108
|
+
def membership_entity_attribute
|
|
109
|
+
entity.class.name.underscore.to_sym
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def role_is_present
|
|
113
|
+
errors.add(:role, :blank) if role.blank?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def user_not_already_member
|
|
117
|
+
return unless email.present? && entity.present?
|
|
118
|
+
|
|
119
|
+
existing_user = user_class.find_by(email: email)
|
|
120
|
+
return unless existing_user
|
|
121
|
+
|
|
122
|
+
membership = membership_class.find_by(
|
|
123
|
+
membership_entity_attribute => entity,
|
|
124
|
+
user_association => existing_user
|
|
125
|
+
)
|
|
126
|
+
errors.add(:email, "is already a member") if membership
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def no_pending_invitation
|
|
130
|
+
return unless email.present? && entity.present?
|
|
131
|
+
|
|
132
|
+
pending = invite_class.find_by(
|
|
133
|
+
entity: entity,
|
|
134
|
+
email: email,
|
|
135
|
+
state: :pending
|
|
136
|
+
)
|
|
137
|
+
errors.add(:email, "already has a pending invitation") if pending
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Override if user association has a different name
|
|
141
|
+
def user_association
|
|
142
|
+
:user
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
module Concerns
|
|
6
|
+
# ResendInvite provides the core logic for resending invitations.
|
|
7
|
+
#
|
|
8
|
+
# Include this in your ResendInviteInteraction to get the default behavior,
|
|
9
|
+
# then override methods as needed.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# class ResendInviteInteraction < Plutonium::Resource::Interaction
|
|
13
|
+
# include Plutonium::Invites::Concerns::ResendInvite
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example With custom expiry
|
|
17
|
+
# class ResendInviteInteraction < Plutonium::Resource::Interaction
|
|
18
|
+
# include Plutonium::Invites::Concerns::ResendInvite
|
|
19
|
+
#
|
|
20
|
+
# def new_expiry
|
|
21
|
+
# 2.weeks.from_now
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
module ResendInvite
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
presents label: "Resend Invitation", icon: Phlex::TablerIcons::MailForward
|
|
30
|
+
|
|
31
|
+
attribute :resource
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def execute
|
|
35
|
+
unless resource.pending?
|
|
36
|
+
return failed("Can only resend pending invitations")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
resource.update!(expires_at: new_expiry)
|
|
40
|
+
send_invitation_email
|
|
41
|
+
|
|
42
|
+
succeed(resource).with_message(success_message)
|
|
43
|
+
rescue => error
|
|
44
|
+
failed("Failed to resend: #{error.message}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Override to customize expiry duration
|
|
50
|
+
def new_expiry
|
|
51
|
+
1.week.from_now
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Override to customize email sending
|
|
55
|
+
def send_invitation_email
|
|
56
|
+
resource.invitation_mailer.invitation(resource).deliver_later
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Override to customize success message
|
|
60
|
+
def success_message
|
|
61
|
+
"Invitation resent to #{resource.email}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|