Skip to content

Commit 126b8e6

Browse files
authored
Email anonymous registrations a confirmation link that signs them in (#4018)
An anonymous register-flow registration can't create its bike — `Ownership` needs a creator — so step 1 now emails the address a link. Following it proves the address, creates a passwordless account, signs them in, and creates the bike the registration was holding, dropping them on whatever step they're on. - **The link carries its own single-use token**, minted only in the send path, bound to the address it was mailed to, and spent on use. The registration's `id_token` is already in the registrant's URL before any email goes out, so a click carrying it would prove nothing about who received what. Binding matters because step 1 stays resubmittable: without it, editing `owner_email` after the link went out would let that link confirm — and sign you in as — an address you never received mail at. - **`BParam::TOKEN_EXPIRATION` (90 days) governs both** the link and how long the register flow resumes by token, since a link outliving the registration it resumes is a dead end. The confirm lookup applies no window of its own, so an expired link can say so and send a new one rather than dumping them into a fresh registration. - **The landing page only renders a form that posts itself** via #4019's `auto-submit` controller — a link scanner's GET can't spend the token. Resends are rate limited off a stamp written by the sender, not by the delivering job. - **The account it makes is a `passwordless_user`**, so it gets #4019's offer to set a password like every other emailed sign in; an address that already has an unconfirmed account gets confirmed, as the magic link does. Creating one moves out of `Users::ProcessOrganizationRoleJob` into `UserServices::PasswordlessCreator`, and the non-redirecting half of `Sessionable#sign_in_and_redirect` becomes `sign_in_user`, so this lands in the same session state as every other sign in. - **The email rides on `Email::PartialRegistrationJob`**, which now takes the notification kind alongside the id — it and `partial_registration` are the two a registration sends before it has a bike, so they share the domain check and the notification. Both are seeded now, since nothing else leaves a b_param mid-flow for the mailer previews. New accounts still land on the terms-of-service gate the first time they visit their account — same as passwordless org accounts. If step 1's submit should count as acceptance, that's a follow-up.
1 parent 86ab5e7 commit 126b8e6

32 files changed

Lines changed: 712 additions & 69 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
en:
3+
components:
4+
emails:
5+
partial_register_confirmation:
6+
click_below_to_pick_up_where_you_left_off: Click below to confirm this email
7+
address and pick up where you left off.
8+
confirm_and_continue: Confirm my email
9+
confirm_your_email: Confirm your email
10+
link_signs_you_in: >-
11+
This link signs you in to Bike Index, so don't forward it. It works for
12+
%{days} days.
13+
we_saved_your_registration_html: >-
14+
We saved your registration for a <strong>%{color_and_brand}</strong> on
15+
the world's most comprehensive bike registry.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<h1><%= translation("confirm_your_email") %></h1>
2+
3+
<p>
4+
<%= translation("we_saved_your_registration_html", color_and_brand: @b_param.color_and_brand) %>
5+
</p>
6+
7+
<p><%= translation("click_below_to_pick_up_where_you_left_off") %></p>
8+
9+
<%= link_to translation("confirm_and_continue"), tokenized_url, "data-pm-no-track" => true, class: "binx-button" %>
10+
11+
<p><%= translation("link_signs_you_in", days: expiration_days) %></p>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module Emails
4+
module PartialRegisterConfirmation
5+
class Component < ApplicationComponent
6+
def initialize(b_param:)
7+
@b_param = b_param
8+
end
9+
10+
private
11+
12+
def expiration_days
13+
BParam::TOKEN_EXPIRATION.in_days.to_i
14+
end
15+
16+
def tokenized_url
17+
confirm_register_url(b_param_token: @b_param.id_token,
18+
confirmation_token: @b_param.email_confirmation_token)
19+
end
20+
end
21+
end
22+
end

app/components/emails/partial_registration/component.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<h1><%= translation("youre_almost_done") %></h1>
22

33
<p>
4-
<%= translation("just_a_few_steps_away_html", color_and_brand: color_and_brand) %>
4+
<%= translation("just_a_few_steps_away_html", color_and_brand: @b_param.color_and_brand) %>
55
</p>
66

77
<% if organization_snippet_body.present? %>

app/components/emails/partial_registration/component.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@ def organization
1818
@b_param.creation_organization
1919
end
2020

21-
def color_and_brand
22-
[@b_param.primary_frame_color.presence, @b_param.mnfg_name].compact.join(" ")
23-
end
24-
2521
def tokenized_url
2622
@email_preview ? OrganizedServices::EmailPreview::TOKEN_PATH : new_bike_url(b_param_token: @b_param.id_token)
2723
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
en:
3+
components:
4+
register:
5+
confirm:
6+
confirming_your_email: Confirming your email
7+
continue: Continue
8+
this_only_takes_a_moment: Hang on, we're picking up where you left off.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<%= render Register::Page::Component.new do %>
2+
<div class="tw:px-1 tw:py-6 tw:text-center">
3+
<%= render UI::Header::Component.new(text: translation(".confirming_your_email"),
4+
subtitle: translation(".this_only_takes_a_moment")) %>
5+
6+
<%= form_with(url: confirm_email_register_path, method: :post,
7+
data: {controller: "auto-submit"}) do %>
8+
<%= hidden_field_tag :b_param_token, @b_param.id_token %>
9+
<%= hidden_field_tag :confirmation_token, @token %>
10+
11+
<%= render UI::Button::Component.new(text: translation(".continue"), color: :primary, size: :lg,
12+
kind: :submit, spinner: true, html_class: "tw:w-full") %>
13+
<% end %>
14+
</div>
15+
<% end %>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
module Register
4+
module Confirm
5+
# Where the emailed confirmation link lands. Nothing is confirmed by rendering it -
6+
# the form posts itself, so a link scanner's GET can't spend the token
7+
class Component < ApplicationComponent
8+
def initialize(b_param:, token:)
9+
@b_param = b_param
10+
@token = token
11+
end
12+
end
13+
end
14+
end

app/components/register/step1/component.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
<%= render UI::Button::Component.new(text: translation(".next"), color: :primary, size: :lg,
6868
kind: :submit, spinner: true, html_class: "tw:w-full") %>
6969

70-
<% if confirmation_email_pending? %>
70+
<% if BikeServices::Register.confirmation_email_pending?(@b_param, user: @current_user) %>
7171
<p
7272
class="
7373
tw:mt-3 tw:text-center tw:text-xs tw:text-gray-500

app/components/register/step1/component.rb

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,6 @@ def cycle_type_names
4747
CycleType.slug_translation_hash_lowercase_short
4848
end
4949

50-
# Only an address the registrant hasn't proven is theirs has anything to wait on
51-
def confirmation_email_pending?
52-
!@b_param.self_made?(@current_user)
53-
end
54-
5550
# Step 1 is only revisitable once submitted, so this is a return from step 2
5651
# - where abandoning the registration is worth offering
5752
def returned_from_step_2?

0 commit comments

Comments
 (0)