';
+ h += `
})
`;
+ h += '
Verify Your Card
';
+ h += '
';
+
+ // -- Loading: setup call in flight, Stripe.js loading --
+ h += `
${dark_spinner}
`;
+
+ // -- Card entry: Stripe Payment Element (hidden until setup succeeds) --
+ h += '
';
+
+ // -- Unavailable: setup failed (hidden unless it does) --
+ h += '
';
+ h += '
Card verification is temporarily unavailable. Please try again in a few minutes.
';
+ h += `
`;
+ if ( ! options.logout_in_footer ) {
+ h += '
';
+ h += `${i18n('log_out')}`;
+ h += '
';
+ }
+ h += '
';
+
+ if ( options.logout_in_footer ) {
+ h += '
';
+ h += `${i18n('log_out')}`;
+ h += '
';
+ }
+ h += '
';
+
+ const el_window = await UIWindow({
+ title: null,
+ icon: null,
+ uid: null,
+ is_dir: false,
+ body_content: h,
+ has_head: false,
+ selectable_body: false,
+ draggable_body: true,
+ allow_context_menu: false,
+ is_draggable: options.is_draggable ?? true,
+ is_droppable: false,
+ is_resizable: false,
+ stay_on_top: options.stay_on_top ?? false,
+ allow_native_ctxmenu: true,
+ allow_user_select: true,
+ backdrop: true,
+ close_on_backdrop_click: false,
+ width: 390,
+ dominant: true,
+ ...options.window_options,
+ window_class: 'window-card-verification',
+ window_css: {
+ height: 'initial',
+ // Dominant windows pin to 15vh from the top; with a tall Stripe
+ // iframe that drops the log-out below the viewport. Sit higher so
+ // the dialog always ends above the browser's bottom edge.
+ top: '5vh',
+ },
+ body_css: {
+ // border-box so max-height includes the padding — keeps the math
+ // exact: 5vh top + 85vh body = 90vh, always within the viewport.
+ 'box-sizing': 'border-box',
+ padding: '30px',
+ width: 'initial',
+ height: 'initial',
+ // The Stripe Payment Element can run tall; cap the dialog to the
+ // viewport and let the body scroll instead of overflowing it.
+ 'max-height': '85vh',
+ 'overflow-y': 'auto',
+ 'background-color': 'rgb(247 251 255)',
+ 'backdrop-filter': 'blur(3px)',
+ },
+ });
+
+ const showError = (msg) => {
+ $(el_window).find('.error').html(html_encode(msg)).fadeIn();
+ };
+ const clearError = () => {
+ $(el_window).find('.error').hide();
+ };
+ const showStep = (name) => {
+ $(el_window).find('.card-step').hide();
+ $(el_window).find(`.card-step-${name}`).show();
+ };
+
+ const finish = () => {
+ $(el_window).close();
+ window.refresh_user_data(window.auth_token);
+ resolve(true);
+ };
+
+ const mountPaymentElement = async (publishable_key, client_secret) => {
+ await loadStripeJs();
+ stripe = window.Stripe(publishable_key);
+ elements = stripe.elements({ clientSecret: client_secret });
+ if ( payment_element ) {
+ payment_element.destroy();
+ }
+ payment_element = elements.create('payment');
+ payment_element.mount($(el_window).find('.card-payment-element').get(0));
+ };
+
+ // -- Setup: fetch a client_secret and mount the Payment Element. Also
+ // the restart path after a rejected card (a succeeded SetupIntent is
+ // spent, so trying another card needs a fresh client_secret). --
+ const startSetup = (setup_options = {}) => {
+ if ( is_setting_up ) return;
+ is_setting_up = true;
+ // After a rejection the error explains why the flow restarted, so
+ // keep it on screen through the new setup call.
+ if ( ! setup_options.keep_error ) clearError();
+ showStep('loading');
+
+ $.ajax({
+ url: `${window.api_origin}/card-verification/setup`,
+ type: 'POST',
+ async: true,
+ contentType: 'application/json',
+ headers: { 'Authorization': `Bearer ${window.auth_token}` },
+ statusCode: { 401: (xhr) => window.handle401(xhr) },
+ success: async function (res) {
+ // Already verified, or the feature was disabled server-side
+ // (kill switch) — either way the gate is satisfied.
+ if ( res.card_verified ) {
+ finish();
+ return;
+ }
+ confirmed_setup_intent_id = null;
+ try {
+ await mountPaymentElement(res.publishable_key, res.client_secret);
+ $(el_window)
+ .find('.card-verify-btn')
+ .prop('disabled', false)
+ .html(verify_btn_txt);
+ showStep('form');
+ } catch (e) {
+ console.debug('Could not mount the payment element:', e);
+ showStep('unavailable');
+ }
+ },
+ error: function (xhr) {
+ if ( xhr.responseJSON?.error ) {
+ showError(xhr.responseJSON.error);
+ }
+ showStep('unavailable');
+ },
+ complete: function () {
+ is_setting_up = false;
+ },
+ });
+ };
+
+ $(el_window).find('.card-retry-btn').on('click', function () {
+ startSetup();
+ });
+
+ // -- Confirm: run the SetupIntent client-side, then verify it server-side --
+ $(el_window).find('.card-verify-btn').on('click submit', async function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if ( is_submitting ) return;
+ is_submitting = true;
+ clearError();
+ $(el_window).find('.card-verify-btn').prop('disabled', true).html(spinner);
+
+ // Skipped when a previous attempt already confirmed the SetupIntent
+ // but the server call failed — retrying re-uses the confirmed intent.
+ if ( ! confirmed_setup_intent_id ) {
+ let result;
+ try {
+ result = await stripe.confirmSetup({
+ elements,
+ redirect: 'if_required',
+ });
+ } catch (error) {
+ result = { error };
+ }
+ if ( result.error ) {
+ showError(result.error.message ?? 'Could not verify your card.');
+ $(el_window)
+ .find('.card-verify-btn')
+ .prop('disabled', false)
+ .html(verify_btn_txt);
+ is_submitting = false;
+ return;
+ }
+ confirmed_setup_intent_id = result.setupIntent.id;
+ }
+
+ $.ajax({
+ url: `${window.api_origin}/card-verification/confirm`,
+ type: 'POST',
+ data: JSON.stringify({ setup_intent_id: confirmed_setup_intent_id }),
+ async: true,
+ contentType: 'application/json',
+ headers: { 'Authorization': `Bearer ${window.auth_token}` },
+ statusCode: { 401: (xhr) => window.handle401(xhr) },
+ success: function (res) {
+ if ( res.card_verified ) {
+ finish();
+ return;
+ }
+ // Rejected. The succeeded SetupIntent is spent, so restart
+ // with a fresh setup call to let the user try another card.
+ confirmed_setup_intent_id = null;
+ if ( res.reason === 'card_already_used' ) {
+ showError('This card has already been used to verify other accounts. Please try a different card.');
+ } else {
+ showError('We couldn\'t verify this card. Please try a different card.');
+ }
+ startSetup({ keep_error: true });
+ },
+ error: function (xhr) {
+ // Transient failure — the SetupIntent already succeeded
+ // client-side, so keep it and let the user retry the
+ // server confirmation.
+ showError(xhr.responseJSON?.error ?? 'Could not verify your card. Please try again.');
+ $(el_window)
+ .find('.card-verify-btn')
+ .prop('disabled', false)
+ .html(verify_btn_txt);
+ },
+ complete: function () {
+ is_submitting = false;
+ },
+ });
+ });
+
+ // logout
+ $(el_window).find('.card-log-out').on('click', function () {
+ window.logout();
+ $(el_window).close();
+ });
+
+ startSetup();
+ });
+}
+
+def(UIWindowCardVerificationRequired, 'ui.UIConfirmCard');
+
+export default UIWindowCardVerificationRequired;
diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js
new file mode 100644
index 000000000..e8cdf53a7
--- /dev/null
+++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js
@@ -0,0 +1,383 @@
+/**
+ * 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 ';
+ h += `
${phoneIcon}
`;
+ h += '
Verify your phone number
';
+
+ // -- Step 1: phone number --
+ h += '
';
+
+ // -- Step 2: 6-digit code (hidden until a code is sent) --
+ h += '
';
+ h += '
';
+
+ const el_window = await UIWindow({
+ title: null,
+ icon: null,
+ uid: null,
+ is_dir: false,
+ body_content: h,
+ has_head: false,
+ selectable_body: false,
+ draggable_body: true,
+ allow_context_menu: false,
+ is_draggable: options.is_draggable ?? true,
+ is_droppable: false,
+ is_resizable: false,
+ stay_on_top: options.stay_on_top ?? false,
+ allow_native_ctxmenu: true,
+ allow_user_select: true,
+ backdrop: true,
+ close_on_backdrop_click: false,
+ width: 390,
+ dominant: true,
+ ...options.window_options,
+ onAppend: function (el_window) {
+ $(el_window).find('.phone-input').first().focus();
+ },
+ window_class: 'window-confirm-phone-using-code',
+ window_css: {
+ height: 'initial',
+ },
+ body_css: {
+ padding: '30px',
+ width: 'initial',
+ height: 'initial',
+ 'background-color': 'rgb(247 251 255)',
+ 'backdrop-filter': 'blur(3px)',
+ },
+ });
+
+ const showError = (msg) => {
+ $(el_window).find('.error').html(html_encode(msg)).fadeIn();
+ };
+ const clearError = () => {
+ $(el_window).find('.error').hide();
+ };
+
+ // -- Step 1: send the code --
+ const sendCode = () => {
+ 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.');
+ return;
+ }
+ is_sending = true;
+ $(el_window).find('.phone-send-btn').prop('disabled', true).html(spinner);
+
+ $.ajax({
+ url: `${window.api_origin}/send-confirm-phone`,
+ type: 'POST',
+ data: JSON.stringify({ phone }),
+ async: true,
+ contentType: 'application/json',
+ headers: { 'Authorization': `Bearer ${window.auth_token}` },
+ statusCode: { 401: (xhr) => window.handle401(xhr) },
+ success: function () {
+ // Advance to the code-entry step.
+ $(el_window).find('.phone-target').text(phone);
+ $(el_window).find('.phone-step-1').hide();
+ $(el_window).find('.phone-step-2').show();
+ $(el_window).find('.digit-input').first().focus();
+ },
+ error: function (xhr) {
+ showError(
+ xhr.responseJSON?.error ??
+ 'Could not send a code to that number.',
+ );
+ },
+ complete: function () {
+ is_sending = false;
+ $(el_window)
+ .find('.phone-send-btn')
+ .prop('disabled', false)
+ .html(send_btn_txt);
+ },
+ });
+ };
+
+ $(el_window).find('.phone-send-btn').on('click submit', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ sendCode();
+ });
+
+ // 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();
+ });
+
+ // -- 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();
+ });
+
+ // -- 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 ) {
+ return (target.value = null);
+ }
+ const inputLength = target.value.length;
+ let currentIndex = Number(target.dataset.numberCodeInput);
+ if ( inputLength === 2 ) {
+ const inputValues = target.value.split('');
+ target.value = inputValues[0];
+ } else if ( inputLength > 1 ) {
+ const inputValues = target.value.split('');
+ inputValues.forEach((value, valueIndex) => {
+ const nextValueIndex = currentIndex + valueIndex;
+ if ( nextValueIndex >= numberCodeInputs.length ) {
+ return;
+ }
+ numberCodeInputs[nextValueIndex].value = value;
+ });
+ currentIndex += inputValues.length - 2;
+ }
+
+ const nextIndex = currentIndex + 1;
+ if ( nextIndex < numberCodeInputs.length ) {
+ numberCodeInputs[nextIndex].focus();
+ }
+
+ final_code = '';
+ for ( let i = 0; i < numberCodeInputs.length; i++ ) {
+ final_code += numberCodeInputs[i].value;
+ }
+ 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');
+ }
+ });
+
+ numberCodeForm.addEventListener('keydown', (e) => {
+ const { code, target } = e;
+ const currentIndex = Number(target.dataset.numberCodeInput);
+ const previousIndex = currentIndex - 1;
+ const nextIndex = currentIndex + 1;
+ const hasPreviousIndex = previousIndex >= 0;
+ const hasNextIndex = nextIndex <= numberCodeInputs.length - 1;
+
+ switch ( code ) {
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ if ( hasPreviousIndex ) numberCodeInputs[previousIndex].focus();
+ e.preventDefault();
+ break;
+ case 'ArrowRight':
+ case 'ArrowDown':
+ if ( hasNextIndex ) numberCodeInputs[nextIndex].focus();
+ e.preventDefault();
+ break;
+ case 'Backspace':
+ if ( !e.target.value.length && hasPreviousIndex ) {
+ numberCodeInputs[previousIndex].value = null;
+ numberCodeInputs[previousIndex].focus();
+ }
+ break;
+ default:
+ break;
+ }
+ });
+ });
+}
+
+def(UIWindowPhoneVerificationRequired, 'ui.UIConfirmPhone');
+
+export default UIWindowPhoneVerificationRequired;
diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js
index 392d09780..a151d929f 100644
--- a/src/gui/src/UI/UIWindowSignup.js
+++ b/src/gui/src/UI/UIWindowSignup.js
@@ -20,6 +20,8 @@
import check_password_strength from '../helpers/check_password_strength.js';
import UIWindow from './UIWindow.js';
import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js';
+import UIWindowPhoneVerificationRequired from './UIWindowPhoneVerificationRequired.js';
+import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js';
import UIWindowLogin from './UIWindowLogin.js';
function UIWindowSignup (options) {
@@ -396,15 +398,46 @@ function UIWindowSignup (options) {
// either options.redirect_url or the current page
const redirectUrl = options.redirect_url || '/';
window.location.replace(redirectUrl);
- } else if ( options.send_confirmation_code || data.user?.requires_email_confirmation ) {
+ } else if ( data.user?.requires_phone_verification || data.user?.requires_card_verification || options.send_confirmation_code || data.user?.requires_email_confirmation ) {
$(el_window).close();
- let is_verified = await UIWindowEmailConfirmationRequired({
- stay_on_top: true,
- has_head: true,
- reload_on_success: options.reload_on_success,
- window_options: options.window_options ?? {},
- });
- resolve(is_verified);
+ // Low-reputation signups must clear every flagged gate.
+ // Phone (SMS) and email come first; the card gate only
+ // shows once those are cleared.
+ if ( data.user?.requires_phone_verification ) {
+ let phone_ok = false;
+ do {
+ phone_ok = await UIWindowPhoneVerificationRequired({
+ show_close_button: false,
+ stay_on_top: true,
+ has_head: true,
+ window_options: options.window_options ?? {},
+ });
+ }
+ while ( !phone_ok );
+ }
+ let email_verified = true;
+ if ( options.send_confirmation_code || data.user?.requires_email_confirmation ) {
+ email_verified = await UIWindowEmailConfirmationRequired({
+ stay_on_top: true,
+ has_head: true,
+ reload_on_success: options.reload_on_success,
+ window_options: options.window_options ?? {},
+ });
+ }
+ // Card verification is the last gate.
+ if ( data.user?.requires_card_verification ) {
+ let card_ok = false;
+ do {
+ card_ok = await UIWindowCardVerificationRequired({
+ show_close_button: false,
+ stay_on_top: true,
+ has_head: true,
+ window_options: options.window_options ?? {},
+ });
+ }
+ while ( !card_ok );
+ }
+ resolve(email_verified);
} else {
resolve(true);
}
diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js
index ef70a5832..a50fd2e71 100644
--- a/src/gui/src/initgui.js
+++ b/src/gui/src/initgui.js
@@ -26,6 +26,8 @@ import UIWindowAuthMe from './UI/UIWindowAuthMe.js';
import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js';
import UIWindowCopyToken from './UI/UIWindowCopyToken.js';
import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js';
+import UIWindowPhoneVerificationRequired from './UI/UIWindowPhoneVerificationRequired.js';
+import UIWindowCardVerificationRequired from './UI/UIWindowCardVerificationRequired.js';
import UIWindowLogin from './UI/UIWindowLogin.js';
import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js';
import UIWindowNewPassword from './UI/UIWindowNewPassword.js';
@@ -665,6 +667,21 @@ window.initgui = async function (options) {
}
if ( whoami ) {
+ // is phone verification required? (hard gate for low-rep signups)
+ if ( whoami.requires_phone_verification ) {
+ let is_verified;
+ do {
+ is_verified = await UIWindowPhoneVerificationRequired({
+ show_close_button: false,
+ stay_on_top: true,
+ has_head: false,
+ window_options: {
+ is_draggable: false,
+ },
+ });
+ }
+ while ( !is_verified );
+ }
if ( whoami.requires_email_confirmation ) {
let is_verified;
do {
@@ -679,6 +696,22 @@ window.initgui = async function (options) {
}
while ( !is_verified );
}
+ // 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 ) {
+ let is_verified;
+ do {
+ is_verified = await UIWindowCardVerificationRequired({
+ show_close_button: false,
+ stay_on_top: true,
+ has_head: false,
+ window_options: {
+ is_draggable: false,
+ },
+ });
+ }
+ while ( !is_verified );
+ }
// if user is logging in using an auth token that means it's not their first ever visit to Puter.com
// it might be their first visit to Puter on this specific device but it's not their first time ever visiting Puter.
window.first_visit_ever = false;
@@ -833,6 +866,23 @@ window.initgui = async function (options) {
}
// update local user data
if ( whoami ) {
+ // is phone verification required? (hard gate for low-rep signups)
+ if ( whoami.requires_phone_verification ) {
+ let is_verified;
+ do {
+ is_verified = await UIWindowPhoneVerificationRequired({
+ show_close_button: false,
+ stay_on_top: true,
+ has_head: false,
+ logout_in_footer: true,
+ window_options: {
+ is_draggable: false,
+ cover_page: window.is_embedded,
+ },
+ });
+ }
+ while ( !is_verified );
+ }
// is email confirmation required?
if ( whoami.requires_email_confirmation ) {
let is_verified;
@@ -850,6 +900,24 @@ window.initgui = async function (options) {
}
while ( !is_verified );
}
+ // 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 ) {
+ let is_verified;
+ do {
+ is_verified = await UIWindowCardVerificationRequired({
+ show_close_button: false,
+ stay_on_top: true,
+ has_head: false,
+ logout_in_footer: true,
+ window_options: {
+ is_draggable: false,
+ cover_page: window.is_embedded,
+ },
+ });
+ }
+ while ( !is_verified );
+ }
await window.update_auth_data(whoami.token || window.auth_token, whoami);
// -------------------------------------------------------------------------------------