Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
en:
components:
emails:
partial_register_confirmation:
click_below_to_pick_up_where_you_left_off: Click below to confirm this email
address and pick up where you left off.
confirm_and_continue: Confirm my email
confirm_your_email: Confirm your email
link_signs_you_in: >-
This link signs you in to Bike Index, so don't forward it. It works for
%{days} days.
we_saved_your_registration_html: >-
We saved your registration for a <strong>%{color_and_brand}</strong> on
the world's most comprehensive bike registry.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<h1><%= translation("confirm_your_email") %></h1>

<p>
<%= translation("we_saved_your_registration_html", color_and_brand: @b_param.color_and_brand) %>
</p>

<p><%= translation("click_below_to_pick_up_where_you_left_off") %></p>

<%= link_to translation("confirm_and_continue"), tokenized_url, "data-pm-no-track" => true, class: "binx-button" %>

<p><%= translation("link_signs_you_in", days: expiration_days) %></p>
22 changes: 22 additions & 0 deletions app/components/emails/partial_register_confirmation/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Emails
module PartialRegisterConfirmation
class Component < ApplicationComponent
def initialize(b_param:)
@b_param = b_param
end

private

def expiration_days
BParam::TOKEN_EXPIRATION.in_days.to_i
end

def tokenized_url
confirm_register_url(b_param_token: @b_param.id_token,
confirmation_token: @b_param.email_confirmation_token)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<h1><%= translation("youre_almost_done") %></h1>

<p>
<%= translation("just_a_few_steps_away_html", color_and_brand: color_and_brand) %>
<%= translation("just_a_few_steps_away_html", color_and_brand: @b_param.color_and_brand) %>
</p>

<% if organization_snippet_body.present? %>
Expand Down
4 changes: 0 additions & 4 deletions app/components/emails/partial_registration/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ def organization
@b_param.creation_organization
end

def color_and_brand
[@b_param.primary_frame_color.presence, @b_param.mnfg_name].compact.join(" ")
end

def tokenized_url
@email_preview ? OrganizedServices::EmailPreview::TOKEN_PATH : new_bike_url(b_param_token: @b_param.id_token)
end
Expand Down
8 changes: 8 additions & 0 deletions app/components/register/confirm/component.en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
en:
components:
register:
confirm:
confirming_your_email: Confirming your email
continue: Continue
this_only_takes_a_moment: Hang on, we're picking up where you left off.
15 changes: 15 additions & 0 deletions app/components/register/confirm/component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<%= render Register::Page::Component.new do %>
<div class="tw:px-1 tw:py-6 tw:text-center">
<%= render UI::Header::Component.new(text: translation(".confirming_your_email"),
subtitle: translation(".this_only_takes_a_moment")) %>

<%= form_with(url: confirm_email_register_path, method: :post,
data: {controller: "auto-submit"}) do %>
<%= hidden_field_tag :b_param_token, @b_param.id_token %>
<%= hidden_field_tag :confirmation_token, @token %>

<%= render UI::Button::Component.new(text: translation(".continue"), color: :primary, size: :lg,
kind: :submit, spinner: true, html_class: "tw:w-full") %>
<% end %>
</div>
<% end %>
14 changes: 14 additions & 0 deletions app/components/register/confirm/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Register
module Confirm
# Where the emailed confirmation link lands. Nothing is confirmed by rendering it -
# the form posts itself, so a link scanner's GET can't spend the token
class Component < ApplicationComponent
def initialize(b_param:, token:)
@b_param = b_param
@token = token
end
end
end
end
2 changes: 1 addition & 1 deletion app/components/register/step1/component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
<%= render UI::Button::Component.new(text: translation(".next"), color: :primary, size: :lg,
kind: :submit, spinner: true, html_class: "tw:w-full") %>

<% if confirmation_email_pending? %>
<% if BikeServices::Register.confirmation_email_pending?(@b_param, user: @current_user) %>
<p
class="
tw:mt-3 tw:text-center tw:text-xs tw:text-gray-500
Expand Down
5 changes: 0 additions & 5 deletions app/components/register/step1/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,6 @@ def cycle_type_names
CycleType.slug_translation_hash_lowercase_short
end

