From 1975a04f14b4660b11c206d80211905d59d8d54d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 8 Apr 2026 09:09:36 -0400 Subject: [PATCH] Add JSON API support for email address changes (#2807) --- .../confirmations_controller.rb | 10 ++- .../users/email_addresses_controller.rb | 30 ++++++-- docs/api/sections/users.md | 43 +++++++++++ .../confirmations_controller_test.rb | 38 ++++++++++ .../users/email_addresses_controller_test.rb | 73 +++++++++++++++++++ 5 files changed, 186 insertions(+), 8 deletions(-) diff --git a/app/controllers/users/email_addresses/confirmations_controller.rb b/app/controllers/users/email_addresses/confirmations_controller.rb index b5fd334ea..f844285da 100644 --- a/app/controllers/users/email_addresses/confirmations_controller.rb +++ b/app/controllers/users/email_addresses/confirmations_controller.rb @@ -12,9 +12,15 @@ class Users::EmailAddresses::ConfirmationsController < ApplicationController terminate_session if Current.session start_new_session_for @user.identity - redirect_to edit_user_url(script_name: @user.account.slug, id: @user) + respond_to do |format| + format.html { redirect_to edit_user_url(script_name: @user.account.slug, id: @user) } + format.json { head :no_content } + end else - render :invalid_token, status: :unprocessable_entity + respond_to do |format| + format.html { render :invalid_token, status: :unprocessable_entity } + format.json { render json: { error: "Token is invalid or has expired" }, status: :unprocessable_entity } + end end end diff --git a/app/controllers/users/email_addresses_controller.rb b/app/controllers/users/email_addresses_controller.rb index 69957b1f6..58d8a2723 100644 --- a/app/controllers/users/email_addresses_controller.rb +++ b/app/controllers/users/email_addresses_controller.rb @@ -1,18 +1,17 @@ class Users::EmailAddressesController < ApplicationController before_action :set_user + before_action :ensure_valid_email_address, only: :create rate_limit to: 5, within: 1.hour, only: :create def new end def create - identity = Identity.find_by_email_address(new_email_address) + @user.send_email_address_change_confirmation(new_email_address) - if identity&.users&.exists?(account: @user.account) - flash[:alert] = "You already have a user in this account with that email address" - redirect_to new_user_email_address_path(@user) - else - @user.send_email_address_change_confirmation(new_email_address) + respond_to do |format| + format.html + format.json { head :created } end end @@ -21,6 +20,25 @@ class Users::EmailAddressesController < ApplicationController @user = Current.identity.users.find(params[:user_id]) end + def ensure_valid_email_address + if !new_email_address.match?(URI::MailTo::EMAIL_REGEXP) + error = "Please enter a valid email address" + elsif (identity = Identity.find_by_email_address(new_email_address)) + if identity == @user.identity + error = "That is already your email address" + elsif identity.users.exists?(account: @user.account) + error = "You already have a user in this account with that email address" + end + end + + if error + respond_to do |format| + format.html { redirect_to new_user_email_address_path(@user), alert: error } + format.json { render json: { error: error }, status: :unprocessable_entity } + end + end + end + def new_email_address params.expect :email_address end diff --git a/docs/api/sections/users.md b/docs/api/sections/users.md index cbbed314c..b30986c10 100644 --- a/docs/api/sections/users.md +++ b/docs/api/sections/users.md @@ -98,6 +98,49 @@ __Response:__ Returns `204 No Content` on success. +## `POST /:account_slug/users/:user_id/email_addresses` + +Initiates an email address change for the user. A confirmation email is sent to the new address with a token link. You can only change your own email address. + +This is a two-step process, similar to magic link authentication: + +1. Request the change (this endpoint) — a confirmation email is sent to the new address +2. Confirm the change (`POST .../confirmation`) — the token from the email is submitted to complete the change + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `email_address` | string | Yes | The new email address | + +__Response:__ + +Returns `201 Created` on success. The user must check the new email address for a confirmation link. + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `400 Bad Request` | Missing `email_address` parameter | +| `404 Not Found` | User is not your own | +| `422 Unprocessable Entity` | Invalid email format, same as current email, or already belongs to a user in the same account | +| `429 Too Many Requests` | Rate limit exceeded (5 per hour) | + +## `POST /:account_slug/users/:user_id/email_addresses/:token/confirmation` + +Confirms an email address change using the token from the confirmation email. This endpoint does not require authentication — the token itself serves as proof of access to the new email. + +The token is the full value from the confirmation URL sent in the email. It expires after 30 minutes. + +__Response:__ + +Returns `204 No Content` on success. The user's email address has been changed. + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `422 Unprocessable Entity` | Token is invalid or has expired | +| `429 Too Many Requests` | Rate limit exceeded (5 per hour) | + ## `DELETE /:account_slug/users/:user_id` Deactivates a user. You can only deactivate users you have permission to change. diff --git a/test/controllers/users/email_addresses/confirmations_controller_test.rb b/test/controllers/users/email_addresses/confirmations_controller_test.rb index 5cd2b4a7e..9d1764a37 100644 --- a/test/controllers/users/email_addresses/confirmations_controller_test.rb +++ b/test/controllers/users/email_addresses/confirmations_controller_test.rb @@ -27,4 +27,42 @@ class Users::EmailAddresses::ConfirmationsControllerTest < ActionDispatch::Integ assert_response :unprocessable_entity assert_match /Link expired/, response.body end + + test "create as JSON" do + post user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug), as: :json + + assert_response :no_content + assert_equal @new_email, @user.reload.identity.email_address + end + + test "create as JSON with invalid token" do + post user_email_address_confirmation_path(user_id: @user.id, email_address_token: "invalid", script_name: @user.account.slug), as: :json + + assert_response :unprocessable_entity + assert_equal "Token is invalid or has expired", @response.parsed_body["error"] + assert_equal @old_email, @user.reload.identity.email_address + end + + test "create as JSON with expired token" do + expired_token = @user.send(:generate_email_address_change_token, to: @new_email, expires_in: 0.seconds) + + travel_to 1.minute.from_now do + post user_email_address_confirmation_path(user_id: @user.id, email_address_token: expired_token, script_name: @user.account.slug), as: :json + + assert_response :unprocessable_entity + assert_equal "Token is invalid or has expired", @response.parsed_body["error"] + assert_equal @old_email, @user.reload.identity.email_address + end + end + + test "create as JSON changes identity" do + assert_equal @old_email, @user.identity.email_address + + post user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug), as: :json + + assert_response :no_content + @user.reload + assert_equal @new_email, @user.identity.email_address + assert_not_equal @old_email, @user.identity.email_address + end end diff --git a/test/controllers/users/email_addresses_controller_test.rb b/test/controllers/users/email_addresses_controller_test.rb index d9b973d88..064345c20 100644 --- a/test/controllers/users/email_addresses_controller_test.rb +++ b/test/controllers/users/email_addresses_controller_test.rb @@ -20,6 +20,22 @@ class Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "create with invalid email format" do + assert_no_emails do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: "notanemail" } + end + assert_redirected_to new_user_email_address_path(@user) + assert_equal "Please enter a valid email address", flash[:alert] + end + + test "create with same email as current" do + assert_no_emails do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: @user.identity.email_address } + end + assert_redirected_to new_user_email_address_path(@user) + assert_equal "That is already your email address", flash[:alert] + end + test "create with existing email in same account" do existing_user = users(:kevin) existing_email = existing_user.identity.email_address @@ -37,4 +53,61 @@ class Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest end assert_response :not_found end + + test "create as JSON" do + assert_emails 1 do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: "newemail@example.com" }, as: :json + end + assert_response :created + end + + test "create as JSON with existing email in same account" do + existing_email = users(:kevin).identity.email_address + + assert_no_emails do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: existing_email }, as: :json + end + assert_response :unprocessable_entity + assert_equal "You already have a user in this account with that email address", @response.parsed_body["error"] + end + + test "create as JSON with blank email" do + assert_no_emails do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: "" }, as: :json + end + assert_response :bad_request + end + + test "create as JSON with same email as current" do + assert_no_emails do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: @user.identity.email_address }, as: :json + end + assert_response :unprocessable_entity + assert_equal "That is already your email address", @response.parsed_body["error"] + end + + test "create as JSON with same email different case" do + assert_no_emails do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: @user.identity.email_address.upcase }, as: :json + end + assert_response :unprocessable_entity + assert_equal "That is already your email address", @response.parsed_body["error"] + end + + test "create as JSON with invalid email format" do + assert_no_emails do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: "notanemail" }, as: :json + end + assert_response :unprocessable_entity + assert_equal "Please enter a valid email address", @response.parsed_body["error"] + end + + test "create as JSON for other user" do + other_user = users(:kevin) + + assert_no_emails do + post user_email_addresses_path(other_user, script_name: @user.account.slug), params: { email_address: "newemail@example.com" }, as: :json + end + assert_response :not_found + end end