Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 6704fa64ed Answer a framed email confirmation closed with Escape with the code check it was closed during
Escape in the framed code or save-account window closes that window, and the window's answer closes the frame. A code already sent is waited for, as it is when the window's close button is used. Before, Escape closed the frame and answered false while the check went on to confirm the email.
2026-11-09 12:56:11 -07:00
Nariman Jelveh 917daf12af Answer a save-account request made while the save-account window is open
The window opens once, so the second request got no window and never
answered, which left an email confirmation that asked for it waiting too.
2026-11-09 12:37:32 -07:00
Nariman Jelveh f7c0fdda9f Hide the save-account window while its email code window shows
A save-account window with a backdrop stayed under the code window's own, so
the page behind was dimmed twice, and on a phone the window showed around the
code window's edge.
2026-11-09 12:36:48 -07:00
Nariman Jelveh 4efd966be9 Leave the email code window unsettled when any logout closes it
Only a logout from its footer did. One from elsewhere, such as after a 401,
answered false, so the boot's email gate opened a new code window while the
page was navigating away.
2026-11-09 12:34:43 -07:00
Nariman Jelveh 11c519ed08 Skip the email confirmation frame on calls after the user confirmed in one
The page's cached whoami kept reading the email as unconfirmed, so each later
call framed the GUI again and flashed its loading scrim before resolving true.
2026-11-09 12:34:21 -07:00
Nariman Jelveh 41f6edeab1 Show why a new email code couldn't be sent
A failed re-send, such as one past the hourly limit or a network failure,
showed nothing, so the link seemed to do nothing.
2026-11-09 12:33:13 -07:00
Nariman Jelveh 7ca1d97ef3 Keep the email code window's answer in step with a code check it was closed during
Closing the window while a code was being checked answered false, and the
check went on to confirm the email. A code not sent yet is no longer sent, and
the window waits for one already sent before it closes.
2026-11-09 12:32:34 -07:00
Nariman Jelveh 40eed41ce5 Read the email code from the code window it was typed in
A second code window read the inputs of the first one on the page, so typing
in it never filled in the code or enabled its button.
2026-11-09 12:31:09 -07:00
Nariman Jelveh 3ef18a2259 Answer an email confirmation asked for while one is showing with the windows already open
A second walk-through opened a second code window over the first, and for a
temporary account it never answered, since the save-account window opens once.
2026-11-09 12:30:48 -07:00
Nariman Jelveh 1621da6bf9 Let the email code window recover from a failed code check with no response body
A network failure has no JSON body, so reading its error threw, leaving the
window spinning with its inputs disabled. It now shows an unknown-error message
and lets the user try again.
2026-11-09 11:47:11 -07:00
Nariman Jelveh 10e4606b9c Close the framed email confirmation on Escape in the save-account window
A temporary account sees the save-account window first, where Escape did
nothing, while it closed the code window that follows.
2026-11-09 11:46:02 -07:00
Nariman Jelveh 6d06c23cc7 Skip the email confirmation frame when the page knows the email is confirmed
A confirmed email never goes back to unconfirmed, so requestEmailConfirmation()
on a first-party page resolves { response: true } from the page's whoami
instead of booting the GUI in a frame behind a loading scrim.
2026-11-09 11:45:18 -07:00
Nariman Jelveh b30e1c9af5 Answer requestEmailConfirmation() once, and show it in a frame on first-party pages
puter.ui.requestEmailConfirmation() in an app opened a second code dialog
after the first: a confirmed user got a dialog they couldn't complete and
two answers, and an unconfirmed one entered the code and then got a second
dialog, whose result was the only answer. The desktop handler now runs one
walk-through and answers once with its result.

That walk-through is ensureConfirmedEmail: save a temporary account, then
ask an unconfirmed email for its code, each window at most once. Publishing
a website or worker uses it too, replacing two copies of the same steps.
The code window now resolves false when it closes without a confirmation,
so dismissing it answers the app instead of leaving it waiting, and it
marks window.user confirmed before resolving, so a caller that checks next
doesn't ask again before the refresh lands. A logout from its footer still
leaves it unsettled, so the boot gates don't reopen it. In the dashboard
the windows open above the app's window, which stays on top and covered
them.

