Add phone verification UI improvements and helpers

Revamps the phone verification dialog and adds reusable phone helper modules.

- Heavily refactors UIWindowPhoneVerificationRequired.js to add a searchable inline country picker, national-number live formatting, country autodetect for "+" input, WebOTP auto-fill support, resend cooldown timer, accessibility improvements, proper teardown/cleanup, and last-used country persistence.
- Introduces helpers/src/helpers/country_codes.js (get_country_list, flag emoji) which builds a localized list of dialable countries from libphonenumber-js and Intl.DisplayNames.
- Introduces helpers/src/helpers/phone.js with thin wrappers around libphonenumber-js: format_phone_as_you_type, inspect_phone, phone_example_for, detect_country_from_input.
- Adds English i18n strings for the new UI in src/gui/src/i18n/translations/en.js.

These changes keep submission canonicalization using libphonenumber-js (E.164), improve UX for selecting/formatting numbers, and make the country list and phone utilities reusable across the app.
This commit is contained in:
jelveh
2026-06-18 16:50:08 -07:00
parent b576f01d12
commit 5f86f80313
4 changed files with 924 additions and 133 deletions
@@ -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 =
'<svg style="width:20px; margin-top: 5px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#fff" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#eee" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
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 =
'<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>';
// 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 <li> option. `data-search` is a pre-lowercased haystack
// (name + dial with/without "+" + iso) so filtering is a substring test.
// `data-group` lets the filter hide the Suggested copies while searching.
const renderOption = (c, idPrefix, group) => {
const search =
`${c.name} ${c.dial} ${c.dial.replace('+', '')} ${c.iso}`.toLowerCase();
return (
`<li class="phone-country-option" role="option" id="${idPrefix}${c.iso}" data-iso="${c.iso}" data-group="${group}" data-dial="${html_encode(c.dial)}" data-search="${html_encode(search)}">` +
`<span class="cc-flag">${c.flag}</span>` +
`<span class="cc-name">${html_encode(c.name)}</span>` +
`<span class="cc-dial">${html_encode(c.dial)}</span>` +
`<span class="cc-check" aria-hidden="true">✓</span>` +
`</li>`
);
};
let countryOptions = '';
if (suggested.length) {
countryOptions += `<li class="cc-group-label" aria-hidden="true">${T.suggested}</li>`;
countryOptions += suggested
.map((c) => renderOption(c, 'cc-sug-', 'suggested'))
.join('');
countryOptions += `<li class="cc-group-label" aria-hidden="true">${T.all_countries}</li>`;
}
countryOptions += countries
.map((c) => renderOption(c, 'cc-opt-', 'all'))
.join('');
let h = '';
// Scoped styling for this dialog.
h += `<style>
@@ -85,9 +221,97 @@ function UIWindowPhoneVerificationRequired(options) {
background: #fff;
}
.window-confirm-phone-using-code .phone-input::placeholder { color: #aebac6; }
.window-confirm-phone-using-code .phone-input:focus {
/* Field combines the country picker (left) with the national number. */
.window-confirm-phone-using-code .phone-input-group {
display: flex; align-items: stretch;
border: 1.5px solid #d4dde6; border-radius: 9px;
background: #fff; overflow: hidden;
transition: border-color .15s, box-shadow .15s;
}
.window-confirm-phone-using-code .phone-input-group.focused {
border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.15);
}
.window-confirm-phone-using-code .phone-input-group .phone-input {
border: none; border-radius: 0; flex: 1; min-width: 0;
}
.window-confirm-phone-using-code .phone-input-group .phone-input:focus {
border: none; box-shadow: none;
}
.window-confirm-phone-using-code .phone-country-btn {
display: flex; align-items: center; gap: 6px;
padding: 0 11px; background: #f3f7fb; border: none;
border-right: 1.5px solid #d4dde6; cursor: pointer;
font-size: 16px; color: #2c3e50; white-space: nowrap;
}
.window-confirm-phone-using-code .phone-country-btn:hover { background: #eaf1f9; }
.window-confirm-phone-using-code .phone-country-btn:focus-visible {
outline: 2px solid #3b82f6; outline-offset: -2px;
}
.window-confirm-phone-using-code .phone-country-btn .cc-flag { font-size: 18px; line-height: 1; }
.window-confirm-phone-using-code .phone-country-btn-dial { font-size: 15px; }
.window-confirm-phone-using-code .phone-country-btn-caret { color: #8a99a8; font-size: 11px; }
.window-confirm-phone-using-code .phone-valid-check {
display: flex; align-items: center; padding: 0 12px;
color: #16a34a; font-weight: 700; opacity: 0; transition: opacity .15s;
}
.window-confirm-phone-using-code .phone-input-group.valid .phone-valid-check { opacity: 1; }
/* Inline-expanding country list (not an overlay — avoids clipping by
the scrollable window body). */
.window-confirm-phone-using-code .phone-country-panel {
margin-top: 8px; border: 1.5px solid #d4dde6; border-radius: 9px;
background: #fff; overflow: hidden;
}
.window-confirm-phone-using-code .phone-country-panel[hidden],
.window-confirm-phone-using-code .phone-country-empty[hidden] { display: none; }
.window-confirm-phone-using-code .phone-country-search {
width: 100%; box-sizing: border-box; padding: 11px 12px;
font-size: 16px; color: #2c3e50; border: none;
border-bottom: 1px solid #e9eef3; outline: none; background: #fff;
}
/* Override the global input[type=text]:focus rule (style.css), which
swaps in a 2px border + 7px padding on focus and makes this field
jump. Keep border/padding identical to the unfocused state — the
open panel already signals that the search is active. */
.window-confirm-phone-using-code .phone-country-search:focus {
border: none; border-bottom: 1px solid #e9eef3;
padding: 11px 12px; outline: none; box-shadow: none;
}
.window-confirm-phone-using-code .phone-country-list {
list-style: none; margin: 0; padding: 4px; max-height: 216px; overflow-y: auto;
}
.window-confirm-phone-using-code .phone-country-option {
display: flex; align-items: center; gap: 10px;
padding: 9px 10px; border-radius: 7px; cursor: pointer;
font-size: 14px; color: #2c3e50;
}
.window-confirm-phone-using-code .phone-country-option .cc-flag {
font-size: 18px; width: 22px; text-align: center; flex: none;
}
.window-confirm-phone-using-code .phone-country-option .cc-name {
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.window-confirm-phone-using-code .phone-country-option .cc-dial {
color: #8a99a8; flex: none; font-variant-numeric: tabular-nums;
}
.window-confirm-phone-using-code .phone-country-option .cc-check {
flex: none; width: 14px; text-align: center; color: #3b82f6;
font-weight: 700; opacity: 0;
}
.window-confirm-phone-using-code .phone-country-option.selected .cc-check { opacity: 1; }
.window-confirm-phone-using-code .phone-country-option.selected .cc-name { font-weight: 600; }
/* Only on real hover devices, so the highlight doesn't stick after a tap. */
@media (hover: hover) {
.window-confirm-phone-using-code .phone-country-option:hover { background: #eef4fe; }
}
.window-confirm-phone-using-code .phone-country-option.active { background: #eaf2fe; }
.window-confirm-phone-using-code .cc-group-label {
list-style: none; padding: 10px 10px 4px; font-size: 11px;
font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
color: #a7b4c2;
}
.window-confirm-phone-using-code .phone-country-empty {
padding: 16px; text-align: center; color: #8a99a8; font-size: 13px;
}
.window-confirm-phone-using-code .phone-send-btn,
.window-confirm-phone-using-code .phone-verify-btn {
margin-top: 18px; height: 42px; font-size: 15px; font-weight: 500;
@@ -100,9 +324,23 @@ function UIWindowPhoneVerificationRequired(options) {
color: #3b82f6; cursor: pointer; text-decoration: none;
}
.window-confirm-phone-using-code .phone-footer a:hover { text-decoration: underline; }
.window-confirm-phone-using-code .phone-footer a.disabled {
color: #b7c2cd; cursor: default; text-decoration: none; pointer-events: none;
}
.window-confirm-phone-using-code .error {
color: #c0392b; font-size: 13px; text-align: center; margin-bottom: 10px;
}
/* Touch devices: meet the ~44px minimum tap target for list rows
and the search field; keep the input >=16px to avoid iOS focus-zoom. */
@media (pointer: coarse) {
.window-confirm-phone-using-code .phone-country-option {
min-height: 44px; font-size: 16px;
}
.window-confirm-phone-using-code .phone-country-search { min-height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.window-confirm-phone-using-code * { transition: none !important; }
}
</style>`;
if (options.show_close_button !== false) {
h +=
@@ -111,20 +349,31 @@ function UIWindowPhoneVerificationRequired(options) {
h +=
'<div style="-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #3e5362; max-width: 330px; margin: 0 auto;">';
h += `<div class="phone-icon-badge">${phoneIcon}</div>`;
h += '<h3 class="phone-title">Verify your phone number</h3>';
h += `<h3 class="phone-title">${T.title}</h3>`;
// -- Step 1: phone number --
h += '<form class="phone-step phone-step-1">';
h +=
'<p class="phone-subtitle">We\'ll text you a verification code to confirm it\'s really you.</p>';
h += `<p class="phone-subtitle">${T.subtitle}</p>`;
// Offer a friendly human fallback so verification is never a dead end —
// worded as help, not as an accusation.
h += `<p style="text-align:center; font-size:12px; line-height:1.4; color:#8a99a8; margin:-12px auto 18px; max-width:320px;">Need help? Email <a href="mailto:hi@puter.com" style="color:#3b82f6; text-decoration:none;">hi@puter.com</a> and we'll assist creating your account.</p>`;
h += '<div class="error"></div>';
h += '<div class="error" role="alert" aria-live="assertive"></div>';
h += `<label class="phone-field-label" id="phone-number-label" for="phone-verif-input">${T.number_label}</label>`;
h += '<div class="phone-input-group">';
h += `<button type="button" class="phone-country-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="${T.select_country}">`;
h += '<span class="cc-flag phone-country-btn-flag"></span>';
h += '<span class="phone-country-btn-dial"></span>';
h += '<span class="phone-country-btn-caret" aria-hidden="true">▾</span>';
h += '</button>';
h +=
'<label class="phone-field-label" for="phone-verif-input">Phone number</label>';
h +=
'<input id="phone-verif-input" class="phone-input" type="tel" autocomplete="tel" placeholder="+1 (555) 123-4567" />';
'<input id="phone-verif-input" class="phone-input" type="tel" inputmode="tel" autocomplete="tel-national" />';
h += '<span class="phone-valid-check" aria-hidden="true">✓</span>';
h += '</div>';
h += '<div class="phone-country-panel" hidden>';
h += `<input type="text" class="phone-country-search" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" placeholder="${T.search_countries}" aria-label="${T.search_countries}" role="combobox" aria-expanded="true" aria-controls="phone-country-listbox" />`;
h += `<ul class="phone-country-list" id="phone-country-listbox" role="listbox" aria-label="${T.country_label}">${countryOptions}</ul>`;
h += `<div class="phone-country-empty" hidden>${T.no_matches}</div>`;
h += '</div>';
h += `<button type="submit" class="button button-block button-primary phone-send-btn">${send_btn_txt}</button>`;
if (options.logout_in_footer) {
h += `<div class="phone-footer"><a class="phone-log-out">${i18n('log_out')}</a></div>`;
@@ -133,22 +382,20 @@ function UIWindowPhoneVerificationRequired(options) {
// -- Step 2: 6-digit code (hidden until a code is sent) --
h += '<form class="phone-step phone-step-2" style="display:none;">';
h +=
'<p class="phone-subtitle">Enter the 6-digit code sent to <strong style="font-weight: 600; color:#3e5362;" class="phone-target"></strong></p>';
h += '<div class="error"></div>';
h += `<p class="phone-subtitle">${T.code_sent_to} <strong style="font-weight: 600; color:#3e5362;" class="phone-target"></strong></p>`;
h += '<div class="error" role="alert" aria-live="assertive"></div>';
h += ` <fieldset name="number-code" style="border: none; padding:0;" data-number-code-form>
<input class="digit-input" type="number" min='0' max='9' name='number-code-0' data-number-code-input='0' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-1' data-number-code-input='1' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-2' data-number-code-input='2' required />
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" autocomplete="one-time-code" name='number-code-0' data-number-code-input='0' required />
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" name='number-code-1' data-number-code-input='1' required />
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" name='number-code-2' data-number-code-input='2' required />
<span class="email-confirm-code-hyphen">-</span>
<input class="digit-input" type="number" min='0' max='9' name='number-code-3' data-number-code-input='3' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-4' data-number-code-input='4' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-5' data-number-code-input='5' required />
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" name='number-code-3' data-number-code-input='3' required />
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" name='number-code-4' data-number-code-input='4' required />
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" name='number-code-5' data-number-code-input='5' required />
</fieldset>`;
h += `<button type="submit" class="button button-block button-primary phone-verify-btn" disabled>${verify_btn_txt}</button>`;
h += '<div class="phone-footer">';
h +=
'<a class="phone-resend-code">Re-send code</a> &nbsp;&bull;&nbsp; <a class="phone-change-number">Change number</a>';
h += `<a class="phone-resend-code">${T.resend_code}</a> &nbsp;&bull;&nbsp; <a class="phone-change-number">${T.change_number}</a>`;
if (options.logout_in_footer) {
h += ' &nbsp;&bull;&nbsp; ';
h += `<a class="phone-log-out">${i18n('log_out')}</a>`;
@@ -178,8 +425,18 @@ function UIWindowPhoneVerificationRequired(options) {
width: 390,
dominant: true,
...options.window_options,
// Teardown that must run on EVERY close path (close button, parent
// cascade, programmatic .close()), not just the buttons we wire
// below — otherwise the resend setInterval keeps firing on a
// detached node. Also settles the promise for the closable variant.
on_close: () => {
cleanup();
if (!logging_out) settle(false);
},
onAppend: function (el_window) {
$(el_window).find('.phone-input').first().focus();
const $inp = $(el_window).find('.phone-input').first();
$inp.focus();
$inp.closest('.phone-input-group').addClass('focused');
},
window_class: 'window-confirm-phone-using-code',
window_css: {
@@ -195,21 +452,265 @@ function UIWindowPhoneVerificationRequired(options) {
});
const showError = (msg) => {
$(el_window).find('.error').html(html_encode(msg)).fadeIn();
$(el_window).find('.phone-step:visible .error').html(html_encode(msg)).fadeIn();
};
const clearError = () => {
$(el_window).find('.error').hide();
};
// 6-digit code DOM refs (used by paste/WebOTP and the per-digit logic).
const numberCodeForm = el_window.querySelector('[data-number-code-form]');
const numberCodeInputs = [
...numberCodeForm.querySelectorAll('[data-number-code-input]'),
];
// -- teardown: clear timers / listeners that outlive a single render --
let resendTimer = null;
let otpAbort = null;
const onDocMouseDown = (e) => {
// Self-heal: if the window is gone, detach and stop.
if (!document.body.contains(el_window)) {
document.removeEventListener('mousedown', onDocMouseDown, true);
return;
}
if (!comboOpen) return;
const grp = el_window.querySelector('.phone-input-group');
const pnl = el_window.querySelector('.phone-country-panel');
if ((grp && grp.contains(e.target)) || (pnl && pnl.contains(e.target))) return;
closeCombo();
};
const cleanup = () => {
try { if (resendTimer) { clearInterval(resendTimer); resendTimer = null; } } catch (_) {}
try { otpAbort?.abort(); } catch (_) {}
try { document.removeEventListener('mousedown', onDocMouseDown, true); } catch (_) {}
};
// ---------- Country picker (searchable, inline-expanding) ----------
let comboOpen = false;
const $panel = () => $(el_window).find('.phone-country-panel');
const searchEl = () => el_window.querySelector('.phone-country-search');
const allOptions = () => [
...el_window.querySelectorAll('.phone-country-option'),
];
const visibleOptions = () =>
allOptions().filter((o) => o.style.display !== 'none');
const updateValidityHint = () => {
const inp = el_window.querySelector('#phone-verif-input');
const { is_valid } = inspect_phone(inp ? inp.value : '', currentIso);
$(el_window).find('.phone-input-group').toggleClass('valid', is_valid);
};
const setCountry = (iso) => {
const c = countryByIso[iso];
if (!c) return;
currentIso = c.iso;
currentDial = c.dial;
const $btn = $(el_window).find('.phone-country-btn');
$btn.find('.phone-country-btn-flag').text(c.flag || '');
$btn.find('.phone-country-btn-dial').text(c.dial);
$btn.attr('aria-label', `${T.select_country}: ${c.name} ${c.dial}`);
const inp = el_window.querySelector('#phone-verif-input');
if (inp) inp.setAttribute('placeholder', phone_example_for(c.iso) || '');
// Mark the currently-selected country (both its Suggested and
// All-countries copies) so the check stays visible as the keyboard
// cursor / hover moves elsewhere.
allOptions().forEach((o) =>
o.classList.toggle('selected', o.dataset.iso === c.iso),
);
try { localStorage.setItem('phone_verif_last_country', c.iso); } catch (_) {}
updateValidityHint();
};
const reformatNationalValue = () => {
const inp = el_window.querySelector('#phone-verif-input');
if (!inp) return;
inp.value = format_phone_as_you_type(inp.value, currentIso);
updateValidityHint();
};
const setActiveOption = (el) => {
allOptions().forEach((o) => o.classList.remove('active'));
const s = searchEl();
if (el) {
el.classList.add('active');
if (s) s.setAttribute('aria-activedescendant', el.id);
el.scrollIntoView({ block: 'nearest' });
} else if (s) {
s.removeAttribute('aria-activedescendant');
}
};
const filterOptions = (q) => {
const query = (q || '').trim().toLowerCase();
const labels = [...el_window.querySelectorAll('.cc-group-label')];
let anyVisible = false;
if (query) {
// Searching: drop the Suggested group + its labels so results
// aren't shown twice, then substring-filter the full list.
labels.forEach((l) => (l.style.display = 'none'));
for (const o of allOptions()) {
if (o.dataset.group === 'suggested') {
o.style.display = 'none';
continue;
}
const match = o.dataset.search.indexOf(query) !== -1;
o.style.display = match ? '' : 'none';
if (match) anyVisible = true;
}
} else {
labels.forEach((l) => (l.style.display = ''));
for (const o of allOptions()) o.style.display = '';
anyVisible = true;
}
$(el_window).find('.phone-country-empty').prop('hidden', anyVisible);
setActiveOption(visibleOptions()[0] || null);
};
const openCombo = () => {
if (comboOpen) return;
comboOpen = true;
$panel().prop('hidden', false);
$(el_window).find('.phone-country-btn').attr('aria-expanded', 'true');
const s = searchEl();
if (s) s.value = '';
filterOptions('');
// Open at the top so the pinned Suggested group is what users see
// first, then highlight the selected country (its Suggested copy).
const list = el_window.querySelector('.phone-country-list');
if (list) list.scrollTop = 0;
const sel = el_window.querySelector(
`.phone-country-option[data-iso="${currentIso}"]`,
);
if (sel) setActiveOption(sel);
if (s && !isTouch) s.focus();
};
const closeCombo = () => {
if (!comboOpen) return;
comboOpen = false;
$panel().prop('hidden', true);
$(el_window).find('.phone-country-btn').attr('aria-expanded', 'false');
};
const toggleCombo = () => (comboOpen ? closeCombo() : openCombo());
const selectOption = (el) => {
if (!el) return;
setCountry(el.dataset.iso);
reformatNationalValue();
closeCombo();
const inp = el_window.querySelector('#phone-verif-input');
if (inp) inp.focus();
};
// National-number input: live formatting with caret preservation, plus
// auto-detecting the country when the user types a "+..." number.
const handleNationalInput = () => {
const inp = el_window.querySelector('#phone-verif-input');
if (!inp) return;
const before = inp.value;
const caret = inp.selectionStart ?? before.length;
const digitsBeforeCaret = before
.slice(0, caret)
.replace(/\D/g, '').length;
const detected = detect_country_from_input(before);
if (detected && detected !== currentIso && countryByIso[detected]) {
setCountry(detected);
}
const formatted = format_phone_as_you_type(before, currentIso);
inp.value = formatted;
// Put the caret back after the same number of digits it preceded.
let pos = 0;
let seen = 0;
while (pos < formatted.length && seen < digitsBeforeCaret) {
if (/\d/.test(formatted[pos])) seen++;
pos++;
}
try { inp.setSelectionRange(pos, pos); } catch (_) {}
updateValidityHint();
};
// ---------- Resend cooldown ----------
const fmtMMSS = (s) => {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec < 10 ? '0' : ''}${sec}`;
};
const startResendCooldown = (secs) => {
const $link = $(el_window).find('.phone-resend-code');
let remaining = secs;
const render = () => {
// Self-heal: stop ticking if the window is gone.
if (!document.body.contains(el_window)) {
if (resendTimer) { clearInterval(resendTimer); resendTimer = null; }
return;
}
if (remaining <= 0) {
if (resendTimer) { clearInterval(resendTimer); resendTimer = null; }
$link.removeClass('disabled').text(T.resend_code);
return;
}
$link.addClass('disabled').text(
i18n('phone_resend_in', [fmtMMSS(remaining)]),
);
remaining--;
};
if (resendTimer) { clearInterval(resendTimer); resendTimer = null; }
render();
resendTimer = setInterval(render, 1000);
};
// ---------- WebOTP: auto-fill the SMS code on supporting devices ----------
const fillCode = (code) => {
const digits = (code || '').replace(/\D/g, '').slice(0, 6).split('');
if (!digits.length) return;
numberCodeInputs.forEach((inp, i) => {
inp.value = digits[i] ?? '';
});
final_code = digits.join('');
if (final_code.length === 6) {
$(el_window).find('.phone-verify-btn').prop('disabled', false);
doVerify();
} else {
(numberCodeInputs[digits.length] ?? numberCodeInputs[numberCodeInputs.length - 1]).focus();
}
};
const startWebOTP = () => {
if (!('OTPCredential' in window)) return;
try {
if (otpAbort) { try { otpAbort.abort(); } catch (_) {} }
otpAbort = new AbortController();
navigator.credentials
.get({ otp: { transport: ['sms'] }, signal: otpAbort.signal })
.then((otp) => {
if (otp && otp.code) fillCode(String(otp.code));
})
.catch(() => {});
} catch (_) {}
};
// -- Step 1: send the code --
const sendCode = async () => {
if (is_sending) return;
clearError();
const phone = $(el_window).find('.phone-input').val();
if (!phone || phone.trim().length < 5) {
showError('Please enter a valid phone number.');
const inp = el_window.querySelector('#phone-verif-input');
const raw = (inp ? inp.value : '').trim();
const digits = raw.replace(/\D/g, '');
if (digits.length < 4) {
showError(T.enter_valid);
return;
}
// Canonical E.164 from libphonenumber; fall back to a naive join
// only if parsing fails entirely, so we always send *something* and
// let the server be the final arbiter (never block a real signup).
const { e164 } = inspect_phone(raw, currentIso);
const phone = e164 || `${currentDial}${digits}`;
is_sending = true;
$(el_window)
.find('.phone-send-btn')
@@ -235,18 +736,29 @@ function UIWindowPhoneVerificationRequired(options) {
headers: { Authorization: `Bearer ${window.auth_token}` },
statusCode: { 401: (xhr) => window.handle401(xhr) },
success: function () {
// Advance to the code-entry step.
// Advance to the code-entry step with a clean slate.
$(el_window).find('.phone-target').text(phone);
numberCodeInputs.forEach((i) => {
i.value = '';
i.disabled = false;
});
final_code = '';
$(el_window)
.find('.phone-verify-btn')
.prop('disabled', true)
.html(verify_btn_txt);
$(el_window).find('.phone-step-1').hide();
$(el_window).find('.phone-step-2').show();
$(el_window).find('.digit-input').first().focus();
numberCodeInputs[0]?.focus();
startResendCooldown(RESEND_COOLDOWN_SECONDS);
startWebOTP();
},
error: function (xhr) {
const reason = xhr.responseJSON?.reason;
showError(
SEND_REASON_MESSAGES[reason] ??
xhr.responseJSON?.error ??
'Could not send a code to that number.',
T.could_not_send,
);
},
complete: function () {
@@ -259,117 +771,152 @@ function UIWindowPhoneVerificationRequired(options) {
});
};
$(el_window)
.find('.phone-send-btn')
.on('click submit', function (e) {
e.preventDefault();
e.stopPropagation();
sendCode();
// -- Step 2: verify the code --
const doVerify = () => {
if (is_checking_code) return;
if (!final_code || final_code.length !== 6) return;
is_checking_code = true;
clearError();
$(el_window).find('.phone-verify-btn').prop('disabled', true).html(spinner);
$(el_window).find('.digit-input').prop('disabled', true);
$.ajax({
url: `${window.api_origin}/confirm-phone`,
type: 'POST',
data: JSON.stringify({ code: final_code }),
async: true,
contentType: 'application/json',
headers: { Authorization: `Bearer ${window.auth_token}` },
statusCode: { 401: (xhr) => window.handle401(xhr) },
success: function (res) {
if (res.phone_verified) {
// Settle before close() so the on_close hook's
// settle(false) is a no-op; cleanup runs via on_close.
settle(true);
window.refresh_user_data(window.auth_token);
$(el_window).close();
} else {
showError(T.invalid_code);
$(el_window).find('.digit-input').val('').prop('disabled', false);
final_code = '';
numberCodeInputs[0]?.focus();
$(el_window)
.find('.phone-verify-btn')
.prop('disabled', true)
.html(verify_btn_txt);
}
},
error: function (xhr) {
showError(xhr.responseJSON?.error ?? T.could_not_verify);
$(el_window).find('.digit-input').val('').prop('disabled', false);
final_code = '';
numberCodeInputs[0]?.focus();
$(el_window)
.find('.phone-verify-btn')
.prop('disabled', true)
.html(verify_btn_txt);
},
complete: function () {
is_checking_code = false;
},
});
};
// ---------- Wire up events ----------
$(el_window).find('.phone-country-btn').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
toggleCombo();
});
const sEl = searchEl();
if (sEl) {
sEl.addEventListener('input', (e) => filterOptions(e.target.value));
sEl.addEventListener('keydown', (e) => {
const vis = visibleOptions();
const active = el_window.querySelector('.phone-country-option.active');
const idx = vis.indexOf(active);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (vis.length) setActiveOption(vis[Math.min(idx + 1, vis.length - 1)] ?? vis[0]);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (vis.length) setActiveOption(vis[Math.max(idx - 1, 0)]);
} else if (e.key === 'Enter') {
e.preventDefault();
selectOption(active || vis[0]);
} else if (e.key === 'Escape') {
e.preventDefault();
closeCombo();
$(el_window).find('.phone-country-btn').focus();
}
});
}
$(el_window)
.find('.phone-country-list')
.on('click', '.phone-country-option', function () {
selectOption(this);
});
document.addEventListener('mousedown', onDocMouseDown, true);
const $inp = $(el_window).find('#phone-verif-input');
$inp.on('input', handleNationalInput);
$inp.on('focus', function () {
$(this).closest('.phone-input-group').addClass('focused');
});
$inp.on('blur', function () {
$(this).closest('.phone-input-group').removeClass('focused');
});
$(el_window).find('.phone-send-btn').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
sendCode();
});
$(el_window).find('.phone-step-1').on('submit', function (e) {
e.preventDefault();
sendCode();
});
$(el_window).find('.phone-verify-btn').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
doVerify();
});
$(el_window).find('.phone-step-2').on('submit', function (e) {
e.preventDefault();
doVerify();
});
// Re-send / change number on the code step.
$(el_window)
.find('.phone-resend-code')
.on('click', function () {
sendCode();
});
$(el_window)
.find('.phone-change-number')
.on('click', function () {
clearError();
$(el_window).find('.phone-step-2').hide();
$(el_window).find('.phone-step-1').show();
$(el_window).find('.phone-input').focus();
});
$(el_window).find('.phone-resend-code').on('click', function () {
if ($(this).hasClass('disabled')) return;
sendCode();
});
$(el_window).find('.phone-change-number').on('click', function () {
clearError();
try { otpAbort?.abort(); } catch (_) {}
$(el_window).find('.phone-step-2').hide();
$(el_window).find('.phone-step-1').show();
$(el_window).find('#phone-verif-input').focus();
});
// -- Step 2: verify the code --
$(el_window)
.find('.phone-verify-btn')
.on('click submit', function (e) {
e.preventDefault();
e.stopPropagation();
$(el_window).find('.phone-verify-btn').prop('disabled', true);
$(el_window).find('.digit-input').prop('disabled', true);
clearError();
if (is_checking_code) return;
is_checking_code = true;
$(el_window).find('.phone-verify-btn').html(spinner);
setTimeout(() => {
$.ajax({
url: `${window.api_origin}/confirm-phone`,
type: 'POST',
data: JSON.stringify({ code: final_code }),
async: true,
contentType: 'application/json',
headers: {
Authorization: `Bearer ${window.auth_token}`,
},
statusCode: { 401: (xhr) => window.handle401(xhr) },
success: function (res) {
if (res.phone_verified) {
$(el_window).close();
window.refresh_user_data(window.auth_token);
resolve(true);
} else {
showError('Invalid verification code.');
$(el_window).find('.digit-input').val('');
$(el_window)
.find('.digit-input')
.first()
.focus();
$(el_window)
.find('.phone-verify-btn')
.prop('disabled', false)
.html(verify_btn_txt);
$(el_window)
.find('.digit-input')
.prop('disabled', false);
}
},
error: function (xhr) {
showError(
xhr.responseJSON?.error ??
'Could not verify code.',
);
$(el_window).find('.digit-input').val('');
$(el_window).find('.digit-input').first().focus();
$(el_window)
.find('.phone-verify-btn')
.prop('disabled', false)
.html(verify_btn_txt);
$(el_window)
.find('.digit-input')
.prop('disabled', false);
},
complete: function () {
is_checking_code = false;
},
});
}, 1000);
});
// logout
$(el_window)
.find('.phone-log-out')
.on('click', function () {
window.logout();
$(el_window).close();
});
// logout — handled by the global 'logout' event; flag it so the
// close hook doesn't resolve(false) and trigger a caller re-open.
// (Teardown for the close button and all other paths runs via on_close.)
$(el_window).find('.phone-log-out').on('click', function () {
logging_out = true;
window.logout();
$(el_window).close();
});
// -- 6-digit input handling (mirrors the email confirmation dialog) --
const numberCodeForm = el_window.querySelector(
'[data-number-code-form]',
);
const numberCodeInputs = [
...numberCodeForm.querySelectorAll('[data-number-code-input]'),
];
numberCodeForm.addEventListener('input', ({ target }) => {
if (!target.value.length) {
final_code = '';
$(el_window).find('.phone-verify-btn').prop('disabled', true);
return (target.value = null);
}
const inputLength = target.value.length;
@@ -401,7 +948,7 @@ function UIWindowPhoneVerificationRequired(options) {
if (final_code.length === 6) {
$(el_window).find('.phone-verify-btn').prop('disabled', false);
$(el_window).find('.digit-input').prop('disabled', false);
$(el_window).find('.phone-verify-btn').trigger('click');
doVerify();
}
});
@@ -435,6 +982,9 @@ function UIWindowPhoneVerificationRequired(options) {
break;
}
});
// Initialize the picker button (flag, dial, placeholder example).
setCountry(currentIso);
});
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { getCountries, getCountryCallingCode } from 'libphonenumber-js';
// The country list for the SMS phone-verification dropdown is derived at
// runtime from libphonenumber-js (the authoritative set of dialable countries
// and their calling codes) and Intl.DisplayNames (localized country names).
// Nothing is hand-maintained, so the list can never drift out of sync with the
// parsing/validation logic that uses the same library.
/**
* Convert a two-letter ISO 3166-1 alpha-2 country code into its flag emoji by
* mapping each ASCII letter to its regional-indicator symbol. Returns an empty
* string for malformed input so callers can safely concatenate the result.
*
* @global
* @param {string} iso - Two-letter country code, e.g. "US".
* @returns {string} The flag emoji, or '' if the code is not two ASCII letters.
*/
const flag_emoji_for = (iso) => {
if (typeof iso !== 'string' || iso.length !== 2) return '';
const base = 0x1f1e6; // regional indicator symbol letter A
const cc = iso.toUpperCase();
const a = cc.charCodeAt(0) - 65;
const b = cc.charCodeAt(1) - 65;
if (a < 0 || a > 25 || b < 0 || b > 25) return '';
return String.fromCodePoint(base + a) + String.fromCodePoint(base + b);
};
// Cache the assembled list per locale — building it touches ~245 countries.
const _list_cache = {};
/**
* The full list of dialable countries for the given locale, each entry:
* { iso: 'US', dial: '+1', name: 'United States', flag: '🇺🇸' }
* Sorted by localized name. Cached per locale. Always returns an array (empty
* only in the extremely unlikely event libphonenumber yields nothing).
*
* @global
* @param {string} [locale] - BCP-47 locale for country names (defaults to 'en').
* @returns {Array<{ iso: string, dial: string, name: string, flag: string }>}
*/
const get_country_list = (locale) => {
const key = locale || 'en';
if (_list_cache[key]) return _list_cache[key];
let display_names = null;
try {
display_names = new Intl.DisplayNames([key, 'en'], { type: 'region' });
} catch (_) {
try {
display_names = new Intl.DisplayNames(['en'], { type: 'region' });
} catch (__) {
display_names = null;
}
}
let isos = [];
try {
isos = getCountries();
} catch (_) {
isos = [];
}
const list = [];
for (const iso of isos) {
let dial = '';
try {
dial = '+' + getCountryCallingCode(iso);
} catch (_) {
continue; // skip anything without a usable calling code
}
let name = iso;
try {
name = (display_names && display_names.of(iso)) || iso;
} catch (_) {
name = iso;
}
list.push({ iso, dial, name, flag: flag_emoji_for(iso) });
}
try {
list.sort((a, b) => a.name.localeCompare(b.name, key));
} catch (_) {
list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
_list_cache[key] = list;
return list;
};
export { get_country_list, flag_emoji_for };
+113
View File
@@ -0,0 +1,113 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
AsYouType,
parsePhoneNumber,
getExampleNumber,
} from 'libphonenumber-js';
import phoneExamples from 'libphonenumber-js/mobile/examples';
// Thin, defensive wrappers around libphonenumber-js used by the SMS
// phone-verification dialog. Every function swallows the library's throws
// (it throws on empty/garbage input) and returns a safe fallback, so the UI
// can call them on every keystroke without guarding each call site.
/**
* Format a partially-typed national number for display as the user types,
* e.g. "2015550123" → "(201) 555-0123" for US. Purely cosmetic: the value is
* always re-parsed before submission, so a formatting miss can never corrupt
* the number actually sent. Returns the original input on any error.
*
* @param {string} value - Raw input value.
* @param {string} iso - Selected country (ISO 3166-1 alpha-2).
* @returns {string}
*/
const format_phone_as_you_type = (value, iso) => {
try {
return new AsYouType(iso).input(value ?? '');
} catch (_) {
return value ?? '';
}
};
/**
* Parse a typed number against the selected country and report its canonical
* E.164 form and whether it is a valid number. Accepts either a national
* number (interpreted using `iso`) or a full international number (leading
* "+", which overrides `iso`). libphonenumber correctly handles trunk
* prefixes here — e.g. UK "07400 123456" → "+447400123456".
*
* @param {string} input - Raw input value.
* @param {string} iso - Selected country (ISO 3166-1 alpha-2).
* @returns {{ e164: string|null, is_valid: boolean }}
*/
const inspect_phone = (input, iso) => {
const raw = (input ?? '').trim();
if (!raw) return { e164: null, is_valid: false };
try {
const parsed = parsePhoneNumber(raw, iso);
if (!parsed) return { e164: null, is_valid: false };
return { e164: parsed.number, is_valid: parsed.isValid() };
} catch (_) {
return { e164: null, is_valid: false };
}
};
/**
* An example national-format number for a country, used as the field
* placeholder so users see the expected shape. "" when unavailable.
*
* @param {string} iso - Country (ISO 3166-1 alpha-2).
* @returns {string}
*/
const phone_example_for = (iso) => {
try {
const ex = getExampleNumber(iso, phoneExamples);
return ex ? ex.formatNational() : '';
} catch (_) {
return '';
}
};
/**
* If the user typed or pasted a full international number (leading "+"),
* detect which country it belongs to so the picker can follow along. Returns
* an ISO code, or null when the input isn't international or is ambiguous.
*
* @param {string} input - Raw input value.
* @returns {string|null}
*/
const detect_country_from_input = (input) => {
const raw = (input ?? '').trim();
if (!raw.startsWith('+')) return null;
try {
const parsed = parsePhoneNumber(raw);
return parsed?.country ?? null;
} catch (_) {
return null;
}
};
export {
format_phone_as_you_type,
inspect_phone,
phone_example_for,
detect_country_from_input,
};
+19
View File
@@ -228,6 +228,25 @@ const en = {
paste_into_folder: 'Paste Into Folder',
path: 'Path',
personalization: 'Personalization',
phone_verify_title: 'Verify your phone number',
phone_verify_subtitle: "We'll text you a verification code to confirm it's really you.",
phone_country_label: 'Country',
phone_number_label: 'Phone number',
phone_send_code: 'Send Code',
phone_verify_btn: 'Verify Phone',
phone_resend_code: 'Re-send code',
phone_resend_in: 'Resend in %%',
phone_change_number: 'Change number',
phone_enter_valid: 'Please enter a valid phone number.',
phone_invalid_code: 'Invalid verification code.',
phone_could_not_send: 'Could not send a code to that number.',
phone_could_not_verify: 'Could not verify code.',
phone_search_countries: 'Search countries',
phone_no_matches: 'No matches',
phone_suggested: 'Suggested',
phone_all_countries: 'All countries',
phone_select_country: 'Select country',
phone_code_sent_to: 'Enter the 6-digit code sent to',
pick_name_for_website: 'Pick a name for your website:',
pick_name_for_worker: 'Pick a name for your worker:',
picture: 'Picture',