Compare commits

...
11 Commits
Author SHA1 Message Date
Nariman Jelveh b144f1d11c docs(embed): list the top-up tests among the checks to run 2026-11-10 14:39:01 -07:00
Nariman Jelveh 2ceeaacd31 fix(puter-js): offer Add credits after an upgrade when whoami was read again
The user menu keeps the whoami object it loaded and ignores a token set
again unchanged, but the SDK reads whoami into a new object each time a
token is set. A framed upgrade then marked the new object as subscribed
and the menu went on reading the old one, so Add credits stayed hidden
until a reload. The menu now reads the SDK's cached whoami when it is
the same account's.
2026-11-10 14:38:57 -07:00
Nariman Jelveh 0c984ca75b fix(puter-js): keep requestUpgrade from framing the upgrade over an open top-up
The usage-limit dialog already stays away while the top-up shows, but a
page's own requestUpgrade() call, such as one in the handler of a call
refused mid-payment, still mounted the upgrade frame. Frames are single
per path, so it covered the top-up and the payment being typed in it,
and the two dialogs' own guard can't see across documents. The call now
waits for the top-up and resolves { upgraded: false } once it closes.
2026-11-10 14:38:37 -07:00
Nariman Jelveh 16916a903d fix(puter-js): let a top-up that failed to start be asked for again
showTopUp_ cleared its in-flight marker in a finally inside the async
function it stored. A failure before that function's first await, such
as building the dashboard address from an origin with no scheme, ran the
finally before the marker was set, so the marker kept the rejected
promise for good: every later call got the same rejection, and
topUpShowing_ went on suppressing the usage-limit dialog. The marker is
now cleared once the stored promise settles.
2026-11-10 14:05:10 -07:00
Nariman Jelveh eda4015dc7 fix(puter-js): offer Add credits after an upgrade made on the page
The user menu showed Add credits by the whoami it loaded with the page.
A user who subscribed in the framed upgrade therefore found no Add
credits in the menu until a reload. A framed upgrade that reports a plan
now marks the page's cached whoami as subscribed, as a confirmed email
already is, and the menu reads it again each time it opens.
2026-11-10 14:04:35 -07:00
Nariman Jelveh 429b7e2f90 docs(embed): say when the top-up dialog's open() resolves
It resolves after the account has loaded, not as soon as the dialog
shows.
2026-11-10 13:36:11 -07:00
Nariman Jelveh b41ed5dfd6 fix(puter-js): return keyboard focus to the account button after Add credits
Choosing Add credits closed the menu with focus on the item it had just
hidden, and the frame restored focus to the menu's shadow host, which
can't take it, so a keyboard user was left on the page body once the
dialog closed. The menu now moves focus to its button first, and a
framed popup restores focus to the element inside a shadow root.
2026-11-10 13:35:42 -07:00
Nariman Jelveh b3dd98d24e fix(puter-js): open the top-up tab on the page's own deployment
The tab the Add credits item falls back to went to puter.com whatever
GUI origin the page belongs to, so on another deployment it opened a
different one, signed in as whoever is signed in there. It now opens the
dashboard of the GUI origin, like the menu's Usage link beside it.
2026-11-10 13:31:48 -07:00
Nariman Jelveh 1522ed3cdd fix(puter-js): leave Add credits out of the user menu for a team seat
whoami reports a seat as subscribed through its team's plan, so the menu
offered Add credits to an account that can't buy any: the dialog only
answers that the team pays. The item now stays hidden when whoami names a
team paying for the account, as the desktop's own menu already does.
2026-11-10 13:31:23 -07:00
Nariman Jelveh e936038f80 fix(puter-js): show no usage-limit dialog over an open top-up
A call refused for funds while the framed top-up was open put the
usage-limit dialog in the top layer over it, covering the payment the
user was making to clear that refusal. The prompt now stays out of the
way while a top-up shows, as it does for the framed upgrade.
2026-11-10 13:31:04 -07:00
Nariman Jelveh a9f5e818db feat(gui): let a first-party page frame the deployment's top-up dialog
`/embed/top-up` joins the framed popups. Like the upgrade, it covers the
framing page with a transparent GUI page, serves only the page's own
user, and reports `unavailable` unless the deployment defines
`window.UITopUp` with `embeddable === true` and no account check is
waiting. It runs `new UITopUp({ onClose }).open()` and posts
`closed { toppedUp }` once the dialog closes. The frame's lifecycle is the
upgrade's, now shared by both.

`<puter-user-menu>` gets an Add credits item under Usage, shown only when
whoami reports `subscribed: true`. It frames the top-up on a page that can
frame the GUI, and otherwise opens the dashboard with `?topup=` in a new
tab, within the click. Hidden items are skipped by the menu's arrow keys.

`puter.ui.showTopUp_()` is internal; no public SDK method is added.
2026-11-10 13:00:13 -07:00
17 changed files with 765 additions and 43 deletions

No files matched your search

