Add JSON API support for email address changes (#2807)

This commit is contained in:
Rob Zolkos
2026-04-08 09:09:36 -04:00
committed by GitHub
parent d08aa11bfb
commit 1975a04f14
5 changed files with 186 additions and 8 deletions
@@ -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
@@ -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
+43
View File
@@ -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.
@@ -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
@@ -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