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
39 changed files with 3242 additions and 152 deletions

No files matched your search

+80 -1
View File
@@ -213,6 +213,85 @@ the frame again, once per `ready`.
before a picker's answer isn't adopted, since it is for the page's own user.
Dismissing the loading scrim settles the picker as cancelled.
## Upgrade
`GET /embed/upgrade` is the deployment's upgrade dialog for a first-party page,
served like the popups above: the GUI shell with the same framing rules, query
redirect, exact-path redirect and `no-store`, naming the framing page as
`gui_params.embed_host_origin`. It runs only when framed by an allowed page.
puter.js frames it on a signed-in first-party page (the same pages as the
pickers) for `puter.ui.requestUpgrade(details)`, and for the Upgrade button of
the usage limit dialog a refused call shows there. Elsewhere both keep their
behavior: inside an app `requestUpgrade()` asks the desktop, and on any other
page the usage limit dialog opens the dashboard's upgrade page in a new tab.
The frame covers the page with no box or close button, and the GUI page is
transparent (`html.popup-frame-transparent`) so the dialog's own scrim covers
the host. The frame allows `payment` as well as `otp-credentials`. Escape on the
page is left to the dialog, since one there shouldn't drop a payment in
progress. Its fragment names `user` (the page's signed-in user) and, when the
refusal gave them, `reason` (`funds`, `subscription` or `storage`) and `method`.
The GUI reports `unavailable` unless its session belongs to `user`, the account
has no verification or password change waiting (which the frame couldn't be
closed from), and the deployment defines `window.UIUpgradeAccount` with
`UIUpgradeAccount.embeddable === true`. Otherwise it runs:
```js
new UIUpgradeAccount({ attribution: { appUid, reason, method }, onClose }).open_as_window();
```
`appUid` is the app the framing page's origin resolves to, never one the
fragment names. `open_as_window()` resolves once the dialog shows, and the GUI
then posts `ready`. While the page still hides the frame, which Chromium and
WebKit lay out at 0x0, the GUI posts `ready` first and opens the dialog once
the frame has a viewport, so a window it opens doesn't center on 0x0.
`onClose({ upgraded })` must run exactly once, when the
dialog closes or when it declines to open for the account; the GUI then posts
`{ msg: 'puterPopupFrame', state: 'closed', upgraded }` and the page removes
the frame. If `open_as_window()` rejects, the GUI posts `unavailable` instead. `requestUpgrade()` resolves `{ upgraded }`. A call made while the
frame shows settles with it rather than replacing it, and a call refused while
it shows still rejects but opens no usage limit dialog over it.
When the frame can't show the upgrade (refused, never ready, `unavailable`, or
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
@@ -220,7 +299,7 @@ Chromium installed), and the backend route tests:
```sh
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
```
+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,13 +36,17 @@ import type {
/**
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
* instead of opening `/action/<action>` in a window.
* 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',
'show-open-file-picker',
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'request-email-confirmation',
] as const;
/** Longest description/og:description/twitter:description on `/app/<name>`. */
+3 -1
View File
@@ -413,6 +413,8 @@ describe('PuterServer host header validation', () => {
'show-open-file-picker',
'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) =>
@@ -445,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/'])(
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);
});
});
+15
View File
@@ -140,6 +140,21 @@ body {
background-color: #ccccccbe !important;
}
/* A popup framed over its whole page, such as the upgrade, shows the page
around its own dialog. The root's color scheme matches the frame's, or the
browser paints the frame opaque; the GUI inside keeps the light scheme it
has everywhere else, so its form fields don't turn dark. */
html.popup-frame-transparent,
html.popup-frame-transparent body {
background: transparent !important;
}
html.popup-frame-transparent {
color-scheme: light dark;
}
html.popup-frame-transparent body {
color-scheme: light;
}
.disable-user-select {
cursor: default;
-webkit-touch-callout: none !important;
+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();
}
+249 -15
View File
@@ -29,17 +29,47 @@ export const FRAMED_POPUP_ACTIONS = new Set([
'show-open-file-picker',
'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.
const HOST_USER_ACTIONS = new Set([
'show-open-file-picker',
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'request-email-confirmation',
]);
// Actions framed over the whole page with no box, which their own dialog's
// scrim covers.
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
const UPGRADE_METHOD_MAX = 200;
// Modal UI the upgrade dialog and the GUI's windows show. Once some has shown
// and all of it has been gone this long, the dialog closed without reporting
// it, and the frame mustn't be left covering the page.
const UPGRADE_MODAL_UI = '.window, [aria-modal="true"]';
const UPGRADE_GONE_MS = 500;
// How long a hidden frame waits for the page to show it before opening anyway.
const FRAME_SIZE_TIMEOUT_MS = 1000;
// A focus() the frame ignored is retried this often, this many times.
const FOCUS_RETRY_MS = 50;
const FOCUS_RETRIES = 20;
/** Lifecycle messages to the framing page carry this as `msg`. */
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.
@@ -56,6 +86,24 @@ export const framedPopupAction = (pathname) => {
return match && FRAMED_POPUP_ACTIONS.has(match[1]) ? match[1] : null;
};
/**
* Whether a framed `action` serves only the framing page's own user, named in
* the fragment as `user`.
*
* @param {string | null | undefined} action
* @returns {boolean}
*/
export const framesHostUser = (action) => HOST_USER_ACTIONS.has(action);
/**
* Whether a framed `action` lets the framing page show through around its
* dialog.
*
* @param {string | null | undefined} action
* @returns {boolean}
*/
export const isTransparentPopupFrame = (action) => TRANSPARENT_ACTIONS.has(action);
/**
* The popup parameters the framing page put in the fragment, and no others.
* The route refuses a query string, so nothing else reaches the boot.
@@ -82,11 +130,14 @@ export const framedPopupParams = (hash) => {
*
* @param {Window} win
* @param {string | null | undefined} hostOrigin - `gui_params.embed_host_origin`
* @returns {((state: 'loading' | 'ready' | 'unavailable' | 'closed' | 'provider', fields?: Record<string, string>) => void) | null}
* @returns {((state: 'loading' | 'ready' | 'unavailable' | 'closed' | 'provider', fields?: Record<string, unknown>) => void) | null}
*/
export const startPopupFrame = (win, hostOrigin) => {
if ( ! hostOrigin || win.parent === win ) return null;
const parent = win.parent;
if ( isTransparentPopupFrame(framedPopupAction(win.location?.pathname)) ) {
win.document.documentElement.classList.add('popup-frame-transparent');
}
const tell = (state, fields = {}) =>
parent.postMessage({ ...fields, msg: POPUP_FRAME_MSG, state }, hostOrigin);
@@ -117,42 +168,225 @@ export const startPopupFrame = (win, hostOrigin) => {
* with nothing focused, until the user presses a key or the pointer in it. A
* cross-origin frame can't take focus until the framing page focuses it, which
* also resets it, so a save picker's file name would start unfocused.
* Firefox and WebKit also ignore a focus() for a moment after the page shows
* the frame, without a `focus` event after it, so one that didn't take is
* retried for a while.
*
* @param {Window} win
*/
export const focusOnFrameFocus = (win) => {
const focus = win.HTMLElement.prototype.focus;
let latest = null;
let retryTimer = null;
let retries = 0;
// Whether the latest focus() still needs giving back.
const pending = () => {
const active = win.document.activeElement;
return !!latest?.el.isConnected && (!active || active === win.document.body);
};
const giveBack = () => {
if ( pending() ) focus.apply(latest.el, latest.args);
};
const retry = () => {
retryTimer = null;
giveBack();
if ( pending() && ++retries < FOCUS_RETRIES ) retryTimer = setTimeout(retry, FOCUS_RETRY_MS);
};
win.HTMLElement.prototype.focus = function (...args) {
latest = { el: this, args };
return focus.apply(this, args);
const result = focus.apply(this, args);
clearTimeout(retryTimer);
retryTimer = null;
retries = 0;
if ( pending() ) retryTimer = setTimeout(retry, FOCUS_RETRY_MS);
return result;
};
const forget = () => {
latest = null;
clearTimeout(retryTimer);
retryTimer = null;
win.HTMLElement.prototype.focus = focus;
};
win.addEventListener('pointerdown', forget, { capture: true, once: true });
win.addEventListener('keydown', forget, { capture: true, once: true });
win.addEventListener('focus', () => {
const active = win.document.activeElement;
if ( ! latest?.el.isConnected || (active && active !== win.document.body) ) return;
focus.apply(latest.el, latest.args);
});
win.addEventListener('focus', giveBack);
};
/**
* 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);
});
};
/**
* Whether a framed upgrade can show for the GUI session `session` (its
* `/whoami`): the deployment has an upgrade dialog that reports when it
* closes, and no account check is waiting, which a frame couldn't be closed
* from.
*
* @param {Window} win
* @param {Record<string, unknown> | null} session
* @returns {boolean}
*/
export const canShowUpgradeFrame = (win, session) =>
win.UIUpgradeAccount?.embeddable === true &&
!!session &&
!session.requires_email_confirmation &&
!session.requires_phone_verification &&
!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.
*
* @param {string} hash - `location.hash`
* @returns {{ reason?: string, method?: string }}
*/
export const framedUpgradeDetails = (hash) => {
const given = new URLSearchParams(String(hash ?? '').replace(/^#/, ''));
const details = {};
const reason = given.get('reason');
if ( UPGRADE_REASONS.has(reason) ) details.reason = reason;
const method = given.get('method');
if ( method && method.length <= UPGRADE_METHOD_MAX ) details.method = method;
return details;
};
/**
* Shows the deployment's upgrade dialog in a framed popup and tells the
* framing page it's ready once it's open, or before it opens when the page
* still hides the frame. Resolves whether the user upgraded,
* once the dialog closes, or false once every dialog and window it showed has
* gone without it reporting a close.
*
* @param {Window} win
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
* an empty one is passed as null, so the dialog credits the prompt it remembers
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolve, reject) => {
let settled = false;
let seen = false;
let goneTimer = null;
const settle = (finish) => {
if ( settled ) return;
settled = true;
observer.disconnect();
clearTimeout(goneTimer);
finish();
};
const shown = () => !!win.document.querySelector(UPGRADE_MODAL_UI);
const check = () => {
if ( shown() ) {
seen = true;
clearTimeout(goneTimer);
goneTimer = null;
} else if ( seen && ! goneTimer ) {
goneTimer = setTimeout(() => {
goneTimer = null;
if ( ! shown() ) settle(() => resolve(false));
}, UPGRADE_GONE_MS);
}
};
const observer = new win.MutationObserver(check);
observer.observe(win.document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-modal'] });
let toldReady = false;
const ready = () => {
if ( toldReady || settled ) return;
toldReady = true;
tell('ready');
};
const open = () => {
const dialog = new win.UIUpgradeAccount({
attribution: Object.keys(attribution).length ? attribution : null,
onClose: (result) => settle(() => resolve(result?.upgraded === true)),
});
return dialog.open_as_window();
};
whenFrameSized(win, ready)
.then(open)
.then(() => {
check();
ready();
}, (e) => settle(() => reject(e)));
});
/**
* Resolves once the frame has a viewport. The page keeps a loading frame
* hidden, which Chromium and WebKit lay out at 0x0, and a GUI window opened
* then (such as an alert) centers itself on that. Without one, `reveal` asks
* the page to show the frame first.
*
* @param {Window} win
* @param {() => void} reveal
* @returns {Promise<void>}
*/
const whenFrameSized = (win, reveal) => new Promise((resolve) => {
const sized = () => win.innerWidth > 0 && win.innerHeight > 0;
if ( sized() ) {
resolve();
return;
}
let timer = null;
const done = () => {
win.removeEventListener('resize', onResize);
clearTimeout(timer);
resolve();
};
const onResize = () => {
if ( sized() ) done();
};
win.addEventListener('resize', onResize);
// Opening on a 0x0 viewport is better than never opening.
timer = setTimeout(done, FRAME_SIZE_TIMEOUT_MS);
reveal();
});
+376
View File
@@ -2,10 +2,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
POPUP_FRAME_MSG,
canShowEmailConfirmationFrame,
canShowUpgradeFrame,
closeAccountChecksOnEscape,
confirmEmailInFrame,
focusOnFrameFocus,
framedPopupAction,
framedPopupParams,
framedUpgradeDetails,
framesHostUser,
showFramedUpgrade,
startPopupFrame,
} from './popupFrame.js';
@@ -33,6 +39,7 @@ const statesSent = (win) => win.parent.postMessage.mock.calls.map(([data, origin
afterEach(() => {
document.body.innerHTML = '';
document.documentElement.classList.remove('popup-frame-transparent');
});
describe('framedPopupAction', () => {
@@ -42,6 +49,8 @@ describe('framedPopupAction', () => {
['/embed/show-open-file-picker', 'show-open-file-picker'],
['/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],
@@ -70,6 +79,288 @@ describe('framedPopupParams', () => {
});
});
describe('framesHostUser', () => {
it.each([
['show-open-file-picker', true],
['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) => {
expect(framesHostUser(action)).toBe(expected);
});
});
describe('framedUpgradeDetails', () => {
it('keeps a known reason and the method', () => {
expect(framedUpgradeDetails('#user=abc&reason=storage&method=puter.fs.upload&appUid=app-x'))
.toEqual({ reason: 'storage', method: 'puter.fs.upload' });
});
it('drops an unknown reason and an overlong method', () => {
expect(framedUpgradeDetails(`#reason=free-money&method=${'m'.repeat(201)}`)).toEqual({});
expect(framedUpgradeDetails('')).toEqual({});
expect(framedUpgradeDetails(undefined)).toEqual({});
});
});
describe('canShowUpgradeFrame', () => {
const win = { UIUpgradeAccount: Object.assign(function () {}, { embeddable: true }) };
const session = { uuid: 'u-1' };
it('shows for a session with nothing to verify, when the dialog reports its close', () => {
expect(canShowUpgradeFrame(win, session)).toBe(true);
});
it('needs a dialog that reports its close', () => {
expect(canShowUpgradeFrame({}, session)).toBe(false);
expect(canShowUpgradeFrame({ UIUpgradeAccount: function () {} }, session)).toBe(false);
});
it.each([
'requires_email_confirmation',
'requires_phone_verification',
'requires_card_verification',
'requires_password_change',
])('leaves an account with %s to the tab', (check) => {
expect(canShowUpgradeFrame(win, { ...session, [check]: true })).toBe(false);
});
it('needs a session', () => {
expect(canShowUpgradeFrame(win, null)).toBe(false);
});
});
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 = [];
const events = new EventTarget();
const win = {
document,
innerWidth: width,
innerHeight: height,
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events),
MutationObserver: window.MutationObserver,
UIUpgradeAccount: function (options) {
created.push(options);
this.open_as_window = () => openAsWindow(options);
},
};
return { win, created };
};
/** Stands in for the dialog's own modal root. */
const showModal = () => {
const root = document.createElement('div');
root.setAttribute('aria-modal', 'true');
document.body.append(root);
return root;
};
afterEach(() => {
vi.useRealTimers();
});
it('passes what prompted it, tells the page it is ready once open, and resolves the close', async () => {
const tell = vi.fn();
let options;
const { win, created } = upgradeWindow(async (o) => {
options = o;
});
const result = showFramedUpgrade(win, { appUid: 'app-1', reason: 'funds' }, tell);
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
expect(created[0].attribution).toEqual({ appUid: 'app-1', reason: 'funds' });
expect(tell).toHaveBeenCalledTimes(1);
options.onClose({ upgraded: true });
await expect(result).resolves.toBe(true);
});
it('passes no attribution when nothing is known, so the dialog credits the prompt it remembers', async () => {
const { win, created } = upgradeWindow(async (o) => o.onClose());
await showFramedUpgrade(win, {}, vi.fn());
expect(created[0].attribution).toBeNull();
});
it('resolves false for a close without an upgrade, even one before it opened', async () => {
const { win } = upgradeWindow(async (o) => o.onClose({ upgraded: false }));
await expect(showFramedUpgrade(win, {}, vi.fn())).resolves.toBe(false);
const { win: bare } = upgradeWindow(async (o) => o.onClose());
await expect(showFramedUpgrade(bare, {}, vi.fn())).resolves.toBe(false);
});
it('resolves false once the dialog is gone without reporting a close', async () => {
vi.useFakeTimers();
const tell = vi.fn();
let root;
const { win } = upgradeWindow(async () => {
root = showModal();
});
const result = showFramedUpgrade(win, {}, tell);
await vi.advanceTimersByTimeAsync(0);
expect(tell).toHaveBeenCalledWith('ready');
root.remove();
await vi.advanceTimersByTimeAsync(499);
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
await vi.advanceTimersByTimeAsync(1);
await expect(result).resolves.toBe(false);
});
it('keeps waiting while one window replaces another', async () => {
vi.useFakeTimers();
let options;
const { win } = upgradeWindow(async (o) => {
options = o;
// An alert, which is a GUI window, rather than the dialog itself.
document.body.append(Object.assign(document.createElement('div'), { className: 'window' }));
});
const result = showFramedUpgrade(win, {}, vi.fn());
await vi.advanceTimersByTimeAsync(0);
document.querySelector('.window').remove();
showModal();
await vi.advanceTimersByTimeAsync(2000);
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
options.onClose({ upgraded: true });
await expect(result).resolves.toBe(true);
});
it('waits for the dialog to show before watching for it to go', async () => {
vi.useFakeTimers();
let options;
const { win } = upgradeWindow(async (o) => {
options = o;
});
const result = showFramedUpgrade(win, {}, vi.fn());
await vi.advanceTimersByTimeAsync(2000);
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
options.onClose({ upgraded: false });
await expect(result).resolves.toBe(false);
});
it('has a hidden frame shown before opening the dialog, so it lays out on a real viewport', async () => {
vi.useFakeTimers();
const tell = vi.fn();
const sizes = [];
const { win, created } = upgradeWindow(async () => {
sizes.push([win.innerWidth, win.innerHeight]);
}, { width: 0, height: 0 });
const result = showFramedUpgrade(win, {}, tell);
await vi.advanceTimersByTimeAsync(0);
expect(tell).toHaveBeenCalledWith('ready');
expect(created).toHaveLength(0);
win.innerWidth = 1280;
win.innerHeight = 800;
win.dispatchEvent(new Event('resize'));
await vi.advanceTimersByTimeAsync(0);
expect(sizes).toEqual([[1280, 800]]);
// Told once, not again once it opens.
expect(tell).toHaveBeenCalledTimes(1);
created[0].onClose({ upgraded: false });
await expect(result).resolves.toBe(false);
});
it('opens the dialog anyway when a hidden frame never gets a viewport', async () => {
vi.useFakeTimers();
const { win, created } = upgradeWindow(async () => {}, { width: 0, height: 0 });
showFramedUpgrade(win, {}, vi.fn());
await vi.advanceTimersByTimeAsync(999);
expect(created).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(created).toHaveLength(1);
});
it('rejects when the dialog fails to open', async () => {
const { win } = upgradeWindow(async () => {
throw new Error('broken');
});
await expect(showFramedUpgrade(win, {}, vi.fn())).rejects.toThrow('broken');
});
});
describe('startPopupFrame', () => {
it('does nothing without a framing page to answer', () => {
const win = frameWindow();
@@ -105,6 +396,28 @@ describe('startPopupFrame', () => {
);
});
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', () => {
const win = frameWindow();
const tell = startPopupFrame(win, HOST);
tell('closed', { upgraded: true });
expect(win.parent.postMessage).toHaveBeenLastCalledWith(
{ upgraded: true, msg: POPUP_FRAME_MSG, state: 'closed' },
HOST,
);
});
it('leaves other window.open calls alone', () => {
const win = frameWindow();
const open = win.open;
@@ -169,6 +482,57 @@ describe('focusOnFrameFocus', () => {
}
});
/** A window whose focus() is ignored while `blocked`, as a frame just shown is. */
const blockableWindow = () => {
const { win, input } = ownWindow();
const nativeFocus = win.HTMLElement.prototype.focus;
const state = { blocked: true };
win.HTMLElement.prototype.focus = function (...args) {
if ( ! state.blocked ) nativeFocus.apply(this, args);
};
return { win, input, state };
};
it('retries a focus() the frame ignored until it takes', async () => {
vi.useFakeTimers();
try {
const { win, input, state } = blockableWindow();
focusOnFrameFocus(win);
input.focus();
expect(win.document.activeElement).toBe(win.document.body);
await vi.advanceTimersByTimeAsync(120);
expect(win.document.activeElement).toBe(win.document.body);
state.blocked = false;
await vi.advanceTimersByTimeAsync(50);
expect(win.document.activeElement).toBe(input);
} finally {
vi.useRealTimers();
}
});
it('stops retrying after a while, and once the user presses a key', async () => {
vi.useFakeTimers();
try {
const { win, input, state } = blockableWindow();
focusOnFrameFocus(win);
input.focus();
await vi.advanceTimersByTimeAsync(1000);
state.blocked = false;
await vi.advanceTimersByTimeAsync(1000);
expect(win.document.activeElement).toBe(win.document.body);
const other = blockableWindow();
focusOnFrameFocus(other.win);
other.input.focus();
other.win.document.body.dispatchEvent(new other.win.Event('keydown', { bubbles: true }));
other.state.blocked = false;
await vi.advanceTimersByTimeAsync(1000);
expect(other.win.document.activeElement).toBe(other.win.document.body);
} finally {
vi.useRealTimers();
}
});
it('leaves an element that has focus, and one that has left the page', () => {
const { win, input } = ownWindow();
const nativeFocus = win.HTMLElement.prototype.focus;
@@ -207,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'));
@@ -232,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.',
+81 -15
View File
@@ -47,8 +47,21 @@ import UIAuthRecoverPassword from './UI/Auth/UIAuthRecoverPassword.js';
import { PROCESS_RUNNING } from './definitions.js';
import { isAttestedOrigin } from './helpers/attestedOrigin.js';
import { embedHostUser } from './helpers/embedHostUser.js';
import { closeAccountChecksOnEscape, focusOnFrameFocus, framedPopupAction, framedPopupParams, startPopupFrame } from './helpers/popupFrame.js';
import {
canShowEmailConfirmationFrame,
canShowUpgradeFrame,
closeAccountChecksOnEscape,
confirmEmailInFrame,
focusOnFrameFocus,
framedPopupAction,
framedPopupParams,
framedUpgradeDetails,
framesHostUser,
showFramedUpgrade,
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 {
@@ -95,7 +108,6 @@ import { ThemeService } from './services/ThemeService.js';
import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js';
import { resolveAPIOrigin } from './util/apiOrigin.js';
import {
defersTokenToOpener,
deliversTokenAtBoot,
relationshipIsConsent,
relaysTokenAtBoot,
@@ -568,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();
@@ -636,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
@@ -711,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();
@@ -992,6 +1004,46 @@ const postAuthActions = async (action) => {
}
}
// -------------------------------------------------------------------------------------
// Action: upgrade — the deployment's upgrade dialog for the first-party page framing
// it, which hears whether the user upgraded once the dialog closes. The app it credits
// is the one the framing page's origin resolves to, never one the fragment names.
// -------------------------------------------------------------------------------------
if ( action === 'upgrade' && window.embedded_in_frame ) {
let upgraded;
try {
upgraded = await showFramedUpgrade(window, {
...(window.host_app_uid ? { appUid: window.host_app_uid } : {}),
...framedUpgradeDetails(window.location.hash),
}, window.tell_popup_frame);
} catch (e) {
// A dialog that failed to open leaves the page to open the upgrade in a tab.
console.error('upgrade action failed', e);
window.tell_popup_frame('unavailable');
return;
}
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.
@@ -1558,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);
@@ -1741,21 +1797,27 @@ window.initgui = async function (options) {
// this is the referrer in terms of user acquisition
window.referrerStr = window.openerOrigin;
// A framed picker shows only the Puter session of the user the
// framing page is signed in as; for anyone else the page uses the
// popup, which can sign in and switch accounts.
if (window.embedded_in_frame && defersTokenToOpener(action)) {
// A framed picker or upgrade shows only the Puter session of the user
// the framing page is signed in as; for anyone else the page uses the
// popup or tab, which can sign in and switch accounts.
if (window.embedded_in_frame && framesHostUser(action)) {
const host_user = embedHostUser(window.location.hash);
let session = null;
let session_user = null;
if (host_user && window.is_auth()) {
try {
const session = await puter.os.user({ query: 'icon_size=64' });
session = await puter.os.user({ query: 'icon_size=64' });
session_user = String(session?.uuid ?? '').toLowerCase();
} catch (e) {
session_user = null;
}
}
if (!host_user || session_user !== host_user) {
if (
!host_user ||
session_user !== host_user ||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
) {
window.tell_popup_frame('unavailable');
return;
}
@@ -1993,11 +2055,11 @@ window.initgui = async function (options) {
);
window.userAppToken = response?.token;
// A framed picker already holds the framing page's user, so it
// doesn't offer to switch.
// A framed picker or upgrade already holds the framing page's user,
// so it doesn't offer to switch.
if (
!picked_a_user_for_sdk_login &&
!(window.embedded_in_frame && defersTokenToOpener(action)) &&
!(window.embedded_in_frame && framesHostUser(action)) &&
window.logged_in_users.length > 1 &&
(!window.userAppToken ||
window.url_query_params.get('request_auth'))
@@ -2374,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);
}
});
}
});
+2
View File
@@ -18,6 +18,7 @@ declare global {
// -- Shared --
export type {
APILoggingConfig,
EmailConfirmationResult,
ListPage,
ListPaginationOptions,
ListStreamOptions,
@@ -28,6 +29,7 @@ export type {
ToolSchema,
UpgradeReason,
UpgradeRequestDetails,
UpgradeResult,
} from './types/lib/types.js';
export type { default as APICallLogger } from './types/lib/APICallLogger.js';
+109 -10
View File
@@ -61,6 +61,82 @@ const RESERVED_LABELS = new Set(['www', 'api', 'js', 'dav', 'about', 'developer'
// Dismisses the single-instance embed showing at each path.
const showingEmbeds = new Map();
// Embed dialogs showing over the page, and the page's own styles to put back
// once none is.
const scrollLockers = new Set();
let pageStyle = null;
/**
* The element whose overflow the page's viewport uses: the body's while the
* root's is `visible`. Hiding the root's then would make the body a scroller
* of its own, which starts at the top.
*
* @returns {HTMLElement}
*/
const viewportOverflowElement = () => {
const root = document.documentElement;
const body = document.body;
const visible = (el) => {
const style = getComputedStyle(el);
return style.overflowX === 'visible' && style.overflowY === 'visible';
};
return body && visible(root) && ! visible(body) ? body : root;
};
/**
* Stops the page scrolling behind `dialog` while it shows: Firefox and WebKit
* pass a scroll over the frame on to the page, whatever the dialog's
* `overscroll-behavior`. The root takes the width its scrollbar gave back as
* padding, so the page doesn't shift. Returns the function that lets it scroll again.
*
* @param {HTMLDialogElement} dialog
* @returns {() => void}
*/
const lockPageScroll = (dialog) => {
const root = document.documentElement;
const unlockPage = () => {
const { scroller, overflow, overflowX, overflowY } = pageStyle;
scroller.style.overflow = overflow;
// A shorthand reads empty when the page set only one axis.
if ( ! overflow ) {
scroller.style.overflowX = overflowX;
scroller.style.overflowY = overflowY;
}
root.style.paddingRight = pageStyle.paddingRight;
pageStyle = null;
};
// One the page removed itself never unlocks, so the page's own styles
// go back before they're read again.
for ( const shown of scrollLockers ) if ( ! shown.isConnected ) scrollLockers.delete(shown);
if ( scrollLockers.size === 0 && pageStyle ) unlockPage();
if ( scrollLockers.size === 0 ) {
const scroller = viewportOverflowElement();
pageStyle = {
scroller,
overflow: scroller.style.overflow,
overflowX: scroller.style.overflowX,
overflowY: scroller.style.overflowY,
paddingRight: root.style.paddingRight,
};
const hasScrollbar = window.innerWidth > root.clientWidth;
const width = hasScrollbar ? root.getBoundingClientRect().width : 0;
scroller.style.overflow = 'hidden';
// Measured, since a gutter the page keeps (`scrollbar-gutter: stable`)
// gives nothing back.
const released = hasScrollbar ? root.getBoundingClientRect().width - width : 0;
if ( released > 0 ) {
const padding = parseFloat(getComputedStyle(root).paddingRight) || 0;
root.style.paddingRight = `${padding + released}px`;
}
}
scrollLockers.add(dialog);
return () => {
if ( ! scrollLockers.delete(dialog) ) return;
for ( const shown of scrollLockers ) if ( ! shown.isConnected ) scrollLockers.delete(shown);
if ( scrollLockers.size === 0 && pageStyle ) unlockPage();
};
};
/**
* Whether a page at `pageUrl` may frame an embed served by `guiOrigin`:
* exactly one label under its host, on the same scheme and port, and not one
@@ -215,6 +291,12 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
let wasReady = false;
let restarted = false;
let restartTimer = null;
let unlockScroll = null;
// A dialog the page removes itself, such as by re-rendering its body,
// counts as dismissed, so whoever awaits it isn't left waiting.
const removal = new MutationObserver(() => {
if ( ! dialog.isConnected ) dismiss();
});
const restoreFocus = document.activeElement;
const wait = (ms) => {
clearTimeout(timer);
@@ -226,9 +308,11 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
clearTimeout(timer);
clearTimeout(loadingTimer);
clearTimeout(restartTimer);
removal.disconnect();
window.removeEventListener('message', onWindowMessage);
if ( showingEmbeds.get(path) === dismiss ) showingEmbeds.delete(path);
dialog.remove();
unlockScroll?.();
if ( shown ) restoreFocus?.focus?.({ preventScroll: true });
resolve(result);
};
@@ -244,6 +328,7 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
if ( dialog.open ) return;
shown = true;
dialog.showModal();
unlockScroll = lockPageScroll(dialog);
};
const onWindowMessage = (event) => {
if ( event.origin !== origin || event.source !== frame.contentWindow ) return;
@@ -320,6 +405,7 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
frame.src = url.href;
// Hidden at first, so a quickly refused session shows nothing.
document.body.append(dialog);
removal.observe(document.documentElement, { childList: true, subtree: true });
onFrame?.(frame.contentWindow);
wait(READY_TIMEOUT_MS);
loadingTimer = setTimeout(() => {
@@ -357,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 },
@@ -365,24 +454,33 @@ const POPUP_FRAME_BOXES = {
'show-directory-picker': { width: 700, height: 400 },
};
// The upgrade has no box: it covers the page and its dialog draws its own
// scrim. A payment in it may use the Payment Request API, which a frame can
// use only when allowed, and Escape is left to the dialog, which doesn't close
// mid-entry.
const UNBOXED_POPUP_FRAMES = {
upgrade: { allow: 'payment; otp-credentials', escapeDismisses: false },
};
/**
* Shows the GUI popup for `action` in a frame over the page instead of a
* window, from `/embed/<action>`. The GUI runs the popup unchanged and posts
* its answers to this page as it would to an opener; `onMessage` sees each of
* them and finishes with the result. The frame closing on its own, as the
* popup would, finishes with `closed()`, and so does the user dismissing it.
* Resolves null when the frame can't serve the request (refused, never
* answering, or reporting itself unavailable), so the caller can open the
* popup instead. Never rejects.
* popup would, finishes with `closed(data)`, `data` being its closing
* message, and the user dismissing it finishes with `closed()`. Resolves null
* when the frame can't serve the request (refused, never answering, or
* reporting itself unavailable), so the caller can open the popup instead.
* Never rejects.
*
* @template T
* @param {Object} opts
* @param {string} opts.guiOrigin
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker
* @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 {() => T} opts.closed
* @param {(data?: Record<string, unknown>) => T} opts.closed
* @param {(data: Record<string, unknown>, finish: (result: T) => void) => void} opts.onMessage
* @param {(frame: Window) => void} [opts.onFrame]
* @returns {Promise<T | null>}
@@ -400,13 +498,14 @@ export const showPopupFrame = ({ guiOrigin, action, fragment, title, closeLabel,
allow: 'otp-credentials',
// Escape inside the frame closes it as well.
escapeDismisses: true,
...UNBOXED_POPUP_FRAMES[action],
// A second request for this popup, such as from a double click, replaces the
// first. Each popup has its own window name, so other popups stay open.
single: true,
dismissed: closed,
dismissed: () => closed(),
onFrame,
onMessage: (data, finish) => {
if ( data.msg === 'puterPopupFrame' && data.state === 'closed' ) finish(closed());
if ( data.msg === 'puterPopupFrame' && data.state === 'closed' ) finish(closed(data));
else onMessage(data, finish);
},
});
+145
View File
@@ -459,6 +459,15 @@ describe('showPopupFrame', () => {
await expect(picker).resolves.toBe('closed');
});
it('finishes as closed when the page removes it, letting the page scroll again', async () => {
const result = show({ action: 'upgrade', fragment: { user: USER } });
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
expect(document.documentElement.style.overflow).toBe('hidden');
document.body.innerHTML = '<main>re-rendered</main>';
await expect(result).resolves.toBe('closed');
expect(document.documentElement.style.overflow).toBe('');
});
it('keeps the scrim behind a ready frame from dismissing it', () => {
show();
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
@@ -475,3 +484,139 @@ describe('showPopupFrame', () => {
expect(popupFrame().closest('dialog').isConnected).toBe(true);
});
});
describe('page scroll behind an embed', () => {
const root = document.documentElement;
const popupFrame = (action) => document.querySelector(`dialog.puter-popup-frame iframe[src*="/embed/${action}"]`);
const show = (action, closed = () => 'closed') => showPopupFrame({
guiOrigin: GUI_ORIGIN,
action,
fragment: { user: USER },
title: action,
closed,
onMessage: () => {},
});
const tell = (action, state) => window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'puterPopupFrame', state },
origin: GUI_ORIGIN,
source: popupFrame(action).contentWindow,
}));
/**
* Gives the page a classic scrollbar this wide, or none, whose space comes
* back once the root stops scrolling unless the page keeps its gutter.
*/
const scrollbar = (width, { gutterKept = false } = {}) => {
vi.spyOn(root, 'clientWidth', 'get').mockReturnValue(window.innerWidth - width);
vi.spyOn(root, 'getBoundingClientRect').mockImplementation(() => ({
width: window.innerWidth - (root.style.overflow === 'hidden' && ! gutterKept ? 0 : width),
}));
};
// Earlier tests leave dialogs that never closed, which locked the page.
beforeEach(() => {
root.removeAttribute('style');
});
afterEach(() => {
vi.restoreAllMocks();
root.removeAttribute('style');
document.body.removeAttribute('style');
});
it('stops while the embed shows and comes back as the page had it once it closes', async () => {
scrollbar(0);
root.style.overflow = 'clip';
const result = show('upgrade');
tell('upgrade', 'loading');
expect(root.style.overflow).toBe('clip');
tell('upgrade', 'ready');
expect(root.style.overflow).toBe('hidden');
expect(root.style.paddingRight).toBe('');
tell('upgrade', 'closed');
await expect(result).resolves.toBe('closed');
expect(root.style.overflow).toBe('clip');
});
it('stops the body instead when the viewport scrolls by the body\'s overflow', async () => {
scrollbar(0);
root.style.overflow = 'visible';
document.body.style.overflowX = 'hidden';
const result = show('upgrade');
tell('upgrade', 'ready');
expect(document.body.style.overflow).toBe('hidden');
expect(root.style.overflow).toBe('visible');
tell('upgrade', 'closed');
await result;
expect(document.body.style.overflowX).toBe('hidden');
expect(document.body.style.overflowY).toBe('');
});
it('keeps the space of a classic scrollbar, so the page doesn\'t shift', async () => {
scrollbar(15);
root.style.paddingRight = '8px';
const result = show('upgrade');
tell('upgrade', 'ready');
expect(root.style.overflow).toBe('hidden');
expect(root.style.paddingRight).toBe('23px');
tell('upgrade', 'closed');
await result;
expect(root.style.paddingRight).toBe('8px');
expect(root.style.overflow).toBe('');
});
it('adds no space for a scrollbar whose gutter the page keeps', async () => {
scrollbar(15, { gutterKept: true });
const result = show('upgrade');
tell('upgrade', 'ready');
expect(root.style.overflow).toBe('hidden');
expect(root.style.paddingRight).toBe('');
tell('upgrade', 'closed');
await result;
});
it('stops behind the loading scrim too, and stays stopped until the last embed closes', async () => {
vi.useFakeTimers();
scrollbar(0);
const upgrade = show('upgrade');
await vi.advanceTimersByTimeAsync(200);
expect(root.style.overflow).toBe('hidden');
const signIn = show('sign-in');
tell('sign-in', 'ready');
tell('sign-in', 'closed');
await signIn;
expect(root.style.overflow).toBe('hidden');
tell('upgrade', 'closed');
await upgrade;
expect(root.style.overflow).toBe('');
});
it('gives the page its own styles back after an embed the page removed itself', async () => {
scrollbar(15);
root.style.paddingRight = '8px';
show('upgrade');
tell('upgrade', 'ready');
document.querySelector('dialog.puter-popup-frame').remove();
const signIn = show('sign-in');
tell('sign-in', 'ready');
expect(root.style.overflow).toBe('hidden');
expect(root.style.paddingRight).toBe('23px');
tell('sign-in', 'closed');
await signIn;
expect(root.style.overflow).toBe('');
expect(root.style.paddingRight).toBe('8px');
});
it('leaves the page alone for an embed that never showed', async () => {
scrollbar(0);
const result = show('upgrade');
tell('upgrade', 'unavailable');
await expect(result).resolves.toBeNull();
expect(root.style.overflow).toBe('');
});
});
+17
View File
@@ -50,6 +50,23 @@
* @property {string} [message] One sentence saying what was refused and why.
*/
/**
* How a framed upgrade on a first-party page ended.
*
* @typedef {Object} UpgradeResult
* @property {boolean} upgraded `true` when the user subscribed to a plan or upgraded to a higher
* one before closing the dialog. `false` otherwise, including after a downgrade or cancellation,
* 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.
+19 -3
View File
@@ -26,7 +26,7 @@ import { showUsageLimitDialog } from '../modules/UsageLimitDialog.js';
*
* @typedef {{
* env?: string,
* ui?: { requestUpgrade: Function },
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean },
* authToken?: string,
* appID?: string,
* getAppIDFromAuthToken?: (token: unknown) => string | null,
@@ -109,7 +109,8 @@ const messageFor = (reason, error, context) => {
* Inside an app the desktop's upgrade flow is asked for (and not awaited: it
* never answers). The SDK's own dialog covers storage refusals everywhere
* else - the desktop leans on it for those - and funding / plan refusals on
* third-party sites only. The error is not consumed; callers still reject.
* websites only. On a first-party page its Upgrade button shows the upgrade
* in a frame over the page. The error is not consumed; callers still reject.
*
* @param {unknown} error The rejection, in whatever shape it arrived.
* @param {UpgradePromptContext} [context] Which SDK method was refused and,
@@ -127,11 +128,26 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
if ( puter?.env === 'app' ) {
puter.ui.requestUpgrade({ reason, method, message });
} else if ( puter?.env === 'web' || reason === 'storage' ) {
// The upgrade a first-party page already shows answers this refusal
// too, and a dialog over it would block it.
if ( puter.ui?.upgradeFrameShowing_?.() === true ) return;
// A first-party page shows the upgrade in a frame over itself
// instead of a dashboard tab.
const framed = puter?.env === 'web' && puter.ui?.canShowUpgradeFrame_?.() === true;
showUsageLimitDialog(message, {
title: TITLES[reason],
method,
reason,
appID: appIDOf(puter),
...(framed ? {
// The page may have stopped being able to frame it, such as
// by signing out, since the dialog opened.
onUpgrade: () => {
if ( puter.ui.canShowUpgradeFrame_?.() !== true ) return false;
puter.ui.requestUpgrade({ reason, method, message });
return true;
},
} : {}),
});
}
};
@@ -143,5 +159,5 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
* @param {PromptingSdk | undefined} puter
* @returns {string | undefined}
*/
const appIDOf = (puter) =>
export const appIDOf = (puter) =>
puter?.getAppIDFromAuthToken?.(puter.authToken) || puter?.appID || undefined;
@@ -179,6 +179,57 @@ describe('promptIfUpgradeRequired', () => {
});
});
describe('on a first-party page that can frame the upgrade', () => {
beforeEach(() => {
globalThis.puter = {
env: 'web',
ui: { requestUpgrade: vi.fn(), canShowUpgradeFrame_: () => true },
};
});
it('has the dialog ask for the framed upgrade instead of opening a tab', () => {
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
const [message, options] = showUsageLimitDialog.mock.calls[0];
expect(message).toBe('Your account does not have enough funding to complete this request.');
expect(options).toMatchObject({ title: 'Low Balance', method: 'puter.ai.chat', reason: 'funds' });
expect(options.onUpgrade()).toBe(true);
expect(globalThis.puter.ui.requestUpgrade).toHaveBeenCalledWith({
reason: 'funds',
method: 'puter.ai.chat',
message: 'Your account does not have enough funding to complete this request.',
});
});
it('leaves the tab to the dialog when the page can no longer frame it by the click', () => {
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
expect(showUsageLimitDialog.mock.calls[0][1].onUpgrade()).toBe(false);
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
});
it('shows no dialog over an upgrade the page already shows', () => {
globalThis.puter.ui.upgradeFrameShowing_ = () => true;
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
expect(showUsageLimitDialog).not.toHaveBeenCalled();
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
});
it('keeps the tab when the page cannot frame it', () => {
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
expect(showUsageLimitDialog.mock.calls[0][1]).not.toHaveProperty('onUpgrade');
});
it('keeps the tab for a storage refusal outside a website', () => {
globalThis.puter.env = 'gui';
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
expect(showUsageLimitDialog.mock.calls[0][1]).not.toHaveProperty('onUpgrade');
});
});
it('does nothing for a rejection an upgrade would not clear', () => {
promptIfUpgradeRequired({ status: 403 });
promptIfUpgradeRequired(new Error('offline'));
+170 -14
View File
@@ -4,6 +4,8 @@ import FSItem from './FSItem.js';
import PuterDialog from './PuterDialog.js';
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showFeedbackEmbed, showPopupFrame } from '../lib/guiEmbed.js';
import { hasUserActivation } from '../lib/auth-popup.js';
import { appIDOf } from '../lib/upgradePrompt.js';
import { upgradeUrl } from './UsageLimitDialog.js';
import { checkPermissions } from './perms/lib/holds.js';
@@ -533,6 +535,12 @@ export class UIModule extends EventListener {
// Set while the framed feedback dialog is open on a first-party page.
#feedbackEmbedOpen = false;
// 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 = [];
@@ -620,7 +628,7 @@ export class UIModule extends EventListener {
* @param {(win: Window | null, how?: { framed: boolean }) => (() => void) | void} [opts.onWindow]
*/
#showWebPicker ({ action, msgId, url, title, fragment = {}, onWindow }) {
if ( ! this.#canFramePicker() ) {
if ( ! this.#canFrameForUser() ) {
// window.open() returns synchronously and the popup cannot post
// back until this function yields, so there is no race.
const popup = this.#openPickerPopup(url, title);
@@ -662,8 +670,8 @@ export class UIModule extends EventListener {
})();
}
/** Whether a picker can show framed: a signed-in first-party page, not in a frame of another site. */
#canFramePicker () {
/** Whether a picker or upgrade can show framed: a signed-in first-party page, not in a frame of another site. */
#canFrameForUser () {
return this.env === 'web' &&
!globalThis.crossOriginIsolated &&
!!globalThis.document?.body &&
@@ -680,9 +688,8 @@ export class UIModule extends EventListener {
* @returns {Promise<boolean>}
*/
async #showPickerFrame ({ action, msgId, title, fragment, onWindow }) {
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
if ( typeof user?.uuid !== 'string' || ! user.uuid ) return false;
const user = await this.#frameUser();
if ( ! user ) return false;
let frame = null;
let stopListening;
// The frame only serves this page's own user, so the token it sends
@@ -708,6 +715,18 @@ export class UIModule extends EventListener {
return true;
}
/**
* The page's signed-in user that a frame is shown for, from the cached
* whoami when there is one, or null without a uuid.
*
* @returns {Promise<{ uuid: string, email_confirmed?: boolean } | null>}
*/
async #frameUser () {
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
return typeof user?.uuid === 'string' && user.uuid ? user : null;
}
// Whether a message on the window may drive this module.
#isTrustedMessageSource (e) {
if ( this.messageTarget ) {
@@ -1318,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
@@ -1700,16 +1761,112 @@ export class UIModule extends EventListener {
* fills them in when a call is refused for want of credit, a plan, or
* storage.
*
* On a signed-in first-party page one label under the Puter origin, the
* upgrade shows in a frame over the page instead, and the promise
* resolves once it closes. When the frame can't show it, the upgrade
* opens in a new tab and the promise resolves `{ upgraded: false }`.
* Anywhere else the promise never settles: the desktop doesn't answer an
* app, and a website has no desktop to ask.
*
* @param {import('../lib/types.js').UpgradeRequestDetails} [details]
* @returns {Promise<unknown>}
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
*/
requestUpgrade (details) {
const { reason, method, message } = details ?? {};
if ( this.canShowUpgradeFrame_() ) return this.#showUpgradeFrame({ reason, method });
return new Promise((resolve) => {
this.#postMessageWithCallback('requestUpgrade', resolve, { reason, method, message });
});
};
/**
* Whether {@link requestUpgrade} shows the upgrade in a frame over this page.
*
* @internal
* @returns {boolean}
*/
canShowUpgradeFrame_ () {
return this.#canFrameForUser();
}
/**
* Whether {@link requestUpgrade} is showing the upgrade in a frame, about
* to, or asking to open it in a tab instead.
*
* @internal
* @returns {boolean}
*/
upgradeFrameShowing_ () {
return this.#upgradeFrame !== null;
}
/**
* The upgrade in a frame over the page, for the page's signed-in user, or
* in a new tab when the frame can't show it. A call made while one is
* showing settles with it, so a payment in progress isn't replaced.
*
* @param {{ reason?: string, method?: string }} details
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
*/
#showUpgradeFrame ({ reason, method }) {
if ( this.#upgradeFrame ) return this.#upgradeFrame;
this.#upgradeFrame = (async () => {
try {
const user = await this.#frameUser();
const framed = user && await showPopupFrame({
guiOrigin: puter.defaultGUIOrigin,
action: 'upgrade',
fragment: {
user: user.uuid,
...(reason ? { reason } : {}),
...(method ? { method } : {}),
},
title: 'Upgrade',
closed: (data) => ({ upgraded: data?.upgraded === true }),
onMessage: () => {},
});
if ( framed ) return framed;
await this.#openUpgradeTab(upgradeUrl({ reason, method, appID: appIDOf(puter) }));
return { upgraded: false };
} finally {
this.#upgradeFrame = null;
}
})();
return this.#upgradeFrame;
}
/**
* Opens the upgrade page in a new tab. The click that asked for it may
* have expired by now, so without it the user opens it from a consent
* dialog. Resolves once the tab opens or the user answers the dialog.
*
* @param {string} url
* @returns {Promise<void>}
*/
#openUpgradeTab (url) {
if ( hasUserActivation() && window.open(url, '_blank') ) return Promise.resolve();
return new Promise((resolve) => {
let consent = null;
const answered = () => {
consent?.remove();
resolve();
};
try {
consent = PuterDialog.show({
message: 'This website wants to open the Puter upgrade page.',
onContinue: () => {
window.open(url, '_blank');
answered();
},
onCancel: answered,
});
} catch {
// No dialog either; as with a blocked tab, the page never opens.
resolve();
}
});
}
/**
* Shows a picker for choosing where to save a file, and saves `content`
* there. Resolves to the saved `FSItem`; if the user cancels, the promise
@@ -2423,9 +2580,8 @@ export class UIModule extends EventListener {
if ( this.#feedbackEmbedOpen ) return { sent: false };
this.#feedbackEmbedOpen = true;
try {
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
if ( typeof user?.uuid !== 'string' || ! user.uuid ) return null;
const user = await this.#frameUser();
if ( ! user ) return null;
return await showFeedbackEmbed({ guiOrigin: puter.defaultGUIOrigin, userUuid: user.uuid });
} finally {
this.#feedbackEmbedOpen = false;
@@ -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,260 @@
// @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 APP = 'app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10';
const DETAILS = { reason: 'funds', method: 'puter.ai.chat', message: 'Your account does not have enough funding.' };
const upgradeDialog = () => document.querySelector('dialog.puter-popup-frame');
const upgradeFrame = () => upgradeDialog()?.querySelector('iframe') ?? null;
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(upgradeFrame().src).hash.slice(1)));
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
data, origin: GUI_ORIGIN, source: upgradeFrame().contentWindow,
}));
const makeUI = (env = 'web') => new UIModule(
{ env, authToken: 'token-1', APIOrigin: 'https://api.puter.test', appID: APP, util: {} },
{ appInstanceID: 'instance-1' },
);
const fakePuter = ({ signedIn = true, guiOrigin = GUI_ORIGIN } = {}) => {
globalThis.puter = {
defaultGUIOrigin: guiOrigin,
appID: APP,
whoamiCache_: Promise.resolve({ uuid: USER }),
auth: {
isSignedIn: () => signedIn,
getUser: vi.fn(async () => ({ uuid: USER })),
},
};
};
const setActivation = (isActive) => Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive },
});
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 }));
setActivation(true);
fakePuter();
});
afterEach(() => {
delete navigator.userActivation;
delete globalThis.puter;
vi.restoreAllMocks();
});
describe('ui.requestUpgrade on a signed-in first-party page', () => {
it('shows the upgrade in a frame over the page and resolves what the dialog reports', async () => {
const ui = makeUI();
expect(ui.canShowUpgradeFrame_()).toBe(true);
const result = ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
expect(new URL(upgradeFrame().src).pathname).toBe('/embed/upgrade');
expect(fragment()).toEqual({ user: USER, reason: 'funds', method: 'puter.ai.chat' });
// It covers the page, and the dialog inside draws the scrim.
expect(upgradeDialog().querySelector('.puter-popup-frame-box')).toBeNull();
expect(upgradeDialog().querySelector('.puter-popup-frame-close')).toBeNull();
expect(upgradeFrame().getAttribute('allow')).toBe('payment; otp-credentials');
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
expect(upgradeDialog().open).toBe(true);
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
await expect(result).resolves.toEqual({ upgraded: true });
expect(upgradeFrame()).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});
it('says the upgrade is showing from the call until it closes', async () => {
const ui = makeUI();
expect(ui.upgradeFrameShowing_()).toBe(false);
const result = ui.requestUpgrade();
expect(ui.upgradeFrameShowing_()).toBe(true);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await result;
expect(ui.upgradeFrameShowing_()).toBe(false);
});
it('settles, and stops showing, when the page removes the frame', async () => {
const ui = makeUI();
const result = ui.requestUpgrade();
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
upgradeDialog().remove();
await expect(result).resolves.toEqual({ upgraded: false });
expect(ui.upgradeFrameShowing_()).toBe(false);
expect(window.open).not.toHaveBeenCalled();
});
it('resolves upgraded false when the dialog closes without an upgrade', async () => {
const result = makeUI().requestUpgrade();
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
expect(fragment()).toEqual({ user: USER });
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toEqual({ upgraded: false });
});
it('leaves Escape on the page to the dialog once it shows', async () => {
const result = makeUI().requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
upgradeDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
expect(upgradeFrame()).not.toBeNull();
expect(await settled(result)).toBe(PENDING);
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: false });
await expect(result).resolves.toEqual({ upgraded: false });
});
it('settles a call made while the upgrade shows with it, without replacing it', async () => {
const ui = makeUI();
const first = ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
const frame = upgradeFrame();
const second = ui.requestUpgrade({ reason: 'storage' });
expect(second).toBe(first);
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
expect(upgradeFrame()).toBe(frame);
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
await expect(second).resolves.toEqual({ upgraded: true });
// A later call shows a new one.
ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
expect(upgradeFrame()).not.toBe(frame);
});
it('opens the dashboard upgrade in a tab when the frame cannot show it', async () => {
const result = makeUI().requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await expect(result).resolves.toEqual({ upgraded: false });
expect(upgradeFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
const [url, target] = window.open.mock.calls[0];
expect(target).toBe('_blank');
expect(url).toBe(`https://puter.com/dashboard/?upgrade=funds&upgrade_app=${APP}&upgrade_method=puter.ai.chat#home`);
});
it('asks for a click before opening the tab once the original click has expired', async () => {
const ui = makeUI();
const result = ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
setActivation(false);
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
const consent = document.querySelector('puter-dialog');
expect(window.open).not.toHaveBeenCalled();
expect(consent.shadowRoot.querySelector('.about').textContent)
.toBe('This website wants to open the Puter upgrade page.');
// Until it's answered the upgrade counts as showing, so a second call
// joins it instead of asking again.
expect(ui.upgradeFrameShowing_()).toBe(true);
expect(await settled(result)).toBe(PENDING);
const again = ui.requestUpgrade(DETAILS);
expect(document.querySelectorAll('puter-dialog')).toHaveLength(1);
consent.shadowRoot.querySelector('#launch-auth-popup').click();
await expect(result).resolves.toEqual({ upgraded: false });
await expect(again).resolves.toEqual({ upgraded: false });
expect(ui.upgradeFrameShowing_()).toBe(false);
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.open.mock.calls[0][0]).toContain('https://puter.com/dashboard/?upgrade=funds');
expect(document.querySelector('puter-dialog')).toBeNull();
});
it('asks for a click when the browser blocks the tab', async () => {
window.open = vi.fn(() => null);
const result = makeUI().requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
document.querySelector('puter-dialog').shadowRoot.querySelector('#launch-auth-popup-cancel').click();
expect(document.querySelector('puter-dialog')).toBeNull();
await expect(result).resolves.toEqual({ upgraded: false });
expect(window.open).toHaveBeenCalledTimes(1);
});
it('opens the tab 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().requestUpgrade(DETAILS)).resolves.toEqual({ upgraded: false });
expect(upgradeFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
});
});
describe('ui.requestUpgrade elsewhere', () => {
it('asks the desktop inside an app, as before', () => {
const post = vi.spyOn(window, 'postMessage').mockImplementation(() => {});
const ui = makeUI('app');
expect(ui.canShowUpgradeFrame_()).toBe(false);
const result = ui.requestUpgrade(DETAILS);
expect(post).toHaveBeenCalledWith(expect.objectContaining({
msg: 'requestUpgrade',
env: 'app',
appInstanceID: 'instance-1',
...DETAILS,
}), '*');
expect(result).toBeInstanceOf(Promise);
expect(upgradeFrame()).toBeNull();
});
it.each([
['a signed-out first-party page', { signedIn: false }],
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
])('neither frames nor opens a tab on %s', async (_label, options) => {
fakePuter(options);
const ui = makeUI();
expect(ui.canShowUpgradeFrame_()).toBe(false);
const result = ui.requestUpgrade(DETAILS);
expect(await settled(result)).toBe(PENDING);
expect(upgradeFrame()).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});
it('does not frame on a page in a frame of another site', () => {
Object.defineProperty(window.location, 'ancestorOrigins', { configurable: true, value: ['https://other.test'] });
try {
expect(makeUI().canShowUpgradeFrame_()).toBe(false);
} finally {
delete window.location.ancestorOrigins;
}
});
});
+7 -2
View File
@@ -6,6 +6,8 @@ import { escapeHtml } from '../lib/html.js';
* @property {string} [method] The SDK call that was refused.
* @property {string} [reason] Which upgrade the refusal asks for.
* @property {string} [appID] The app the refused call ran as.
* @property {() => boolean} [onUpgrade] Runs when the user clicks Upgrade, instead of
* opening the dashboard's upgrade page in a new tab. Returning false opens the tab anyway.
*/
const UPGRADE_PAGE = 'https://puter.com/dashboard/';
@@ -31,13 +33,14 @@ class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
* @param {string} message Plain-text line saying what was refused.
* @param {UsageLimitDialogOptions} [options]
*/
constructor (message, { title = 'Low Balance', method, reason, appID } = {}) {
constructor (message, { title = 'Low Balance', method, reason, appID, onUpgrade } = {}) {
super();
this.message = message || 'You have reached your usage limit for this account.';
// Not `this.title`: on an HTMLElement that is the tooltip attribute.
this.heading = title;
this.method = method;
this.upgradeUrl = upgradeUrl({ reason, appID, method });
this.onUpgrade = onUpgrade;
this.attachShadow({ mode: 'open' });
@@ -270,8 +273,10 @@ class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
});
this.shadowRoot.querySelector('#upgrade-btn').addEventListener('click', () => {
window.open(this.upgradeUrl, '_blank');
// Closed first, so the frame the upgrade shows in isn't under this modal.
this.close();
const shown = this.onUpgrade ? this.onUpgrade() !== false : false;
if ( ! shown ) window.open(this.upgradeUrl, '_blank');
});
// Close on backdrop click
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { showUsageLimitDialog } from './UsageLimitDialog.js';
// jsdom has no top-layer dialogs.
HTMLDialogElement.prototype.showModal = function () {
this.open = true;
};
HTMLDialogElement.prototype.close = function () {
this.open = false;
};
const upgradeButton = () => document.querySelector('usage-limit-dialog').shadowRoot.querySelector('#upgrade-btn');
beforeEach(() => {
document.body.innerHTML = '';
window.open = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('the usage limit dialog Upgrade button', () => {
it('opens the dashboard upgrade in a new tab', () => {
showUsageLimitDialog('Not enough storage space available.', { reason: 'storage', method: 'puter.fs.upload' });
upgradeButton().click();
expect(window.open).toHaveBeenCalledWith(
'https://puter.com/dashboard/?upgrade=storage&upgrade_method=puter.fs.upload#home',
'_blank',
);
expect(document.querySelector('usage-limit-dialog')).toBeNull();
});
it('runs onUpgrade instead, once the dialog has closed', () => {
const onUpgrade = vi.fn(() => {
expect(document.querySelector('usage-limit-dialog')).toBeNull();
});
showUsageLimitDialog('Not enough storage space available.', { reason: 'storage', onUpgrade });
upgradeButton().click();
expect(onUpgrade).toHaveBeenCalledTimes(1);
expect(window.open).not.toHaveBeenCalled();
});
it('opens the tab after all when onUpgrade can\'t show the upgrade', () => {
showUsageLimitDialog('Not enough storage space available.', { reason: 'storage', onUpgrade: () => false });
upgradeButton().click();
expect(window.open).toHaveBeenCalledWith('https://puter.com/dashboard/?upgrade=storage#home', '_blank');
expect(document.querySelector('usage-limit-dialog')).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>
@@ -337,3 +337,410 @@ button { margin: 0 6px 8px 0; min-width: 88px; min-height: 44px; box-shadow: 0 1
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
});
// The open-source GUI has no upgrade dialog, so the frame gets a stand-in for
// the deployment's: it records what prompted it and closes from its buttons.
const STUB_UPGRADE_DIALOG = () => {
if ( location.pathname !== '/embed/upgrade' ) return;
window.UIUpgradeAccount = class {
static embeddable = true;
constructor ({ attribution, onClose }) {
window.__upgradeAttribution = attribution;
this.onClose = onClose;
}
async open_as_window () {
const dialog = document.createElement('div');
dialog.className = 'stub-upgrade';
dialog.setAttribute('role', 'dialog');
dialog.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5)';
dialog.innerHTML = '<button id="buy">Buy</button><button id="done">Close</button>';
dialog.querySelector('#buy').onclick = () => {
this.upgraded = true;
};
dialog.querySelector('#done').onclick = () => {
dialog.remove();
this.onClose({ upgraded: this.upgraded === true });
};
document.body.append(dialog);
}
};
};
/** Signs the first-party page in as the admin, whose Puter session the GUI holds. */
const signInFirstPartyPage = async (page) => {
await page.goto('/');
await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });
await openFirstPartyPage(page);
await runFromClick(page, 'puter.auth.signIn()');
await frameReady(page);
await frame(page).locator('.auth-dialog [data-screen="accounts"] button').first().click();
expect(await result(page)).toMatchObject({ ok: true });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
};
/** Stands in for the dashboard the upgrade falls back to, so no request leaves the machine. */
const routeDashboard = (page) => page.context().route('https://puter.com/**', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>dashboard</title>' }));
test.describe('the upgrade a first-party page shows framed', () => {
test('requestUpgrade() shows it over the page and resolves whether the user upgraded', async ({ page }) => {
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
await signInFirstPartyPage(page);
const popups = [];
page.on('popup', (p) => popups.push(p));
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds", method: "puter.ai.chat", message: "x" })');
await frameReady(page);
const upgradeFrame = page.frames().find((f) => new URL(f.url()).pathname === '/embed/upgrade');
// The app it credits is the page's own, resolved from its origin.
expect(await upgradeFrame.evaluate(() => window.__upgradeAttribution)).toEqual({
appUid: expect.stringMatching(/^app-/),
reason: 'funds',
method: 'puter.ai.chat',
});
// The page shows through around the dialog.
expect(await upgradeFrame.evaluate(() => [
getComputedStyle(document.documentElement).backgroundColor,
getComputedStyle(document.body).backgroundColor,
])).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)']);
await frame(page).locator('#buy').click();
await frame(page).locator('#done').click();
expect(await result(page)).toEqual({ ok: true, value: { upgraded: true } });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
expect(popups).toHaveLength(0);
});
test('the usage limit dialog\'s Upgrade button shows it instead of a tab', async ({ page }) => {
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
await signInFirstPartyPage(page);
const popups = [];
page.on('popup', (p) => popups.push(p));
// The account is out of credit.
await page.route(`${API_ORIGIN}/drivers/call`, (route) => route.fulfill({
status: route.request().method() === 'OPTIONS' ? 204 : 402,
headers: {
'access-control-allow-origin': FIRST_PARTY,
'access-control-allow-headers': '*',
'access-control-allow-credentials': 'true',
},
contentType: 'application/json',
body: JSON.stringify({ success: false, error: { code: 'insufficient_funds', message: 'Not enough credit.' } }),
}));
await page.evaluate(() => puter.kv.set('upgrade-frame', 'x').catch(() => {}));
await page.locator('usage-limit-dialog #upgrade-btn').click();
await frameReady(page);
await expect(page.locator('usage-limit-dialog')).toHaveCount(0);
const upgradeFrame = page.frames().find((f) => new URL(f.url()).pathname === '/embed/upgrade');
expect(await upgradeFrame.evaluate(() => window.__upgradeAttribution)).toMatchObject({ reason: 'funds' });
await frame(page).locator('#done').click();
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
expect(popups).toHaveLength(0);
});
test('a call refused while the upgrade shows leaves it uncovered', async ({ page }) => {
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
await signInFirstPartyPage(page);
await page.route(`${API_ORIGIN}/drivers/call`, (route) => route.fulfill({
status: route.request().method() === 'OPTIONS' ? 204 : 402,
headers: {
'access-control-allow-origin': FIRST_PARTY,
'access-control-allow-headers': '*',
'access-control-allow-credentials': 'true',
},
contentType: 'application/json',
body: JSON.stringify({ success: false, error: { code: 'insufficient_funds', message: 'Not enough credit.' } }),
}));
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds" })');
await frameReady(page);
const refused = await page.evaluate(() => puter.kv.set('upgrade-frame', 'x').then(() => false, () => true));
expect(refused).toBe(true);
await expect(page.locator('usage-limit-dialog')).toHaveCount(0);
await frame(page).locator('#done').click();
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
await page.evaluate(() => puter.kv.set('upgrade-frame', 'x').catch(() => {}));
await expect(page.locator('usage-limit-dialog')).toHaveCount(1);
});
test('a deployment without an upgrade dialog opens the dashboard upgrade in a tab', async ({ page }) => {
await routeDashboard(page);
await signInFirstPartyPage(page);
const popup = page.waitForEvent('popup', { timeout: 60_000 });
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "storage", method: "puter.fs.upload" })');
const url = new URL((await popup).url());
expect(url.origin + url.pathname).toBe('https://puter.com/dashboard/');
expect(url.searchParams.get('upgrade')).toBe('storage');
expect(url.searchParams.get('upgrade_method')).toBe('puter.fs.upload');
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
test('an upgrade dialog that fails to open leaves the dashboard upgrade in a tab', async ({ page }) => {
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
await page.context().addInitScript(() => {
if ( location.pathname !== '/embed/upgrade' ) return;
window.UIUpgradeAccount.prototype.open_as_window = async () => {
throw new Error('broken dialog');
};
});
await routeDashboard(page);
await signInFirstPartyPage(page);
const popup = page.waitForEvent('popup', { timeout: 60_000 });
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds" })');
expect(new URL((await popup).url()).pathname).toBe('/dashboard/');
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
await expect(page.locator('dialog.puter-popup-frame')).toHaveCount(0);
});
test('a page signed in as someone else gets the dashboard upgrade in a tab', async ({ page }) => {
await page.context().addInitScript(STUB_UPGRADE_DIALOG);
await routeDashboard(page);
await signInFirstPartyPage(page);
await page.evaluate(async () => {
await puter.whoamiCache_;
puter.whoami = { uuid: '00000000-0000-4000-8000-000000000000' };
});
const popup = page.waitForEvent('popup', { timeout: 60_000 });
await runFromClick(page, 'puter.ui.requestUpgrade({ reason: "funds" })');
expect(new URL((await popup).url()).pathname).toBe('/dashboard/');
expect(await result(page)).toEqual({ ok: true, value: { upgraded: false } });
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);
});
});