diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js index d3d151e48..551666575 100644 --- a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js +++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js @@ -18,12 +18,24 @@ */ import UIWindow from './UIWindow.js'; +import { get_country_list } from '../helpers/country_codes.js'; +import { + format_phone_as_you_type, + inspect_phone, + phone_example_for, + detect_country_from_input, +} from '../helpers/phone.js'; // SMS phone verification dialog. Two steps in one window: // 1. Enter a phone number → POST /send-confirm-phone (Prelude sends an SMS). // 2. Enter the 6-digit code → POST /confirm-phone (Prelude validates it). // The 6-digit code UX mirrors UIWindowEmailConfirmationRequired.js. Used as a // hard gate for low-reputation signups, so by default it has no close button. +// +// The number field combines a searchable country-code picker with the national +// number. Everything the user types is normalized to E.164 with libphonenumber +// before it's sent, so country selection and on-screen formatting are purely a +// convenience — they can never change which number actually reaches the server. // Friendly, non-accusatory messages for the abuse `reason` codes the backend // forwards on a refused send. The backend never interprets these (the abuse @@ -40,6 +52,9 @@ const SEND_REASON_MESSAGES = { "We couldn't verify your device. Please email hi@puter.com for assistance.", }; +// Seconds the "Re-send code" link stays disabled after a send. +const RESEND_COOLDOWN_SECONDS = 30; + function UIWindowPhoneVerificationRequired(options) { return new Promise(async (resolve) => { options = options ?? {}; @@ -47,15 +62,136 @@ function UIWindowPhoneVerificationRequired(options) { let final_code = ''; let is_checking_code = false; let is_sending = false; + // Resolve the returned promise at most once. Success resolves(true); + // a user-initiated close resolves(false). Idempotent so the close hook + // can fire after a success without clobbering the result. + let settled = false; + const settle = (val) => { + if (settled) return; + settled = true; + resolve(val); + }; + // Logout is handled by a global 'logout' event, not by resolving this + // gate. Setting this before closing keeps the close hook from + // resolving(false) — which would make the caller's do/while reopen the + // dialog instead of letting the logout transition take over. + let logging_out = false; + // Touch devices: skip auto-focusing the country search (which would pop + // the on-screen keyboard over the inline list); larger tap targets and + // tap-to-select carry the interaction instead. Users can still tap the + // search field to filter, at which point the keyboard is expected. + const isTouch = (() => { + try { + return !!( + window.matchMedia && + window.matchMedia('(pointer: coarse)').matches + ); + } catch (_) { + return false; + } + })(); + + // Localized UI strings. i18n() falls back to English and then to the + // raw key, so missing translations degrade gracefully rather than break. + const T = { + title: i18n('phone_verify_title'), + subtitle: i18n('phone_verify_subtitle'), + country_label: i18n('phone_country_label'), + number_label: i18n('phone_number_label'), + send_code: i18n('phone_send_code'), + verify_btn: i18n('phone_verify_btn'), + resend_code: i18n('phone_resend_code'), + change_number: i18n('phone_change_number'), + enter_valid: i18n('phone_enter_valid'), + invalid_code: i18n('phone_invalid_code'), + could_not_send: i18n('phone_could_not_send'), + could_not_verify: i18n('phone_could_not_verify'), + search_countries: i18n('phone_search_countries'), + no_matches: i18n('phone_no_matches'), + select_country: i18n('phone_select_country'), + code_sent_to: i18n('phone_code_sent_to'), + suggested: i18n('phone_suggested'), + all_countries: i18n('phone_all_countries'), + }; const spinner = ''; - const send_btn_txt = 'Send Code'; - const verify_btn_txt = 'Verify Phone'; + const send_btn_txt = T.send_code; + const verify_btn_txt = T.verify_btn; const phoneIcon = ''; + // Authoritative country list (libphonenumber + localized names). + const countries = get_country_list(window.locale); + const countryByIso = {}; + for (const c of countries) countryByIso[c.iso] = c; + + // Best-effort starting country: last-used (this browser), else the + // browser locale's region, else US, else whatever's first. + const detectDefaultIso = () => { + try { + const stored = localStorage.getItem('phone_verif_last_country'); + if (stored && countryByIso[stored]) return stored; + } catch (_) {} + try { + const langs = navigator.languages?.length + ? navigator.languages + : [navigator.language]; + for (const l of langs) { + const region = (l || '').split('-')[1]; + if (region && countryByIso[region.toUpperCase()]) { + return region.toUpperCase(); + } + } + } catch (_) {} + return countryByIso['US'] ? 'US' : countries[0]?.iso; + }; + let currentIso = detectDefaultIso(); + let currentDial = (countryByIso[currentIso] || {}).dial || ''; + + // A short "Suggested" group pinned to the top: the chosen country + // (last-used or locale-detected) plus a few common ones, deduped. This + // keeps the typical pick one click away despite the ~245-item list. + const POPULAR_ISOS = ['US', 'GB', 'CA', 'AU', 'IN', 'DE', 'FR']; + const suggestedIsos = []; + const pushSuggested = (iso) => { + if (iso && countryByIso[iso] && !suggestedIsos.includes(iso)) { + suggestedIsos.push(iso); + } + }; + pushSuggested(currentIso); // already prefers last-used, then locale + POPULAR_ISOS.forEach(pushSuggested); + const suggested = suggestedIsos.slice(0, 6).map((iso) => countryByIso[iso]); + + // Render one