Reinstate email changes

This commit is contained in:
Stanko K.R.
2025-11-14 08:33:00 +01:00
committed by Mike Dalessio
parent 4d8b3ed719
commit 56c41d45eb
14 changed files with 230 additions and 14 deletions
@@ -12,7 +12,7 @@ class Users::EmailAddresses::ConfirmationsController < ApplicationController
user = User.change_email_address_using_token(token) user = User.change_email_address_using_token(token)
terminate_session if Current.session terminate_session if Current.session
start_new_session_for user.reload.identity start_new_session_for user.identity
redirect_to edit_user_url(script_name: user.account.slug, id: user) redirect_to edit_user_url(script_name: user.account.slug, id: user)
end end
+7
View File
@@ -0,0 +1,7 @@
class IdentityMailer < ApplicationMailer
def email_change_confirmation(email_address:, token:, user:)
@token = token
@user = user
mail to: email_address, subject: "Confirm your new email address"
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class User < ApplicationRecord class User < ApplicationRecord
include Accessor, Assignee, Attachable, Configurable, include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable,
Mentionable, Named, Notifiable, Role, Searcher, Watcher Mentionable, Named, Notifiable, Role, Searcher, Watcher
include Timelined # Depends on Accessor include Timelined # Depends on Accessor
@@ -0,0 +1,57 @@
module User::EmailAddressChangeable
EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address"
EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes
extend ActiveSupport::Concern
class_methods do
def change_email_address_using_token(token)
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
user = parsed_token&.find
if parsed_token.nil?
raise ArgumentError, "The token is invalid"
elsif user.nil?
raise ArgumentError, "The user no longer exists"
elsif user.identity.email_address != parsed_token.params.fetch("old_email_address")
raise ArgumentError, "The token was generated for a different email address"
else
new_email_address = parsed_token.params.fetch("new_email_address")
user.change_email_address(new_email_address)
end
end
end
def send_email_address_change_confirmation(new_email_address)
token = generate_email_address_change_token(
to: new_email_address,
expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION
)
IdentityMailer.email_change_confirmation(
email_address: new_email_address,
token: token,
user: self
).deliver_later
end
def change_email_address(new_email_address)
transaction do
new_identity = Identity.find_or_create_by!(email_address: new_email_address)
update!(identity: new_identity)
end
self
end
private
def generate_email_address_change_token(from: identity.email_address, to:, **options)
options = options.reverse_merge(
for: EMAIL_CHANGE_TOKEN_PURPOSE,
old_email_address: from,
new_email_address: to,
)
to_sgid(**options).to_s
end
end
@@ -1,6 +1,6 @@
<p class="subtitle">Confirm your email address change</p> <p class="subtitle">Confirm your email address change</p>
<%= link_to "Yes use use this email address", email_address_confirmation_url(user_id: @user.id, email_address_token: @token), class: "btn" %> <%= link_to "Yes use use this email address", user_email_address_confirmation_url(user_id: @user.id, email_address_token: @token), class: "btn" %>
<p class="margin-block-start-double">If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p> <p class="margin-block-start-double">If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>
@@ -3,6 +3,6 @@ Confirm your email address change
Hit the link below to use this email address in Fizzy: Hit the link below to use this email address in Fizzy:
<%= email_address_confirmation_url(user_id: @user.id, email_address_token: @token) %> <%= user_email_address_confirmation_url(user_id: @user.id, email_address_token: @token) %>
If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button. If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.
@@ -8,7 +8,7 @@
<p class="margin-none">Just a sec while we confirm your new email address.</p> <p class="margin-none">Just a sec while we confirm your new email address.</p>
</header> </header>
<%= form_with url: email_address_confirmation_path(user_id: @user.id), method: :post, data: { controller: "form auto-submit" } do |form| %> <%= form_with url: user_email_address_confirmation_path(user_id: @user.id), method: :post, data: { controller: "form auto-submit" } do |form| %>
<%= form.hidden_field :email_address_token, value: params[:email_address_token] %> <%= form.hidden_field :email_address_token, value: params[:email_address_token] %>
<button type="submit" class="btn btn--link center" data-form-target="submit"> <button type="submit" class="btn btn--link center" data-form-target="submit">
@@ -5,7 +5,7 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest
@identity = identities(:kevin) @identity = identities(:kevin)
end end
test "show with no memberships" do test "show with no account" do
sign_in_as @identity sign_in_as @identity
@identity.users.delete_all @identity.users.delete_all
@@ -16,7 +16,7 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest
assert_response :success, "Renders an empty menu" assert_response :success, "Renders an empty menu"
end end
test "show with exactly one membership" do test "show with exactly one account" do
sign_in_as @identity sign_in_as @identity
@identity.users.delete_all @identity.users.delete_all
account = Account.create!(external_account_id: 9999991, name: "Test Account") account = Account.create!(external_account_id: 9999991, name: "Test Account")
@@ -30,7 +30,7 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_url(script_name: "/9999991") assert_redirected_to root_url(script_name: "/9999991")
end end
test "show with multiple memeberships" do test "show with multiple accounts" do
sign_in_as @identity sign_in_as @identity
@identity.users.delete_all @identity.users.delete_all
account1 = Account.create!(external_account_id: 9999992, name: "37signals") account1 = Account.create!(external_account_id: 9999992, name: "37signals")
@@ -0,0 +1,35 @@
require "test_helper"
class Users::EmailAddresses::ConfirmationsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:david)
@new_email = "newemail@example.com"
@token = @user.send(:generate_email_address_change_token, to: @new_email)
end
test "show" do
untenanted do
get user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token)
assert_response :success
end
end
test "create" do
old_email = @user.identity.email_address
untenanted do
post user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token)
assert_equal @new_email, @user.reload.identity.email_address
assert_redirected_to edit_user_url(script_name: @user.account.slug, id: @user)
end
end
test "create with invalid token" do
untenanted do
assert_raises(ArgumentError) do
post user_email_address_confirmation_path(user_id: @user.id, email_address_token: "invalid")
end
end
end
end
@@ -0,0 +1,48 @@
require "test_helper"
class Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest
include ActionMailer::TestHelper
setup do
sign_in_as :david
@user = users(:david)
end
test "new" do
untenanted do
get new_user_email_address_path(@user)
assert_response :success
end
end
test "create" do
untenanted do
assert_emails 1 do
post user_email_addresses_path(@user), params: { email_address: "newemail@example.com" }
end
assert_response :success
end
end
test "create with existing email in same account" do
existing_user = users(:kevin)
existing_email = existing_user.identity.email_address
untenanted do
post user_email_addresses_path(@user), params: { email_address: existing_email }
assert_redirected_to new_user_email_address_path(@user)
assert_equal "You already have a user in this account with that email address", flash[:alert]
end
end
test "create for other user" do
other_user = users(:kevin)
untenanted do
assert_no_emails do
post user_email_addresses_path(other_user), params: { email_address: "newemail@example.com" }
end
assert_response :not_found
end
end
end
+4 -4
View File
@@ -18,13 +18,13 @@ class ApplicationHelperTest < ActionView::TestCase
assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy" assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy"
end end
test "page_title_tag on tenanted page when user has a single membership" do test "page_title_tag on tenanted page when user has a single account" do
Current.session = sessions(:david) Current.session = sessions(:david)
assert_select parse(page_title_tag), "title", text: "Fizzy" assert_select parse(page_title_tag), "title", text: "Fizzy"
end end
test "page_title_tag on tenanted page when user has multiple memberships" do test "page_title_tag on tenanted page when user has multiple accounts" do
Current.session = sessions(:david) Current.session = sessions(:david)
other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account") other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account")
identities(:david).users.create!(account: other_account, name: "David") identities(:david).users.create!(account: other_account, name: "David")
@@ -32,14 +32,14 @@ class ApplicationHelperTest < ActionView::TestCase
assert_select parse(page_title_tag), "title", text: "37signals | Fizzy" assert_select parse(page_title_tag), "title", text: "37signals | Fizzy"
end end
test "page_title_tag on tenanted page with a page title when user has a single membership" do test "page_title_tag on tenanted page with a page title when user has a single account" do
Current.session = sessions(:david) Current.session = sessions(:david)
@page_title = "Holodeck" @page_title = "Holodeck"
assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy" assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy"
end end
test "page_title_tag on tenanted page with a page title when user has multiple memberships" do test "page_title_tag on tenanted page with a page title when user has multiple account" do
Current.session = sessions(:david) Current.session = sessions(:david)
other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account") other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account")
identities(:david).users.create!(account: other_account, name: "David") identities(:david).users.create!(account: other_account, name: "David")
@@ -0,0 +1,13 @@
class IdentityMailerPreview < ActionMailer::Preview
def email_change_confirmation
new_email_address = "new.email@example.com"
user = User.all.sample
token = user.send(:generate_email_address_change_token, to: new_email_address)
IdentityMailer.email_change_confirmation(
email_address: new_email_address,
token: token,
user: user
)
end
end
+3 -2
View File
@@ -19,7 +19,7 @@ class IdentityTest < ActiveSupport::TestCase
assert_not Identity.new(email_address: "test@example.com").staff? assert_not Identity.new(email_address: "test@example.com").staff?
end end
test "join creates membership and user for account" do test "join" do
identity = identities(:david) identity = identities(:david)
account = accounts(:initech) account = accounts(:initech)
@@ -27,7 +27,8 @@ class IdentityTest < ActiveSupport::TestCase
identity.join(account) identity.join(account)
end end
user = account.users.find_by(identity: identity) user = account.users.find_by!(identity: identity)
assert_not_nil user assert_not_nil user
assert_equal identity, user.identity assert_equal identity, user.identity
assert_equal identity.email_address, user.name assert_equal identity.email_address, user.name
@@ -0,0 +1,55 @@
require "test_helper"
class User::EmailAddressChangeableTest < ActiveSupport::TestCase
include ActionMailer::TestHelper
setup do
@identity = identities(:kevin)
@user = @identity.users.find_by!(account: accounts("37s"))
@new_email = "newart@example.com"
end
test "send_email_address_change_confirmation" do
assert_emails 1 do
@user.send_email_address_change_confirmation(@new_email)
end
end
test "change_email_address" do
old_identity = @identity
assert_difference -> { Identity.count }, +1 do
@user.change_email_address(@new_email)
end
assert_equal @new_email, @user.reload.identity.email_address
assert_not old_identity.reload.users.exists?(id: @user.id)
assert_equal @new_email, @user.reload.identity.email_address
assert_no_difference -> { Identity.count } do
@user.change_email_address(identities(:david).email_address)
end
assert_equal identities(:david).email_address, @user.reload.identity.email_address
end
test "change_email_address_using_token" do
token = @user.send(:generate_email_address_change_token, to: @new_email)
User.change_email_address_using_token(token)
assert_equal @new_email, @user.reload.identity.email_address
end
test "change_email_address_using_token with invalid token" do
assert_raises(ArgumentError, match: /invalid/) do
User.change_email_address_using_token("invalid_token")
end
token = @user.send(:generate_email_address_change_token, to: @new_email)
@identity.update!(email_address: "different@example.com")
assert_raises(ArgumentError, match: /different email address/) do
User.change_email_address_using_token(token)
end
end
end