feat: better verification step handling (#3717)

This commit is contained in:
Daniel Salazar
2026-09-01 21:17:19 -07:00
committed by GitHub
parent 1736d23ee7
commit 0385072e06
6 changed files with 244 additions and 32 deletions
+34
View File
@@ -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 ) {
+63
View File
@@ -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<boolean>}
*/
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;
}
@@ -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);
});
});
+4 -30
View File
@@ -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);
});
// -------------------------------------------------------------------------------------
+57 -2
View File
@@ -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);
+16
View File
@@ -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<boolean>}
*/
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