# Only an address the registrant hasn't proven is theirs has anything to wait on
def confirmation_email_pending?
!@b_param.self_made?(@current_user)
end

# Step 1 is only revisitable once submitted, so this is a return from step 2
# - where abandoning the registration is worth offering
def returned_from_step_2?
Expand Down
2 changes: 1 addition & 1 deletion app/components/register/step2/component.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<%= render Register::Page::Component.new do %>
<%= render Register::Progress::Component.new(step: 2, total: BikeServices::Register.total_steps(@sequence)) %>

<% if awaiting_confirmation? %>
<% if BikeServices::Register.confirmation_email_pending?(@b_param, user: @current_user) %>
<%= render UI::Alerts::Base::Component.new(kind: :purple, text: translation(".confirmation_link_sent"),
icon: helpers.inline_svg_tag("icons/envelope.svg", class: "tw:-mb-0.5 tw:h-4 tw:w-4 tw:shrink-0", aria_hidden: true)) %>
<% end %>
Expand Down
6 changes: 0 additions & 6 deletions app/components/register/step2/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ def submit_text
translation(".complete_registration", cycle_type: @b_param.type_titleize)
end

# Registering to the signed-in account's own address proves it, so only an
# address belonging to someone else is ever waiting on being confirmed
def awaiting_confirmation?
!@b_param.self_made?(@current_user) && @b_param.email_unconfirmed?
end

def organization
@organization ||= @b_param.creation_organization
end
Expand Down
26 changes: 16 additions & 10 deletions app/controllers/concerns/sessionable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,7 @@ def sign_in_and_redirect(user, signed_up: false)
flash.now[:error] = translation(:user_is_banned, scope: SIGN_IN_SCOPE)
redirect_back(fallback_location: new_session_url) && return
end
confirm_user_from_claim_token(user)
session[:last_seen] = Time.current
session[:render_donation_request] = user.render_donation_request if user&.render_donation_request
set_passive_organization(user.default_organization) # Set that organization!
user.update_last_login(forwarded_ip_address)
if Binxtils::InputNormalizer.boolean(params.dig(:session, :remember_me)) || session.delete(:magic_link_remember_me)
cookies.permanent.signed[ControllerHelpers::AUTH_COOKIE_KEY] = cookie_options(user)
else
default_session_set(user)
end
sign_in_user(user)

if sign_in_partner.present?
session.delete(:partner) # Only removing once signed in, PR#1435
Expand All @@ -46,6 +37,21 @@ def sign_in_and_redirect(user, signed_up: false)
end
end

# Everything signing in does apart from deciding where to go next, for flows with
# a destination of their own
def sign_in_user(user)
confirm_user_from_claim_token(user)
session[:last_seen] = Time.current
session[:render_donation_request] = user.render_donation_request if user&.render_donation_request
set_passive_organization(user.default_organization) # Set that organization!
user.update_last_login(forwarded_ip_address)
if Binxtils::InputNormalizer.boolean(params.dig(:session, :remember_me)) || session.delete(:magic_link_remember_me)
cookies.permanent.signed[ControllerHelpers::AUTH_COOKIE_KEY] = cookie_options(user)
else
default_session_set(user)
end
end

def default_session_set(user)
cookies.signed[ControllerHelpers::AUTH_COOKIE_KEY] = cookie_options(user)
end
Expand Down
78 changes: 69 additions & 9 deletions app/controllers/register_controller.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
class RegisterController < ApplicationController
before_action :find_b_param, except: %i[new create]
include Sessionable

