Rework the email change flow to tie to memberships
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
class Identity::EmailAddresses::ConfirmationsController < ApplicationController
|
||||
require_untenanted_access
|
||||
|
||||
before_action :set_identity
|
||||
rate_limit to: 3, within: 1.hour, only: :create
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
@identity.change_email_address_using_token(token)
|
||||
|
||||
# Redirect to the tenant that initiated the change
|
||||
tenant = SignedGlobalID.parse(token, for: Identity::EmailAddressChangeable::EMAIL_CHANGE_TOKEN_PURPOSE)&.params&.fetch("tenant")
|
||||
|
||||
if tenant
|
||||
redirect_to "#{tenant}/users/#{Current.user.id}/edit"
|
||||
else
|
||||
redirect_to session_menu_path
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
flash[:alert] = e.message
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
def set_identity
|
||||
@identity = Current.identity
|
||||
end
|
||||
|
||||
def token
|
||||
params.expect :email_address_token
|
||||
end
|
||||
end
|
||||
@@ -1,36 +0,0 @@
|
||||
class Identity::EmailAddressesController < ApplicationController
|
||||
require_untenanted_access
|
||||
|
||||
before_action :set_identity
|
||||
rate_limit to: 3, within: 1.hour, only: :create
|
||||
|
||||
def new
|
||||
@tenant = params[:tenant]
|
||||
@user = ApplicationRecord.with_tenant(@tenant) { Current.identity.user }
|
||||
end
|
||||
|
||||
def create
|
||||
@tenant = tenant
|
||||
@user = ApplicationRecord.with_tenant(@tenant) { Current.identity.user }
|
||||
|
||||
if Identity.exists?(email_address: new_email_address)
|
||||
flash.now[:alert] = "Someone else already uses that email"
|
||||
render :new, status: :unprocessable_entity
|
||||
else
|
||||
@identity.send_email_address_change_confirmation(new_email_address, tenant: tenant)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_identity
|
||||
@identity = Current.identity
|
||||
end
|
||||
|
||||
def new_email_address
|
||||
params.expect :email_address
|
||||
end
|
||||
|
||||
def tenant
|
||||
params.expect :tenant
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
class Memberships::EmailAddresses::ConfirmationsController < ApplicationController
|
||||
require_untenanted_access
|
||||
allow_unauthenticated_access
|
||||
|
||||
before_action :set_membership
|
||||
rate_limit to: 5, within: 1.hour, only: :create
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
Membership.change_email_address_using_token(token)
|
||||
|
||||
terminate_session
|
||||
start_new_session_for @membership.reload.identity
|
||||
|
||||
redirect_to edit_user_url(script_name: "/#{@membership.tenant}", id: @membership.user)
|
||||
end
|
||||
|
||||
private
|
||||
def set_membership
|
||||
@membership = Membership.find(params[:membership_id])
|
||||
end
|
||||
|
||||
def token
|
||||
params.expect :email_address_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class Memberships::EmailAddressesController < ApplicationController
|
||||
require_untenanted_access
|
||||
|
||||
before_action :set_membership
|
||||
rate_limit to: 5, within: 1.hour, only: :create
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@membership.send_email_address_change_confirmation(new_email_address)
|
||||
end
|
||||
|
||||
private
|
||||
def set_membership
|
||||
@membership = Current.identity.memberships.find(params[:membership_id])
|
||||
end
|
||||
|
||||
def new_email_address
|
||||
params.expect :email_address
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,10 @@ class ApplicationMailer < ActionMailer::Base
|
||||
|
||||
private
|
||||
def default_url_options
|
||||
super.merge(script_name: Account.sole.slug)
|
||||
if ApplicationRecord.current_tenant
|
||||
super.merge(script_name: Account.sole.slug)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
class IdentityMailer < ApplicationMailer
|
||||
def email_change_confirmation(identity:, email_address:, token:, tenant:)
|
||||
@identity = identity
|
||||
def email_change_confirmation(email_address:, token:, membership:)
|
||||
@token = token
|
||||
@tenant = tenant
|
||||
|
||||
@membership = membership
|
||||
mail to: email_address, subject: "Confirm your new email address"
|
||||
end
|
||||
end
|
||||
|
||||
+1
-11
@@ -1,5 +1,5 @@
|
||||
class Identity < UntenantedRecord
|
||||
include EmailAddressChangeable, Transferable
|
||||
include Transferable
|
||||
|
||||
has_many :memberships, dependent: :destroy
|
||||
has_many :magic_links, dependent: :destroy
|
||||
@@ -14,16 +14,6 @@ class Identity < UntenantedRecord
|
||||
end
|
||||
end
|
||||
|
||||
def link_to(tenant, context: nil)
|
||||
memberships.find_or_create_by!(tenant: tenant) do |membership|
|
||||
membership.context = context
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_from(tenant)
|
||||
memberships.find_by(tenant: tenant)&.destroy
|
||||
end
|
||||
|
||||
def staff?
|
||||
email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com")
|
||||
end
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
module Identity::EmailAddressChangeable
|
||||
EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address"
|
||||
EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes
|
||||
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def send_email_address_change_confirmation(new_email_address, tenant:)
|
||||
token = generate_email_address_change_token(to: new_email_address, tenant: tenant, expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION)
|
||||
IdentityMailer.email_change_confirmation(identity: self, email_address: new_email_address, token: token, tenant: tenant).deliver_later
|
||||
end
|
||||
|
||||
def generate_email_address_change_token(from: email_address, to:, tenant:, **options)
|
||||
options = options.reverse_merge(
|
||||
for: EMAIL_CHANGE_TOKEN_PURPOSE,
|
||||
old_email_address: from,
|
||||
new_email_address: to,
|
||||
tenant: tenant
|
||||
)
|
||||
|
||||
to_sgid(**options).to_s
|
||||
end
|
||||
|
||||
def change_email_address_using_token(token)
|
||||
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
|
||||
|
||||
if parsed_token.nil?
|
||||
raise ArgumentError, "The token is invalid"
|
||||
elsif parsed_token.find != self
|
||||
raise ArgumentError, "The token is for another identity"
|
||||
elsif email_address != parsed_token.params.fetch("old_email_address")
|
||||
raise ArgumentError, "The token was generated for a different email address"
|
||||
else
|
||||
tenant = parsed_token.params.fetch("tenant")
|
||||
new_email_address = parsed_token.params.fetch("new_email_address")
|
||||
change_email_address(new_email_address, tenant: tenant)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def change_email_address(new_email_address, tenant:)
|
||||
old_email_address = email_address
|
||||
|
||||
# First update the identity email
|
||||
update!(email_address: new_email_address)
|
||||
|
||||
begin
|
||||
# Then update the membership to point to the new identity
|
||||
Membership.change_email_address(from: old_email_address, to: new_email_address, tenant: tenant)
|
||||
|
||||
# Also update the user's email in the tenant database (read-only from untenanted context)
|
||||
# This will be handled by the user updating their own record
|
||||
rescue => e
|
||||
# Rollback identity email if membership update fails
|
||||
update!(email_address: old_email_address)
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Membership < UntenantedRecord
|
||||
include EmailAddressChangeable
|
||||
|
||||
belongs_to :identity, touch: true
|
||||
|
||||
serialize :context, coder: JSON
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
module Membership::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)
|
||||
membership = parsed_token&.find
|
||||
|
||||
if parsed_token.nil?
|
||||
raise ArgumentError, "The token is invalid"
|
||||
elsif membership.nil?
|
||||
raise ArgumentError, "The membership no longer exists"
|
||||
elsif membership.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")
|
||||
membership.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,
|
||||
membership: 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
|
||||
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,7 +1,7 @@
|
||||
<h1 class="title">Confirm your email address change</h1>
|
||||
<p class="subtitle">Hit the button below to use this email address in Fizzy.</p>
|
||||
|
||||
<%= link_to "Yes use use this email address", identity_email_address_confirmation_url(@token), class: "btn" %>
|
||||
<%= link_to "Yes use use this email address", email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token), class: "btn" %>
|
||||
|
||||
<p class="margin-block-start-double">If you didn’t 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:
|
||||
|
||||
<%= identity_email_address_confirmation_url(@token) %>
|
||||
<%= email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token) %>
|
||||
|
||||
If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
<p class="margin-none">Just a sec while we confirm your new email address.</p>
|
||||
</header>
|
||||
|
||||
<%= form_with url: identity_email_address_confirmation_path(params[:email_address_token]), method: :post, data: { controller: "auto-submit" } do |form| %>
|
||||
<%= form_with url: email_address_confirmation_path(membership_id: @membership.id), method: :post, data: { controller: "auto-submit" } do |form| %>
|
||||
<%= form.hidden_field :email_address_token, value: params[:email_address_token] %>
|
||||
|
||||
<button type="submit" class="btn btn--link center">
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__title">
|
||||
<%= link_to edit_user_path(@user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<%= link_to edit_user_path(@membership.user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<span class="overflow-ellipsis">← <strong>My profile</strong></span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -13,5 +13,5 @@
|
||||
<p class="margin-none">We just sent an email to <strong><%= params[:email_address] %></strong></p>
|
||||
<p class="margin-none-block-start">Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.</p>
|
||||
|
||||
<%= link_to "Done", edit_user_path(@user, script_name: "/#{@tenant}"), class: "btn btn--link center" %>
|
||||
<%= link_to "Done", edit_user_path(@membership.user, script_name: "/#{@tenant}"), class: "btn btn--link center" %>
|
||||
</div>
|
||||
+2
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__title">
|
||||
<%= link_to edit_user_path(@user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<%= link_to edit_user_path(@membership.user, script_name: "/#{@membership.tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<span class="overflow-ellipsis">← <strong>My profile</strong></span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -16,8 +16,7 @@
|
||||
<p class="margin-none">Enter your new email address. We'll send you a confirmation to verify it.</p>
|
||||
</header>
|
||||
|
||||
<%= form_with url: identity_email_addresses_path(tenant: @tenant), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %>
|
||||
<%= form.hidden_field :tenant, value: @tenant %>
|
||||
<%= form_with url: email_addresses_path(membership_id: @membership.id), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %>
|
||||
<div class="flex align-center gap">
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.email_field :email_address, class: "input full-width", autocomplete: "email", placeholder: "New email address", autofocus: true, required: true %>
|
||||
@@ -32,8 +32,8 @@
|
||||
</div>
|
||||
<div class="flex align-center gap">
|
||||
<div class="flex align-center gap input input--actor">
|
||||
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true %>
|
||||
<%= link_to "Change email", new_identity_email_address_url(script_name: nil, tenant: @user.tenant), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
|
||||
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true, value: @user.identity.email_address %>
|
||||
<%= link_to "Change email", new_email_address_url(script_name: nil, membership_id: Current.membership.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="log_in" class="btn btn--reversed center">
|
||||
|
||||
+4
-6
@@ -136,12 +136,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resource :identity, only: [] do
|
||||
resources :email_addresses, only: %i[ new create ], param: :token, module: :identity do
|
||||
resource :confirmation, only: %i[ show create ], module: :email_addresses
|
||||
end
|
||||
end
|
||||
|
||||
resources :users do
|
||||
scope module: :users do
|
||||
resource :avatar
|
||||
@@ -158,6 +152,10 @@ Rails.application.routes.draw do
|
||||
|
||||
scope module: :memberships, path: "memberships/:membership_id" do
|
||||
resource :unlink, only: %i[ show create ], controller: :unlink, as: :unlink_membership
|
||||
|
||||
resources :email_addresses, only: %i[ new create ], param: :token do
|
||||
resource :confirmation, only: %i[ show create ], module: :email_addresses
|
||||
end
|
||||
end
|
||||
|
||||
namespace :my do
|
||||
|
||||
@@ -45,8 +45,9 @@ class Signup
|
||||
def create_identity
|
||||
return false unless valid?(:identity_creation)
|
||||
|
||||
@identity = Identity.find_or_create_by!(email_address: email_address)
|
||||
@new_user = @identity.new_record?
|
||||
@identity = Identity.find_by(email_address: email_address)
|
||||
@new_user = !@identity
|
||||
@identity = Identity.create!(email_address: email_address) unless @identity
|
||||
@identity.send_magic_link
|
||||
end
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class Signups::MembershipsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "create with validation errors" do
|
||||
test "create with invalid params" do
|
||||
untenanted do
|
||||
assert_no_difference -> { Membership.count } do
|
||||
post saas.signup_membership_path, params: {
|
||||
@@ -66,7 +66,7 @@ class Signups::MembershipsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "create with new_user flag generates personal account name" do
|
||||
test "create with new_user flag" do
|
||||
untenanted do
|
||||
post saas.signup_membership_path, params: {
|
||||
signup: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
require "test_helper"
|
||||
|
||||
class Identity::TransferableTest < ActiveSupport::TestCase
|
||||
test "transfer_id" do
|
||||
identity = identities(:david)
|
||||
transfer_id = identity.transfer_id
|
||||
|
||||
assert_not_nil transfer_id
|
||||
|
||||
# Should be able to find the identity using the transfer_id
|
||||
found_identity = Identity.find_by_transfer_id(transfer_id)
|
||||
assert_equal identity, found_identity
|
||||
end
|
||||
|
||||
test "find_by_transfer_id" do
|
||||
identity = identities(:kevin)
|
||||
transfer_id = identity.transfer_id
|
||||
|
||||
found = Identity.find_by_transfer_id(transfer_id)
|
||||
assert_equal identity, found
|
||||
end
|
||||
|
||||
test "find_by_transfer_id with invalid id" do
|
||||
found = Identity.find_by_transfer_id("invalid_id")
|
||||
assert_nil found
|
||||
end
|
||||
|
||||
test "find_by_transfer_id with expired id" do
|
||||
identity = identities(:jz)
|
||||
|
||||
# Generate a transfer_id with short expiry
|
||||
expired_id = identity.signed_id(purpose: :transfer, expires_in: -1.second)
|
||||
|
||||
found = Identity.find_by_transfer_id(expired_id)
|
||||
assert_nil found
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityTest < ActiveSupport::TestCase
|
||||
include ActionMailer::TestHelper
|
||||
|
||||
test "send_magic_link" do
|
||||
identity = identities(:david)
|
||||
|
||||
assert_emails 1 do
|
||||
magic_link = identity.send_magic_link
|
||||
assert_not_nil magic_link
|
||||
assert_equal identity, magic_link.identity
|
||||
end
|
||||
end
|
||||
|
||||
test "staff?" do
|
||||
assert Identity.new(email_address: "test@37signals.com").staff?
|
||||
assert Identity.new(email_address: "test@basecamp.com").staff?
|
||||
assert_not Identity.new(email_address: "test@example.com").staff?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
require "test_helper"
|
||||
|
||||
class Membership::EmailAddressChangeableTest < ActiveSupport::TestCase
|
||||
include ActionMailer::TestHelper
|
||||
|
||||
setup do
|
||||
@identity = identities(:kevin)
|
||||
@tenant = ApplicationRecord.current_tenant
|
||||
@membership = @identity.memberships.find_by!(tenant: @tenant)
|
||||
@new_email = "newart@example.com"
|
||||
end
|
||||
|
||||
test "send_email_address_change_confirmation" do
|
||||
assert_emails 1 do
|
||||
@membership.send_email_address_change_confirmation(@new_email)
|
||||
end
|
||||
end
|
||||
|
||||
test "change_email_address" do
|
||||
old_identity = @identity
|
||||
|
||||
assert_difference -> { Identity.count }, +1 do
|
||||
@membership.change_email_address(@new_email)
|
||||
end
|
||||
|
||||
assert_equal @new_email, @membership.reload.identity.email_address
|
||||
assert_not old_identity.reload.memberships.exists?(id: @membership.id)
|
||||
assert_equal @new_email, @membership.reload.identity.email_address
|
||||
|
||||
assert_no_difference -> { Identity.count } do
|
||||
@membership.change_email_address(identities(:david).email_address)
|
||||
end
|
||||
assert_equal identities(:david).email_address, @membership.reload.identity.email_address
|
||||
end
|
||||
|
||||
test "change_email_address_using_token" do
|
||||
token = @membership.send(:generate_email_address_change_token, to: @new_email)
|
||||
|
||||
Membership.change_email_address_using_token(token)
|
||||
|
||||
assert_equal @new_email, @membership.reload.identity.email_address
|
||||
end
|
||||
|
||||
test "change_email_address_using_token with invalid token" do
|
||||
assert_raises(ArgumentError, match: /invalid/) do
|
||||
Membership.change_email_address_using_token("invalid_token")
|
||||
end
|
||||
|
||||
token = @membership.send(:generate_email_address_change_token, to: @new_email)
|
||||
@identity.update!(email_address: "different@example.com")
|
||||
|
||||
assert_raises(ArgumentError, match: /different email address/) do
|
||||
Membership.change_email_address_using_token(token)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user