diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb
new file mode 100644
index 0000000000..d2026c64e2
--- /dev/null
+++ b/app/controllers/users/invitations_controller.rb
@@ -0,0 +1,28 @@
+# 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]
+
+ # Removed logging of invitation tokens for security reasons
+
+
+ render :edit
+ end
+
+ # PUT /users/invitation
+ def update
+ # Removed logging of invitation tokens for security reasons
+ 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)
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