Add JSON API support for email address changes (#2807)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user