From 96d0745c0173665f39d116f26167164434dfcaa2 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:59:32 -0400 Subject: [PATCH 1/4] transpose mini-levenshtein for damerau-levenshtein haha, gte it, becasue damreau cacthes tarnsposed letetrs? --- Gemfile | 2 +- Gemfile.lock | 4 ++-- app/services/papers_please_engine/aadhaar_scrutinizer.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 3faecc3b..a8a249e1 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index 31195286..54bbaaed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/app/services/papers_please_engine/aadhaar_scrutinizer.rb b/app/services/papers_please_engine/aadhaar_scrutinizer.rb index f7423516..6af9781d 100644 --- a/app/services/papers_please_engine/aadhaar_scrutinizer.rb +++ b/app/services/papers_please_engine/aadhaar_scrutinizer.rb @@ -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)" From 3fc4124fab1ee1d07000107d52079678c1927063 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:15:48 -0400 Subject: [PATCH 2/4] nicer banner styling! --- .../stylesheets/snippets/banners.scss | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/app/frontend/stylesheets/snippets/banners.scss b/app/frontend/stylesheets/snippets/banners.scss index b30ed734..0dfc40eb 100644 --- a/app/frontend/stylesheets/snippets/banners.scss +++ b/app/frontend/stylesheets/snippets/banners.scss @@ -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 { @@ -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-action) { + flex-direction: row; + align-items: center; + } + &.warning { @include banner-warning; } @@ -87,6 +115,12 @@ } } +.banner-action { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; +} + .banner-actions { display: flex; gap: 0.75rem; @@ -105,4 +139,4 @@ align-items: center; gap: 1rem; flex-wrap: wrap; -} \ No newline at end of file +} From 6a702cc8e8aa606aae7794b5d36f35b78d6fa693 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:17:10 -0400 Subject: [PATCH 3/4] Did You Mean? --- app/models/identity.rb | 8 ++++ app/services/email_domain_suggester.rb | 53 ++++++++++++++++++++++++++ app/views/identities/new.html.erb | 29 ++++++++++++-- config/locales/en.yml | 2 + 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 app/services/email_domain_suggester.rb diff --git a/app/models/identity.rb b/app/models/identity.rb index 568b245f..64b36326 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -52,6 +52,8 @@ class Identity < ApplicationRecord has_country_enum + attr_accessor :suggested_email + has_many :sessions, class_name: "IdentitySession", dependent: :destroy has_many :login_attempts, dependent: :destroy has_many :login_codes, class_name: "Identity::LoginCode", dependent: :destroy @@ -474,6 +476,12 @@ def validate_primary_email return end + self.suggested_email = EmailDomainSuggester.suggest(primary_email) + if suggested_email + errors.add(:primary_email, :typo) + return + end + return unless Rails.env.production? if address.disposable? diff --git a/app/services/email_domain_suggester.rb b/app/services/email_domain_suggester.rb new file mode 100644 index 00000000..e5777add --- /dev/null +++ b/app/services/email_domain_suggester.rb @@ -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 diff --git a/app/views/identities/new.html.erb b/app/views/identities/new.html.erb index 68ff3936..e5c9af27 100644 --- a/app/views/identities/new.html.erb +++ b/app/views/identities/new.html.erb @@ -29,12 +29,33 @@ <% if params[:return_to].present? %> <%= hidden_field_tag :return_to, params[:return_to] %> <% end %> - <% if @identity.errors.any? %> + <% if @identity.suggested_email.present? %> + + <% end %> + <% other_errors = @identity.errors.reject { |e| e.attribute == :primary_email && @identity.suggested_email.present? } %> + <% if other_errors.any? %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 86486e6d..0e220956 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -409,6 +409,8 @@ 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 %{suggested}?" + email_typo_fix: "yeah, that one! →" update: success: saved changes! create: From c7583d56de5335f547d7fb3071bc223d370332e6 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:31:50 -0400 Subject: [PATCH 4/4] wraow --- app/controllers/identities_controller.rb | 1 + .../stylesheets/snippets/banners.scss | 11 ++--- app/models/identity.rb | 13 +++--- app/views/identities/new.html.erb | 41 ++++++++++++------- config/locales/en.yml | 1 + 5 files changed, 39 insertions(+), 28 deletions(-) diff --git a/app/controllers/identities_controller.rb b/app/controllers/identities_controller.rb index 5b5c1f0c..9374fe62 100644 --- a/app/controllers/identities_controller.rb +++ b/app/controllers/identities_controller.rb @@ -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) diff --git a/app/frontend/stylesheets/snippets/banners.scss b/app/frontend/stylesheets/snippets/banners.scss index 0dfc40eb..42712d60 100644 --- a/app/frontend/stylesheets/snippets/banners.scss +++ b/app/frontend/stylesheets/snippets/banners.scss @@ -89,7 +89,7 @@ align-items: flex-start; } - &:has(.banner-action) { + &:has(.banner-actions) { flex-direction: row; align-items: center; } @@ -115,16 +115,11 @@ } } -.banner-action { - margin-left: auto; - flex-shrink: 0; - white-space: nowrap; -} - .banner-actions { display: flex; - gap: 0.75rem; + gap: 0.5rem; flex-shrink: 0; + white-space: nowrap; margin-left: auto; form { display: inline; } diff --git a/app/models/identity.rb b/app/models/identity.rb index 64b36326..e02c004c 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -52,7 +52,7 @@ class Identity < ApplicationRecord has_country_enum - attr_accessor :suggested_email + attr_accessor :suggested_email, :email_typo_confirmed, :typo_dismissable has_many :sessions, class_name: "IdentitySession", dependent: :destroy has_many :login_attempts, dependent: :destroy @@ -476,10 +476,13 @@ def validate_primary_email return end - self.suggested_email = EmailDomainSuggester.suggest(primary_email) - if suggested_email - errors.add(:primary_email, :typo) - return + 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? diff --git a/app/views/identities/new.html.erb b/app/views/identities/new.html.erb index e5c9af27..74a8b989 100644 --- a/app/views/identities/new.html.erb +++ b/app/views/identities/new.html.erb @@ -30,26 +30,37 @@ <%= hidden_field_tag :return_to, params[:return_to] %> <% end %> <% if @identity.suggested_email.present? %> -