diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 65ffc9a03..1f96d01d5 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -37,6 +37,7 @@ import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js'; import UIWindowSignup from './UI/UIWindowSignup.js'; import UINotification from './UI/UINotification.js'; +import { openVerificationGateWindow } from './helpers/verification_gates.js'; import { PROCESS_IPC_ATTACHED } from './definitions.js'; import TeePromise from './util/TeePromise.js'; import { createFeedbackDialogGuard } from './util/feedbackDialogGuard.js'; @@ -235,6 +236,39 @@ const ipc_listener = async (event, handled) => { }, '*'); } //-------------------------------------------------------- + // requestVerificationGate + //-------------------------------------------------------- + else if ( event.data.msg === 'requestVerificationGate' ) { + // Raised by puter.js when a request hit a 403 account-verification + // gate. The cached user object may not know about a gate applied + // mid-session (or one cleared in another tab), so refresh it before + // deciding; if the gate is off after the refresh, respond success so + // the app replays its request. + // Mirrors the server's assertVerifiedAccount conditions. + const gate_flags = { + email_confirmation_required: (user) => user?.requires_email_confirmation && !user?.email_confirmed, + phone_verification_required: (user) => user?.requires_phone_verification, + card_verification_required: (user) => user?.requires_card_verification, + }; + const gate_is_on = gate_flags[event.data.code]; + let response = true; + if ( gate_is_on ) { + try { + await window.refresh_user_data(window.auth_token); + } catch (e) { + // Stale data is still decidable; the gate window can handle it. + } + if ( gate_is_on(window.user) ) { + response = await openVerificationGateWindow(event.data.code); + } + } + target_iframe.contentWindow.postMessage({ + original_msg_id: msg_id, + msg: 'requestVerificationGateResponded', + response, + }, '*'); + } + //-------------------------------------------------------- // ALERT //-------------------------------------------------------- else if ( event.data.msg === 'ALERT' && event.data.message !== undefined ) { diff --git a/src/gui/src/helpers/verification_gates.js b/src/gui/src/helpers/verification_gates.js new file mode 100644 index 000000000..e054840a9 --- /dev/null +++ b/src/gui/src/helpers/verification_gates.js @@ -0,0 +1,63 @@ +/* + * Shared opener for the account-verification gate windows (the 403 + * `*_required` codes). Used by the global ajax interceptor in initgui.js and + * by the `requestPhoneVerification` IPC handler, so a gate raised by GUI code + * and one raised by an app share a single window instead of stacking. + */ + +import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js'; +import UIWindowPhoneVerificationRequired from '../UI/UIWindowPhoneVerificationRequired.js'; +import UIWindowCardVerificationRequired from '../UI/UIWindowCardVerificationRequired.js'; + +const gate_windows = { + phone_verification_required: UIWindowPhoneVerificationRequired, + email_confirmation_required: UIWindowEmailConfirmationRequired, + card_verification_required: UIWindowCardVerificationRequired, +}; + +// Single-flight: while a gate window is open, every caller awaits the same +// resolution regardless of which code they arrived with — a caller whose gate +// is actually a different one just retries and raises it then. +let pending = null; + +/** + * Open the gate window for a verification error code and resolve `true` when + * the user clears it (user data is refreshed first). Unknown codes resolve + * `false` without opening anything. + * + * @param {string} code - The 403 error code (e.g. `phone_verification_required`). + * @returns {Promise} + */ +export async function openVerificationGateWindow (code) { + const UIWindowVerificationGate = gate_windows[code]; + if ( !UIWindowVerificationGate ) { + return false; + } + if ( !pending ) { + pending = (async () => { + try { + // The gate window resolves truthy once cleared (the phone gate + // resolves the string 'card' when the card fallback cleared it). + const is_verified = await UIWindowVerificationGate({ + show_close_button: false, + stay_on_top: true, + has_head: false, + logout_in_footer: true, + window_options: { + is_draggable: false, + }, + }); + if ( is_verified ) { + await window.refresh_user_data(window.auth_token); + } + return Boolean(is_verified); + } catch (e) { + console.error('verification gate dialog failed:', e); + return false; + } finally { + pending = null; + } + })(); + } + return pending; +} diff --git a/src/gui/src/helpers/verification_gates.test.js b/src/gui/src/helpers/verification_gates.test.js new file mode 100644 index 000000000..ce60be741 --- /dev/null +++ b/src/gui/src/helpers/verification_gates.test.js @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const phoneGate = vi.fn(); +vi.mock('../UI/UIWindowPhoneVerificationRequired.js', () => ({ + default: (...args) => phoneGate(...args), +})); +vi.mock('../UI/UIWindowEmailConfirmationRequired.js', () => ({ + default: vi.fn(), +})); +vi.mock('../UI/UIWindowCardVerificationRequired.js', () => ({ + default: vi.fn(), +})); + +const { openVerificationGateWindow } = await import('./verification_gates.js'); + +beforeEach(() => { + vi.clearAllMocks(); + globalThis.window = { + auth_token: 'tok', + refresh_user_data: vi.fn(async () => {}), + }; +}); + +describe('openVerificationGateWindow', () => { + test('unknown codes resolve false without opening anything', async () => { + await expect(openVerificationGateWindow('nope')).resolves.toBe(false); + expect(phoneGate).not.toHaveBeenCalled(); + }); + + test('resolves true and refreshes user data once the gate clears', async () => { + phoneGate.mockResolvedValueOnce(true); + await expect( + openVerificationGateWindow('phone_verification_required'), + ).resolves.toBe(true); + expect(window.refresh_user_data).toHaveBeenCalledWith('tok'); + }); + + test("the phone gate's 'card' resolution counts as cleared", async () => { + phoneGate.mockResolvedValueOnce('card'); + await expect( + openVerificationGateWindow('phone_verification_required'), + ).resolves.toBe(true); + }); + + test('concurrent callers share one window', async () => { + let settle; + phoneGate.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }), + ); + const first = openVerificationGateWindow('phone_verification_required'); + const second = openVerificationGateWindow('phone_verification_required'); + settle(true); + await expect(first).resolves.toBe(true); + await expect(second).resolves.toBe(true); + expect(phoneGate).toHaveBeenCalledTimes(1); + }); + + test('a dialog failure resolves false and releases the single-flight', async () => { + phoneGate.mockRejectedValueOnce(new Error('boom')); + await expect( + openVerificationGateWindow('phone_verification_required'), + ).resolves.toBe(false); + phoneGate.mockResolvedValueOnce(true); + await expect( + openVerificationGateWindow('phone_verification_required'), + ).resolves.toBe(true); + }); +}); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index d22b6239f..d9745161b 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -30,6 +30,7 @@ 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 { openVerificationGateWindow } from './helpers/verification_gates.js'; import UIWindowLogin from './UI/UIWindowLogin.js'; import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js'; import UIWindowNewPassword from './UI/UIWindowNewPassword.js'; @@ -1941,14 +1942,8 @@ window.initgui = async function (options) { window.location.replace(window.is_dashboard_mode ? '/' : '/desktop'); }); - const verification_gate_windows = { - phone_verification_required: UIWindowPhoneVerificationRequired, - email_confirmation_required: UIWindowEmailConfirmationRequired, - card_verification_required: UIWindowCardVerificationRequired, - }; - let verification_gate_open = false; $(document).ajaxError(async function (event, jqxhr) { - if (jqxhr?.status !== 403 || verification_gate_open) { + if (jqxhr?.status !== 403) { return; } let body = jqxhr.responseJSON; @@ -1959,29 +1954,8 @@ window.initgui = async function (options) { body = null; } } - const UIWindowVerificationGate = verification_gate_windows[body?.code]; - if (!UIWindowVerificationGate) { - return; - } - verification_gate_open = true; - try { - const is_verified = await UIWindowVerificationGate({ - show_close_button: false, - stay_on_top: true, - has_head: false, - logout_in_footer: true, - window_options: { - is_draggable: false, - }, - }); - if (is_verified) { - await window.refresh_user_data(window.auth_token); - } - } catch (e) { - console.error('verification gate dialog failed:', e); - } finally { - verification_gate_open = false; - } + // Single-flighted in the helper; unknown codes are a no-op. + await openVerificationGateWindow(body?.code); }); // ------------------------------------------------------------------------------------- diff --git a/src/puter-js/src/lib/networkUtils.js b/src/puter-js/src/lib/networkUtils.js index 57345d6ac..23d535c23 100644 --- a/src/puter-js/src/lib/networkUtils.js +++ b/src/puter-js/src/lib/networkUtils.js @@ -433,6 +433,42 @@ async function resolvePermission(permission) { } } +// The 403 account-verification gates the hosting GUI can walk a user through. +const VERIFICATION_GATE_CODES = new Set([ + 'email_confirmation_required', + 'phone_verification_required', + 'card_verification_required', +]); + +// Single-flighted verification prompt: concurrent gated requests share one +// GUI dialog rather than stacking windows. +let pendingVerificationGate = null; + +/** + * Drive the hosting GUI's verification flow for a 403 `*_required` gate code. + * Only apps hosted by the Puter GUI can prompt; every other environment + * resolves unverified and the rejection reaches the caller unchanged. + * + * @param {string} code - The gate's error code. + * @returns {Promise<{ verified: boolean }>} + */ +async function resolveVerificationGate(code) { + if (globalThis.puter?.env !== 'app') return { verified: false }; + if (!pendingVerificationGate) { + pendingVerificationGate = (async () => { + try { + const verified = await puter.ui.requestVerificationGate(code); + return { verified: verified === true }; + } catch (e) { + return { verified: false }; + } finally { + pendingVerificationGate = null; + } + })(); + } + return pendingVerificationGate; +} + /** * Send one attempt. Resolves with a terminal outcome: { streamed: true, xhr, * lineStream } — NDJSON, resolved at HEADERS_RECEIVED { xhr, status } — @@ -529,8 +565,9 @@ function sendOnce(spec) { } /** - * Classify a completed attempt into a retry decision. Reauth and permission are - * one-shot (tracked in `ctx.done`) and apply to any request; transient backoff + * Classify a completed attempt into a retry decision. Reauth, permission, and + * the phone-verification gate are one-shot (tracked in `ctx.done`) and apply + * to any request; transient backoff * applies only to `ctx.retrySafe` requests and honors the autoRetry kill * switch. Memoizes the parsed body on `outcome.parsed` and stashes any reauth * error on `outcome.reauthError` for the shaper. @@ -582,6 +619,24 @@ async function classifyRetry(outcome, ctx) { return null; } + // account verification gate (403) — one-shot per gate, any method, no + // backoff. The gate rejects in middleware before the handler runs, so + // replay is safe once the user clears it; a user behind several gates + // clears them one replay at a time (email → phone → card). + const gateCode = [parsed?.code, parsed?.error?.code].find((c) => + VERIFICATION_GATE_CODES.has(c), + ); + if (status === 403 && gateCode) { + if (!ctx.done.has(gateCode)) { + const res = await resolveVerificationGate(gateCode); + if (res.verified) { + ctx.done.add(gateCode); + return { delayMs: 0 }; + } + } + return null; + } + // gate rejection — any method, honors kill switch, fixed schedule. if (status === GATE_REJECT_STATUS) return gateRetry(ctx); diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 7a23f8a07..5a399c1fc 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -1056,6 +1056,22 @@ export class UIModule extends EventListener { }); }; + /** + * Asks the desktop to walk the user through clearing an account + * verification gate (a 403 `*_required` code — email confirmation, phone + * verification, or card verification). Resolves `true` once the gate is + * cleared, `false` otherwise. + * + * @internal + * @param {string} code - The gate's error code. + * @returns {Promise} + */ + requestVerificationGate (code) { + return new Promise((resolve) => { + this.#postMessageWithCallback('requestVerificationGate', resolve, { code }); + }).then((res) => res?.response === true); + }; + /** * Shows an alert dialog, blocking the parent window until the user picks a * button. Resolves to that button's `value`, or its `label` when no value