Skip to content
Open
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
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ gem "audits1984", "~> 0.1.7"

gem "propshaft", "~> 1.3"

gem "mini-levenshtein", "~> 0.1.2"
gem "damerau-levenshtein", "~> 1.3"

gem "faraday", "~> 2.14"
gem "faraday-retry", "~> 2.2"
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ GEM
css_parser (2.0.0)
addressable
csv (3.3.5)
damerau-levenshtein (1.3.3)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
Expand Down Expand Up @@ -351,7 +352,6 @@ GEM
marcel (1.1.0)
mcp (0.13.0)
json-schema (>= 4.1)
mini-levenshtein (0.1.2)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
Expand Down Expand Up @@ -692,6 +692,7 @@ DEPENDENCIES
cloudflare-rails
console1984 (~> 0.2.3)
countries (~> 7.1)
damerau-levenshtein (~> 1.3)
debug
doorkeeper (~> 5.8)
doorkeeper-openid_connect (~> 1.8)
Expand All @@ -717,7 +718,6 @@ DEPENDENCIES
literal (~> 1.9)
lockbox (~> 2.1)
lz_string (~> 0.3.0)
mini-levenshtein (~> 0.1.2)
openssl (~> 3.3)
paper_trail (~> 16.0)
pg (~> 1.6)
Expand Down
1 change: 1 addition & 0 deletions app/controllers/identities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def create
attrs[:country] ||= detected_country_alpha2
attrs[:onboarding_scenario] = @onboarding_scenario.class.slug
@identity = Identity.new(attrs)
@identity.email_typo_confirmed = params[:email_typo_confirmed] == "1"

if @identity.save
track_event("signup.completed", scenario: analytics_scenario, country: @identity.country)
Expand Down
55 changes: 42 additions & 13 deletions app/frontend/stylesheets/snippets/banners.scss
Original file line number Diff line number Diff line change
@@ -1,35 +1,53 @@
@mixin banner-base {
display: flex;
align-items: center;
border-radius: 10px;
padding: .875rem 1.125rem;
gap: 0.75rem;
border-radius: $radius-lg;
padding: $space-3 $space-4;
margin-bottom: 1rem;
font-size: 0.9rem;
font-weight: 500;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.3),
inset 0 -1px 1px rgba(0, 0, 0, 0.08);
line-height: 1.5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);

@include dark-mode {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}

& > svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.5rem;
flex-shrink: 0;
}
}

