Skip to content

Commit 4078e65

Browse files
committed
Implement invitation token authorization and enhance event invitation handling
- Introduced InvitationTokenAuthorization concern for context-aware authorization. - Updated EventsController to handle invitation tokens and mark notifications as read. - Enhanced RegistrationsController to pre-fill user email from event invitations. - Modified EventInvitationsMailer to read invitations from parameters. - Added event invitation handling in policies for access control. - Created views for invitation review and notifications. - Updated Person model to associate with event invitations.
1 parent 6eba191 commit 4078e65

File tree

16 files changed

+377
-54
lines changed

16 files changed

+377
-54
lines changed

app/controllers/better_together/events/invitations_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ def notify_invitee(invitation) # rubocop:todo Metrics/AbcSize, Metrics/MethodLen
117117

118118
if invitation.for_existing_user? && invitation.invitee.present?
119119
# Send notification to existing user through the notification system
120-
BetterTogether::EventInvitationNotifier.with(invitation:).deliver_later(invitation.invitee)
120+
BetterTogether::EventInvitationNotifier.with(record: invitation.invitable,
121+
invitation:).deliver_later(invitation.invitee)
121122
invitation.update_column(:last_sent, Time.zone.now)
122123
elsif invitation.for_email?
123124
# Send email directly to external email address (bypassing notification system)

app/controllers/better_together/events_controller.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
module BetterTogether
44
# CRUD for BetterTogether::Event
55
class EventsController < FriendlyResourceController # rubocop:todo Metrics/ClassLength
6+
include InvitationTokenAuthorization
7+
include NotificationReadable
8+
69
before_action if: -> { Rails.env.development? } do
710
# Make sure that all subclasses are loaded in dev to generate type selector
811
Rails.application.eager_load!
@@ -25,6 +28,11 @@ def show
2528
return
2629
end
2730

31+
# Check for valid invitation if accessing via invitation token
32+
@current_invitation = find_invitation_by_token
33+
34+
mark_match_notifications_read_for(resource_instance)
35+
2836
super
2937
end
3038

@@ -91,6 +99,33 @@ def resource_class
9199
::BetterTogether::Event
92100
end
93101

102+
def resource_collection
103+
# Set invitation token for policy scope
104+
invitation_token = params[:invitation_token] || session[:event_invitation_token]
105+
set_current_invitation_token(invitation_token)
106+
107+
super
108+
end
109+
110+
# Override the parent's authorize_resource method to include invitation token context
111+
def authorize_resource
112+
# Set invitation token for authorization
113+
invitation_token = params[:invitation_token] || session[:event_invitation_token]
114+
set_current_invitation_token(invitation_token)
115+
116+
authorize resource_instance
117+
end
118+
119+
# Helper method to find invitation by token
120+
def find_invitation_by_token
121+
return nil unless current_invitation_token.present?
122+
123+
BetterTogether::EventInvitation.find_by(
124+
token: current_invitation_token,
125+
invitable: @event
126+
)
127+
end
128+
94129
private
95130

96131
# rubocop:todo Metrics/MethodLength

app/controllers/better_together/invitations_controller.rb

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def accept # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
3232
end
3333

3434
def decline # rubocop:todo Metrics/MethodLength
35-
ensure_authenticated!
35+
# ensure_authenticated!
3636
return if performed?
3737

3838
if @invitation.respond_to?(:decline!)
@@ -61,9 +61,30 @@ def find_invitation_by_token
6161
end
6262

6363
def ensure_authenticated!
64-
return if helpers.current_person.present?
64+
return if current_user
65+
66+
# Store invitation token in session for after authentication
67+
if @invitation.is_a?(BetterTogether::EventInvitation)
68+
session[:event_invitation_token] = @invitation.token
69+
session[:event_invitation_expires_at] = 24.hours.from_now
70+
end
71+
72+
if BetterTogether::User.find_by(email: @invitation.invitee_email).present?
73+
redirect_path = new_user_session_path(locale: I18n.locale)
74+
redirect_notice = t('better_together.invitations.login_to_respond',
75+
default: 'Please log in to respond to your invitation.')
76+
else
77+
redirect_path = new_user_registration_path(locale: I18n.locale)
78+
redirect_notice = t('better_together.invitations.register_to_respond',
79+
default: 'Please register to respond to your invitation.')
80+
end
81+
82+
redirect_to redirect_path, notice: redirect_notice
83+
end
6584

66-
redirect_to new_user_session_path(locale: I18n.locale), alert: t('flash.generic.unauthorized')
85+
def set_event_invitation_from_session
86+
# This ensures @event_invitation is available in ApplicationController
87+
@event_invitation = @invitation if @invitation.is_a?(BetterTogether::EventInvitation)
6788
end
6889
end
6990
end

app/controllers/better_together/users/registrations_controller.rb

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class RegistrationsController < ::Devise::RegistrationsController # rubocop:todo
88

