plutonium 0.39.2 → 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 +43 -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/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,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
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
# Controller provides the invitation acceptance flow for controllers.
|
|
6
|
+
#
|
|
7
|
+
# This concern handles:
|
|
8
|
+
# - Showing the invitation landing page
|
|
9
|
+
# - Accepting invitations for logged-in users
|
|
10
|
+
# - Signup flow for new users
|
|
11
|
+
# - Cookie management for pending invitations
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# class UserInvitationsController < ApplicationController
|
|
15
|
+
# include Plutonium::Invites::Controller
|
|
16
|
+
#
|
|
17
|
+
# layout "invitation"
|
|
18
|
+
#
|
|
19
|
+
# private
|
|
20
|
+
#
|
|
21
|
+
# def invite_class
|
|
22
|
+
# UserInvite
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# def after_accept_path
|
|
26
|
+
# root_path
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def login_path
|
|
30
|
+
# rodauth.login_path
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
module Controller
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
included do
|
|
38
|
+
helper_method :current_user if respond_to?(:helper_method)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# GET /invitations/:token
|
|
42
|
+
#
|
|
43
|
+
# Shows the invitation landing page. If the user is logged in,
|
|
44
|
+
# shows the acceptance form. If not, shows signup/login options.
|
|
45
|
+
def show
|
|
46
|
+
return unless (@invite = load_and_validate_invite(params[:token]))
|
|
47
|
+
|
|
48
|
+
# Store invitation token in cookie for later use
|
|
49
|
+
cookies.encrypted[:pending_invitation] = {
|
|
50
|
+
value: params[:token],
|
|
51
|
+
expires: 1.hour.from_now
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if current_user
|
|
55
|
+
begin
|
|
56
|
+
@invite.validate_email_constraints!(current_user.email)
|
|
57
|
+
render :show
|
|
58
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
59
|
+
@error_title = "Email Validation Error"
|
|
60
|
+
@error_message = e.record.errors.full_messages.join(", ")
|
|
61
|
+
render :error, status: :forbidden
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
render :landing
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# POST /invitations/:token/accept
|
|
69
|
+
#
|
|
70
|
+
# Accepts the invitation for the currently logged-in user.
|
|
71
|
+
def accept
|
|
72
|
+
return unless (@invite = load_and_validate_invite(params[:token]))
|
|
73
|
+
|
|
74
|
+
unless current_user
|
|
75
|
+
redirect_to invitation_path(token: params[:token]),
|
|
76
|
+
alert: "Please sign in to accept this invitation"
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@invite.accept_for_user!(current_user)
|
|
81
|
+
cookies.delete(:pending_invitation)
|
|
82
|
+
|
|
83
|
+
redirect_to after_accept_path,
|
|
84
|
+
notice: "Invitation accepted! Welcome to #{@invite.entity.to_label}!"
|
|
85
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
86
|
+
@error_title = "Acceptance Error"
|
|
87
|
+
@error_message = e.record.errors.full_messages.join(", ")
|
|
88
|
+
render :error, status: :forbidden
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# GET/POST /invitations/:token/signup
|
|
92
|
+
#
|
|
93
|
+
# Handles new user signup directly from the invitation.
|
|
94
|
+
def signup
|
|
95
|
+
return unless (@invite = load_and_validate_invite(params[:token]))
|
|
96
|
+
|
|
97
|
+
if request.post?
|
|
98
|
+
handle_signup_submission
|
|
99
|
+
else
|
|
100
|
+
render :signup
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Load and validate an invite by token.
|
|
107
|
+
#
|
|
108
|
+
# @param token [String] the invitation token
|
|
109
|
+
# @return [Object, nil] the invite or nil (renders error)
|
|
110
|
+
def load_and_validate_invite(token)
|
|
111
|
+
invite = invite_class.find_for_acceptance(token)
|
|
112
|
+
|
|
113
|
+
unless invite
|
|
114
|
+
@error_title = "Invalid or Expired Invitation"
|
|
115
|
+
@error_message = "This invitation link is no longer valid. It may have expired or already been used."
|
|
116
|
+
render :error, status: :not_found
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
invite
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Handle the signup form submission.
|
|
124
|
+
def handle_signup_submission
|
|
125
|
+
email = @invite.enforce_email? ? @invite.email : params[:email]
|
|
126
|
+
password = params[:password]
|
|
127
|
+
password_confirmation = params[:password_confirmation]
|
|
128
|
+
|
|
129
|
+
if password != password_confirmation
|
|
130
|
+
flash.now[:alert] = "Passwords don't match"
|
|
131
|
+
render :signup
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
ActiveRecord::Base.transaction do
|
|
137
|
+
existing_user = user_class.find_by(email: email)
|
|
138
|
+
if existing_user
|
|
139
|
+
flash.now[:alert] = "An account with this email already exists. Please sign in instead."
|
|
140
|
+
render :signup
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
user = create_user_for_signup(email, password)
|
|
145
|
+
|
|
146
|
+
if user&.persisted?
|
|
147
|
+
@invite.accept_for_user!(user)
|
|
148
|
+
cookies.delete(:pending_invitation)
|
|
149
|
+
sign_in_user(user)
|
|
150
|
+
redirect_to after_accept_path
|
|
151
|
+
else
|
|
152
|
+
flash.now[:alert] = "Failed to create account"
|
|
153
|
+
render :signup
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
157
|
+
if e.record.is_a?(invite_class)
|
|
158
|
+
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
|
159
|
+
else
|
|
160
|
+
flash.now[:alert] = "Failed to create account: #{e.record.errors.full_messages.join(", ")}"
|
|
161
|
+
end
|
|
162
|
+
render :signup
|
|
163
|
+
rescue => e
|
|
164
|
+
flash.now[:alert] = "Failed to create account: #{e.message}"
|
|
165
|
+
render :signup
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Override to specify the invite model class.
|
|
170
|
+
#
|
|
171
|
+
# @return [Class] the invite model class
|
|
172
|
+
# @raise [NotImplementedError] if not overridden
|
|
173
|
+
def invite_class
|
|
174
|
+
raise NotImplementedError, "#{self.class}#invite_class must return the invite model class"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Override to specify the user model class.
|
|
178
|
+
#
|
|
179
|
+
# @return [Class] the user model class
|
|
180
|
+
def user_class
|
|
181
|
+
User
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Override to customize redirect after acceptance.
|
|
185
|
+
#
|
|
186
|
+
# @return [String] the path to redirect to
|
|
187
|
+
def after_accept_path
|
|
188
|
+
"/"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Override to customize the login path.
|
|
192
|
+
#
|
|
193
|
+
# @return [String] the login path
|
|
194
|
+
def login_path
|
|
195
|
+
"/login"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Override to create a user during signup.
|
|
199
|
+
#
|
|
200
|
+
# This method should be overridden to integrate with your
|
|
201
|
+
# authentication system (e.g., Rodauth).
|
|
202
|
+
#
|
|
203
|
+
# @param email [String] the user's email
|
|
204
|
+
# @param password [String] the user's password
|
|
205
|
+
# @return [Object] the created user
|
|
206
|
+
def create_user_for_signup(email, password)
|
|
207
|
+
raise NotImplementedError, "#{self.class}#create_user_for_signup must be implemented for signup flow"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Override to sign in the user after signup.
|
|
211
|
+
#
|
|
212
|
+
# @param user [Object] the user to sign in
|
|
213
|
+
def sign_in_user(user)
|
|
214
|
+
# Override in controller to sign in the user
|
|
215
|
+
# e.g., for Rodauth: rodauth.account_from_login(user.email); rodauth.login("signup")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Override to return the current logged-in user.
|
|
219
|
+
#
|
|
220
|
+
# @return [Object, nil] the current user or nil
|
|
221
|
+
def current_user
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
# PendingInviteCheck provides post-login invitation handling.
|
|
6
|
+
#
|
|
7
|
+
# Include this in a controller that users land on after login
|
|
8
|
+
# (e.g., WelcomeController, DashboardController) to check for
|
|
9
|
+
# pending invitations stored in cookies.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# class WelcomeController < ApplicationController
|
|
13
|
+
# include Plutonium::Invites::PendingInviteCheck
|
|
14
|
+
#
|
|
15
|
+
# def index
|
|
16
|
+
# return if redirect_to_pending_invite!
|
|
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
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
module PendingInviteCheck
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Check for a pending invitation and redirect if found.
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] true if redirected, false otherwise
|
|
37
|
+
def redirect_to_pending_invite!
|
|
38
|
+
token = cookies.encrypted[:pending_invitation]
|
|
39
|
+
return false unless token
|
|
40
|
+
|
|
41
|
+
invite = invite_class.find_for_acceptance(token)
|
|
42
|
+
|
|
43
|
+
if invite
|
|
44
|
+
redirect_to invitation_path(token: token)
|
|
45
|
+
true
|
|
46
|
+
else
|
|
47
|
+
cookies.delete(:pending_invitation)
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns the pending invite if one exists.
|
|
53
|
+
#
|
|
54
|
+
# @return [Object, nil] the pending invite or nil
|
|
55
|
+
def pending_invite
|
|
56
|
+
token = cookies.encrypted[:pending_invitation]
|
|
57
|
+
return nil unless token
|
|
58
|
+
|
|
59
|
+
invite = invite_class.find_for_acceptance(token)
|
|
60
|
+
unless invite
|
|
61
|
+
cookies.delete(:pending_invitation)
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
invite
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Override to specify the invite model class.
|
|
69
|
+
#
|
|
70
|
+
# @return [Class] the invite model class
|
|
71
|
+
def invite_class
|
|
72
|
+
raise NotImplementedError, "#{self.class}#invite_class must return the invite model class"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|