mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-11 15:55:44 +00:00
feat: better verification step handling (#3717)
This commit is contained in:
@@ -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 ) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user