mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cafef587e | ||
|
|
a031f26dfc | ||
|
|
e02cd75e99 | ||
|
|
6edfdd2382 | ||
|
|
b0c4a28787 | ||
|
|
380f372eca | ||
|
|
f64ba23f8a | ||
|
|
f229747581 | ||
|
|
9ec6f165a4 | ||
|
|
49f116e02e | ||
|
|
719b9c7b01 | ||
|
|
52e6e82b7b | ||
|
|
e7c4c80c3b | ||
|
|
0b688872df | ||
|
|
081a399c46 | ||
|
|
af40c9e8bc | ||
|
|
5ab3d8a90f |
No files matched your search
@@ -259,39 +259,6 @@ no user to name), puter.js opens the dashboard's upgrade page in a new tab with
|
||||
what was refused, through a consent dialog when the click has expired or the
|
||||
tab is blocked, and `requestUpgrade()` resolves `{ upgraded: false }`.
|
||||
|
||||
## Email confirmation
|
||||
|
||||
`GET /embed/request-email-confirmation` walks a first-party page's user through
|
||||
confirming their email, served like the upgrade: the GUI shell with the same
|
||||
framing rules, redirects and `no-store`, run only when framed by an allowed
|
||||
page. puter.js frames it on a signed-in first-party page for
|
||||
`puter.ui.requestEmailConfirmation()`. Inside an app the desktop shows the same
|
||||
windows over the app instead.
|
||||
|
||||
Like the upgrade, the frame covers the page with no box or close button, over a
|
||||
transparent GUI page, and its fragment names `user`. Escape closes it, on the
|
||||
page or on the save-account or code window. On a window, Escape closes that
|
||||
window as its close button does, so a code already sent still answers. The GUI
|
||||
reports `unavailable` unless its session belongs to `user` and the account has
|
||||
no phone, card or password check waiting.
|
||||
An email check is fine: the boot skips its own email gate for this action, so
|
||||
the user gets one code window they can close.
|
||||
|
||||
The GUI then runs `ensureConfirmedEmail` (`src/gui/src/helpers/ensureConfirmedEmail.js`),
|
||||
which the desktop's handler uses too: a temporary account gets the save-account
|
||||
window first, then an unconfirmed email gets the code window, each at most
|
||||
once. When the page's whoami already reads the email as confirmed,
|
||||
`requestEmailConfirmation()` resolves `{ response: true }` without framing
|
||||
anything, and a confirmation in the frame marks that whoami confirmed for the
|
||||
calls after it. An email the GUI reads as confirmed shows nothing. Before
|
||||
opening a window the GUI waits for the frame to have a viewport, as the upgrade
|
||||
does. It then posts
|
||||
`{ msg: 'puterPopupFrame', state: 'closed', confirmed }`, and
|
||||
`requestEmailConfirmation()` resolves `{ response: confirmed }`, the field an
|
||||
app reads from the desktop's answer. A call made while the frame shows settles
|
||||
with it. A frame that is refused, never ready, or `unavailable` resolves
|
||||
`{ response: false }`, since there's no popup to fall back to.
|
||||
|
||||
## Checks
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
|
||||
@@ -420,11 +420,7 @@ export type EventMap = {
|
||||
old_username: string;
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': {
|
||||
user_id: number;
|
||||
old_email: string | null;
|
||||
new_email: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
/** An account was suspended, or its suspension lifted. Local events. */
|
||||
'user.suspended': { userId: number };
|
||||
'user.unsuspended': { userId: number };
|
||||
|
||||
@@ -7468,11 +7468,6 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
|
||||
const heard: Array<Record<string, unknown>> = [];
|
||||
eventClient.on('user.email-changed', (_k, data) => {
|
||||
heard.push(data as Record<string, unknown>);
|
||||
});
|
||||
|
||||
const req = makeReq({});
|
||||
(req as unknown as { query: Record<string, string> }).query = {
|
||||
token: linkJwt,
|
||||
@@ -7480,11 +7475,6 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
const res = makeRes();
|
||||
await controller.handleChangeEmailConfirm(req, res);
|
||||
expect(res.sent).toMatch(/Email changed successfully/);
|
||||
expect(heard).toContainEqual({
|
||||
user_id: user.id,
|
||||
old_email: user.email ?? null,
|
||||
new_email: newEmail,
|
||||
});
|
||||
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
|
||||
@@ -2647,7 +2647,6 @@ export class AuthController extends PuterController {
|
||||
'user.email-changed' as never,
|
||||
{
|
||||
user_id: user.id,
|
||||
old_email: user.email ?? null,
|
||||
new_email: newEmail,
|
||||
} as never,
|
||||
{},
|
||||
|
||||
@@ -36,9 +36,8 @@ import type {
|
||||
|
||||
/**
|
||||
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade dialog,
|
||||
* which it would otherwise open in a dashboard tab, and the email confirmation,
|
||||
* which it otherwise can't show.
|
||||
* instead of opening `/action/<action>` in a window, and the upgrade dialog,
|
||||
* which it would otherwise open in a dashboard tab.
|
||||
*/
|
||||
export const FRAMED_POPUP_ACTIONS = [
|
||||
'sign-in',
|
||||
@@ -46,7 +45,6 @@ export const FRAMED_POPUP_ACTIONS = [
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
] as const;
|
||||
|
||||
/** Longest description/og:description/twitter:description on `/app/<name>`. */
|
||||
|
||||
@@ -414,7 +414,6 @@ describe('PuterServer host header validation', () => {
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
])('lets a deployment subdomain frame the %s popup and names it to the GUI', async (action) => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
const guiParams = (body: string) =>
|
||||
@@ -447,7 +446,7 @@ describe('PuterServer host header validation', () => {
|
||||
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Request-Email-Confirmation'])(
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/'])(
|
||||
'lets a first-party page frame only the exact spelling of %s',
|
||||
async (spelling) => {
|
||||
const res = await request(spelling, {
|
||||
|
||||
+42
-4
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import download from './helpers/download.js';
|
||||
import ensureConfirmedEmail from './helpers/ensureConfirmedEmail.js';
|
||||
import item_icon from './helpers/itemIcon.js';
|
||||
import socialLink from './helpers/socialLink.js';
|
||||
import update_mouse_position from './helpers/updateMousePosition.js';
|
||||
@@ -32,9 +31,11 @@ import UIWindow from './UI/UIWindow.js';
|
||||
import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js';
|
||||
import UIWindowAttachCustomDomain from './UI/UIWindowAttachCustomDomain.js';
|
||||
import UIWindowColorPicker from './UI/UIWindowColorPicker.js';
|
||||
import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowFontPicker from './UI/UIWindowFontPicker.js';
|
||||
import UIPermissionDialog from './UI/UIPermissionDialog.js';
|
||||
import UIEnablePushDialog from './UI/UIEnablePushDialog.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
import UIAuthSignup from './UI/Auth/UIAuthSignup.js';
|
||||
import UINotification from './UI/UINotification.js';
|
||||
|
||||
@@ -234,12 +235,49 @@ const ipc_listener = async (event, handled) => {
|
||||
// requestEmailConfirmation
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'requestEmailConfirmation' ) {
|
||||
// Above the app's window, which stays on top in the dashboard.
|
||||
const confirmed = await ensureConfirmedEmail(i18n('save_account_to_proceed'), { stay_on_top: true });
|
||||
// If the user has an email and it is confirmed, respond with success
|
||||
if ( window.user.email && window.user.email_confirmed ) {
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: true,
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// If the user is a temporary user, show the save account window
|
||||
if ( window.user.is_temp &&
|
||||
!await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: 'Please create an account to proceed.',
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) ) {
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: false,
|
||||
}, '*');
|
||||
return;
|
||||
}
|
||||
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) {
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: false,
|
||||
}, '*');
|
||||
return;
|
||||
}
|
||||
|
||||
const email_confirm_resp = await UIWindowEmailConfirmationRequired({
|
||||
email: window.user.email,
|
||||
});
|
||||
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: confirmed,
|
||||
response: email_confirm_resp,
|
||||
}, '*');
|
||||
}
|
||||
//--------------------------------------------------------
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.def = globalThis.def ?? ((cls) => cls);
|
||||
globalThis.use = globalThis.use ?? (() => ({}));
|
||||
globalThis.$ = globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
|
||||
const { stub, confirmEmail, saveAccount } = vi.hoisted(() => ({
|
||||
stub: () => ({ default: () => {} }),
|
||||
confirmEmail: vi.fn(),
|
||||
saveAccount: vi.fn(),
|
||||
}));
|
||||
vi.mock('./helpers/download.js', stub);
|
||||
vi.mock('./helpers/itemIcon.js', stub);
|
||||
vi.mock('./helpers/socialLink.js', stub);
|
||||
vi.mock('./helpers/updateMousePosition.js', stub);
|
||||
vi.mock('./UI/UIAlert.js', stub);
|
||||
vi.mock('./UI/UIContextMenu.js', stub);
|
||||
vi.mock('./UI/UIItem.js', stub);
|
||||
vi.mock('./UI/UIPopover.js', stub);
|
||||
vi.mock('./UI/UIPrompt.js', stub);
|
||||
vi.mock('./UI/UIWindow.js', stub);
|
||||
vi.mock('./UI/UIWindowAppFeedback.js', stub);
|
||||
vi.mock('./UI/UIWindowColorPicker.js', stub);
|
||||
vi.mock('./UI/UIWindowEmailConfirmationRequired.js', () => ({ default: confirmEmail }));
|
||||
vi.mock('./UI/UIWindowFontPicker.js', stub);
|
||||
vi.mock('./UI/UIPermissionDialog.js', stub);
|
||||
vi.mock('./UI/UIEnablePushDialog.js', stub);
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: saveAccount }));
|
||||
vi.mock('./UI/Auth/UIAuthSignup.js', stub);
|
||||
vi.mock('./UI/UINotification.js', stub);
|
||||
vi.mock('./helpers/godmodeTokens.js', () => ({ renewGodmodeToken: vi.fn() }));
|
||||
vi.mock('./helpers/watchItems.js', () => ({ addItemWatcher: vi.fn() }));
|
||||
vi.mock('./helpers/verification_gates.js', () => ({
|
||||
openVerificationGateWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
let iframe;
|
||||
let posted;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.when_puter_happens = [];
|
||||
window.appCallbackFunctions = {};
|
||||
await import('./IPC.js');
|
||||
globalThis.puter = { services: undefined, util: { rpc: { send: vi.fn() } } };
|
||||
for (const fn of window.when_puter_happens) await fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
confirmEmail.mockReset();
|
||||
saveAccount.mockReset();
|
||||
document.body.innerHTML = '';
|
||||
const win = document.createElement('div');
|
||||
win.setAttribute('data-app_uuid', 'app-1');
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('data-app', 'notes');
|
||||
win.append(iframe);
|
||||
document.body.append(win);
|
||||
posted = vi.spyOn(iframe.contentWindow, 'postMessage').mockImplementation(() => {});
|
||||
window.app_instance_ids = new Set(['inst-1']);
|
||||
window.iframe_for_app_instance = (id) => (id === 'inst-1' ? iframe : null);
|
||||
window.window_for_app_instance = () => win;
|
||||
window.is_auth = () => true;
|
||||
window.ipc_handlers = {};
|
||||
globalThis.services = { get: () => ({ get_by_uuid: () => ({ name: 'caller', uuid: 'inst-1' }) }) };
|
||||
});
|
||||
|
||||
const request = () =>
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: { appInstanceID: 'inst-1', env: 'app', msg: 'requestEmailConfirmation', uuid: 'cb-1' },
|
||||
source: iframe.contentWindow,
|
||||
origin: 'https://app.example',
|
||||
}),
|
||||
);
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const replies = () => posted.mock.calls
|
||||
.map(([data]) => data)
|
||||
.filter((data) => data.msg === 'requestEmailConfirmationResponded');
|
||||
|
||||
describe('requestEmailConfirmation', () => {
|
||||
it('answers a confirmed email true once, without a dialog', async () => {
|
||||
window.user = { email: '[email protected]', email_confirmed: true };
|
||||
request();
|
||||
await settle();
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: true }]);
|
||||
expect(confirmEmail).not.toHaveBeenCalled();
|
||||
expect(saveAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])('shows an unconfirmed email one dialog and answers its result, %s', async (answer) => {
|
||||
window.user = { email: '[email protected]', email_confirmed: false };
|
||||
confirmEmail.mockResolvedValueOnce(answer);
|
||||
request();
|
||||
await settle();
|
||||
expect(confirmEmail).toHaveBeenCalledTimes(1);
|
||||
// The app's window stays on top in the dashboard, so the dialog has to as well.
|
||||
expect(confirmEmail).toHaveBeenCalledWith({ window_options: { stay_on_top: true } });
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: answer }]);
|
||||
});
|
||||
|
||||
it('answers false once when a temporary account isn\'t saved', async () => {
|
||||
window.user = { is_temp: true };
|
||||
saveAccount.mockResolvedValueOnce(false);
|
||||
request();
|
||||
await settle();
|
||||
expect(saveAccount).toHaveBeenCalledTimes(1);
|
||||
expect(saveAccount.mock.calls[0][0].window_options).toMatchObject({ stay_on_top: true });
|
||||
expect(confirmEmail).not.toHaveBeenCalled();
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: false }]);
|
||||
});
|
||||
|
||||
it('asks a temporary account that confirmed while saving for nothing more', async () => {
|
||||
window.user = { is_temp: true };
|
||||
saveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email: '[email protected]', email_confirmed: true };
|
||||
return true;
|
||||
});
|
||||
request();
|
||||
await settle();
|
||||
expect(confirmEmail).not.toHaveBeenCalled();
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: true }]);
|
||||
});
|
||||
});
|
||||
@@ -111,8 +111,8 @@ const runVerificationGates = async (user, show_email, reload_on_success, page) =
|
||||
let email_verified = true;
|
||||
const emailGate = async () => {
|
||||
if ( ! show_email ) return;
|
||||
// No ✕: the signup card it replaced is already gone, so there's
|
||||
// nothing to go back to.
|
||||
// Its ✕ would close it without an answer, leaving the caller
|
||||
// waiting on a card that is already gone.
|
||||
email_verified = await UIWindowEmailConfirmationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
*/
|
||||
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import ensureConfirmedEmail from '../helpers/ensureConfirmedEmail.js';
|
||||
import UIWindowSaveAccount from './UIWindowSaveAccount.js';
|
||||
import UIPopover from './UIPopover.js';
|
||||
import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js';
|
||||
import UIContextMenu from './UIContextMenu.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
import UIWindowPublishWorker from './UIWindowPublishWorker.js';
|
||||
@@ -1254,7 +1255,23 @@ async function UIItem (options) {
|
||||
menu_items.push({
|
||||
html: i18n('publish_as_serverless_worker'),
|
||||
onClick: async function () {
|
||||
if ( ! await ensureConfirmedEmail(i18n('save_account_to_publish')) ) return;
|
||||
if ( window.user.is_temp &&
|
||||
!await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: 'Please create an account to proceed.',
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UIWindowPublishWorker(options.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -934,7 +934,7 @@ async function UIWindow (options) {
|
||||
// LEGACY SUPPORT, remove this in the future when Polotno uses the new SDK
|
||||
// this is literally put in here to support Polotno's legacy code
|
||||
...(selected_files.length === 1 && selected_files[0]),
|
||||
}, window.openerOrigin);
|
||||
}, '*');
|
||||
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
@@ -1018,7 +1018,7 @@ async function UIWindow (options) {
|
||||
// LEGACY SUPPORT, remove this in the future when Polotno uses the new SDK
|
||||
// this is literally put in here to support Polotno's legacy code
|
||||
...(selected_dirs.length === 1 && selected_dirs[0]),
|
||||
}, window.openerOrigin);
|
||||
}, '*');
|
||||
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
|
||||
@@ -20,24 +20,12 @@
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
|
||||
/**
|
||||
* Asks for the code sent to the user's email.
|
||||
*
|
||||
* @returns {Promise<boolean>} True once the email is confirmed, false when the
|
||||
* window closes without it. A logout, from its footer or anywhere else,
|
||||
* leaves it unsettled, so a gate that reopens it on false doesn't race the
|
||||
* logout.
|
||||
*/
|
||||
function UIWindowEmailConfirmationRequired (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
options.window_options = options.window_options ?? {};
|
||||
let final_code = '';
|
||||
let is_checking_code = false;
|
||||
let logging_out = false;
|
||||
let submit_timer = null;
|
||||
let code_sent = false;
|
||||
let close_after_check = false;
|
||||
|
||||
const submit_btn_txt = 'Confirm Email';
|
||||
|
||||
@@ -104,20 +92,6 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
// A code on its way to the server answers for the window, so the
|
||||
// window waits for it. One not sent yet isn't sent.
|
||||
on_before_exit: () => {
|
||||
if ( code_sent && ! logging_out ) {
|
||||
close_after_check = true;
|
||||
return false;
|
||||
}
|
||||
clearTimeout(submit_timer);
|
||||
return true;
|
||||
},
|
||||
// A no-op after a confirmation, which resolves first.
|
||||
on_close: () => {
|
||||
if ( ! logging_out && window.auth_token ) resolve(false);
|
||||
},
|
||||
body_css: {
|
||||
padding: '30px',
|
||||
width: 'initial',
|
||||
@@ -148,8 +122,7 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
// set animation
|
||||
$(el_window).find('.email-confirm-btn').html('<svg style="width:20px; margin-top: 5px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#fff" 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="#eee" 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>');
|
||||
|
||||
submit_timer = setTimeout(() => {
|
||||
code_sent = true;
|
||||
setTimeout(() => {
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/confirm-email`,
|
||||
type: 'POST',
|
||||
@@ -168,13 +141,9 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
},
|
||||
success: function (res) {
|
||||
if ( res.email_confirmed ) {
|
||||
// Callers check window.user next, before the refresh lands.
|
||||
if ( window.user ) window.user.email_confirmed = true;
|
||||
resolve(true);
|
||||
code_sent = false;
|
||||
close_after_check = false;
|
||||
$(el_window).close();
|
||||
window.refresh_user_data(window.auth_token);
|
||||
resolve(true);
|
||||
} else {
|
||||
$(el_window).find('.error').html('Invalid confirmation code.');
|
||||
$(el_window).find('.error').fadeIn();
|
||||
@@ -186,8 +155,7 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
}
|
||||
},
|
||||
error: function (res) {
|
||||
// A network failure has no body to read.
|
||||
$(el_window).find('.error').html(html_encode(res.responseJSON?.error ?? i18n('error_unknown_cause')));
|
||||
$(el_window).find('.error').html(html_encode(res.responseJSON.error));
|
||||
$(el_window).find('.error').fadeIn();
|
||||
$(el_window).find('.digit-input').val('');
|
||||
$(el_window).find('.digit-input').first().focus();
|
||||
@@ -197,8 +165,6 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
},
|
||||
complete: function () {
|
||||
is_checking_code = false;
|
||||
code_sent = false;
|
||||
if ( close_after_check ) $(el_window).close();
|
||||
},
|
||||
});
|
||||
}, 1000);
|
||||
@@ -206,7 +172,6 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
|
||||
// send email confirmation
|
||||
$(el_window).find('.send-conf-email').on('click', function (e) {
|
||||
$(el_window).find('.error').hide();
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/send-confirm-email`,
|
||||
type: 'POST',
|
||||
@@ -229,22 +194,19 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
});
|
||||
$(el_window).find('.digit-input').first().focus();
|
||||
},
|
||||
error: function (res) {
|
||||
$(el_window).find('.error').html(html_encode(res.responseJSON?.error ?? i18n('error_unknown_cause')));
|
||||
$(el_window).find('.error').fadeIn();
|
||||
complete: function () {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// logout
|
||||
$(el_window).find('.conf-email-log-out').on('click', function (e) {
|
||||
logging_out = true;
|
||||
window.logout();
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
// Elements
|
||||
const numberCodeForm = $(el_window).find('[data-number-code-form]').get(0);
|
||||
const numberCodeForm = document.querySelector('[data-number-code-form]');
|
||||
const numberCodeInputs = [...numberCodeForm.querySelectorAll('[data-number-code-input]')];
|
||||
|
||||
// Event listeners
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
// Renders the body and, as the real window does, asks on_before_exit before
|
||||
// closing and runs on_close once it's removed.
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: vi.fn(async (opts) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = `window ${opts.window_class}`;
|
||||
el.innerHTML = opts.body_content;
|
||||
el.onClose = opts.on_close;
|
||||
el.onBeforeExit = opts.on_before_exit;
|
||||
document.body.append(el);
|
||||
return el;
|
||||
}),
|
||||
}));
|
||||
vi.mock('./UIAlert.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
|
||||
const { default: UIWindowEmailConfirmationRequired } = await import('./UIWindowEmailConfirmationRequired.js');
|
||||
|
||||
const PENDING = Symbol('pending');
|
||||
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
|
||||
const win = () => jQuery('.window-confirm-email-using-code');
|
||||
|
||||
/** Opens the window and waits for it to render. Returns its answer, still pending. */
|
||||
const open = async (options) => {
|
||||
const result = UIWindowEmailConfirmationRequired(options);
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(1));
|
||||
return { result };
|
||||
};
|
||||
|
||||
const enterCode = () => {
|
||||
const first = document.querySelector('[data-number-code-input="0"]');
|
||||
first.value = '123456';
|
||||
first.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = function () {
|
||||
this.each((_, el) => {
|
||||
if ( ! el.isConnected || el.onBeforeExit?.() === false ) return;
|
||||
el.remove();
|
||||
el.onClose?.();
|
||||
});
|
||||
return this;
|
||||
};
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
window.icons = {};
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'token-1';
|
||||
window.user = { email: '[email protected]', email_confirmed: false };
|
||||
window.refresh_user_data = vi.fn();
|
||||
window.logout = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
delete jQuery.fn.close;
|
||||
});
|
||||
|
||||
describe('UIWindowEmailConfirmationRequired', () => {
|
||||
it('resolves true once the code confirms the email, and says so on window.user straight away', async () => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.success({ email_confirmed: true });
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(ajax).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'https://api.puter.test/confirm-email',
|
||||
data: JSON.stringify({ code: '123456' }),
|
||||
}));
|
||||
// Callers read it next, before the refresh lands.
|
||||
expect(window.user.email_confirmed).toBe(true);
|
||||
expect(window.refresh_user_data).toHaveBeenCalledWith('token-1');
|
||||
expect(win()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stays open on a wrong code', async () => {
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.success({ email_confirmed: false });
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(window.user.email_confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the server\'s error', { status: 429, responseJSON: { error: 'Too many requests.' } }, 'Too many requests.'],
|
||||
['an unknown error on a network failure', { status: 0 }, 'error_unknown_cause'],
|
||||
])('shows %s and lets the user try again', async (_label, xhr, shown) => {
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.error(xhr);
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(win().find('.error').text()).toBe(shown);
|
||||
expect(win().find('.digit-input').prop('disabled')).toBe(false);
|
||||
expect(win().find('.email-confirm-btn').text()).toBe('Confirm Email');
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it('takes the code typed in a second window open over another', async () => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.success({ email_confirmed: true });
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result: under } = await open();
|
||||
const over = UIWindowEmailConfirmationRequired();
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(2));
|
||||
vi.useFakeTimers();
|
||||
const first = win().get(1).querySelector('[data-number-code-input="0"]');
|
||||
first.value = '123456';
|
||||
first.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect(over).resolves.toBe(true);
|
||||
expect(ajax).toHaveBeenCalledWith(expect.objectContaining({ data: JSON.stringify({ code: '123456' }) }));
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(await settled(under)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the server\'s error', { status: 429, responseJSON: { error: 'Too many requests.' } }, 'Too many requests.'],
|
||||
['an unknown error on a network failure', { status: 0 }, 'error_unknown_cause'],
|
||||
])('shows %s when a new code can\'t be sent', async (_label, xhr, shown) => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => opts.error(xhr));
|
||||
await open();
|
||||
win().find('.send-conf-email').trigger('click');
|
||||
|
||||
expect(ajax).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://api.puter.test/send-confirm-email' }));
|
||||
expect(win().find('.error').text()).toBe(shown);
|
||||
});
|
||||
|
||||
it('resolves false when it closes without a confirmation', async () => {
|
||||
const { result } = await open();
|
||||
win().close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('sends no code when it closes before the code is sent', async () => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation(() => {});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
win().close();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(ajax).not.toHaveBeenCalled();
|
||||
expect(window.user.email_confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['true when the code confirms the email', { email_confirmed: true }, true],
|
||||
['false when it doesn\'t', { email_confirmed: false }, false],
|
||||
])('waits for a code already sent when asked to close, and resolves %s', async (_label, answer, expected) => {
|
||||
let respond;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
respond = () => {
|
||||
opts.success(answer);
|
||||
opts.complete();
|
||||
};
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
win().close();
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
|
||||
respond();
|
||||
await expect(result).resolves.toBe(expected);
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(window.user.email_confirmed).toBe(expected);
|
||||
});
|
||||
|
||||
it('closes once a code already sent fails, when asked to close meanwhile', async () => {
|
||||
let fail;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
fail = () => {
|
||||
opts.error({ status: 0 });
|
||||
opts.complete();
|
||||
};
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
win().close();
|
||||
fail();
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(win()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('closes for a logout from its footer while a code is being checked', async () => {
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation(() => {});
|
||||
const { result } = await open({ logout_in_footer: true });
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
win().find('.conf-email-log-out').trigger('click');
|
||||
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it('stays unsettled when a logout elsewhere closes it, so a gate doesn\'t reopen it', async () => {
|
||||
const { result } = await open();
|
||||
// As the logout handler leaves things when it closes every window.
|
||||
window.auth_token = null;
|
||||
win().close();
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it('stays unsettled on a logout from its footer, so a gate doesn\'t reopen it', async () => {
|
||||
const { result } = await open({ logout_in_footer: true });
|
||||
win().find('.conf-email-log-out').trigger('click');
|
||||
expect(window.logout).toHaveBeenCalledTimes(1);
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
});
|
||||
@@ -117,12 +117,6 @@ async function UIWindowSaveAccount (options) {
|
||||
},
|
||||
});
|
||||
|
||||
// One is already open, which UIWindow focused instead of opening this.
|
||||
if ( ! el_window ) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
$(el_window).find('.signup-btn').on('click', function (e) {
|
||||
// todo do some basic validation client-side
|
||||
|
||||
@@ -170,15 +164,10 @@ async function UIWindowSaveAccount (options) {
|
||||
|
||||
//close this window
|
||||
if ( data.user.requires_email_confirmation && !data.user.email_confirmed ) {
|
||||
// The code window brings its own backdrop; this
|
||||
// one under it would dim the page twice.
|
||||
const $backdrop = $(el_window).closest('.window-backdrop');
|
||||
$backdrop.css('visibility', 'hidden');
|
||||
let is_verified = await UIWindowEmailConfirmationRequired({
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
});
|
||||
$backdrop.css('visibility', '');
|
||||
resolve(is_verified);
|
||||
} else {
|
||||
resolve(true);
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
// As the real window does, opens once: a second one focuses the first and returns nothing.
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: vi.fn(async (opts) => {
|
||||
if ( document.querySelector('.window-save-account') ) return undefined;
|
||||
const el = document.createElement('div');
|
||||
el.className = `window ${opts.window_class}`;
|
||||
el.innerHTML = opts.body_content;
|
||||
el.onClose = opts.on_close;
|
||||
document.body.append(el);
|
||||
return el;
|
||||
}),
|
||||
}));
|
||||
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
|
||||
const { default: UIWindowSaveAccount } = await import('./UIWindowSaveAccount.js');
|
||||
|
||||
const PENDING = Symbol('pending');
|
||||
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
|
||||
const win = () => jQuery('.window-save-account');
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = function () {
|
||||
this.each((_, el) => {
|
||||
el.remove();
|
||||
el.onClose?.();
|
||||
});
|
||||
return this;
|
||||
};
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'id-1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete jQuery.fn.close;
|
||||
});
|
||||
|
||||
describe('UIWindowSaveAccount', () => {
|
||||
it('resolves false when it closes without saving', async () => {
|
||||
const result = UIWindowSaveAccount();
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(1));
|
||||
win().close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false when one is already open, and leaves that one waiting', async () => {
|
||||
const first = UIWindowSaveAccount();
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(1));
|
||||
|
||||
await expect(UIWindowSaveAccount()).resolves.toBe(false);
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(await settled(first)).toBe(PENDING);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import ensureConfirmedEmail from './ensureConfirmedEmail.js';
|
||||
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
|
||||
|
||||
/**
|
||||
* Walks a temporary account through saving itself, and an unconfirmed one
|
||||
@@ -29,7 +30,22 @@ import ensureConfirmedEmail from './ensureConfirmedEmail.js';
|
||||
*/
|
||||
const ensure_can_publish = async function (required) {
|
||||
if ( ! required ) return true;
|
||||
return ensureConfirmedEmail(i18n('save_account_to_publish'));
|
||||
if ( window.user.is_temp && ! await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: i18n('save_account_to_publish'),
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) ) {
|
||||
return false;
|
||||
}
|
||||
// Saving refreshes window.user; a deployment that doesn't confirm at
|
||||
// signup leaves the new account unconfirmed, and it still has to confirm.
|
||||
if ( ! window.user.email_confirmed ) {
|
||||
return !! await UIWindowEmailConfirmationRequired();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default ensure_can_publish;
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
|
||||
|
||||
// The walk-through showing, which a call made meanwhile joins.
|
||||
let showing = null;
|
||||
|
||||
const walkThrough = async (message, window_options) => {
|
||||
if ( window.user.is_temp && ! await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message,
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
...window_options,
|
||||
},
|
||||
}) ) {
|
||||
return false;
|
||||
}
|
||||
// Saving refreshes window.user; a deployment that doesn't confirm at
|
||||
// signup leaves the new account unconfirmed, and it still has to confirm.
|
||||
if ( ! window.user.email_confirmed ) {
|
||||
return !! await UIWindowEmailConfirmationRequired({ window_options });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walks a temporary account through saving itself, and an unconfirmed one
|
||||
* through confirming its email, showing each window at most once. A call made
|
||||
* while the windows show settles with them, without opening its own.
|
||||
*
|
||||
* @param {string} message - What the save-account window says to a temporary account.
|
||||
* @param {Record<string, unknown>} [window_options] - Passed to both windows, such as
|
||||
* `stay_on_top` to show them above an app.
|
||||
* @returns {Promise<boolean>} Whether the email is confirmed; false if the user backed out of either step.
|
||||
*/
|
||||
const ensureConfirmedEmail = (message, window_options = {}) => {
|
||||
showing ??= walkThrough(message, window_options).finally(() => {
|
||||
showing = null;
|
||||
});
|
||||
return showing;
|
||||
};
|
||||
|
||||
export default ensureConfirmedEmail;
|
||||
@@ -1,133 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
|
||||
const { default: ensureConfirmedEmail } = await import('./ensureConfirmedEmail.js');
|
||||
const { default: UIWindowSaveAccount } = await import('../UI/UIWindowSaveAccount.js');
|
||||
const { default: UIWindowEmailConfirmationRequired } = await import('../UI/UIWindowEmailConfirmationRequired.js');
|
||||
|
||||
beforeEach(() => {
|
||||
UIWindowSaveAccount.mockReset();
|
||||
UIWindowEmailConfirmationRequired.mockReset();
|
||||
});
|
||||
|
||||
describe('ensureConfirmedEmail', () => {
|
||||
it('shows nothing for a confirmed email', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: true };
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])('asks an unconfirmed email for its code once, and answers %s as it does', async (answer) => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(answer);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(answer);
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
|
||||
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has a temporary account saved first, with the message given', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockResolvedValueOnce(false);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(false);
|
||||
expect(UIWindowSaveAccount).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'save it',
|
||||
send_confirmation_code: true,
|
||||
}));
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the window options to both windows', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
return true;
|
||||
});
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(true);
|
||||
await ensureConfirmedEmail('save it', { stay_on_top: true });
|
||||
expect(UIWindowSaveAccount.mock.calls[0][0].window_options).toEqual({
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
stay_on_top: true,
|
||||
});
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledWith({ window_options: { stay_on_top: true } });
|
||||
});
|
||||
|
||||
it('asks no code again from an account that confirmed it while saving', async () => {
|
||||
window.user = { is_temp: true };
|
||||
// Saving asked for the code, which marks window.user confirmed.
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: true };
|
||||
return true;
|
||||
});
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks a just-saved account for its code when saving left it unconfirmed', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
return true;
|
||||
});
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(true);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('answers a call made while the code window shows with that window, without a second one', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
let answer;
|
||||
UIWindowEmailConfirmationRequired.mockReturnValueOnce(new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
const first = ensureConfirmedEmail('save it');
|
||||
const second = ensureConfirmedEmail('save it');
|
||||
await Promise.resolve();
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
|
||||
answer(true);
|
||||
expect(await Promise.all([first, second])).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('answers a call made while the save-account window shows with that window', async () => {
|
||||
window.user = { is_temp: true };
|
||||
let answer;
|
||||
UIWindowSaveAccount.mockReturnValueOnce(new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
const first = ensureConfirmedEmail('save it');
|
||||
const second = ensureConfirmedEmail('save it');
|
||||
expect(UIWindowSaveAccount).toHaveBeenCalledTimes(1);
|
||||
answer(false);
|
||||
expect(await Promise.all([first, second])).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it('opens a new window for a call made after the last one answered', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(false);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -232,7 +232,7 @@ Please try recreating the link.`);
|
||||
// LEGACY SUPPORT, remove this in the future when Polotno uses the new SDK
|
||||
// this is literally put in here to support Polotno's legacy code
|
||||
...(!Array.isArray(res) && res),
|
||||
}, window.openerOrigin);
|
||||
}, '*');
|
||||
|
||||
window.close();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ export const FRAMED_POPUP_ACTIONS = new Set([
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
// Actions a frame shows only for the user the framing page is signed in as.
|
||||
@@ -39,12 +38,11 @@ const HOST_USER_ACTIONS = new Set([
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
// Actions framed over the whole page with no box, which their own dialog's
|
||||
// scrim covers.
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade']);
|
||||
|
||||
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
|
||||
const UPGRADE_METHOD_MAX = 200;
|
||||
@@ -66,10 +64,9 @@ export const POPUP_FRAME_MSG = 'puterPopupFrame';
|
||||
// The popup's query parameters a framing page may pass, in the fragment.
|
||||
const FRAGMENT_PARAMS = ['msg_id', 'options', 'request_auth'];
|
||||
|
||||
// The account checks a sign-in can show (email, phone, card), and the
|
||||
// save-account window an email confirmation shows a temporary account first,
|
||||
// which are windows rather than auth screens.
|
||||
const ACCOUNT_CHECK_WINDOWS = '.window-confirm-email-using-code, .window-confirm-phone-using-code, .window-card-verification, .window-save-account';
|
||||
// The account checks a sign-in can show (email, phone, card), which are
|
||||
// windows rather than auth screens.
|
||||
const ACCOUNT_CHECK_WINDOWS = '.window-confirm-email-using-code, .window-confirm-phone-using-code, .window-card-verification';
|
||||
|
||||
// What a framed popup shows first; until one is on the page the framing page
|
||||
// keeps the frame hidden behind its loading scrim.
|
||||
@@ -213,23 +210,19 @@ export const focusOnFrameFocus = (win) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes a framed sign-in or email confirmation on Escape in one of its
|
||||
* account windows, as Escape on the sign-in's auth screens does. Those
|
||||
* screens handle Escape themselves; the account windows don't.
|
||||
* Closes a framed sign-in on Escape in one of its account checks, as Escape
|
||||
* on its auth screens does. Those screens handle Escape themselves; the
|
||||
* account checks are windows, which don't.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {(accountWindow: Element) => void} [close] - closes the account window
|
||||
* Escape was pressed in, when its own closing is what answers the frame;
|
||||
* without it the frame closes
|
||||
*/
|
||||
export const closeAccountChecksOnEscape = (win, close = () => win.close()) => {
|
||||
export const closeAccountChecksOnEscape = (win) => {
|
||||
win.document.addEventListener('keydown', (e) => {
|
||||
// Safari sends the Escape that ends an IME conversion as keyCode 229
|
||||
// with isComposing false.
|
||||
if ( e.key !== 'Escape' || e.defaultPrevented || e.isComposing || e.keyCode === 229 ) return;
|
||||
const el = e.target === win.document.body ? win.document.querySelector('.window-active') : e.target;
|
||||
const accountWindow = el?.closest?.(ACCOUNT_CHECK_WINDOWS);
|
||||
if ( accountWindow ) close(accountWindow);
|
||||
if ( el?.closest?.(ACCOUNT_CHECK_WINDOWS) ) win.close();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -251,37 +244,6 @@ export const canShowUpgradeFrame = (win, session) =>
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Whether a framed email confirmation can show for the GUI session `session`
|
||||
* (its `/whoami`): no check other than the email is waiting, since those show
|
||||
* at boot with no way to close the frame.
|
||||
*
|
||||
* @param {Record<string, unknown> | null} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const canShowEmailConfirmationFrame = (session) =>
|
||||
!!session &&
|
||||
!session.requires_phone_verification &&
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Confirms the email of the framed user with `confirm`, which shows the GUI's
|
||||
* windows for it. An email already confirmed resolves true without showing
|
||||
* anything, so the page never shows the frame.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {() => Promise<boolean>} confirm
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const confirmEmailInFrame = async (win, confirm, tell) => {
|
||||
if ( win.user?.email_confirmed === true ) return true;
|
||||
// The windows center themselves when they open.
|
||||
await whenFrameSized(win, () => tell('ready'));
|
||||
return (await confirm()) === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* What prompted a framed upgrade, from its `reason` and `method` fragment
|
||||
* parameters, keeping only well-formed values.
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
POPUP_FRAME_MSG,
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
focusOnFrameFocus,
|
||||
framedPopupAction,
|
||||
framedPopupParams,
|
||||
@@ -50,7 +48,6 @@ describe('framedPopupAction', () => {
|
||||
['/embed/show-save-file-picker', 'show-save-file-picker'],
|
||||
['/embed/show-directory-picker', 'show-directory-picker'],
|
||||
['/embed/upgrade', 'upgrade'],
|
||||
['/embed/request-email-confirmation', 'request-email-confirmation'],
|
||||
['/embed/apps', null],
|
||||
['/embed/feedback', null],
|
||||
['/embed/request-permission', null],
|
||||
@@ -85,7 +82,6 @@ describe('framesHostUser', () => {
|
||||
['show-save-file-picker', true],
|
||||
['show-directory-picker', true],
|
||||
['upgrade', true],
|
||||
['request-email-confirmation', true],
|
||||
['sign-in', false],
|
||||
[null, false],
|
||||
])('%s serves only the framing page\'s user: %s', (action, expected) => {
|
||||
@@ -133,83 +129,6 @@ describe('canShowUpgradeFrame', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowEmailConfirmationFrame', () => {
|
||||
const session = { uuid: 'u-1' };
|
||||
|
||||
it('shows for a session with nothing to verify, or only the email', () => {
|
||||
expect(canShowEmailConfirmationFrame(session)).toBe(true);
|
||||
expect(canShowEmailConfirmationFrame({ ...session, requires_email_confirmation: true })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'requires_phone_verification',
|
||||
'requires_card_verification',
|
||||
'requires_password_change',
|
||||
])('leaves an account with %s unavailable', (check) => {
|
||||
expect(canShowEmailConfirmationFrame({ ...session, [check]: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('needs a session', () => {
|
||||
expect(canShowEmailConfirmationFrame(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmEmailInFrame', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const confirmWindow = ({ user, width = 1280, height = 800 } = {}) =>
|
||||
Object.assign(new EventTarget(), { user, innerWidth: width, innerHeight: height });
|
||||
|
||||
it('resolves true for a confirmed email without showing anything', async () => {
|
||||
const confirm = vi.fn();
|
||||
const tell = vi.fn();
|
||||
await expect(confirmEmailInFrame(confirmWindow({ user: { email_confirmed: true } }), confirm, tell))
|
||||
.resolves.toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(tell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['confirms', true, true],
|
||||
['backs out', false, false],
|
||||
['gets no answer', undefined, false],
|
||||
])('resolves whether the email is confirmed when the user %s', async (_label, answer, expected) => {
|
||||
const confirm = vi.fn(async () => answer);
|
||||
await expect(confirmEmailInFrame(confirmWindow({ user: { email_confirmed: false } }), confirm, vi.fn()))
|
||||
.resolves.toBe(expected);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('walks a temporary account through it', async () => {
|
||||
const confirm = vi.fn(async () => true);
|
||||
await expect(confirmEmailInFrame(confirmWindow({ user: { is_temp: true } }), confirm, vi.fn()))
|
||||
.resolves.toBe(true);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has a hidden frame shown before its windows open', async () => {
|
||||
vi.useFakeTimers();
|
||||
const win = confirmWindow({ user: {}, width: 0, height: 0 });
|
||||
const sizes = [];
|
||||
const tell = vi.fn();
|
||||
const result = confirmEmailInFrame(win, async () => {
|
||||
sizes.push([win.innerWidth, win.innerHeight]);
|
||||
return true;
|
||||
}, tell);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(tell).toHaveBeenCalledWith('ready');
|
||||
expect(sizes).toEqual([]);
|
||||
|
||||
win.innerWidth = 1280;
|
||||
win.innerHeight = 800;
|
||||
win.dispatchEvent(new Event('resize'));
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(sizes).toEqual([[1280, 800]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFramedUpgrade', () => {
|
||||
const upgradeWindow = (openAsWindow, { width = 1280, height = 800 } = {}) => {
|
||||
const created = [];
|
||||
@@ -396,16 +315,12 @@ describe('startPopupFrame', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the framing page show through an upgrade or email confirmation frame only', () => {
|
||||
it('lets the framing page show through an upgrade frame only', () => {
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/sign-in' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(false);
|
||||
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/upgrade' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
|
||||
|
||||
document.documentElement.classList.remove('popup-frame-transparent');
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/request-email-confirmation' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends a boolean field with a state', () => {
|
||||
@@ -571,7 +486,6 @@ describe('closeAccountChecksOnEscape', () => {
|
||||
'window-confirm-email-using-code',
|
||||
'window-confirm-phone-using-code',
|
||||
'window-card-verification',
|
||||
'window-save-account',
|
||||
])('closes on Escape in the %s window', (cls) => {
|
||||
const win = signInWindow();
|
||||
escapeOn(showWindow(win, `window window-active ${cls}`).querySelector('input'));
|
||||
@@ -597,15 +511,4 @@ describe('closeAccountChecksOnEscape', () => {
|
||||
escapeOn(showWindow(win, 'window window-alert').querySelector('input'));
|
||||
expect(win.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands the account window to the close it is given, leaving the frame open', () => {
|
||||
const win = { document: document.implementation.createHTMLDocument(), close: vi.fn() };
|
||||
const close = vi.fn();
|
||||
closeAccountChecksOnEscape(win, close);
|
||||
const el = showWindow(win);
|
||||
escapeOn(el.querySelector('input'));
|
||||
escapeOn(win.document.body);
|
||||
expect(close.mock.calls).toEqual([[el], [el]]);
|
||||
expect(win.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -557,7 +557,6 @@ const en = {
|
||||
saturation: 'Saturation',
|
||||
save_account: 'Save account',
|
||||
save_account_to_get_copy_link: 'Please create an account to proceed.',
|
||||
save_account_to_proceed: 'Please create an account to proceed.',
|
||||
save_account_to_publish: 'Please create an account to proceed.',
|
||||
save_session: 'Save session',
|
||||
save_session_c2a: 'Create an account to save your current session and avoid losing your work.',
|
||||
|
||||
+5
-36
@@ -48,10 +48,8 @@ import { PROCESS_RUNNING } from './definitions.js';
|
||||
import { isAttestedOrigin } from './helpers/attestedOrigin.js';
|
||||
import { embedHostUser } from './helpers/embedHostUser.js';
|
||||
import {
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
focusOnFrameFocus,
|
||||
framedPopupAction,
|
||||
framedPopupParams,
|
||||
@@ -61,7 +59,6 @@ import {
|
||||
startPopupFrame,
|
||||
} from './helpers/popupFrame.js';
|
||||
import confirm_before_unload from './helpers/confirmBeforeUnload.js';
|
||||
import ensureConfirmedEmail from './helpers/ensureConfirmedEmail.js';
|
||||
import create_access_token from './helpers/createAccessToken.js';
|
||||
import create_gui_token from './helpers/createGuiToken.js';
|
||||
import {
|
||||
@@ -580,7 +577,7 @@ const postAuthActions = async (action) => {
|
||||
window.opener?.postMessage({
|
||||
msg,
|
||||
original_msg_id: msg_id,
|
||||
}, window.openerOrigin);
|
||||
}, '*');
|
||||
// Escape closes only the picker window, which would leave the
|
||||
// page's frame open over an empty desktop.
|
||||
if ( window.embedded_in_frame ) window.close();
|
||||
@@ -648,7 +645,7 @@ const postAuthActions = async (action) => {
|
||||
// send 'sendMeFileData' event to parent
|
||||
window.opener.postMessage({
|
||||
msg: 'sendMeFileData',
|
||||
}, window.openerOrigin);
|
||||
}, '*');
|
||||
|
||||
// listen for 'showSaveFilePickerPopup' event from parent. Only the
|
||||
// page that asked supplies the file, once; any other window that
|
||||
@@ -723,7 +720,7 @@ const postAuthActions = async (action) => {
|
||||
uid: file_signature.uid,
|
||||
path: window.privacy_aware_path(res.path),
|
||||
},
|
||||
}, window.openerOrigin);
|
||||
}, '*');
|
||||
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
@@ -1025,25 +1022,6 @@ const postAuthActions = async (action) => {
|
||||
window.tell_popup_frame('closed', { upgraded });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: request-email-confirmation — puter.ui.requestEmailConfirmation() on the
|
||||
// first-party page framing it, which hears whether the email is confirmed once the
|
||||
// user is done.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if ( action === 'request-email-confirmation' && window.embedded_in_frame ) {
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = await confirmEmailInFrame(
|
||||
window,
|
||||
() => ensureConfirmedEmail(i18n('save_account_to_proceed')),
|
||||
window.tell_popup_frame,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('request-email-confirmation action failed', e);
|
||||
}
|
||||
window.tell_popup_frame('closed', { confirmed });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: attach-custom-domain — puter.hosting.attach() on a website. Hosts the custom
|
||||
// domain dialog for `subdomain` and posts the chosen domain back to the opener.
|
||||
@@ -1610,10 +1588,6 @@ window.initgui = async function (options) {
|
||||
window.gui_action = action;
|
||||
|
||||
if (window.embedded_in_frame && action === 'sign-in') closeAccountChecksOnEscape(window);
|
||||
// Closing the window answers the frame, after any code it already sent.
|
||||
if (window.embedded_in_frame && action === 'request-email-confirmation') {
|
||||
closeAccountChecksOnEscape(window, (accountWindow) => $(accountWindow).close());
|
||||
}
|
||||
|
||||
if (action === 'oidc-2fa') {
|
||||
const result = oidcChallenge?.token && await UIAuth2FA(oidcChallenge.token);
|
||||
@@ -1815,8 +1789,7 @@ window.initgui = async function (options) {
|
||||
if (
|
||||
!host_user ||
|
||||
session_user !== host_user ||
|
||||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
|
||||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
|
||||
(action === 'upgrade' && !canShowUpgradeFrame(window, session))
|
||||
) {
|
||||
window.tell_popup_frame('unavailable');
|
||||
return;
|
||||
@@ -2436,12 +2409,8 @@ window.initgui = async function (options) {
|
||||
// assertVerifiedAccount): email → phone (SMS) → card, or card →
|
||||
// phone → email when the account clears email last.
|
||||
let card_gate_cleared_by_fallback = false;
|
||||
// A framed email confirmation asks for the code itself, in a
|
||||
// window the user can close.
|
||||
const confirmsEmailInAction =
|
||||
window.embedded_in_frame && action === 'request-email-confirmation';
|
||||
const emailGate = async () => {
|
||||
if (whoami.requires_email_confirmation && !confirmsEmailInAction) {
|
||||
if (whoami.requires_email_confirmation) {
|
||||
let is_verified;
|
||||
do {
|
||||
is_verified = await UIWindowEmailConfirmationRequired({
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* A popup's opener can be navigated to another origin while a picker is open,
|
||||
* so messages to it go to the origin attested at boot, not to '*'.
|
||||
*/
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
// Sent before the opener's origin is known, or to report that it is unusable.
|
||||
const unattestedMessages = ['requestOrigin', 'permissionGranted', 'pushEnabled'];
|
||||
|
||||
const openerPosts = (src) => {
|
||||
const posts = [];
|
||||
for (const match of src.matchAll(/window\.opener\??\.postMessage\(/g)) {
|
||||
let depth = 1;
|
||||
let i = match.index + match[0].length;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === '(') depth++;
|
||||
else if (src[i] === ')') depth--;
|
||||
i++;
|
||||
}
|
||||
posts.push(src.slice(match.index, i));
|
||||
}
|
||||
return posts;
|
||||
};
|
||||
|
||||
describe('messages to a popup opener', () => {
|
||||
for (const file of ['./initgui.js', './UI/UIWindow.js', './helpers/openItem.js']) {
|
||||
it(`target the attested opener origin in ${file}`, () => {
|
||||
const posts = openerPosts(read(file));
|
||||
expect(posts.length).toBeGreaterThan(0);
|
||||
for (const post of posts) {
|
||||
if (!/'\*'\s*,?\s*\)$/.test(post)) continue;
|
||||
const msg = post.match(/msg:\s*'([^']+)'/)?.[1];
|
||||
expect(unattestedMessages, post).toContain(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Vendored
-1
@@ -18,7 +18,6 @@ declare global {
|
||||
// -- Shared --
|
||||
export type {
|
||||
APILoggingConfig,
|
||||
EmailConfirmationResult,
|
||||
ListPage,
|
||||
ListPaginationOptions,
|
||||
ListStreamOptions,
|
||||
|
||||
@@ -443,10 +443,7 @@ export const showFeedbackEmbed = ({ guiOrigin, userUuid }) => mountGuiEmbed({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Popup actions a first-party page shows framed, each in a box its popup
|
||||
* window's size. Any other covers the page, and its dialog draws its own scrim.
|
||||
*/
|
||||
/** Popup actions a first-party page shows framed, each in a box its popup window's size. */
|
||||
const POPUP_FRAME_BOXES = {
|
||||
'sign-in': { width: 600, height: 700 },
|
||||
'show-open-file-picker': { width: 700, height: 400 },
|
||||
@@ -476,8 +473,8 @@ const UNBOXED_POPUP_FRAMES = {
|
||||
* @template T
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.guiOrigin
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'request-email-confirmation'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade or the email confirmation
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker or the upgrade
|
||||
* @param {string} opts.title - the frame's accessible name
|
||||
* @param {string} [opts.closeLabel] - puts a close button on the frame
|
||||
* @param {(data?: Record<string, unknown>) => T} opts.closed
|
||||
|
||||
@@ -59,14 +59,6 @@
|
||||
* or when the upgrade opened in a new tab instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How `puter.ui.requestEmailConfirmation()` ended.
|
||||
*
|
||||
* @typedef {Object} EmailConfirmationResult
|
||||
* @property {boolean} response `true` when the user's email is confirmed, including when it already
|
||||
* was. `false` when the user closed the dialog first, or when a website couldn't show it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The legacy positional callbacks most methods accept alongside the promise
|
||||
* they return.
|
||||
|
||||
@@ -538,9 +538,6 @@ export class UIModule extends EventListener {
|
||||
// The framed upgrade showing, which a call made while it's open joins.
|
||||
#upgradeFrame = null;
|
||||
|
||||
// The framed email confirmation showing, which a call made while it's open joins.
|
||||
#emailConfirmationFrame = null;
|
||||
|
||||
// Holds the callback functions for the various events
|
||||
// that are triggered when a watched item has changed.
|
||||
itemWatchCallbackFunctions = [];
|
||||
@@ -719,7 +716,7 @@ export class UIModule extends EventListener {
|
||||
* The page's signed-in user that a frame is shown for, from the cached
|
||||
* whoami when there is one, or null without a uuid.
|
||||
*
|
||||
* @returns {Promise<{ uuid: string, email_confirmed?: boolean } | null>}
|
||||
* @returns {Promise<{ uuid: string } | null>}
|
||||
*/
|
||||
async #frameUser () {
|
||||
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
|
||||
@@ -1337,59 +1334,17 @@ export class UIModule extends EventListener {
|
||||
};
|
||||
|
||||
/**
|
||||
* Walks the user through confirming their email: a temporary account is
|
||||
* saved first, then the user enters the code sent to their address.
|
||||
* Resolves once they're done, or right away when the email is already
|
||||
* confirmed.
|
||||
* Asks the desktop to walk the user through confirming their email.
|
||||
* Resolves once the dialog closes.
|
||||
*
|
||||
* Inside an app the desktop shows it. On a signed-in first-party page one
|
||||
* label under the Puter origin, it shows in a frame over the page, and
|
||||
* `response` is false when the frame can't show it. Anywhere else the
|
||||
* promise never settles, since a website has no desktop to ask.
|
||||
*
|
||||
* @returns {Promise<import('../lib/types.js').EmailConfirmationResult>}
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
requestEmailConfirmation () {
|
||||
if ( this.#canFrameForUser() ) return this.#showEmailConfirmationFrame();
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#postMessageWithCallback('requestEmailConfirmation', resolve, { });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The email confirmation in a frame over the page, for the page's
|
||||
* signed-in user. A call made while one is showing settles with it, and
|
||||
* an email the page already knows is confirmed resolves without one.
|
||||
*
|
||||
* @returns {Promise<import('../lib/types.js').EmailConfirmationResult>}
|
||||
*/
|
||||
#showEmailConfirmationFrame () {
|
||||
if ( this.#emailConfirmationFrame ) return this.#emailConfirmationFrame;
|
||||
this.#emailConfirmationFrame = (async () => {
|
||||
try {
|
||||
const user = await this.#frameUser();
|
||||
// A confirmed email never goes back to unconfirmed, so this
|
||||
// skips the loading scrim the frame would show while it boots.
|
||||
if ( user?.email_confirmed === true ) return { response: true };
|
||||
const framed = user && await showPopupFrame({
|
||||
guiOrigin: puter.defaultGUIOrigin,
|
||||
action: 'request-email-confirmation',
|
||||
fragment: { user: user.uuid },
|
||||
title: 'Confirm your email',
|
||||
closed: (data) => ({ response: data?.confirmed === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
// The page's cached whoami still reads it as unconfirmed,
|
||||
// and would frame the next call too.
|
||||
if ( framed?.response ) user.email_confirmed = true;
|
||||
return framed || { response: false };
|
||||
} finally {
|
||||
this.#emailConfirmationFrame = null;
|
||||
}
|
||||
})();
|
||||
return this.#emailConfirmationFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the desktop to walk the user through clearing an account
|
||||
* verification gate (a 403 `*_required` code — email confirmation, phone
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { UIModule } = await import('./UI.js');
|
||||
|
||||
// jsdom has no top-layer dialogs.
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
this.open = true;
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function () {
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
const GUI_ORIGIN = 'https://puter.test';
|
||||
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
|
||||
const frameDialog = () => document.querySelector('dialog.puter-popup-frame');
|
||||
const confirmFrame = () => frameDialog()?.querySelector('iframe') ?? null;
|
||||
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(confirmFrame().src).hash.slice(1)));
|
||||
|
||||
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
|
||||
data, origin: GUI_ORIGIN, source: confirmFrame().contentWindow,
|
||||
}));
|
||||
|
||||
const makeUI = (env = 'web') => new UIModule(
|
||||
{ env, authToken: 'token-1', APIOrigin: 'https://api.puter.test', appID: 'app-1', util: {} },
|
||||
{ appInstanceID: 'instance-1' },
|
||||
);
|
||||
|
||||
const fakePuter = ({ signedIn = true, guiOrigin = GUI_ORIGIN } = {}) => {
|
||||
globalThis.puter = {
|
||||
defaultGUIOrigin: guiOrigin,
|
||||
whoamiCache_: Promise.resolve({ uuid: USER }),
|
||||
auth: {
|
||||
isSignedIn: () => signedIn,
|
||||
getUser: vi.fn(async () => ({ uuid: USER })),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const PENDING = Symbol('pending');
|
||||
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
window.open = vi.fn(() => ({ closed: false }));
|
||||
fakePuter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.puter;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('ui.requestEmailConfirmation on a signed-in first-party page', () => {
|
||||
it('shows the confirmation in a frame over the page and resolves whether the email is confirmed', async () => {
|
||||
const result = makeUI().requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
|
||||
expect(new URL(confirmFrame().src).pathname).toBe('/embed/request-email-confirmation');
|
||||
expect(fragment()).toEqual({ user: USER });
|
||||
// It covers the page, and the window inside draws the scrim.
|
||||
expect(frameDialog().querySelector('.puter-popup-frame-box')).toBeNull();
|
||||
expect(frameDialog().querySelector('.puter-popup-frame-close')).toBeNull();
|
||||
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(frameDialog().open).toBe(true);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', confirmed: true });
|
||||
|
||||
await expect(result).resolves.toEqual({ response: true });
|
||||
expect(confirmFrame()).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves true without a frame when the page knows the email is confirmed', async () => {
|
||||
puter.whoamiCache_ = Promise.resolve({ uuid: USER, email_confirmed: true });
|
||||
const ui = makeUI();
|
||||
const result = ui.requestEmailConfirmation();
|
||||
// Mounted only after the user lookup, so a frame would be in the DOM by now.
|
||||
await expect(result).resolves.toEqual({ response: true });
|
||||
expect(frameDialog()).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
// The next call looks again rather than reusing the settled one.
|
||||
expect(ui.requestEmailConfirmation()).not.toBe(result);
|
||||
});
|
||||
|
||||
it('resolves true without showing the frame when the GUI finds the email already confirmed', async () => {
|
||||
const result = makeUI().requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', confirmed: true });
|
||||
|
||||
await expect(result).resolves.toEqual({ response: true });
|
||||
expect(frameDialog()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the next call true without a frame once the user has confirmed in one', async () => {
|
||||
const ui = makeUI();
|
||||
const first = ui.requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', confirmed: true });
|
||||
await expect(first).resolves.toEqual({ response: true });
|
||||
|
||||
await expect(ui.requestEmailConfirmation()).resolves.toEqual({ response: true });
|
||||
expect(frameDialog()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves false when the user closes it without confirming', async () => {
|
||||
const result = makeUI().requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
// The frame's own close() reports no result.
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await expect(result).resolves.toEqual({ response: false });
|
||||
});
|
||||
|
||||
it('resolves false when Escape on the page dismisses it', async () => {
|
||||
const result = makeUI().requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
frameDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
await expect(result).resolves.toEqual({ response: false });
|
||||
expect(confirmFrame()).toBeNull();
|
||||
});
|
||||
|
||||
it('settles a call made while it shows with it, without replacing it', async () => {
|
||||
const ui = makeUI();
|
||||
const first = ui.requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
const frame = confirmFrame();
|
||||
|
||||
const second = ui.requestEmailConfirmation();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await expect(second).resolves.toEqual({ response: false });
|
||||
|
||||
// A later call shows a new one.
|
||||
ui.requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
expect(confirmFrame()).not.toBe(frame);
|
||||
});
|
||||
|
||||
it('resolves false when the frame cannot show it', async () => {
|
||||
const result = makeUI().requestEmailConfirmation();
|
||||
await vi.waitFor(() => expect(confirmFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
|
||||
|
||||
await expect(result).resolves.toEqual({ response: false });
|
||||
expect(confirmFrame()).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves false when the page has no user to name', async () => {
|
||||
puter.whoamiCache_ = Promise.resolve(null);
|
||||
puter.auth.getUser = vi.fn(async () => {
|
||||
throw new Error('nope');
|
||||
});
|
||||
await expect(makeUI().requestEmailConfirmation()).resolves.toEqual({ response: false });
|
||||
expect(confirmFrame()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.requestEmailConfirmation elsewhere', () => {
|
||||
it('asks the desktop inside an app and resolves its answer', async () => {
|
||||
const post = vi.spyOn(window, 'postMessage').mockImplementation(() => {});
|
||||
const result = makeUI('app').requestEmailConfirmation();
|
||||
|
||||
expect(post).toHaveBeenCalledWith(expect.objectContaining({
|
||||
msg: 'requestEmailConfirmation',
|
||||
env: 'app',
|
||||
appInstanceID: 'instance-1',
|
||||
}), '*');
|
||||
expect(confirmFrame()).toBeNull();
|
||||
|
||||
const { uuid } = post.mock.calls.find(([data]) => data.msg === 'requestEmailConfirmation')[0];
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'requestEmailConfirmationResponded', original_msg_id: uuid, response: true },
|
||||
origin: GUI_ORIGIN,
|
||||
source: window,
|
||||
}));
|
||||
await expect(result).resolves.toMatchObject({ response: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a signed-out first-party page', { signedIn: false }],
|
||||
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
|
||||
])('does not frame on %s', async (_label, options) => {
|
||||
fakePuter(options);
|
||||
const result = makeUI().requestEmailConfirmation();
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
expect(confirmFrame()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>puter-js test fixture: requestEmailConfirmation</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; padding: 16px; }
|
||||
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
|
||||
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
|
||||
.ready #status { color: green; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>puter-js requestEmailConfirmation fixture</h1>
|
||||
<p id="status">Loading puter.js…</p>
|
||||
|
||||
<button id="confirm-email" disabled>Confirm email</button>
|
||||
|
||||
<div id="log" data-testid="log"></div>
|
||||
|
||||
<script>
|
||||
window.PUTER_ORIGIN = 'http://puter.localhost:4100';
|
||||
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
|
||||
</script>
|
||||
<script src="/dist/puter.dev.js"></script>
|
||||
<script>
|
||||
const logEl = document.getElementById('log');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
function log(entry) {
|
||||
const line = document.createElement('div');
|
||||
line.textContent = entry;
|
||||
line.dataset.entry = entry;
|
||||
logEl.appendChild(line);
|
||||
}
|
||||
|
||||
function ready() {
|
||||
document.body.classList.add('ready');
|
||||
statusEl.textContent = `Ready (env=${puter.env})`;
|
||||
document.getElementById('confirm-email').disabled = false;
|
||||
}
|
||||
|
||||
function waitForPuter() {
|
||||
if (window.puter && puter.ui) ready();
|
||||
else setTimeout(waitForPuter, 50);
|
||||
}
|
||||
waitForPuter();
|
||||
|
||||
document.getElementById('confirm-email').addEventListener('click', async () => {
|
||||
try {
|
||||
const result = await puter.ui.requestEmailConfirmation();
|
||||
log(`confirm:${result?.response}`);
|
||||
} catch (e) {
|
||||
log(`confirm:error:${e?.message ?? e}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -514,233 +514,3 @@ test.describe('the upgrade a first-party page shows framed', () => {
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
const EMAIL_FRAME = '/embed/request-email-confirmation';
|
||||
|
||||
/** Has the email confirmation frame's session read as `patch` says, so the account can need a code. */
|
||||
const routeFrameSession = (page, patch) => page.context().route(`${API_ORIGIN}/whoami**`, async (route) => {
|
||||
let frameUrl = '';
|
||||
try {
|
||||
frameUrl = route.request().frame().url();
|
||||
} catch {
|
||||
// A request from no frame, such as a service worker's.
|
||||
}
|
||||
if ( route.request().method() !== 'GET' || ! frameUrl.includes(EMAIL_FRAME) ) return route.fallback();
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ response, json: { ...(await response.json()), ...patch } });
|
||||
});
|
||||
|
||||
/**
|
||||
* Answers the code check as the server would for a right code, so no email has
|
||||
* to be read. Given `until`, it answers once that settles.
|
||||
*/
|
||||
const routeConfirmEmail = (page, until) => page.context().route(`${API_ORIGIN}/confirm-email`, async (route) => {
|
||||
const preflight = route.request().method() === 'OPTIONS';
|
||||
if ( ! preflight ) await until;
|
||||
await route.fulfill({
|
||||
status: preflight ? 204 : 200,
|
||||
headers: {
|
||||
'access-control-allow-origin': PUTER_ORIGIN,
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-credentials': 'true',
|
||||
},
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ email_confirmed: true }),
|
||||
});
|
||||
});
|
||||
|
||||
const UNCONFIRMED = { email_confirmed: false, requires_email_confirmation: true };
|
||||
|
||||
/** Has the page's own whoami read the email as unconfirmed, as it does for such an account. */
|
||||
const pageReadsUnconfirmed = (page) => page.evaluate(async () => {
|
||||
puter.whoami = { ...(await puter.whoamiCache_), email_confirmed: false };
|
||||
});
|
||||
const codeWindow = (page) => frame(page).locator('.window-confirm-email-using-code');
|
||||
|
||||
test.describe('the email confirmation a first-party page shows framed', () => {
|
||||
test('a confirmed email resolves true without a frame', async ({ page }) => {
|
||||
await signInFirstPartyPage(page);
|
||||
const popups = [];
|
||||
page.on('popup', (p) => popups.push(p));
|
||||
const framed = [];
|
||||
page.on('request', (r) => {
|
||||
if ( new URL(r.url()).pathname === EMAIL_FRAME ) framed.push(r.url());
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.__mounted = false;
|
||||
new MutationObserver(() => {
|
||||
if ( document.querySelector('dialog.puter-popup-frame') ) window.__mounted = true;
|
||||
}).observe(document.body, { childList: true });
|
||||
});
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: true } });
|
||||
// No loading scrim flashes over the page.
|
||||
expect(await page.evaluate(() => window.__mounted)).toBe(false);
|
||||
expect(framed).toHaveLength(0);
|
||||
expect(popups).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('an email confirmed since the page loaded resolves true without showing a window', async ({ page }) => {
|
||||
await routeFrameSession(page, { email_confirmed: true, requires_email_confirmation: false });
|
||||
await signInFirstPartyPage(page);
|
||||
// Read before the email was confirmed.
|
||||
await pageReadsUnconfirmed(page);
|
||||
const popups = [];
|
||||
page.on('popup', (p) => popups.push(p));
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: true } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
expect(popups).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('entering the code in one window over the page resolves true', async ({ page }) => {
|
||||
await routeFrameSession(page, UNCONFIRMED);
|
||||
await routeConfirmEmail(page);
|
||||
await signInFirstPartyPage(page);
|
||||
await pageReadsUnconfirmed(page);
|
||||
const popups = [];
|
||||
page.on('popup', (p) => popups.push(p));
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
await frameReady(page);
|
||||
// One window the user can close, not the boot's email gate as well.
|
||||
await expect(codeWindow(page)).toHaveCount(1);
|
||||
await expect(codeWindow(page).locator('.generic-close-window-button')).toBeVisible();
|
||||
const emailFrame = page.frames().find((f) => new URL(f.url()).pathname === EMAIL_FRAME);
|
||||
// The page shows through around the window.
|
||||
expect(await emailFrame.evaluate(() => [
|
||||
getComputedStyle(document.documentElement).backgroundColor,
|
||||
getComputedStyle(document.body).backgroundColor,
|
||||
])).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)']);
|
||||
|
||||
await codeWindow(page).locator('.digit-input').first().click();
|
||||
await page.keyboard.type('123456');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: true } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
expect(popups).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('closing the window resolves false', async ({ page }) => {
|
||||
await routeFrameSession(page, UNCONFIRMED);
|
||||
await signInFirstPartyPage(page);
|
||||
await pageReadsUnconfirmed(page);
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
await frameReady(page);
|
||||
await codeWindow(page).locator('.generic-close-window-button').click();
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Escape on the window resolves false', async ({ page }) => {
|
||||
await routeFrameSession(page, UNCONFIRMED);
|
||||
await signInFirstPartyPage(page);
|
||||
await pageReadsUnconfirmed(page);
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
await frameReady(page);
|
||||
await codeWindow(page).locator('.digit-input').first().click();
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Escape while a code is being checked resolves with the check\'s answer', async ({ page }) => {
|
||||
await routeFrameSession(page, UNCONFIRMED);
|
||||
let answer;
|
||||
await routeConfirmEmail(page, new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
await signInFirstPartyPage(page);
|
||||
await pageReadsUnconfirmed(page);
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
await frameReady(page);
|
||||
await codeWindow(page).locator('.digit-input').first().click();
|
||||
const sent = page.waitForRequest((r) => r.method() === 'POST' && r.url() === `${API_ORIGIN}/confirm-email`);
|
||||
await page.keyboard.type('123456');
|
||||
await sent;
|
||||
await page.keyboard.press('Escape');
|
||||
answer();
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: true } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Escape on the save-account window a temporary account sees resolves false', async ({ page }) => {
|
||||
await routeFrameSession(page, { ...UNCONFIRMED, is_temp: true, email: null });
|
||||
await signInFirstPartyPage(page);
|
||||
await pageReadsUnconfirmed(page);
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
await frameReady(page);
|
||||
await frame(page).locator('.window-save-account .username').click();
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a temporary account saves itself, then enters the code in the only window showing', async ({ page }) => {
|
||||
await routeFrameSession(page, { ...UNCONFIRMED, is_temp: true, email: null });
|
||||
await routeConfirmEmail(page);
|
||||
let user;
|
||||
// Saves the account as the server would, leaving the test's own account as it is.
|
||||
await page.context().route(`${API_ORIGIN}/save_account`, (route) => route.fulfill({
|
||||
status: route.request().method() === 'OPTIONS' ? 204 : 200,
|
||||
headers: {
|
||||
'access-control-allow-origin': PUTER_ORIGIN,
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-credentials': 'true',
|
||||
},
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: { ...user, ...UNCONFIRMED, is_temp: false, email: '[email protected]' } }),
|
||||
}));
|
||||
await signInFirstPartyPage(page);
|
||||
user = await page.evaluate(() => puter.whoamiCache_);
|
||||
await pageReadsUnconfirmed(page);
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
await frameReady(page);
|
||||
const saveWindow = frame(page).locator('.window-save-account');
|
||||
await saveWindow.locator('.username').fill('saved');
|
||||
await saveWindow.locator('.email').fill('[email protected]');
|
||||
await saveWindow.locator('.password').fill('a long enough password');
|
||||
await saveWindow.locator('.signup-btn').click();
|
||||
|
||||
await expect(codeWindow(page)).toBeVisible();
|
||||
// One window over one scrim, not the code window over the save-account window's.
|
||||
await expect(saveWindow).toBeHidden();
|
||||
await expect(frame(page).locator('.window-backdrop:visible')).toHaveCount(1);
|
||||
await codeWindow(page).locator('.digit-input').first().click();
|
||||
await page.keyboard.type('123456');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: true } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an account with another check waiting resolves false', async ({ page }) => {
|
||||
await routeFrameSession(page, { ...UNCONFIRMED, requires_phone_verification: true });
|
||||
await signInFirstPartyPage(page);
|
||||
await pageReadsUnconfirmed(page);
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a page signed in as someone else resolves false', async ({ page }) => {
|
||||
await routeFrameSession(page, UNCONFIRMED);
|
||||
await signInFirstPartyPage(page);
|
||||
await page.evaluate(async () => {
|
||||
await puter.whoamiCache_;
|
||||
puter.whoami = { uuid: '00000000-0000-4000-8000-000000000000' };
|
||||
});
|
||||
const popups = [];
|
||||
page.on('popup', (p) => popups.push(p));
|
||||
|
||||
await runFromClick(page, 'puter.ui.requestEmailConfirmation()');
|
||||
expect(await result(page)).toEqual({ ok: true, value: { response: false } });
|
||||
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
|
||||
expect(popups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { registerTestApp, deleteTestApp, gotoTestApp, FIXTURE_URL } from '../helpers/testApp.js';
|
||||
|
||||
const PUTER_ORIGIN = process.env.PUTER_TEST_ORIGIN || 'http://puter.localhost:4100';
|
||||
const API_ORIGIN = PUTER_ORIGIN.replace('://', '://api.');
|
||||
const CONFIRM_FIXTURE_URL = FIXTURE_URL.replace('menubar-contextmenu.html', 'request-email-confirmation.html');
|
||||
|
||||
/** Answers the code check as the server would for a right code, so no email has to be read. */
|
||||
const routeConfirmEmail = (page) => page.context().route(`${API_ORIGIN}/confirm-email`, (route) => route.fulfill({
|
||||
status: route.request().method() === 'OPTIONS' ? 204 : 200,
|
||||
headers: {
|
||||
'access-control-allow-origin': PUTER_ORIGIN,
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-credentials': 'true',
|
||||
},
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ email_confirmed: true }),
|
||||
}));
|
||||
|
||||
/** Sets whether the desktop's user has confirmed their email. */
|
||||
const setEmailConfirmed = (page, confirmed) =>
|
||||
page.evaluate((confirmed) => {
|
||||
window.user.is_temp = false;
|
||||
window.user.email_confirmed = confirmed;
|
||||
}, confirmed);
|
||||
|
||||
const codeWindow = (page) => page.locator('.window-confirm-email-using-code');
|
||||
|
||||
test.describe('puter.ui.requestEmailConfirmation (env=app)', () => {
|
||||
let appName;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
appName = await registerTestApp(page, { fixtureURL: CONFIRM_FIXTURE_URL });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteTestApp(page, appName);
|
||||
});
|
||||
|
||||
test('a confirmed email resolves true without a dialog', async ({ page }) => {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
await setEmailConfirmed(page, true);
|
||||
|
||||
await appFrame.locator('#confirm-email').click();
|
||||
|
||||
await expect(appFrame.locator('#log [data-entry="confirm:true"]')).toBeVisible();
|
||||
await expect(codeWindow(page)).toHaveCount(0);
|
||||
await expect(appFrame.locator('#log [data-entry]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('entering the code shows one dialog and resolves true', async ({ page }) => {
|
||||
await routeConfirmEmail(page);
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
await setEmailConfirmed(page, false);
|
||||
|
||||
await appFrame.locator('#confirm-email').click();
|
||||
await expect(codeWindow(page)).toHaveCount(1);
|
||||
// A click, not fill(), so it fails if the app's window covers the dialog.
|
||||
await codeWindow(page).locator('.digit-input').first().click();
|
||||
await page.keyboard.type('123456');
|
||||
|
||||
await expect(appFrame.locator('#log [data-entry="confirm:true"]')).toBeVisible({ timeout: 15_000 });
|
||||
// No second dialog opens after the first.
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(codeWindow(page)).toHaveCount(0);
|
||||
await expect(appFrame.locator('#log [data-entry]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('closing the dialog resolves false', async ({ page }) => {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
await setEmailConfirmed(page, false);
|
||||
|
||||
await appFrame.locator('#confirm-email').click();
|
||||
await expect(codeWindow(page)).toHaveCount(1);
|
||||
await codeWindow(page).locator('.generic-close-window-button').click();
|
||||
|
||||
await expect(appFrame.locator('#log [data-entry="confirm:false"]')).toBeVisible();
|
||||
await expect(codeWindow(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a call made while the dialog shows gets its answer, with no second dialog', async ({ page }) => {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
await setEmailConfirmed(page, false);
|
||||
|
||||
await appFrame.locator('#confirm-email').click();
|
||||
await expect(codeWindow(page)).toHaveCount(1);
|
||||
// The dialog covers the button, so the app calls again itself.
|
||||
await appFrame.locator('#confirm-email').evaluate((button) => button.click());
|
||||
await page.waitForTimeout(500);
|
||||
await expect(codeWindow(page)).toHaveCount(1);
|
||||
await codeWindow(page).locator('.generic-close-window-button').click();
|
||||
|
||||
await expect(appFrame.locator('#log [data-entry="confirm:false"]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a temporary account calling twice gets both answers from one save-account window', async ({ page }) => {
|
||||
const appFrame = await gotoTestApp(page, appName);
|
||||
await page.evaluate(() => {
|
||||
window.user.is_temp = true;
|
||||
window.user.email_confirmed = false;
|
||||
});
|
||||
const saveWindow = page.locator('.window-save-account');
|
||||
|
||||
await appFrame.locator('#confirm-email').click();
|
||||
await expect(saveWindow).toHaveCount(1);
|
||||
await appFrame.locator('#confirm-email').evaluate((button) => button.click());
|
||||
await page.waitForTimeout(500);
|
||||
await expect(saveWindow).toHaveCount(1);
|
||||
await saveWindow.locator('.generic-close-window-button').click();
|
||||
|
||||
await expect(appFrame.locator('#log [data-entry="confirm:false"]')).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user