before_action :find_b_param, except: %i[new create confirm confirm_email]
# An expired token starts a registration rather than bouncing and losing the
# submission. assign_organization runs next, so the form's organization_id lands on it
before_action -> { find_b_param(build: true) }, only: %i[create]
before_action :assign_organization, except: %i[new]
before_action :find_registration_sequence, except: %i[new]
# The emailed link resumes a registration the session knows nothing about
before_action :find_b_param_for_confirmation, only: %i[confirm confirm_email]
# confirm renders a self-posting form and nothing else, so it reads neither
before_action :assign_organization, except: %i[new confirm]
before_action :find_registration_sequence, except: %i[new confirm]
before_action :redirect_finished, only: %i[create update acknowledge]
# The step shown depends on server state - a cached page could show a step
# the registration is past (register--revalidate covers Safari's bfcache)
Expand Down Expand Up @@ -57,6 +62,8 @@ def create
if @b_param.errors.any?
render Register::Step1::Component.new(b_param: @b_param, sequence: @registration_sequence, current_user:), status: :unprocessable_entity
elsif @b_param.save
# Step 2 says the link is on its way, so it goes out here rather than at the end
BikeServices::Register.send_confirmation_email(@b_param)
redirect_to step_path(2)
else
@b_param.errors.add(:base, translation(:unable_to_save))
Expand Down Expand Up @@ -96,6 +103,36 @@ def acknowledge
redirect_to step_path(BikeServices::Register.step_after(step, sequence: @registration_sequence))
end

def confirm
@page_title = I18n.t("meta_titles.register_confirm")
render Register::Confirm::Component.new(b_param: @b_param, token: params[:confirmation_token])
end

# The confirmation itself - the proven address gets an account, created here if
# this is their first registration
def confirm_email
# Single use, so a second click has nothing left to do - the first one signed them in
return redirect_to_current_step if @b_param.email_confirmed?

if @b_param.email_confirmation_token_expired? ||
!secure_compare?(params[:confirmation_token], @b_param.email_confirmation_token)
BikeServices::Register.send_confirmation_email(@b_param)
flash[:error] = translation(:confirmation_link_expired)
return redirect_to_current_step
end

# Someone else's session stays theirs - the registration is still finished for the
# address that was emailed, it just isn't that account's own
if current_user.present?
flash[:notice] = translation(:signed_in_as_other, email: current_user.email) unless @b_param.self_made?(current_user)
elsif sign_in_confirmed_user.blank?
return redirect_to_current_step
end

@b_param.confirm_email!(creator_id: current_user.id)
complete_registration
end

private

def save_acknowledgment(step)
Expand All @@ -111,13 +148,27 @@ def save_acknowledgment(step)

def complete_registration
BikeServices::Register.claim_creator(@b_param, current_user)
if BikeServices::Register.creator_available?(@b_param)
redirect_after_bike_creation(BikeServices::Register.create_bike(@b_param, ip_address: forwarded_ip_address))
else
# Everything is saved on the b_param - the bike is created once the
# confirmation link from the partial registration email is clicked
redirect_to step_path(:finished)
bike = BikeServices::Register.create_bike_if_ready(@b_param,
sequence: @registration_sequence, ip_address: forwarded_ip_address)
# No bike yet - everything stays on the b_param until the emailed link is clicked
return redirect_to_current_step if bike.blank?

redirect_after_bike_creation(bike)
end

# The account the confirmed address belongs to, created if it doesn't have one yet
def sign_in_confirmed_user
user, signed_up = UserServices::PasswordlessCreator.find_or_create(@b_param.owner_email)
if user.blank? || user.banned?
flash[:error] = translation(:unable_to_sign_in)
return nil
end

# The link proved the address, so an account that had never confirmed it now has
user.confirm(user.confirmation_token) unless user.confirmed?
sign_in_user(user)
set_sign_in_flash(user, signed_up)
@current_user = user
end

# Wherever the registration now stands: the next unacknowledged page, or the review
Expand Down Expand Up @@ -149,6 +200,15 @@ def find_registration_sequence
@registration_sequence = BikeServices::Register.registration_sequence(@b_param)
end

# Not find_b_param: the emailed token authorizes this, not the session, and an expired
# link has to find its registration to say so rather than dead-end. Nothing is written
# to the session - the token hasn't been checked yet
def find_b_param_for_confirmation
token = params[:b_param_token]
@b_param = BParam.find_by(id_token: token) if token.present?
redirect_to(new_register_path) if @b_param.blank?
end

