From 573fe0bf8c96bf45fcd13812e350f954b86ecbbe Mon Sep 17 00:00:00 2001 From: Linda Goldstein Date: Thu, 11 Dec 2025 15:49:42 -0800 Subject: [PATCH 1/3] Fix: Add local: true and custom controller for invitation acceptance Fixes the "Invitation token can't be blank" error that occurs when new users accept email invitations and try to set their password. Root cause: PR #6528 refactored form_for to form_with, but form_with defaults to remote: true (AJAX submission) which can cause issues with hidden field submission, particularly the invitation_token field. Changes made: 1. Added local: true to form_with to use standard form submission 2. Removed readonly: true from invitation_token hidden field (unnecessary and potentially problematic with form_with) 3. Created custom Users::InvitationsController to: - Explicitly ensure invitation_token is set on resource in edit action - Explicitly permit invitation_token in strong parameters - Add logging to help debug token issues 4. Updated routes to use custom invitations controller The custom controller provides better control over parameter handling and includes debugging logs to identify any future token issues. Related to PR #6528 (form helper refactor) --- .../users/invitations_controller.rb | 30 +++++++++++++++++++ app/views/devise/invitations/edit.html.erb | 4 +-- config/routes.rb | 2 +- 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 app/controllers/users/invitations_controller.rb diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb new file mode 100644 index 0000000000..2c40704c47 --- /dev/null +++ b/app/controllers/users/invitations_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Users::InvitationsController < Devise::InvitationsController + # GET /users/invitation/accept?invitation_token=abcdef123456 + def edit + set_minimum_password_length + # Ensure the invitation_token is set on the resource from the URL parameter + resource.invitation_token = params[:invitation_token] + + Rails.logger.info "Invitation Edit: Token from params: #{params[:invitation_token]}" + Rails.logger.info "Invitation Edit: Token set on resource: #{resource.invitation_token}" + + render :edit + end + + # PUT /users/invitation + def update + Rails.logger.info "Invitation Update: Params received: #{update_resource_params.inspect}" + Rails.logger.info "Invitation Update: invitation_token in params: #{update_resource_params[:invitation_token]}" + + super + end + + protected + + # Permit the invitation_token parameter + def update_resource_params + params.require(resource_name).permit(:invitation_token, :password, :password_confirmation) + end +end diff --git a/app/views/devise/invitations/edit.html.erb b/app/views/devise/invitations/edit.html.erb index 637cbed940..24d5348055 100644 --- a/app/views/devise/invitations/edit.html.erb +++ b/app/views/devise/invitations/edit.html.erb @@ -1,9 +1,9 @@

Send invitation