@mixin banner-theme($bg-color, $border-color, $fg-color, $list-fg-color) {
background: linear-gradient(145deg,
color-mix(in srgb, $bg-color 100%, white 5%),
background: linear-gradient(145deg,
color-mix(in srgb, $bg-color 100%, white 5%),
$bg-color);
border: 1px solid $border-color;
border: 1.5px solid $border-color;
color: $fg-color;

& > ul, p {
& > ul, & > p {
color: $list-fg-color;
& > li {
color: $list-fg-color;
}
}

h4 {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 $space-1;
color: $fg-color;
}

ul {
margin: 0;
padding-left: 1.25rem;

li + li { margin-top: 0.25rem; }
}
}

@mixin banner-warning {
Expand Down Expand Up @@ -66,6 +84,16 @@
@include banner-base;
text-align: left;

&:has(h3, h4, p, ul, ol) {
flex-direction: column;
align-items: flex-start;
}

&:has(.banner-actions) {
flex-direction: row;
align-items: center;
}

&.warning {
@include banner-warning;
}
Expand All @@ -89,8 +117,9 @@

.banner-actions {
display: flex;
gap: 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
white-space: nowrap;
margin-left: auto;
form { display: inline; }

Expand All @@ -105,4 +134,4 @@
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
}
11 changes: 11 additions & 0 deletions app/models/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class Identity < ApplicationRecord

has_country_enum

attr_accessor :suggested_email, :email_typo_confirmed, :typo_dismissable

has_many :sessions, class_name: "IdentitySession", dependent: :destroy
has_many :login_attempts, dependent: :destroy
has_many :login_codes, class_name: "Identity::LoginCode", dependent: :destroy
Expand Down Expand Up @@ -474,6 +476,15 @@ def validate_primary_email
return
end

if new_record? && !email_typo_confirmed
self.suggested_email = EmailDomainSuggester.suggest(primary_email)
if suggested_email
self.typo_dismissable = !(Rails.env.production? && address.disposable_domain?)
errors.add(:primary_email, :typo)
return
end
end

return unless Rails.env.production?

if address.disposable?
Expand Down
53 changes: 53 additions & 0 deletions app/services/email_domain_suggester.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
class EmailDomainSuggester
COMMON_DOMAINS = %w[
gmail.com
icloud.com
outlook.com
outlook.de
outlook.fr
proton.me
hotmail.com
yahoo.com
duck.com
protonmail.com
qq.com
gmx.de
gmx.at
web.de
pm.me
live.com
mail.com
mail.ru
163.com
tutamail.com
mozmail.com
seznam.cz
].freeze

MAX_DISTANCE = 2

def self.suggest(email)
return nil if email.nil? || email.empty? || !email.include?("@")

local, domain = email.split("@", 2)
return nil if domain.nil? || domain.empty?

domain = domain.downcase
return nil if COMMON_DOMAINS.include?(domain)

best_match = nil
best_distance = MAX_DISTANCE + 1

COMMON_DOMAINS.each do |known|
distance = DamerauLevenshtein.distance(domain, known)
if distance <= MAX_DISTANCE && distance < best_distance
best_distance = distance
best_match = known
end
end

return nil unless best_match

"#{local}@#{best_match}"
end
end
2 changes: 1 addition & 1 deletion app/services/papers_please_engine/aadhaar_scrutinizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def run
aadhaar_name = "#{aadhaar_first_name} #{aadhaar_last_name}".downcase

if identity_name != aadhaar_name
issues << if MiniLevenshtein.edit_distance(identity_name, aadhaar_name) > 4
issues << if DamerauLevenshtein.distance(identity_name, aadhaar_name) > 4
"Name doesn't seem to match"
else
"Name doesn't match exactly (this is probably fine)"
Expand Down
40 changes: 36 additions & 4 deletions app/views/identities/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,44 @@
<% if params[:return_to].present? %>
<%= hidden_field_tag :return_to, params[:return_to] %>
<% end %>
<% if @identity.errors.any? %>
<% if @identity.suggested_email.present? %>
<div class="banner warning" aria-live="polite" data-suggested-email="<%= @identity.suggested_email %>">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
<span><%= t(".email_typo", suggested: ERB::Util.html_escape(@identity.suggested_email)).html_safe %></span>
<div class="banner-actions">
<button type="button" class="secondary small-btn" onclick="
var suggested = this.closest('[data-suggested-email]').dataset.suggestedEmail;
var hidden = document.querySelector('input[type=hidden][name*=primary_email]');
var visible = document.querySelector('input[type=email][name*=primary_email]');
if (hidden) hidden.value = suggested;
if (visible) visible.value = suggested;
this.closest('form').submit();
">
<%= t(".email_typo_fix") %>
</button>
<% if @identity.typo_dismissable %>
<button type="button" class="secondary small-btn" onclick="
var f = this.closest('form');
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'email_typo_confirmed'; inp.value = '1';
f.appendChild(inp);
f.submit();
">
<%= t(".email_typo_dismiss") %>
</button>
<% end %>
</div>
</div>
<% end %>
<% other_errors = @identity.errors.reject { |e| e.type == :typo } %>
<% if other_errors.any? %>
<div class="banner danger" aria-live="polite">
<h4 style="color: var(--error-fg);"><%= pluralize(@identity.errors.count, "issue") %> prevented this from being saved:</h4>
<h4><%= pluralize(other_errors.count, "issue") %> prevented this from being saved:</h4>
<ul>
<% @identity.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% other_errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
Expand Down
3 changes: 3 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,9 @@ en:
email_code: We'll send a one-time code to this address.
continue: Continue
wrong_email: "← Wrong email? Go back"
email_typo: "Looks like a typo... did you mean <strong>%{suggested}</strong>?"
email_typo_fix: "yeah, that one! →"
email_typo_dismiss: "no, it's correct"
update:
success: saved changes!
create:
Expand Down
Loading