# build: only step 1's submission, which carries everything a registration needs
def find_b_param(build: false)
@b_param = BikeServices::Register.find_token(params_token: params[:b_param_token],
Expand Down
20 changes: 11 additions & 9 deletions app/jobs/email/partial_registration_job.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
# frozen_string_literal: true

module Email
# The emails a registration sends before it has a bike - only an address has been
# entered, so every one runs the domain check first. kind names both the notification
# and the mailer method, and defaults for jobs enqueued before it took one
class PartialRegistrationJob < ApplicationJob
sidekiq_options queue: "notify", retry: 3

# When we started creating notifications when sending partial registration emails PR#2368
NOTIFICATION_STARTED = Time.at(1690677345).freeze # 2023-07-29 17:35:45

def perform(b_param_id)
b_param = BParam.find(b_param_id)
def perform(b_param_id, kind = "partial_registration")
raise ArgumentError, "Not a b_param kind: #{kind.inspect} (expected one of #{Notification.b_param_kinds.join(", ")})" unless kind.in?(Notification.b_param_kinds)

b_param = BParam.find_by(id: b_param_id)
return if b_param.blank?
# confirm_email! spends the token, so a blank one means there's no link left to send
return if kind == "partial_register_confirmation" && b_param.email_confirmation_token.blank?

if EmailDomain::VERIFICATION_ENABLED
email_domain = EmailDomain.find_or_create_for(b_param.owner_email)
Expand All @@ -18,13 +25,8 @@ def perform(b_param_id)
return if email_domain&.provisional_ban?
end

notification = Notification.create(kind: "partial_registration",
message_channel: "email",
notifiable: b_param)

notification.track_email_delivery do
OrganizedMailer.partial_registration(b_param).deliver_now
end
notification = Notification.create(kind:, message_channel: "email", notifiable: b_param)
notification.track_email_delivery { OrganizedMailer.public_send(kind, b_param).deliver_now }
end
end
end
4 changes: 1 addition & 3 deletions app/jobs/users/process_organization_role_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ def auto_generate_user_for_organization(organization_role)
return false unless organization_role.organization.passwordless_user_creation? &&
organization_role.user.blank?

user = User.new(skip_update: true, passwordless_user: true, email: organization_role.invited_email)
user.save!
user.confirm(user.confirmation_token)
user, _created = UserServices::PasswordlessCreator.find_or_create(organization_role.invited_email)
# We don't want to send users emails in this situation.
organization_role.update(user_id: user.id, email_invitation_sent_at: Time.current, skip_processing: true)
organization_role.reload
Expand Down
23 changes: 13 additions & 10 deletions app/mailers/organized_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,11 @@ class OrganizedMailer < ApplicationMailer
helper :bike

def partial_registration(b_param)
@organization = b_param.creation_organization
component = Emails::PartialRegistration::Component.new(b_param:)
b_param_mail(b_param, Emails::PartialRegistration::Component.new(b_param:), tag: __callee__)
end

I18n.with_locale(@user&.preferred_language) do
mail(
reply_to: reply_to,
to: b_param.owner_email,
subject: default_i18n_subject(default_subject_vars),
tag: __callee__
) { |format| format.html { render component } }
end
def partial_register_confirmation(b_param)
b_param_mail(b_param, Emails::PartialRegisterConfirmation::Component.new(b_param:), tag: __callee__)
end

def finished_registration(ownership)
Expand Down Expand Up @@ -123,6 +117,15 @@ def impound_claim_approved_or_denied(impound_claim)

private

# Addressed to whoever entered the registration, and subjected by the caller's own name
def b_param_mail(b_param, component, tag:)
@organization = b_param.creation_organization
mail(reply_to: reply_to,
to: b_param.owner_email,
subject: default_i18n_subject(default_subject_vars),
tag:) { |format| format.html { render component } }
end

def finished_registration_type(bike, ownership)
return "_stolen" if bike.status_stolen?

Expand Down
Loading