99
skip_before_action :check_platform_privacy
1010
before_action :set_required_agreements, only: %i[new create]
11+
before_action :set_event_invitation_from_session, only: %i[new create]
1112
before_action :configure_account_update_params, only: [:update]
1213

1314
# PUT /resource
@@ -62,7 +63,14 @@ def update # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
6263

6364
def new
6465
super do |user|
66+
# Pre-fill email from platform invitation
6567
user.email = @platform_invitation.invitee_email if @platform_invitation && user.email.empty?
68+
69+
if @event_invitation
70+
# Pre-fill email from event invitation
71+
user.email = @event_invitation.invitee_email if @event_invitation && user.email.empty?
72+
user.person = @event_invitation.invitee if @event_invitation.invitee.present?
73+
end
6674
end
6775
end
6876

@@ -78,22 +86,25 @@ def create # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
7886
super do |user|
7987
return unless user.persisted?
8088

81-
user.build_person(person_params)
89+
if @event_invitation && @event_invitation.invitee.present?
90+
user.person = @event_invitation.invitee
91+
user.person.update(person_params)
92+
else
93+
user.build_person(person_params)
94+
end
8295

8396
if user.save!
8497
user.reload
8598

86-
community_role = if @platform_invitation
87-
@platform_invitation.community_role
88-
else
89-
::BetterTogether::Role.find_by(identifier: 'community_member')
90-
end
99+
# Handle community membership based on invitation type
100+
community_role = determine_community_role
91101

92102
helpers.host_community.person_community_memberships.create!(
93103
member: user.person,
94104
role: community_role
95105
)
96106

107+
# Handle platform invitation
97108
if @platform_invitation
98109
if @platform_invitation.platform_role
99110
helpers.host_platform.person_platform_memberships.create!(
@@ -105,6 +116,16 @@ def create # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
105116
@platform_invitation.accept!(invitee: user.person)
106117
end
107118

119+
# Handle event invitation
120+
if @event_invitation
121+
@event_invitation.update!(invitee: user.person)
122+
@event_invitation.accept!(invitee_person: user.person)
123+
124+
# Clear session data
125+
session.delete(:event_invitation_token)
126+
session.delete(:event_invitation_expires_at)
127+
end
128+
108129
create_agreement_participants(user.person)
109130
end
110131
end
@@ -129,13 +150,39 @@ def set_required_agreements
129150
end
130151

131152
def after_sign_up_path_for(resource)
153+
# Redirect to event if signed up via event invitation
154+
return better_together.event_path(@event_invitation.event) if @event_invitation&.event
155+
132156
if is_navigational_format? && helpers.host_platform&.privacy_private?
133157
return better_together.new_user_session_path
134158
end
135159

136160
super
137161
end
138162

163+
def set_event_invitation_from_session
164+
return unless session[:event_invitation_token].present?
165+
166+
# Check if session token is still valid
167+
return if session[:event_invitation_expires_at].present? &&
168+
Time.current > session[:event_invitation_expires_at]
169+
170+
@event_invitation = ::BetterTogether::EventInvitation.pending.not_expired
171+
.find_by(token: session[:event_invitation_token])
172+
173+
nil if @event_invitation
174+
end
175+
176+
def determine_community_role
177+
return @platform_invitation.community_role if @platform_invitation
178+
179+
# For event invitations, use the event creator's community
180+
return @event_invitation.role if @event_invitation && @event_invitation.role.present?
181+
182+
# Default role
183+
::BetterTogether::Role.find_by(identifier: 'community_member')
184+
end
185+
139186
def after_inactive_sign_up_path_for(resource)
140187
if is_navigational_format? && helpers.host_platform&.privacy_private?
141188
return better_together.new_user_session_path
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
# Concern to override Pundit's authorize method to support invitation token authorization
5+
# This allows policies to receive invitation tokens for context-aware authorization
6+
module InvitationTokenAuthorization
7+
extend ActiveSupport::Concern
8+
9+
included do
10+
attr_reader :current_invitation_token
11+
end
12+
13+
private
14+
15+
# Override Pundit's authorize method to pass invitation token to policies
16+
# @param record [Object] The record to authorize
17+
# @param query [Symbol] The policy method to call (defaults to action query) - can be positional or keyword arg
18+
# @param policy_class [Class] Optional policy class override
19+
# @return [Object] The authorized record
20+
def authorize(record, query = nil, policy_class: nil)
21+
# Handle both old syntax: authorize(record, :query?) and new syntax: authorize(record, query: :query?)
22+
query ||= "#{action_name}?"
23+
policy_class ||= policy_class_for(record)
24+
25+
# Create policy instance with invitation token
26+
policy = policy_class.new(current_user, record, invitation_token: current_invitation_token)
27+
28+
# Check authorization
29+
raise Pundit::NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)
30+
31+
# Mark that authorization was performed (required for verify_authorized)
32+
@_pundit_policy_authorized = true
33+
34+
record
35+
end
36+
37+
# Override Pundit's policy_scope method to pass invitation token to policy scopes
38+
# @param scope [Class] The scope class (typically a model class)
39+
# @param policy_scope_class [Class] Optional policy scope class override
40+
# @return [Object] The scoped collection
41+
def policy_scope(scope, policy_scope_class: nil, invitation_token: nil)
42+
policy_scope_class ||= policy_scope_class_for(scope)
43+
44+
# Use provided invitation token or fall back to current
45+
token = invitation_token || current_invitation_token
46+
47+
# Create policy scope instance with invitation token
48+
policy_scope_class.new(current_user, scope, invitation_token: token).resolve
49+
end
50+
51+
# Set the current invitation token for use in authorization
52+
# @param token [String] The invitation token
53+
def set_current_invitation_token(token)
54+
@current_invitation_token = token
55+
end
56+
57+
# Helper method to determine policy class for a record
58+
# @param record [Object] The record to find policy for
59+
# @return [Class] The policy class
60+
def policy_class_for(record)
61+
if record.is_a?(Class)
62+
"#{record.name}Policy".constantize
63+
else
64+
"#{record.class.name}Policy".constantize
65+
end
66+
end
67+
68+
# Helper method to determine policy scope class for a scope
69+
# @param scope [Class] The scope class
70+
# @return [Class] The policy scope class
71+
def policy_scope_class_for(scope)
72+
"#{scope.name}Policy::Scope".constantize
73+
end
74+
end
75+
end