- <%= form_with(model: resource, as: resource_name, url: invitation_path(resource_name), html: {method: :put}) do |f| %> + <%= form_with(model: resource, as: resource_name, url: invitation_path(resource_name), local: true, html: {method: :put}) do |f| %> <%= render "/shared/error_messages", resource: resource %> - <%= f.hidden_field :invitation_token, readonly: true %> + <%= f.hidden_field :invitation_token %> <% if f.object.class.require_password_on_accepting %>
diff --git a/config/routes.rb b/config/routes.rb index 6f849a7f53..e486ce17cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,7 +5,7 @@ mount Rswag::Api::Engine => "/api-docs" devise_for :all_casa_admins, path: "all_casa_admins", controllers: {sessions: "all_casa_admins/sessions"} - devise_for :users, controllers: {sessions: "users/sessions", passwords: "users/passwords"} + devise_for :users, controllers: {sessions: "users/sessions", passwords: "users/passwords", invitations: "users/invitations"} authenticate :all_casa_admins do mount PgHero::Engine, at: "pg_dashboard", constraints: lambda { |request| admin = request.env["warden"].user(:all_casa_admin) From 2b1aac38aed5df160dfd936ed3dcfbbfeea2e93a Mon Sep 17 00:00:00 2001 From: Linda Goldstein Date: Thu, 11 Dec 2025 15:52:51 -0800 Subject: [PATCH 2/3] Add comprehensive tests for invitation acceptance fix Tests cover: - Invitation edit action with valid/invalid tokens - Invitation update action with various scenarios (valid, invalid token, expired invitation, password validation errors) - Parameter sanitization to ensure invitation_token is permitted - View rendering with local: true form and no readonly on hidden field All 23 examples passing. --- .../users/invitations_controller_spec.rb | 236 ++++++++++++++++++ .../devise/invitations/edit.html.erb_spec.rb | 55 ++++ 2 files changed, 291 insertions(+) create mode 100644 spec/requests/users/invitations_controller_spec.rb create mode 100644 spec/views/devise/invitations/edit.html.erb_spec.rb diff --git a/spec/requests/users/invitations_controller_spec.rb b/spec/requests/users/invitations_controller_spec.rb new file mode 100644 index 0000000000..eb403449e7 --- /dev/null +++ b/spec/requests/users/invitations_controller_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Users::InvitationsController", type: :request do + let(:casa_org) { create(:casa_org) } + let(:volunteer) { create(:volunteer, casa_org: casa_org) } + + describe "GET /users/invitation/accept" do + context "with valid invitation_token" do + before do + volunteer.invite! + end + + it "sets invitation_token on the resource" do + get accept_user_invitation_path(invitation_token: volunteer.raw_invitation_token) + + expect(response).to have_http_status(:success) + expect(response.body).to include("Set my password") + expect(response.body).to include(volunteer.raw_invitation_token) + end + + it "renders the edit template" do + get accept_user_invitation_path(invitation_token: volunteer.raw_invitation_token) + + expect(response).to render_template(:edit) + end + end + + context "without invitation_token" do + it "redirects away" do + get accept_user_invitation_path + + # Devise may redirect to root or sign_in depending on configuration + expect(response).to have_http_status(:redirect) + end + end + end + + describe "PUT /users/invitation" do + let(:valid_password) { "Password123!" } + + context "with valid invitation_token and password" do + before do + volunteer.invite! + end + + let(:params) do + { + user: { + invitation_token: volunteer.raw_invitation_token, + password: valid_password, + password_confirmation: valid_password + } + } + end + + it "accepts the invitation" do + expect { + put user_invitation_path, params: params + }.to change { volunteer.reload.invitation_accepted_at }.from(nil) + end + + it "sets the password" do + put user_invitation_path, params: params + + volunteer.reload + expect(volunteer.valid_password?(valid_password)).to be true + end + + it "signs in the user" do + put user_invitation_path, params: params + + expect(controller.current_user).to eq(volunteer) + end + + it "redirects after acceptance" do + put user_invitation_path, params: params + + expect(response).to redirect_to(authenticated_user_root_path) + end + end + + context "with invalid invitation_token" do + let(:params) do + { + user: { + invitation_token: "invalid_token", + password: valid_password, + password_confirmation: valid_password + } + } + end + + it "does not accept the invitation" do + put user_invitation_path, params: params + + expect(response).to have_http_status(:success) + expect(response.body).to include("Invitation token is invalid") + end + end + + context "with blank invitation_token" do + let(:params) do + { + user: { + invitation_token: "", + password: valid_password, + password_confirmation: valid_password + } + } + end + + it "shows validation error" do + put user_invitation_path, params: params + + expect(response).to have_http_status(:success) + expect(response.body).to include("Invitation token") + end + end + + context "with mismatched passwords" do + before do + volunteer.invite! + end + + let(:params) do + { + user: { + invitation_token: volunteer.raw_invitation_token, + password: valid_password, + password_confirmation: "DifferentPassword123!" + } + } + end + + it "does not accept the invitation" do + expect { + put user_invitation_path, params: params + }.not_to change { volunteer.reload.invitation_accepted_at } + end + + it "shows validation error" do + put user_invitation_path, params: params + + expect(response).to have_http_status(:success) + expect(response.body).to include("Password confirmation") + end + end + + context "with password too short" do + before do + volunteer.invite! + end + + let(:params) do + { + user: { + invitation_token: volunteer.raw_invitation_token, + password: "short", + password_confirmation: "short" + } + } + end + + it "does not accept the invitation" do + expect { + put user_invitation_path, params: params + }.not_to change { volunteer.reload.invitation_accepted_at } + end + + it "shows validation error" do + put user_invitation_path, params: params + + expect(response).to have_http_status(:success) + expect(response.body).to include("Password is too short") + end + end + + context "with expired invitation" do + before do + volunteer.invite! + travel_to 2.years.from_now + end + + after do + travel_back + end + + let(:params) do + { + user: { + invitation_token: volunteer.raw_invitation_token, + password: valid_password, + password_confirmation: valid_password + } + } + end + + it "does not accept the invitation" do + expect { + put user_invitation_path, params: params + }.not_to change { volunteer.reload.invitation_accepted_at } + end + + it "shows validation error" do + put user_invitation_path, params: params + + expect(response).to have_http_status(:success) + expect(response.body).to include("Invitation token is invalid") + end + end + end + + describe "parameter sanitization" do + before do + volunteer.invite! + end + + it "permits invitation_token in update" do + params = { + user: { + invitation_token: volunteer.raw_invitation_token, + password: "Password123!", + password_confirmation: "Password123!", + extra_param: "should_not_be_permitted" + } + } + + put user_invitation_path, params: params + + # If the invitation_token was properly permitted, the invitation should be accepted + expect(volunteer.reload.invitation_accepted_at).to be_present + end + end +end diff --git a/spec/views/devise/invitations/edit.html.erb_spec.rb b/spec/views/devise/invitations/edit.html.erb_spec.rb new file mode 100644 index 0000000000..a01a7bb57e --- /dev/null +++ b/spec/views/devise/invitations/edit.html.erb_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "devise/invitations/edit.html.erb", type: :view do + let(:casa_org) { create(:casa_org) } + let(:volunteer) { create(:volunteer, casa_org: casa_org) } + + before do + volunteer.invite! + assign(:resource, volunteer) + assign(:resource_name, :user) + assign(:devise_mapping, Devise.mappings[:user]) + assign(:minimum_password_length, 6) + + # Set the invitation_token on the resource as the controller does + volunteer.invitation_token = volunteer.raw_invitation_token + + # Allow the class to respond to require_password_on_accepting + allow(volunteer.class).to receive(:require_password_on_accepting).and_return(true) + + render + end + + it "uses form_with with local: true" do + # form_with local: true should not have data-remote="true" + expect(rendered).not_to have_selector('form[data-remote="true"]') + end + + it "includes invitation_token field" do + expect(rendered).to match(/invitation_token/) + end + + it "does not have readonly attribute on invitation_token field" do + expect(rendered).not_to match(/invitation_token.*readonly/) + end + + it "includes password fields" do + expect(rendered).to match(/password/) + expect(rendered).to match(/password_confirmation/) + end + + it "includes submit button" do + expect(rendered).to have_button("Set my password") + end + + it "uses PUT method" do + expect(rendered).to have_selector('form[method="post"]') # Rails uses POST with _method=put + expect(rendered).to have_field("_method", type: :hidden, with: "put") + end + + it "posts to invitation_path" do + expect(rendered).to have_selector("form[action='#{user_invitation_path}']") + end +end From 3b781ee24c317660f64bc9706db892459c36be64 Mon Sep 17 00:00:00 2001 From: compwron Date: Fri, 12 Dec 2025 11:07:37 -0800 Subject: [PATCH 3/3] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/controllers/users/invitations_controller.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index 2c40704c47..d2026c64e2 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -7,17 +7,15 @@ def edit # Ensure the invitation_token is set on the resource from the URL parameter resource.invitation_token = params[:invitation_token] - Rails.logger.info "Invitation Edit: Token from params: #{params[:invitation_token]}" - Rails.logger.info "Invitation Edit: Token set on resource: #{resource.invitation_token}" + # Removed logging of invitation tokens for security reasons + render :edit end # PUT /users/invitation def update - Rails.logger.info "Invitation Update: Params received: #{update_resource_params.inspect}" - Rails.logger.info "Invitation Update: invitation_token in params: #{update_resource_params[:invitation_token]}" - + # Removed logging of invitation tokens for security reasons super end