+31 -1
View File
@@ -259,6 +259,36 @@ 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 }`.
## Top-up
`GET /embed/top-up` is the deployment's top-up dialog, where a user with a plan
buys add-on credit, served and framed like the upgrade: a transparent GUI page
covering the host with no box or close button, allowing `payment` and
`otp-credentials`, with Escape left to the dialog. Its fragment names `user`.
puter.js frames it for the **Add credits** item of `<puter-user-menu>`, which
shows only when whoami reports `subscribed: true` and no `team` paying for the
account. On a page that can't frame the GUI, the item opens the dashboard with
`?topup=` in a new tab instead.
The GUI reports `unavailable` unless its session belongs to `user`, the account
has no verification or password change waiting, and the deployment defines
`window.UITopUp` with `UITopUp.embeddable === true`. Otherwise it runs:
```js
new UITopUp({ onClose }).open();
```
`open()` resolves once the dialog has loaded the account and shows what it can
buy, and `onClose({ toppedUp })` 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', toppedUp }`
and the page removes the frame. When the frame can't show the top-up, puter.js
opens the dashboard's top-up in a new tab, as it does the upgrade.
While the top-up shows, a call refused for funds still rejects but opens no
usage limit dialog over it, and `requestUpgrade()` frames nothing over it: it
resolves `{ upgraded: false }` once the top-up closes.
## Email confirmation
`GET /embed/request-email-confirmation` walks a first-party page's user through
@@ -299,7 +329,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 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 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/modules/UI.showTopUp.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
```
@@ -36,9 +36,9 @@ import type {
/**
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
* instead of opening `/action/<action>` in a window, plus the upgrade dialog,
* which it would otherwise open in a dashboard tab, and the email confirmation,
* which it otherwise can't show.
* instead of opening `/action/<action>` in a window, plus the upgrade and
* top-up dialogs, 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',
@@ -46,6 +46,7 @@ export const FRAMED_POPUP_ACTIONS = [
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
] as const;
+2 -1
View File
@@ -414,6 +414,7 @@ describe('PuterServer host header validation', () => {
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'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}`;
@@ -447,7 +448,7 @@ describe('PuterServer host header validation', () => {
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Request-Email-Confirmation'])(
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Top-Up', '/embed/top-up/', '/embed/Request-Email-Confirmation'])(
'lets a first-party page frame only the exact spelling of %s',
async (spelling) => {
const res = await request(spelling, {
@@ -69,6 +69,7 @@ beforeAll(async () => {
[sessionToken]: { uuid: sessionUser, username: 'session-user' },
'app-token-session-user': { uuid: sessionUser, username: 'session-user' },
'app-token-other-user': { uuid: otherUser, username: 'other-user' },
'app-token-subscriber': { uuid: sessionUser, username: 'session-user', subscribed: true },
};
// The SDK on a sibling subdomain sends credentialed cross-origin requests.
app.use(['/whoami', '/profile', '/rao'], (req, res, next) => {
@@ -513,6 +514,81 @@ describe('embedded app browser', () => {
}
});
it('offers Add credits to an account with a plan, and frames the top-up over the page', async () => {
const context = await menuContext('app-token-subscriber');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').click();
const item = page.locator('puter-user-menu .add-credits');
await item.waitFor();
expect(await item.textContent()).toBe('Add credits');
// Under Usage, in the same row style as the links around it.
expect(await page.locator('puter-user-menu .usage + .add-credits').count()).toBe(1);
expect(await item.evaluate(el => getComputedStyle(el).height))
.toBe(await page.locator('puter-user-menu .feedback').evaluate(el => getComputedStyle(el).height));
await item.click();
const frame = page.locator('dialog.puter-popup-frame iframe');
await frame.waitFor({ state: 'attached' });
expect(await frame.getAttribute('src')).toBe(`${origin}/embed/top-up#user=${sessionUser}`);
expect(await frame.getAttribute('allow')).toBe('payment; otp-credentials');
expect(await page.locator('puter-user-menu .menu').isHidden()).toBe(true);
} finally {
guestApps = [];
await context.close();
}
});
it('returns keyboard focus to the account button when Add credits is chosen', async () => {
const context = await menuContext('app-token-subscriber');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').focus();
await page.keyboard.press('Enter');
await page.locator('puter-user-menu .add-credits').waitFor();
await page.locator('puter-user-menu .add-credits').focus();
await page.keyboard.press('Enter');
await page.locator('dialog.puter-popup-frame iframe').waitFor({ state: 'attached' });
const focused = () => page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className ?? document.activeElement.tagName);
expect(await focused()).toBe('avatar');
// The dialog hands the focus back when it closes.
const fromFrame = (state) => page.evaluate(([guiOrigin, frameState]) => {
const source = document.querySelector('dialog.puter-popup-frame iframe').contentWindow;
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'puterPopupFrame', state: frameState }, origin: guiOrigin, source,
}));
}, [origin, state]);
await fromFrame('ready');
expect(await page.locator('dialog.puter-popup-frame').evaluate(el => el.open)).toBe(true);
await fromFrame('closed');
await page.locator('dialog.puter-popup-frame').waitFor({ state: 'detached' });
expect(await focused()).toBe('avatar');
} finally {
guestApps = [];
await context.close();
}
});
it('leaves Add credits out of the menu of an account without a plan', async () => {
const context = await menuContext('app-token-session-user');
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
await page.locator('puter-user-menu .avatar').click();
await page.locator('puter-user-menu .usage').waitFor();
expect(await page.locator('puter-user-menu .add-credits').isHidden()).toBe(true);
} finally {
guestApps = [];
await context.close();
}
});
it('closes the account menu when Tab leaves it', async () => {
const context = await menuContext('app-token-session-user');
try {
+61 -17
View File
@@ -30,6 +30,7 @@ export const FRAMED_POPUP_ACTIONS = new Set([
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
]);
@@ -39,17 +40,18 @@ const HOST_USER_ACTIONS = new Set([
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
]);
// Actions framed over the whole page with no box, which their own dialog's
// scrim covers.
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'top-up', '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
// Modal UI the upgrade and top-up dialogs 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"]';
@@ -251,6 +253,23 @@ export const canShowUpgradeFrame = (win, session) =>
!session.requires_card_verification &&
!session.requires_password_change;
/**
* Whether a framed top-up can show for the GUI session `session` (its
* `/whoami`): the deployment has a top-up 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 canShowTopUpFrame = (win, session) =>
win.UITopUp?.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
@@ -300,19 +319,18 @@ export const framedUpgradeDetails = (hash) => {
};
/**
* Shows the deployment's upgrade dialog in a framed popup and tells the
* Shows one of the deployment's dialogs 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.
* still hides the frame. Resolves what the dialog reports when it 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 {(closed: (result: boolean) => void) => unknown} open - opens the dialog, which calls `closed` when it closes
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolve, reject) => {
const showFramedDialog = (win, open, tell) => new Promise((resolve, reject) => {
let settled = false;
let seen = false;
let goneTimer = null;
@@ -345,21 +363,47 @@ export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolv
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(() => open((result) => settle(() => resolve(result))))
.then(() => {
check();
ready();
}, (e) => settle(() => reject(e)));
});
/**
* Shows the deployment's upgrade dialog in a framed popup. Resolves whether
* the user upgraded, once the dialog closes.
*
* @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) => showFramedDialog(win, (closed) => {
const dialog = new win.UIUpgradeAccount({
attribution: Object.keys(attribution).length ? attribution : null,
onClose: (result) => closed(result?.upgraded === true),
});
return dialog.open_as_window();
}, tell);
/**
* Shows the deployment's top-up dialog in a framed popup. Resolves whether
* the user bought credit, once the dialog closes.
*
* @param {Window} win
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
const dialog = new win.UITopUp({
onClose: (result) => closed(result?.toppedUp === true),
});
return dialog.open();
}, tell);
/**
* 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
+95
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
POPUP_FRAME_MSG,
canShowEmailConfirmationFrame,
canShowTopUpFrame,
canShowUpgradeFrame,
closeAccountChecksOnEscape,
confirmEmailInFrame,
@@ -11,6 +12,7 @@ import {
framedPopupParams,
framedUpgradeDetails,
framesHostUser,
showFramedTopUp,
showFramedUpgrade,
startPopupFrame,
} from './popupFrame.js';
@@ -50,6 +52,7 @@ describe('framedPopupAction', () => {
['/embed/show-save-file-picker', 'show-save-file-picker'],
['/embed/show-directory-picker', 'show-directory-picker'],
['/embed/upgrade', 'upgrade'],
['/embed/top-up', 'top-up'],
['/embed/request-email-confirmation', 'request-email-confirmation'],
['/embed/apps', null],
['/embed/feedback', null],
@@ -85,6 +88,7 @@ describe('framesHostUser', () => {
['show-save-file-picker', true],
['show-directory-picker', true],
['upgrade', true],
['top-up', true],
['request-email-confirmation', true],
['sign-in', false],
[null, false],
@@ -133,6 +137,34 @@ describe('canShowUpgradeFrame', () => {
});
});
describe('canShowTopUpFrame', () => {
const win = { UITopUp: 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(canShowTopUpFrame(win, session)).toBe(true);
});
it('needs a top-up dialog that reports its close, whatever the upgrade dialog does', () => {
expect(canShowTopUpFrame({}, session)).toBe(false);
expect(canShowTopUpFrame({ UITopUp: function () {} }, session)).toBe(false);
expect(canShowTopUpFrame({ UIUpgradeAccount: Object.assign(function () {}, { embeddable: true }) }, 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(canShowTopUpFrame(win, { ...session, [check]: true })).toBe(false);
});
it('needs a session', () => {
expect(canShowTopUpFrame(win, null)).toBe(false);
});
});
describe('canShowEmailConfirmationFrame', () => {
const session = { uuid: 'u-1' };
@@ -361,6 +393,64 @@ describe('showFramedUpgrade', () => {
});
});
describe('showFramedTopUp', () => {
const topUpWindow = (open) => {
const events = new EventTarget();
return {
document,
innerWidth: 1280,
innerHeight: 800,
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
MutationObserver: window.MutationObserver,
UITopUp: function (options) {
this.open = () => open(options);
},
};
};
afterEach(() => {
vi.useRealTimers();
});
it('tells the page it is ready once open, and resolves whether credit was bought', async () => {
const tell = vi.fn();
let options;
const result = showFramedTopUp(topUpWindow(async (o) => {
options = o;
}), tell);
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
expect(tell).toHaveBeenCalledTimes(1);
options.onClose({ toppedUp: true });
await expect(result).resolves.toBe(true);
});
it('resolves false for a close without a purchase', async () => {
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
});
it('resolves false once the dialog is gone without reporting a close', async () => {
vi.useFakeTimers();
const root = document.createElement('div');
root.setAttribute('aria-modal', 'true');
const result = showFramedTopUp(topUpWindow(async () => {
document.body.append(root);
}), vi.fn());
await vi.advanceTimersByTimeAsync(0);
root.remove();
await vi.advanceTimersByTimeAsync(600);
await expect(result).resolves.toBe(false);
});
it('rejects when the dialog fails to open', async () => {
await expect(showFramedTopUp(topUpWindow(async () => {
throw new Error('no dialog');
}), vi.fn())).rejects.toThrow('no dialog');
});
});
describe('startPopupFrame', () => {
it('does nothing without a framing page to answer', () => {
const win = frameWindow();
@@ -396,6 +486,11 @@ describe('startPopupFrame', () => {
);
});
it('lets the framing page show through a top-up frame', () => {
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/top-up' } }, HOST);
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
});
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);
+20
View File
@@ -49,6 +49,7 @@ import { isAttestedOrigin } from './helpers/attestedOrigin.js';
import { embedHostUser } from './helpers/embedHostUser.js';
import {
canShowEmailConfirmationFrame,
canShowTopUpFrame,
canShowUpgradeFrame,
closeAccountChecksOnEscape,
confirmEmailInFrame,
@@ -57,6 +58,7 @@ import {
framedPopupParams,
framedUpgradeDetails,
framesHostUser,
showFramedTopUp,
showFramedUpgrade,
startPopupFrame,
} from './helpers/popupFrame.js';
@@ -1048,6 +1050,23 @@ const postAuthActions = async (action) => {
window.tell_popup_frame('closed', { upgraded });
}
// -------------------------------------------------------------------------------------
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
// which hears whether the user bought credit once the dialog closes.
// -------------------------------------------------------------------------------------
if ( action === 'top-up' && window.embedded_in_frame ) {
let toppedUp;
try {
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
} catch (e) {
// A dialog that failed to open leaves the page to open the top-up in a tab.
console.error('top-up action failed', e);
window.tell_popup_frame('unavailable');
return;
}
window.tell_popup_frame('closed', { toppedUp });
}
// -------------------------------------------------------------------------------------
// Action: request-email-confirmation — puter.ui.requestEmailConfirmation() on the
// first-party page framing it, which hears whether the email is confirmed once the
@@ -1841,6 +1860,7 @@ window.initgui = async function (options) {
!host_user ||
session_user !== host_user ||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
(action === 'top-up' && !canShowTopUpFrame(window, session)) ||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
) {
window.tell_popup_frame('unavailable');
+10 -7
View File
@@ -297,7 +297,9 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
const removal = new MutationObserver(() => {
if ( ! dialog.isConnected ) dismiss();
});
const restoreFocus = document.activeElement;
// Down through shadow roots: a component's host can't take the focus back itself.
let restoreFocus = document.activeElement;
while ( restoreFocus?.shadowRoot?.activeElement ) restoreFocus = restoreFocus.shadowRoot.activeElement;
const wait = (ms) => {
clearTimeout(timer);
timer = setTimeout(() => finish(null), ms);
@@ -454,12 +456,13 @@ 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.
// The upgrade and the top-up have no box: each 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 },
'top-up': { allow: 'payment; otp-credentials', escapeDismisses: false },
};
/**
@@ -476,8 +479,8 @@ const UNBOXED_POPUP_FRAMES = {
* @template T
* @param {Object} opts
* @param {string} opts.guiOrigin
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'request-email-confirmation'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade or the email confirmation
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'top-up' | 'request-email-confirmation'} opts.action
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade, the top-up or the email confirmation
* @param {string} opts.title - the frame's accessible name
* @param {string} [opts.closeLabel] - puts a close button on the frame
* @param {(data?: Record<string, unknown>) => T} opts.closed
+3 -2
View File
@@ -26,7 +26,7 @@ import { showUsageLimitDialog } from '../modules/UsageLimitDialog.js';
*
* @typedef {{
* env?: string,
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean },
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean, topUpShowing_?: () => boolean },
* authToken?: string,
* appID?: string,
* getAppIDFromAuthToken?: (token: unknown) => string | null,
@@ -129,8 +129,9 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
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.
// too, and a dialog over it, or over a top-up, would block it.
if ( puter.ui?.upgradeFrameShowing_?.() === true ) return;
if ( puter.ui?.topUpShowing_?.() === 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;
@@ -217,6 +217,14 @@ describe('promptIfUpgradeRequired', () => {
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
});
it('shows no dialog over a top-up the page already shows', () => {
globalThis.puter.ui.topUpShowing_ = () => 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' });
+71 -7
View File
@@ -5,7 +5,7 @@ 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 { topUpUrl, upgradeUrl } from './UsageLimitDialog.js';
import { checkPermissions } from './perms/lib/holds.js';
@@ -538,6 +538,9 @@ export class UIModule extends EventListener {
// The framed upgrade showing, which a call made while it's open joins.
#upgradeFrame = null;
// The top-up showing, framed or about to open in a tab, which a call made meanwhile joins.
#topUpFrame = null;
// The framed email confirmation showing, which a call made while it's open joins.
#emailConfirmationFrame = null;
@@ -1803,13 +1806,20 @@ export class UIModule extends EventListener {
/**
* 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.
* showing settles with it, so a payment in progress isn't replaced, and
* one made while the top-up shows resolves `{ upgraded: false }` once
* that closes.
*
* @param {{ reason?: string, method?: string }} details
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
*/
#showUpgradeFrame ({ reason, method }) {
if ( this.#upgradeFrame ) return this.#upgradeFrame;
// A second full-page frame would cover the top-up, and a payment in it.
if ( this.#topUpFrame ) {
const notUpgraded = () => ({ upgraded: false });
return this.#topUpFrame.then(notUpgraded, notUpgraded);
}
this.#upgradeFrame = (async () => {
try {
const user = await this.#frameUser();
@@ -1825,6 +1835,9 @@ export class UIModule extends EventListener {
closed: (data) => ({ upgraded: data?.upgraded === true }),
onMessage: () => {},
});
// The page's cached whoami still reads it as having no plan,
// which is what the user menu offers Add credits by.
if ( framed?.upgraded ) user.subscribed = true;
if ( framed ) return framed;
await this.#openUpgradeTab(upgradeUrl({ reason, method, appID: appIDOf(puter) }));
return { upgraded: false };
@@ -1836,14 +1849,65 @@ export class UIModule extends EventListener {
}
/**
* 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.
* Whether {@link showTopUp_} is showing the top-up in a frame, about to,
* or asking to open it in a tab instead.
*
* @internal
* @returns {boolean}
*/
topUpShowing_ () {
return this.#topUpFrame !== null;
}
/**
* Shows the top-up flow, where a user with a plan buys add-on credit. On
* a signed-in first-party page one label under the Puter origin, it shows
* in a frame over the page, and the promise resolves once it closes. When
* the frame can't show it, and on any other website, it opens on the
* dashboard in a new tab and the promise resolves `{ toppedUp: false }`.
* A call made while one is showing settles with it, so a payment in
* progress isn't replaced.
*
* @internal
* @returns {Promise<{ toppedUp: boolean }>}
*/
showTopUp_ () {
if ( this.#topUpFrame ) return this.#topUpFrame;
const showing = (async () => {
// Not awaited where no frame can show, so the tab opens within the click.
const user = this.#canFrameForUser() ? await this.#frameUser() : null;
const framed = user && await showPopupFrame({
guiOrigin: puter.defaultGUIOrigin,
action: 'top-up',
fragment: { user: user.uuid },
title: 'Add credits',
closed: (data) => ({ toppedUp: data?.toppedUp === true }),
onMessage: () => {},
});
if ( framed ) return framed;
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
return { toppedUp: false };
})();
this.#topUpFrame = showing;
// Cleared from outside: the steps above can fail before their first
// await, which is before the field is set.
const done = () => {
if ( this.#topUpFrame === showing ) this.#topUpFrame = null;
};
showing.then(done, done);
return showing;
}
/**
* Opens the upgrade or top-up 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
* @param {string} [message] - what the consent dialog asks
* @returns {Promise<void>}
*/
#openUpgradeTab (url) {
#openUpgradeTab (url, message = 'This website wants to open the Puter upgrade page.') {
if ( hasUserActivation() && window.open(url, '_blank') ) return Promise.resolve();
return new Promise((resolve) => {
let consent = null;
@@ -1853,7 +1917,7 @@ export class UIModule extends EventListener {
};
try {
consent = PuterDialog.show({
message: 'This website wants to open the Puter upgrade page.',
message,
onContinue: () => {
window.open(url, '_blank');
answered();
@@ -87,6 +87,26 @@ describe('ui.requestUpgrade on a signed-in first-party page', () => {
expect(window.open).not.toHaveBeenCalled();
});
it('marks the page\'s cached account as having a plan once the user upgrades', async () => {
const account = { uuid: USER, subscribed: false };
puter.whoamiCache_ = Promise.resolve(account);
const ui = makeUI();
let result = ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await result;
expect(account.subscribed).toBe(false);
result = ui.requestUpgrade(DETAILS);
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
await result;
expect(account.subscribed).toBe(true);
});
it('says the upgrade is showing from the call until it closes', async () => {
const ui = makeUI();
expect(ui.upgradeFrameShowing_()).toBe(false);
@@ -0,0 +1,239 @@
// @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';
// On the deployment the page belongs to, where the frame would have shown it.
const TOP_UP_TAB = `${GUI_ORIGIN}/dashboard/?topup=#usage`;
const topUpDialog = () => document.querySelector('dialog.puter-popup-frame');
const topUpFrame = () => topUpDialog()?.querySelector('iframe') ?? null;
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(topUpFrame().src).hash.slice(1)));
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
data, origin: GUI_ORIGIN, source: topUpFrame().contentWindow,
}));
const makeUI = (env = 'web') => new UIModule(
{ env, authToken: 'token-1', APIOrigin: 'https://api.puter.test', 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 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.showTopUp_ that fails before anything shows', () => {
it('stops saying a top-up is showing, and tries again on the next call', async () => {
// No scheme, so the dashboard's address can't be built.
fakePuter({ guiOrigin: 'puter.test' });
const ui = makeUI();
await expect(ui.showTopUp_()).rejects.toThrow();
expect(ui.topUpShowing_()).toBe(false);
fakePuter();
const result = ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toEqual({ toppedUp: false });
});
});
describe('ui.showTopUp_ on a signed-in first-party page', () => {
it('shows the top-up in a frame over the page and resolves what the dialog reports', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
expect(fragment()).toEqual({ user: USER });
// It covers the page, and the dialog inside draws the scrim.
expect(topUpDialog().querySelector('.puter-popup-frame-box')).toBeNull();
expect(topUpDialog().querySelector('.puter-popup-frame-close')).toBeNull();
expect(topUpFrame().getAttribute('allow')).toBe('payment; otp-credentials');
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
expect(topUpDialog().open).toBe(true);
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
await expect(result).resolves.toEqual({ toppedUp: true });
expect(topUpFrame()).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});
it('resolves toppedUp false when the dialog closes without a purchase', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await expect(result).resolves.toEqual({ toppedUp: false });
});
it('says a top-up is showing until it closes', async () => {
const ui = makeUI();
expect(ui.topUpShowing_()).toBe(false);
const result = ui.showTopUp_();
expect(ui.topUpShowing_()).toBe(true);
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
expect(ui.topUpShowing_()).toBe(true);
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
await result;
expect(ui.topUpShowing_()).toBe(false);
});
it('leaves Escape on the page to the dialog once it shows', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
topUpDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
expect(topUpFrame()).not.toBeNull();
expect(await settled(result)).toBe(PENDING);
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: false });
await expect(result).resolves.toEqual({ toppedUp: false });
});
it('settles a call made while the top-up shows with it, without replacing it', async () => {
const ui = makeUI();
const first = ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
const frame = topUpFrame();
const second = ui.showTopUp_();
expect(second).toBe(first);
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
await expect(second).resolves.toEqual({ toppedUp: true });
// A later call shows a new one.
ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
expect(topUpFrame()).not.toBe(frame);
});
it('opens the dashboard top-up in a tab when the frame cannot show it', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
await expect(result).resolves.toEqual({ toppedUp: false });
expect(topUpFrame()).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
});
it('asks for a click before opening the tab once the original click has expired', async () => {
const result = makeUI().showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).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 top-up page.');
consent.shadowRoot.querySelector('#launch-auth-popup').click();
await expect(result).resolves.toEqual({ toppedUp: false });
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
});
});
describe('ui.showTopUp_ elsewhere on the web', () => {
it.each([
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
['a signed-out first-party page', { signedIn: false }],
])('opens the dashboard top-up in a tab within the click on %s', async (_label, options) => {
fakePuter(options);
const result = makeUI().showTopUp_();
// Before anything is awaited, so the browser still counts the click.
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.open).toHaveBeenCalledWith(`${globalThis.puter.defaultGUIOrigin}/dashboard/?topup=#usage`, '_blank');
expect(topUpFrame()).toBeNull();
await expect(result).resolves.toEqual({ toppedUp: false });
});
});
describe('ui.requestUpgrade beside the top-up', () => {
it('waits for the top-up showing instead of framing the upgrade over it', async () => {
const ui = makeUI();
const toppingUp = ui.showTopUp_();
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
const upgrading = ui.requestUpgrade({ reason: 'funds' });
await expect(settled(upgrading)).resolves.toBe(PENDING);
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
await expect(toppingUp).resolves.toEqual({ toppedUp: true });
await expect(upgrading).resolves.toEqual({ upgraded: false });
expect(document.querySelector('dialog.puter-popup-frame')).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});
it('still asks before opening the upgrade tab in its own words', async () => {
const result = makeUI().requestUpgrade({ reason: 'funds' });
const frame = () => document.querySelector('dialog.puter-popup-frame iframe');
await vi.waitFor(() => expect(frame()).not.toBeNull());
expect(new URL(frame().src).pathname).toBe('/embed/upgrade');
setActivation(false);
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'puterPopupFrame', state: 'unavailable' }, origin: GUI_ORIGIN, source: frame().contentWindow,
}));
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
const consent = document.querySelector('puter-dialog');
expect(consent.shadowRoot.querySelector('.about').textContent)
.toBe('This website wants to open the Puter upgrade page.');
consent.shadowRoot.querySelector('#launch-auth-popup-cancel').click();
await expect(result).resolves.toEqual({ upgraded: false });
});
});
@@ -28,6 +28,19 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
return url.href;
};
/**
* The dashboard page that opens the top-up flow, for buying add-on credit.
*
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
* @returns {string}
*/
export const topUpUrl = (guiOrigin) => {
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
url.searchParams.set('topup', '');
url.hash = 'usage';
return url.href;
};
class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
/**
* @param {string} message Plain-text line saying what was refused.
+2
View File
@@ -343,6 +343,7 @@ The account button and Puter's app browser for a first-party app on a Puter subd
- Signed in, it shows the user's profile picture, or the username's initial when there is none. Clicking it opens a menu with:
- The user's picture, username and email. The email line is hidden when whoami leaves it out, such as for an app that can't read it.
- **Manage account**, **Usage** and **Open Puter**, links that open the GUI origin's `/dashboard/#account`, `/dashboard/#usage` and `/` in a new tab.
- **Add credits**, under Usage, only when whoami reports `subscribed: true` and no `team` that pays for the account. It shows the top-up dialog in a frame over the page (`/embed/top-up`) on a page that can frame the GUI, and otherwise opens it on the dashboard in a new tab.
- **Send feedback**, which calls `puter.ui.showFeedbackDialog()`.
- **Sign out**, which calls `puter.auth.signOut()`.
- The menu opens with focus on its first item. ArrowUp and ArrowDown move between items and wrap around, and Home and End jump to the first and last.
@@ -362,6 +363,7 @@ The account button and Puter's app browser for a first-party app on a Puter subd
| `account-label` | `Account` | Account button label, read as `Account: <username>` |
| `manage-account-label` | `Manage account` | Menu link to the account settings |
| `usage-label` | `Usage` | Menu link to the usage page |
| `add-credits-label` | `Add credits` | Menu item that opens the top-up dialog |
| `open-puter-label` | `Open Puter` | Menu link to Puter |
| `feedback-label` | `Send feedback` | Menu item that opens the feedback dialog |
| `theme` | follows the OS | `light` or `dark`, for the component and the app browser in it |
@@ -5,7 +5,8 @@
* Signed out (a temporary account counts as signed out), it shows a Sign in
* button. Signed in, it shows the user's profile picture (or initial), which
* opens a menu with the account, links to manage it, see its usage and open
* Puter, Send feedback, and Sign out. On a page one subdomain under
* Puter, Add credits for an account with a plan, Send feedback, and Sign out.
* On a page one subdomain under
* the Puter origin, an Apps button opens Puter's app browser in a panel under
* it, for the same user: signed out, or signed in as an account other than
* the Puter session's, the browser shows only recommended apps. The browser
@@ -13,8 +14,8 @@
* Renders nothing inside the Puter desktop.
*
* Attributes: theme, apps-label, sign-in-label, sign-out-label, close-label,
* account-label, manage-account-label, usage-label, open-puter-label,
* feedback-label
* account-label, manage-account-label, usage-label, add-credits-label,
* open-puter-label, feedback-label
* Parts: apps-button, avatar, sign-in, menu, apps-panel
* Events: signin (detail.user), signout
*/
@@ -37,6 +38,7 @@ const LABELS = {
'account-label': 'Account',
'manage-account-label': 'Manage account',
'usage-label': 'Usage',
'add-credits-label': 'Add credits',
'open-puter-label': 'Open Puter',
'feedback-label': 'Send feedback',
};
@@ -48,6 +50,9 @@ const MENU_LINKS = [
{ className: 'open-puter', label: 'open-puter-label', path: '/' },
];
// Sits under Usage, and shows only for an account with a plan.
const ADD_CREDITS_ITEM = '<button type="button" class="menu-item add-credits" role="menuitem" hidden></button>';
// Gap between a panel and the viewport edge, and between its caret and corners.
const EDGE = 8;
const CARET_INSET = 20;
@@ -513,7 +518,7 @@ class PuterUserMenu extends PuterWebComponent {
<span class="menu-email" hidden></span>
</span>
</div>
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>`).join('')}
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>${link.className === 'usage' ? ADD_CREDITS_ITEM : ''}`).join('')}
<button type="button" class="menu-item feedback" role="menuitem"></button>
<div class="menu-separator" role="separator"></div>
<button type="button" class="menu-item sign-out" role="menuitem"></button>
@@ -548,6 +553,7 @@ class PuterUserMenu extends PuterWebComponent {
for ( const link of MENU_LINKS ) {
this.$(`.${link.className}`).addEventListener('click', () => this._closePanels());
}
this.$('.add-credits').addEventListener('click', () => this._addCredits());
this.$('.feedback').addEventListener('click', () => this._sendFeedback());
this.$('.sign-out').addEventListener('click', () => this._signOut());
this.$('.apps-close').addEventListener('click', () => this._closePanels({ returnFocus: true }));
@@ -581,6 +587,7 @@ class PuterUserMenu extends PuterWebComponent {
close.title = this._label('close-label');
this.$('.sign-in').textContent = this._label('sign-in-label');
this.$('.sign-out').textContent = this._label('sign-out-label');
this.$('.add-credits').textContent = this._label('add-credits-label');
this.$('.feedback').textContent = this._label('feedback-label');
for ( const link of MENU_LINKS ) {
this.$(`.${link.className} .menu-label`).textContent = this._label(link.label);
@@ -598,6 +605,19 @@ class PuterUserMenu extends PuterWebComponent {
this.$('.avatar').setAttribute('aria-label', username ? `${account}: ${username}` : account);
}
/**
* Add-on credit is sold to accounts with a plan, which whoami reports where there are
* plans. A seat's plan is its team's, and so is paying for its usage.
*/
_renderAddCredits () {
// The SDK reads whoami into a new object when the same token is set again,
// and an upgrade made on the page marks that one.
const cached = globalThis.puter.whoami;
const user = cached?.uuid && cached.uuid === this.#user?.uuid ? cached : this.#user;
const isSeat = typeof user?.team?.uid === 'string' && user.team.uid !== '';
this.$('.add-credits').hidden = user?.subscribed !== true || isSeat;
}
_renderState () {
const signedIn = this._signedIn();
const username = this._username();
@@ -614,6 +634,7 @@ class PuterUserMenu extends PuterWebComponent {
const email = typeof this.#user?.email === 'string' ? this.#user.email : '';
this.$('.menu-email').textContent = email;
this.$('.menu-email').hidden = !email;
this._renderAddCredits();
for ( const link of MENU_LINKS ) {
this.$(`.${link.className}`).href = new URL(link.path, globalThis.puter.defaultGUIOrigin).href;
}
@@ -705,13 +726,15 @@ class PuterUserMenu extends PuterWebComponent {
*/
_openMenu ({ fromKeyboard = false } = {}) {
const menu = this.$('.menu');
// An upgrade made on the page since the account loaded gives it a plan.
this._renderAddCredits();
this._showPanel(menu);
(fromKeyboard ? this.$('.menu-item') : menu).focus({ preventScroll: true });
}
/** Moves focus between menu items for an arrow, Home or End key; false for any other key. */
_moveMenuFocus (key) {
const items = [...this.$$('.menu .menu-item')];
const items = [...this.$$('.menu .menu-item:not([hidden])')];
const current = items.indexOf(this.shadowRoot.activeElement);
const last = items.length - 1;
const next = {
@@ -786,6 +809,13 @@ class PuterUserMenu extends PuterWebComponent {
);
}
_addCredits () {
// Focus goes back to the account button, where the dialog leaves it when it closes.
this._closePanels({ returnFocus: true });
// Called inside the click, so a tab it falls back to isn't blocked.
void globalThis.puter.ui.showTopUp_();
}
_sendFeedback () {
this._closePanels();
void globalThis.puter.ui.showFeedbackDialog();
@@ -26,6 +26,7 @@ const fakePuter = ({ token = null, user = null, picture = null } = {}) => {
},
ui: {
showFeedbackDialog: vi.fn(() => new Promise(() => {})),
showTopUp_: vi.fn(() => new Promise(() => {})),
},
auth: {
getProfilePicture: vi.fn(async () => currentPicture),
@@ -225,6 +226,80 @@ describe('<puter-user-menu>', () => {
expect(shown(el, '.menu')).toBe(false);
});
it('offers Add credits, under Usage, to an account with a plan', async () => {
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
vi.stubGlobal('puter', sdk);
const el = await mount();
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(true);
expect(part(el, '.add-credits').textContent).toBe('Add credits');
expect(part(el, '.usage').nextElementSibling).toBe(part(el, '.add-credits'));
part(el, '.add-credits').click();
expect(sdk.ui.showTopUp_).toHaveBeenCalledTimes(1);
expect(shown(el, '.menu')).toBe(false);
});
it.each([
['an account without a plan', { ...USER, subscribed: false }],
['a server that reports no plans', USER],
['a seat whose team pays for it', { ...USER, subscribed: true, team: { uid: 'team-1', name: 'Acme' } }],
])('leaves Add credits out for %s, and out of the arrow keys', async (_label, user) => {
vi.stubGlobal('puter', fakePuter({ token: 't', user }));
const el = await mount();
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(false);
part(el, '.usage').focus();
part(el, '.usage').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(el.shadowRoot.activeElement).toBe(part(el, '.open-puter'));
});
it('offers Add credits the next time the menu opens after an upgrade made on the page', async () => {
const account = { ...USER, subscribed: false };
vi.stubGlobal('puter', fakePuter({ token: 't', user: account }));
const el = await mount();
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(false);
part(el, '.avatar').click();
// What the SDK does to its cached whoami when a framed upgrade reports a plan.
account.subscribed = true;
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(true);
});
it('offers Add credits after an upgrade marks a whoami read again for the same token', async () => {
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: false } });
vi.stubGlobal('puter', sdk);
const el = await mount();
// Setting the token again reads whoami into a new object, which the upgrade then marks.
const reread = { ...USER, subscribed: false };
sdk.signInAs('t', reread);
sdk.whoami = reread;
await settle();
reread.subscribed = true;
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(true);
// Never another account's plan.
part(el, '.avatar').click();
sdk.whoami = { ...OTHER, subscribed: true };
part(el, '.avatar').click();
expect(shown(el, '.add-credits')).toBe(false);
});
it('stops offering Add credits once the sign-in changes to an account without a plan', async () => {
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
vi.stubGlobal('puter', sdk);
const el = await mount();
expect(part(el, '.add-credits').hidden).toBe(false);
sdk.signInAs('other-token', OTHER);
await settle();
expect(part(el, '.add-credits').hidden).toBe(true);
});
it('moves focus through the menu with the arrow, Home and End keys', async () => {
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
const el = await mount();