app/mailers/better_together/event_invitations_mailer.rb

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
module BetterTogether
44
class EventInvitationsMailer < ApplicationMailer # rubocop:todo Style/Documentation
5-
def invite(invitation)
5+
# Parameterized mailer: Noticed calls mailer.with(params).invite
6+
# so read the invitation from params rather than using a positional arg.
7+
def invite
8+
invitation = params[:invitation]
69
@invitation = invitation
7-
@event = invitation.invitable
8-
@invitation_url = invitation.url_for_review
10+
@event = invitation&.invitable
11+
@invitation_url = invitation&.url_for_review
912

10-
to_email = invitation.invitee_email.to_s
13+
to_email = invitation&.invitee_email.to_s
1114
return if to_email.blank?
1215

1316
# Use the invitation's locale for proper internationalization
14-
I18n.with_locale(invitation.locale) do
17+
I18n.with_locale(invitation&.locale) do
1518
mail(to: to_email,
1619
subject: I18n.t('better_together.event_invitations_mailer.invite.subject',
1720
event_name: @event&.name,

app/models/better_together/person.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ def self.primary_community_delegation_attrs
4646
has_many :agreements, through: :agreement_participants
4747

4848
has_many :calendars, foreign_key: :creator_id, class_name: 'BetterTogether::Calendar', dependent: :destroy
49+
4950
has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy
51+
has_many :event_invitations, class_name: 'BetterTogether::EventInvitation', as: :invitee, dependent: :destroy
5052

5153
has_one :user_identification,
5254
lambda {

app/notifiers/better_together/event_invitation_notifier.rb

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,38 @@ class EventInvitationNotifier < ApplicationNotifier # rubocop:todo Style/Documen
99

1010
required_param :invitation
1111

12-
def event
13-
params[:invitation].invitable
12+
notification_methods do
13+
delegate :title, :body, :invitation, :invitable, to: :event
1414
end
1515

16+
def invitation = params[:invitation]
17+
def invitable = params[:invitable] || invitation&.invitable
18+
1619
def title
1720
I18n.with_locale(params[:invitation].locale) do
1821
I18n.t('better_together.notifications.event_invitation.title',
19-
event_name: event&.name, default: 'You have been invited to an event')
22+
event_name: invitable&.name, default: 'You have been invited to an event')
2023
end
2124
end
2225

2326
def body
2427
I18n.with_locale(params[:invitation].locale) do
2528
I18n.t('better_together.notifications.event_invitation.body',
26-
event_name: event&.name, default: 'Invitation to %<event_name>s')
29+
event_name: invitable&.name, default: 'Invitation to %<event_name>s')
2730
end
2831
end
2932

3033
def build_message(_notification)
31-
{ title:, body:, url: params[:invitation].url_for_review }
34+
# Pass the invitable (event) as the notification url object so views can
35+
# link to the event record (consistent with other notifiers that pass
36+
# domain objects like agreement/request).
37+
{ title:, body:, url: invitation.url_for_review }
3238
end
3339

3440
def email_params(_notification)
35-
params[:invitation]
41+
# Include the invitation and the invitable (event) so mailers and views
42+
# have the full context without needing to resolve the invitation.
43+
{ invitation: params[:invitation], invitable: }
3644
end
3745
end
3846
end

0 commit comments

Comments
 (0)