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 terminate_session if Current.session
start_new_session_for @user.identity 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 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
end end
@@ -1,18 +1,17 @@
class Users::EmailAddressesController < ApplicationController class Users::EmailAddressesController < ApplicationController
before_action :set_user before_action :set_user
before_action :ensure_valid_email_address, only: :create
rate_limit to: 5, within: 1.hour, only: :create rate_limit to: 5, within: 1.hour, only: :create
def new def new
end end
def create 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) respond_to do |format|
flash[:alert] = "You already have a user in this account with that email address" format.html
redirect_to new_user_email_address_path(@user) format.json { head :created }
else
@user.send_email_address_change_confirmation(new_email_address)
end end
end end
@@ -21,6 +20,25 @@ class Users::EmailAddressesController < ApplicationController
@user = Current.identity.users.find(params[:user_id]) @user = Current.identity.users.find(params[:user_id])
end 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 def new_email_address
params.expect :email_address params.expect :email_address
end end
+43
View File
@@ -98,6 +98,49 @@ __Response:__
Returns `204 No Content` on success. 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` ## `DELETE /:account_slug/users/:user_id`
Deactivates a user. You can only deactivate users you have permission to change. 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_response :unprocessable_entity
assert_match /Link expired/, response.body assert_match /Link expired/, response.body
end 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 end
@@ -20,6 +20,22 @@ class Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end 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 test "create with existing email in same account" do
existing_user = users(:kevin) existing_user = users(:kevin)
existing_email = existing_user.identity.email_address existing_email = existing_user.identity.email_address
@@ -37,4 +53,61 @@ class Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest
end end
assert_response :not_found assert_response :not_found
end 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 end