On a signed-in page one label under the Puter origin, the call now frames
/embed/request-email-confirmation over the page, served like the upgrade
frame, and resolves { response }, the field an app reads from the
desktop's answer. The GUI shows it only for the framing page's own user
with no phone, card or password check waiting. It skips the boot's
non-closable email gate for this action, so the user gets one code window
they can close or Escape. An email already confirmed resolves true without
showing the frame. A frame that can't show it resolves { response: false }.
2026-11-09 11:15:14 -07:00
Daniel Salazar 6b06793750 fix(gui): send picker results only to the popup opener's origin (#4543)
File, directory and save pickers opened in a popup now post their results,
cancellations and the save-data request to window.openerOrigin instead of
'*', matching the token hand-off. A test checks that opener messages in the
GUI only target '*' when the opener's origin is not yet known.
2026-11-08 23:11:57 -07:00
Neal Shah e441880f13 add more data to user audit events (#4542) 2026-11-08 19:22:09 -05:00
Nariman Jelveh 9a86803418 Show the upgrade in a frame on first-party pages (#4541)
* Show the upgrade in a frame on first-party pages

puter.ui.requestUpgrade() on a signed-in page one label under the Puter
origin now frames /embed/upgrade over the page and resolves { upgraded }
once it closes. The usage limit dialog's Upgrade button does the same on
those pages instead of opening the dashboard in a new tab. Apps, the
desktop and every other page keep their current behavior.

/embed/upgrade is served like the sign-in and picker frames: the GUI
shell with the same framing rules, query and exact-path redirects and
no-store. The GUI shows the deployment's upgrade dialog
(window.UIUpgradeAccount) only for the framing page's own user, with
nothing left to verify, and only when the dialog says it reports its
close (UIUpgradeAccount.embeddable). Otherwise it reports unavailable and
the page opens the dashboard upgrade in a tab, through a consent dialog
when the click has expired. The app it credits is resolved from the
framing page's origin, never read from the fragment.

The frame covers the page with a transparent GUI page so the dialog's own
scrim shows, allows payment, and leaves Escape to the dialog. A call made
while it shows settles with it. If every dialog and window in the frame
goes without a close being reported, the frame closes itself rather than
staying over the page.

* Open the framed upgrade only once the frame has a viewport

The page keeps a loading frame hidden, which Chromium and WebKit lay out at
0x0. The team-seat alert, a GUI window that centers itself when it opens,
landed at (-175, 0) with its message and OK button cut off. A hidden frame
now tells the page it's ready first, and opens the dialog on the resize that
follows, or after a second if none comes.

* Document when the framed upgrade posts ready

* Retry a framed popup's focus() that the frame ignored

Firefox and WebKit ignore focus() inside a frame for a moment after the
page shows it, and Firefox sends no focus event afterwards, so the framed
upgrade dialog and its team-seat alert opened with nothing focused there:
Enter didn't press OK and screen readers didn't land in the dialog. A
focus() that didn't take is now retried briefly, until the user presses a
key or the pointer.

* Keep the framed upgrade's GUI in the light color scheme

The transparent upgrade frame put `color-scheme: light dark` on body as
well as the root, so in dark mode form fields the GUI leaves at browser
defaults turned dark. The email confirmation code digits showed slate
text on a dark grey field inside a white window. Only the root needs to
match the frame's scheme to stay transparent; body now keeps the light
scheme the GUI uses everywhere else.

* Fall back to the upgrade tab when the framed dialog fails to open

If the deployment's upgrade dialog threw while opening, the frame reported
a close, so requestUpgrade() resolved { upgraded: false } and the user's
click did nothing. It now reports itself unavailable, and the page opens
the dashboard's upgrade page as it does for any frame that can't show it.

* Say which plan changes count as upgraded in UpgradeResult

The upgrade dialog sets `upgraded` only for a subscribe or an upgrade;
a downgrade, cancellation or resume closes with false. The type said any
plan change counted.

* Stop the page scrolling behind a framed GUI embed

Firefox and WebKit pass a wheel or touch scroll over a frame on to the
page behind it, whatever the dialog's overscroll-behavior, so scrolling
over the framed upgrade, sign-in or a picker scrolled the page under the
scrim. The page's root now stops scrolling while an embed shows, taking
a classic scrollbar's width as padding so its content doesn't shift, and
gets its own styles back once the last embed closes.

* Skip the usage limit dialog while the framed upgrade shows

A refused call while the upgrade shows in a frame over a first-party
page opened the usage limit dialog on top of it, which made the upgrade
dialog inert until the user closed it. The refusal still rejects.

* Restore the page's root styles after an embed the page removed

An embed dialog the page removed itself never unlocked the page scroll,
and the next embed read the locked styles as the page's own, so the
page stayed unscrollable once it closed. The page's styles now go back
before the next lock reads them.

* Open the upgrade tab when the page can no longer frame it

The usage limit dialog decided to frame the upgrade when it opened, so
an Upgrade click after the page signed out did nothing. onUpgrade now
returns false when the page can't frame it, and the dialog opens the
dashboard tab as it does without onUpgrade.

* Keep the upgrade showing until its tab consent is answered

When the framed upgrade fell back to a consent dialog, requestUpgrade
settled as soon as the dialog showed. A refusal meanwhile opened a usage
limit dialog over it, and a second call asked again. The upgrade now
counts as showing until the user answers, so both join it.

* Say where requestUpgrade never settles

Its JSDoc named only apps, though on any website that can't frame the
upgrade the promise doesn't settle either.

* Treat a GUI embed the page removes as dismissed

A page that re-renders its body took the embed's dialog with it, and the
embed's promise never settled. For the upgrade that left it counted as
showing for good: later requestUpgrade() calls joined the dead one, no
refused call opened the usage limit dialog again, and the page stayed
unscrollable.

* Let the framed upgrade credit the remembered prompt when it knows none

When the framing page's origin resolved to no app and the call named no
reason or method, the frame handed the dialog an empty attribution. The
dialog then remembered that as the latest prompt, erasing another app's
credit, and sent the purchase with no attribution at all. It now gets
null, as from every other entry point.

* Pad the page by the width its scrollbar gives back behind an embed

A page with scrollbar-gutter: stable keeps its gutter once the root stops
scrolling, so padding it by the scrollbar's width as well shifted its
content left while any GUI embed showed. The lock now measures how much
width the root actually gets back.

* Keep the page's scroll position behind an embed when its body scrolls it

A page whose body carries the viewport's overflow (html and body at 100%
height, body with overflow-x hidden) jumped to the top while an embed
showed, and stayed there after: hiding the root's overflow made the body
a scroller of its own. The lock now hides the overflow of whichever
element the viewport uses, and restores a single-axis inline overflow.
2026-11-08 14:27:41 -07:00
32 changed files with 1710 additions and 116 deletions

No files matched your search

+33
View File
@@ -259,6 +259,39 @@ 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
+5 -1
View File
@@ -420,7 +420,11 @@ export type EventMap = {
old_username: string;
new_username: string;
};
'user.email-changed': { user_id: number; new_email: string };
'user.email-changed': {
user_id: number;
old_email: string | null;
new_email: string;
};
/** An account was suspended, or its suspension lifted. Local events. */
'user.suspended': { userId: number };
'user.unsuspended': { userId: number };
@@ -7468,6 +7468,11 @@ 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,
@@ -7475,6 +7480,11 @@ 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,6 +2647,7 @@ 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,8 +36,9 @@ import type {
/**
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
* instead of opening `/action/<action>` in a window, and the upgrade dialog,
* which it would otherwise open in a dashboard tab.
* 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.
*/
export const FRAMED_POPUP_ACTIONS = [
'sign-in',
@@ -45,6 +46,7 @@ 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>`. */
+2 -1
View File
@@ -414,6 +414,7 @@ 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) =>
@@ -446,7 +447,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/'])(
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Request-Email-Confirmation'])(
'lets a first-party page frame only the exact spelling of %s',
async (spelling) => {
const res = await request(spelling, {
+4 -42
View File
@@ -18,6 +18,7 @@
*/
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';
@@ -31,11 +32,9 @@ 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';
@@ -235,49 +234,12 @@ const ipc_listener = async (event, handled) => {
// requestEmailConfirmation
//--------------------------------------------------------
else if ( event.data.msg === 'requestEmailConfirmation' ) {
// 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,
});
// 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 });
target_iframe.contentWindow.postMessage({
original_msg_id: msg_id,
msg: 'requestEmailConfirmationResponded',
response: email_confirm_resp,
response: confirmed,
}, '*');
}
//--------------------------------------------------------
@@ -0,0 +1,146 @@
// @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 }]);
});
});
+2 -2
View File
@@ -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;
// Its ✕ would close it without an answer, leaving the caller
// waiting on a card that is already gone.
// No ✕: the signup card it replaced is already gone, so there's
// nothing to go back to.
email_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
+2 -19
View File
@@ -18,9 +18,8 @@
*/
import UIWindowItemProperties from './UIWindowItemProperties.js';
import UIWindowSaveAccount from './UIWindowSaveAccount.js';
import ensureConfirmedEmail from '../helpers/ensureConfirmedEmail.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';
@@ -1255,23 +1254,7 @@ async function UIItem (options) {
menu_items.push({
html: i18n('publish_as_serverless_worker'),
onClick: async function () {
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;
}
if ( ! await ensureConfirmedEmail(i18n('save_account_to_publish')) ) return;
UIWindowPublishWorker(options.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path'));
},
});
+2 -2
View File
@@ -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,12 +20,24 @@
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';
@@ -92,6 +104,20 @@ 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',
@@ -122,7 +148,8 @@ 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>');
setTimeout(() => {
submit_timer = setTimeout(() => {
code_sent = true;
$.ajax({
url: `${window.api_origin }/confirm-email`,
type: 'POST',
@@ -141,9 +168,13 @@ 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();
@@ -155,7 +186,8 @@ function UIWindowEmailConfirmationRequired (options) {
}
},
error: function (res) {
$(el_window).find('.error').html(html_encode(res.responseJSON.error));
// 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').fadeIn();
$(el_window).find('.digit-input').val('');
$(el_window).find('.digit-input').first().focus();
@@ -165,6 +197,8 @@ function UIWindowEmailConfirmationRequired (options) {
},
complete: function () {
is_checking_code = false;
code_sent = false;
if ( close_after_check ) $(el_window).close();
},
});
}, 1000);
@@ -172,6 +206,7 @@ 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',
@@ -194,19 +229,22 @@ function UIWindowEmailConfirmationRequired (options) {
});
$(el_window).find('.digit-input').first().focus();
},
complete: function () {
error: function (res) {
$(el_window).find('.error').html(html_encode(res.responseJSON?.error ?? i18n('error_unknown_cause')));
$(el_window).find('.error').fadeIn();
},
});
});
// logout
$(el_window).find('.conf-email-log-out').on('click', function (e) {
logging_out = true;
window.logout();
$(el_window).close();
});
// Elements
const numberCodeForm = document.querySelector('[data-number-code-form]');
const numberCodeForm = $(el_window).find('[data-number-code-form]').get(0);
const numberCodeInputs = [...numberCodeForm.querySelectorAll('[data-number-code-input]')];
// Event listeners
@@ -0,0 +1,275 @@
/*
* 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);
});
});
+11
View File
@@ -117,6 +117,12 @@ 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
@@ -164,10 +170,15 @@ 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);
@@ -0,0 +1,82 @@
/*
* 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);
});
});
+2 -18
View File
@@ -17,8 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
import ensureConfirmedEmail from './ensureConfirmedEmail.js';
/**
* Walks a temporary account through saving itself, and an unconfirmed one
@@ -30,22 +29,7 @@ import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRe
*/
const ensure_can_publish = async function (required) {
if ( ! required ) return true;
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;
return ensureConfirmedEmail(i18n('save_account_to_publish'));
};
export default ensure_can_publish;
@@ -0,0 +1,63 @@
/*
* 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;
@@ -0,0 +1,133 @@
// @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);
});
});
+1 -1
View File
@@ -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();
}
+47 -9
View File
@@ -30,6 +30,7 @@ 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.
@@ -38,11 +39,12 @@ 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']);
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
const UPGRADE_METHOD_MAX = 200;
@@ -64,9 +66,10 @@ 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), which are
// windows rather than auth screens.
const ACCOUNT_CHECK_WINDOWS = '.window-confirm-email-using-code, .window-confirm-phone-using-code, .window-card-verification';
// 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';
// What a framed popup shows first; until one is on the page the framing page
// keeps the frame hidden behind its loading scrim.
@@ -210,19 +213,23 @@ export const focusOnFrameFocus = (win) => {
};
/**
* 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.
* 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.
*
* @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) => {
export const closeAccountChecksOnEscape = (win, close = () => win.close()) => {
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;
if ( el?.closest?.(ACCOUNT_CHECK_WINDOWS) ) win.close();
const accountWindow = el?.closest?.(ACCOUNT_CHECK_WINDOWS);
if ( accountWindow ) close(accountWindow);
});
};
@@ -244,6 +251,37 @@ 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.
+98 -1
View File
@@ -2,8 +2,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
POPUP_FRAME_MSG,
canShowEmailConfirmationFrame,
canShowUpgradeFrame,
closeAccountChecksOnEscape,
confirmEmailInFrame,
focusOnFrameFocus,
framedPopupAction,
framedPopupParams,
@@ -48,6 +50,7 @@ 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],
@@ -82,6 +85,7 @@ 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) => {
@@ -129,6 +133,83 @@ 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 = [];
@@ -315,12 +396,16 @@ describe('startPopupFrame', () => {
);
});
it('lets the framing page show through an upgrade frame only', () => {
it('lets the framing page show through an upgrade or email confirmation 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', () => {
@@ -486,6 +571,7 @@ 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'));
@@ -511,4 +597,15 @@ 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();
});
});
+1
View File
@@ -557,6 +557,7 @@ 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.',
+36 -5
View File
@@ -48,8 +48,10 @@ 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,
@@ -59,6 +61,7 @@ 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 {
@@ -577,7 +580,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();
@@ -645,7 +648,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
@@ -720,7 +723,7 @@ const postAuthActions = async (action) => {
uid: file_signature.uid,
path: window.privacy_aware_path(res.path),
},
}, '*');
}, window.openerOrigin);
window.close();
window.open('', '_self').close();
@@ -1022,6 +1025,25 @@ 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.
@@ -1588,6 +1610,10 @@ 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);
@@ -1789,7 +1815,8 @@ window.initgui = async function (options) {
if (
!host_user ||
session_user !== host_user ||
(action === 'upgrade' && !canShowUpgradeFrame(window, session))
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
) {
window.tell_popup_frame('unavailable');
return;
@@ -2409,8 +2436,12 @@ 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) {
if (whoami.requires_email_confirmation && !confirmsEmailInAction) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
+40
View File
@@ -0,0 +1,40 @@
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);
}
});
}
});
+1
View File
@@ -18,6 +18,7 @@ declare global {
// -- Shared --
export type {
APILoggingConfig,
EmailConfirmationResult,
ListPage,
ListPaginationOptions,
ListStreamOptions,
+6 -3
View File
@@ -443,7 +443,10 @@ export const showFeedbackEmbed = ({ guiOrigin, userUuid }) => mountGuiEmbed({
},
});
/** Popup actions a first-party page shows framed, each in a box its popup window's size. */
/**
* 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.
*/
const POPUP_FRAME_BOXES = {
'sign-in': { width: 600, height: 700 },
'show-open-file-picker': { width: 700, height: 400 },
@@ -473,8 +476,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'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker or the upgrade
* @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 {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
+8
View File
@@ -59,6 +59,14 @@
* 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.
+50 -5
View File
@@ -538,6 +538,9 @@ 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 = [];
@@ -716,7 +719,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 } | null>}
* @returns {Promise<{ uuid: string, email_confirmed?: boolean } | null>}
*/
async #frameUser () {
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
@@ -1334,17 +1337,59 @@ export class UIModule extends EventListener {
};
/**
* Asks the desktop to walk the user through confirming their email.
* Resolves once the dialog closes.
* 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.
*
* @returns {Promise<unknown>}
* 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>}
*/
requestEmailConfirmation () {
return new Promise((resolve, reject) => {
if ( this.#canFrameForUser() ) return this.#showEmailConfirmationFrame();
return new Promise((resolve) => {
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
@@ -0,0 +1,200 @@
// @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();
});
});
@@ -0,0 +1,59 @@
<!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,3 +514,233 @@ 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);
});
});
@@ -0,0 +1,113 @@
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);
});
});