fix: nicer sms card fallback (#3621)

This commit is contained in:
Daniel Salazar
2026-08-20 23:12:09 -07:00
committed by GitHub
parent b3923f86da
commit 65572dcf23
5 changed files with 240 additions and 9 deletions
@@ -29,6 +29,18 @@ import UIWindow from './UIWindow.js';
// Stripe.js is loaded lazily from the CDN only when this dialog actually opens.
// Used as a hard gate for low-reputation signups (after phone verification), so
// by default it has no close button.
//
// `options.phone_fallback` opens the same dialog as the SMS escape hatch: a
// user the phone gate keeps blocking verifies a card instead, which clears the
// phone gate server-side (see /card-verification/confirm). Two things change in
// that mode:
// - The dialog can be dismissed back to phone verification, so choosing the
// card path is never a one-way door.
// - `card_verified` alone is NOT treated as success. The phone gate only
// lifts when the server also reports `phone_verified`; anything else (the
// kill switch short-circuit, in particular) would close a dialog that
// reports success while the account is still phone-gated and every gated
// route keeps 403ing. `options.on_unavailable` is called instead.
const STRIPE_JS_URL = 'https://js.stripe.com/v3/';
@@ -65,6 +77,7 @@ function UIWindowCardVerificationRequired(options) {
'<svg style="width:24px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#212121" 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="#212121" 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 verify_btn_txt = 'Verify Card';
const retry_btn_txt = 'Try Again';
const back_to_phone_txt = 'Verify by phone instead';
let h = '';
if (options.show_close_button !== false) {
@@ -83,8 +96,9 @@ function UIWindowCardVerificationRequired(options) {
// -- Card entry: Stripe Payment Element (hidden until setup succeeds) --
h += '<form class="card-step card-step-form" style="display:none;">';
h +=
'<p style="text-align:center; padding: 0 20px; font-size:13px;">Verify a card to continue. You will <b><i>not</i></b> be charged.</p>';
h += options.phone_fallback
? '<p style="text-align:center; padding: 0 20px; font-size:13px;">Verify a card instead of your phone number. You will <b><i>not</i></b> be charged.</p>'
: '<p style="text-align:center; padding: 0 20px; font-size:13px;">Verify a card to continue. You will <b><i>not</i></b> be charged.</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:6px auto 14px; max-width:320px;">Need help? Email <a href="mailto:support@puter.com" style="color:#3b82f6; text-decoration:none;">support@puter.com</a> and we'll assist you creating your account.</p>`;
@@ -98,7 +112,13 @@ function UIWindowCardVerificationRequired(options) {
h +=
'<p style="text-align:center; padding: 0 20px;">Card verification is temporarily unavailable. Please try again in a few minutes.</p>';
h += `<button type="button" class="button button-block button-primary card-retry-btn" style="margin-top:10px;">${retry_btn_txt}</button>`;
if (!options.logout_in_footer) {
if (options.phone_fallback) {
h +=
'<div style="text-align:center; padding:10px; font-size:14px; margin-top:10px;">';
h += `<span class="card-back-to-phone" style="cursor:pointer; color:#3b82f6;">${back_to_phone_txt}</span>`;
h += '</div>';
}
if (!options.logout_in_footer && !options.phone_fallback) {
h +=
'<div style="text-align:center; padding:10px; font-size:14px; margin-top:10px;">';
h += `<span class="card-log-out" style="cursor:pointer;">${i18n('log_out')}</span>`;
@@ -106,7 +126,16 @@ function UIWindowCardVerificationRequired(options) {
}
h += '</div>';
if (options.logout_in_footer) {
// In fallback mode the footer offers the way back to the phone gate
// rather than a log-out: the user still has a working path to an
// account, and dropping them out of the flow entirely is the outcome
// this mode exists to avoid.
if (options.phone_fallback) {
h +=
'<div style="text-align:center; padding:14px 10px 4px; margin-top:6px; border-top:1px solid #e9eef3; font-size:14px;">';
h += `<span class="card-back-to-phone" style="cursor:pointer; color:#3b82f6;">${back_to_phone_txt}</span>`;
h += '</div>';
} else if (options.logout_in_footer) {
h +=
'<div style="text-align:center; padding:14px 10px 4px; margin-top:6px; border-top:1px solid #e9eef3; font-size:14px;">';
h += `<span class="card-log-out" style="cursor:pointer;">${i18n('log_out')}</span>`;
@@ -170,12 +199,38 @@ function UIWindowCardVerificationRequired(options) {
$(el_window).find(`.card-step-${name}`).show();
};
// Resolve at most once: `finish` and `backToPhone` are the only exits
// and a double close would operate on a detached window.
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
$(el_window).close();
window.refresh_user_data(window.auth_token);
resolve(true);
};
// Fallback mode only: leave without clearing the gate. Resolving false
// hands the user back to whoever opened this dialog (the phone
// verification window), which is still the live gate on the account.
const backToPhone = () => {
if (settled) return;
settled = true;
$(el_window).close();
resolve(false);
};
// Fallback mode only: the server answered `card_verified` without
// lifting the phone gate — the card path can't clear this account
// (typically the server-side kill switch). Reporting success here would
// close the dialog on a still-phone-gated account, so bounce back to
// the phone gate with the reason instead.
const cardCannotClearPhoneGate = () => {
options.on_unavailable?.();
backToPhone();
};
const mountPaymentElement = async (publishable_key, client_secret) => {
await loadStripeJs();
stripe = window.Stripe(publishable_key);
@@ -220,6 +275,13 @@ function UIWindowCardVerificationRequired(options) {
// Already verified, or the feature was disabled server-side
// (kill switch) — either way the gate is satisfied.
if (res.card_verified) {
// ...except in fallback mode, where the phone gate is
// the one that has to lift and this short-circuit never
// lifts it.
if (options.phone_fallback) {
cardCannotClearPhoneGate();
return;
}
finish();
return;
}
@@ -321,6 +383,13 @@ function UIWindowCardVerificationRequired(options) {
statusCode: { 401: (xhr) => window.handle401(xhr) },
success: function (res) {
if (res.card_verified) {
// In fallback mode the card is only worth anything
// if it took the phone gate down with it; the
// server says so with `phone_verified`.
if (options.phone_fallback && !res.phone_verified) {
cardCannotClearPhoneGate();
return;
}
finish();
return;
}
@@ -365,6 +434,12 @@ function UIWindowCardVerificationRequired(options) {
$(el_window).close();
});
$(el_window)
.find('.card-back-to-phone')
.on('click', function () {
backToPhone();
});
startSetup();
});
}
@@ -18,6 +18,7 @@
*/
import UIWindow from './UIWindow.js';
import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js';
import { get_country_list } from '../helpers/country_codes.js';
import {
format_phone_as_you_type,
@@ -32,6 +33,13 @@ import {
// 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.
//
// When the server reports `card_fallback_available` on a send (either a
// successful one or a refusal), SMS is not the only way out: the backend has
// opened a card-verification path that clears the phone gate too. This dialog
// surfaces that as an opt-in link rather than leaving the user to retry a send
// that keeps failing. The card dialog can be dismissed straight back here, so
// the choice is reversible either way.
//
// 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
@@ -65,8 +73,13 @@ function UIWindowPhoneVerificationRequired(options) {
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.
// a user-initiated close resolves(false). The card fallback resolves
// the string 'card' — truthy, so callers polling `while (!ok)` are
// unaffected, but distinguishable for the one caller decision it
// changes: a fallback card clears the card gate along with the phone
// gate, so the card dialog must not be opened again afterwards.
// Idempotent so the close hook can fire after a success without
// clobbering the result.
let settled = false;
const settle = (val) => {
if (settled) return;
@@ -113,6 +126,10 @@ function UIWindowPhoneVerificationRequired(options) {
select_country: i18n('phone_select_country'),
code_sent_to: i18n('phone_code_sent_to'),
code_sent_whatsapp: i18n('phone_code_sent_whatsapp'),
card_fallback_prompt: i18n('phone_card_fallback_prompt'),
card_fallback_link: i18n('phone_card_fallback_link'),
card_fallback_note: i18n('phone_card_fallback_note'),
card_fallback_unavailable: i18n('phone_card_fallback_unavailable'),
suggested: i18n('phone_suggested'),
all_countries: i18n('phone_all_countries'),
};
@@ -195,6 +212,15 @@ function UIWindowPhoneVerificationRequired(options) {
.map((c) => renderOption(c, 'cc-opt-', 'all'))
.join('');
// Card escape hatch, rendered into both steps and revealed only when a
// send response says the backend opened it.
const card_fallback_html =
'<div class="phone-card-fallback" hidden>' +
`<p class="phone-card-fallback-prompt">${T.card_fallback_prompt}</p>` +
`<a class="phone-use-card" role="button" tabindex="0">${T.card_fallback_link}</a>` +
`<p class="phone-card-fallback-note">${T.card_fallback_note}</p>` +
'</div>';
let h = '';
// Scoped styling for this dialog.
h += `<style>
@@ -333,6 +359,25 @@ function UIWindowPhoneVerificationRequired(options) {
.window-confirm-phone-using-code .error {
color: #c0392b; font-size: 13px; text-align: center; margin-bottom: 10px;
}
/* Card escape hatch hidden until the server says the fallback is
open, so a working SMS flow never advertises it. */
.window-confirm-phone-using-code .phone-card-fallback {
text-align: center; margin-top: 16px; padding-top: 14px;
border-top: 1px solid #e9eef3;
}
.window-confirm-phone-using-code .phone-card-fallback[hidden] { display: none; }
.window-confirm-phone-using-code .phone-card-fallback-prompt {
font-size: 13px; color: #6b7c8c; margin: 0 0 4px;
}
.window-confirm-phone-using-code .phone-use-card {
display: inline-block; font-size: 14px; font-weight: 500;
color: #3b82f6; cursor: pointer; text-decoration: none;
}
.window-confirm-phone-using-code .phone-use-card:hover { text-decoration: underline; }
.window-confirm-phone-using-code .phone-card-fallback-note {
font-size: 12px; line-height: 1.4; color: #8a99a8;
margin: 6px auto 0; max-width: 300px;
}
/* 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) {
@@ -378,6 +423,7 @@ function UIWindowPhoneVerificationRequired(options) {
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>`;
h += card_fallback_html;
if (options.logout_in_footer) {
h += `<div class="phone-footer"><a class="phone-log-out">${i18n('log_out')}</a></div>`;
}
@@ -399,6 +445,7 @@ function UIWindowPhoneVerificationRequired(options) {
<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 += card_fallback_html;
h += '<div class="phone-footer">';
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) {
@@ -463,6 +510,79 @@ function UIWindowPhoneVerificationRequired(options) {
$(el_window).find('.error').hide();
};
// ---------- Card escape hatch ----------
//
// Revealed by a send response carrying `card_fallback_available`. Once
// revealed it stays: the backend keeps the eligibility open for hours,
// and a user who came back to try SMS again shouldn't lose the way out
// they were already offered.
let card_fallback_available = false;
let card_fallback_in_progress = false;
const revealCardFallback = () => {
if (card_fallback_available) return;
card_fallback_available = true;
$(el_window).find('.phone-card-fallback').prop('hidden', false);
};
// Hand off to the card dialog. This window stays alive behind it (just
// hidden) so a user who backs out — or whose card path turns out to be
// unavailable server-side — lands back on the live gate instead of on
// an empty desktop that 403s every request.
const useCardInstead = async () => {
if (card_fallback_in_progress) return;
card_fallback_in_progress = true;
clearError();
let unavailable = false;
// Hiding the backdrop too, so the card dialog's own backdrop is the
// only one on screen.
const $hidden = $(el_window).closest('.window-backdrop').length
? $(el_window).closest('.window-backdrop')
: $(el_window);
$hidden.hide();
let verified = false;
try {
verified = await UIWindowCardVerificationRequired({
phone_fallback: true,
on_unavailable: () => {
unavailable = true;
},
show_close_button: false,
stay_on_top: options.stay_on_top ?? false,
is_draggable: options.is_draggable,
window_options: options.window_options,
});
} catch (e) {
console.debug('Card verification dialog failed:', e);
}
if (verified) {
// The card cleared the phone gate server-side, so this gate is
// satisfied — settle before closing so the on_close hook's
// resolve(false) is the no-op. 'card' rather than true so the
// caller knows the card gate went down with it.
settle('card');
$(el_window).close();
return;
}
card_fallback_in_progress = false;
// A logout (or a parent cascade) took this window with it; nothing
// left to restore.
if (!document.body.contains(el_window)) return;
$hidden.show();
if (unavailable) showError(T.card_fallback_unavailable);
};
$(el_window)
.find('.phone-use-card')
.on('click', function (e) {
e.preventDefault();
useCardInstead();
})
.on('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
useCardInstead();
});
// 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 = [
@@ -749,6 +869,10 @@ function UIWindowPhoneVerificationRequired(options) {
headers: { Authorization: `Bearer ${window.auth_token}` },
statusCode: { 401: (xhr) => window.handle401(xhr) },
success: function (res) {
// The backend counts send *attempts*, so the fallback can
// open on a send that itself succeeded — the user still may
// never receive the code.
if (res?.card_fallback_available) revealCardFallback();
// Advance to the code-entry step with a clean slate.
$(el_window).find('.phone-target').text(phone);
// `channel` is where Prelude actually delivered the code;
@@ -776,6 +900,8 @@ function UIWindowPhoneVerificationRequired(options) {
startWebOTP();
},
error: function (xhr) {
if (xhr.responseJSON?.card_fallback_available)
revealCardFallback();
const reason = xhr.responseJSON?.reason;
let msg =
SEND_REASON_MESSAGES[reason] ??
+11 -1
View File
@@ -540,6 +540,11 @@ function UIWindowSignup(options) {
options.window_options ?? {},
});
}
// A user the SMS path keeps failing may take the
// card fallback instead; that resolves 'card' and
// clears the card gate along with the phone gate
// (see UIWindowPhoneVerificationRequired).
let card_gate_cleared_by_fallback = false;
if (data.user?.requires_phone_verification) {
let phone_ok = false;
do {
@@ -555,9 +560,14 @@ function UIWindowSignup(options) {
},
);
} while (!phone_ok);
card_gate_cleared_by_fallback =
phone_ok === 'card';
}
// Card verification is the last gate.
if (data.user?.requires_card_verification) {
if (
data.user?.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let card_ok = false;
do {
card_ok =
+4
View File
@@ -282,6 +282,10 @@ const en = {
phone_select_country: 'Select country',
phone_code_sent_to: 'Enter the 6-digit code sent to',
phone_code_sent_whatsapp: 'Enter the 6-digit code sent via WhatsApp to',
phone_card_fallback_prompt: "Can't receive a code?",
phone_card_fallback_link: 'Verify with a card instead',
phone_card_fallback_note: "We only check that the card is valid \u2014 you won't be charged.",
phone_card_fallback_unavailable: "Card verification isn't available right now. Please try your phone number again, or email support@puter.com and we'll help you finish setting up your account.",
pick_name_for_website: 'Pick a name for your website:',
pick_name_for_worker: 'Pick a name for your worker:',
picture: 'Picture',
+18 -2
View File
@@ -1684,6 +1684,10 @@ window.initgui = async function (options) {
} while (!is_verified);
}
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
let card_gate_cleared_by_fallback = false;
if (whoami.requires_phone_verification) {
let is_verified;
do {
@@ -1696,10 +1700,14 @@ window.initgui = async function (options) {
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
// Card verification is the last gate: only show it once the email and
// phone (SMS) gates are cleared, since those show up first.
if (whoami.requires_card_verification) {
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
@@ -1948,6 +1956,10 @@ window.initgui = async function (options) {
} while (!is_verified);
}
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
let card_gate_cleared_by_fallback = false;
if (whoami.requires_phone_verification) {
let is_verified;
do {
@@ -1962,10 +1974,14 @@ window.initgui = async function (options) {
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
// Card verification is the last gate: only show it once the email and
// phone (SMS) gates are cleared, since those show up first.
if (whoami.requires_card_verification) {
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({