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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-controller/SKILL.md +5 -9
  4. data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
  5. data/.claude/skills/plutonium-installation/SKILL.md +9 -7
  6. data/.claude/skills/plutonium-invites/SKILL.md +363 -0
  7. data/.claude/skills/plutonium-package/SKILL.md +2 -1
  8. data/.claude/skills/plutonium-portal/SKILL.md +30 -16
  9. data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
  10. data/CHANGELOG.md +43 -0
  11. data/app/assets/plutonium.css +1 -1
  12. data/config/initializers/sqlite_alias.rb +8 -8
  13. data/docs/.vitepress/config.ts +1 -0
  14. data/docs/getting-started/tutorial/07-author-portal.md +1 -0
  15. data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
  16. data/docs/guides/adding-resources.md +10 -0
  17. data/docs/guides/authentication.md +15 -8
  18. data/docs/guides/creating-packages.md +13 -8
  19. data/docs/guides/index.md +2 -0
  20. data/docs/guides/search-filtering.md +8 -3
  21. data/docs/guides/user-invites.md +497 -0
  22. data/docs/public/templates/base.rb +5 -1
  23. data/docs/public/templates/lite.rb +42 -0
  24. data/docs/public/templates/pluton8.rb +7 -2
  25. data/docs/reference/controller/index.md +12 -7
  26. data/docs/reference/definition/query.md +12 -3
  27. data/docs/reference/generators/index.md +70 -10
  28. data/docs/reference/portal/index.md +22 -11
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
  33. data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
  34. data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
  35. data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
  36. data/lib/generators/pu/invites/USAGE +27 -0
  37. data/lib/generators/pu/invites/install_generator.rb +364 -0
  38. data/lib/generators/pu/invites/invitable/USAGE +31 -0
  39. data/lib/generators/pu/invites/invitable_generator.rb +143 -0
  40. data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
  41. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
  42. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
  43. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
  44. data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
  45. data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
  46. data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
  47. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
  48. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
  49. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
  50. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
  51. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
  52. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
  53. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
  54. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
  55. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
  56. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
  57. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
  58. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
  59. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
  60. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
  61. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
  62. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
  63. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
  64. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
  65. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
  66. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
  67. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
  68. data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
  69. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
  70. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
  71. data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
  72. data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
  73. data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
  74. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
  75. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
  76. data/lib/generators/pu/pkg/portal/USAGE +8 -2
  77. data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
  78. data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
  79. data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
  80. data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
  81. data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
  82. data/lib/generators/pu/res/conn/USAGE +5 -0
  83. data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
  84. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
  85. data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
  86. data/lib/generators/pu/rodauth/account_generator.rb +36 -11
  87. data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
  88. data/lib/generators/pu/rodauth/install_generator.rb +1 -8
  89. data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
  90. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
  91. data/lib/generators/pu/saas/USAGE +22 -0
  92. data/lib/generators/pu/saas/entity/USAGE +19 -0
  93. data/lib/generators/pu/saas/entity_generator.rb +55 -0
  94. data/lib/generators/pu/saas/membership/USAGE +25 -0
  95. data/lib/generators/pu/saas/membership_generator.rb +165 -0
  96. data/lib/generators/pu/saas/setup/USAGE +27 -0
  97. data/lib/generators/pu/saas/setup_generator.rb +98 -0
  98. data/lib/generators/pu/saas/user/USAGE +21 -0
  99. data/lib/generators/pu/saas/user_generator.rb +66 -0
  100. data/lib/plutonium/definition/base.rb +3 -1
  101. data/lib/plutonium/definition/scoping.rb +20 -0
  102. data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
  103. data/lib/plutonium/invites/concerns/invitable.rb +98 -0
  104. data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
  105. data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
  106. data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
  107. data/lib/plutonium/invites/controller.rb +226 -0
  108. data/lib/plutonium/invites/pending_invite_check.rb +76 -0
  109. data/lib/plutonium/invites.rb +6 -0
  110. data/lib/plutonium/resource/controllers/queryable.rb +4 -0
  111. data/lib/plutonium/resource/query_object.rb +3 -5
  112. data/lib/plutonium/version.rb +1 -1
  113. data/package.json +1 -1
  114. metadata +64 -7
  115. data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
  116. data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
  117. data/public/plutonium-assets/plutonium-logo-original.png +0 -0
  118. data/public/plutonium-assets/plutonium-logo-white.png +0 -0
  119. 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Invites
5
+ end
6
+ end