mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c2915c955 | ||
|
|
af35783df4 | ||
|
|
37e1bf35ca | ||
|
|
52fb91e8e2 | ||
|
|
67621d996a | ||
|
|
cb214f28cc | ||
|
|
8ed439e897 | ||
|
|
672a704193 | ||
|
|
35ae464d86 | ||
|
|
79f24ed3df | ||
|
|
f30d2134b1 | ||
|
|
c24c8589cb | ||
|
|
2f8f81e621 | ||
|
|
7d02c7f622 | ||
|
|
d2bbe2b5e2 | ||
|
|
3ff70ef97f | ||
|
|
f791024b2e |
No files matched your search
@@ -14,11 +14,25 @@ to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
|
||||
private apps the user can't open, or a failed token request) open `/app/<name>`
|
||||
on the GUI origin, cut off from the embed. Website shortcuts open their external
|
||||
destination with no opener.
|
||||
The embed follows the OS's light or dark setting. A host with a fixed theme adds
|
||||
`theme=dark` or `theme=light` to the fragment (`#user=<uuid>&theme=dark`), since
|
||||
Chromium doesn't pass an iframe's `color-scheme` on to the page inside it.
|
||||
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
|
||||
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
|
||||
|
||||
## Session and framing
|
||||
|
||||
The embed shows the GUI session only to the user the host page is signed in as.
|
||||
The host names that user's `uuid` (from `puter.auth.getUser()`) in the iframe
|
||||
URL's fragment, as `/embed/apps#user=<uuid>`. The embed reads the session, asks
|
||||
`/whoami` which user it belongs to, and shows the signed-out view unless the two
|
||||
match. So a host that names no user, a malformed one, or a user other than the
|
||||
session's, gets the signed-out view, and so does a session the API refuses. A
|
||||
failed `/whoami` check shows the error state. The fragment never reaches the
|
||||
server and can only narrow what the embed shows: naming the session's own user
|
||||
gives the same view as a direct visit to the GUI. Changing the fragment of a
|
||||
loaded embed reloads it.
|
||||
|
||||
The embed reads the existing GUI session from local storage on its own origin.
|
||||
If that storage holds no session, the embed gets the session from the GUI origin's session cookie through
|
||||
`/get-gui-token`. This matters in Safari, which gives a framed origin separate
|
||||
@@ -29,7 +43,7 @@ does not create accounts, show a login dialog, or accept a token from its parent
|
||||
Being signed in on a subdomain alone does not create a session on the main GUI
|
||||
origin.
|
||||
|
||||
Without a session, the embed shows the recommended apps (`RecommendedAppsService`)
|
||||
Without a session it can show, the embed shows the recommended apps (`RecommendedAppsService`)
|
||||
that the server put in the page, and nothing else: no installed apps, folders, or
|
||||
saved order, and no puter.js. Selecting one opens it at the same URL a signed-in
|
||||
launch uses, with no opener and no token on offer, so the app signs in on its own.
|
||||
@@ -58,11 +72,18 @@ and popups to escape the sandbox.
|
||||
|
||||
## Host integration
|
||||
|
||||
A first-party app that signs in with puter.js can place `<puter-user-menu>` from
|
||||
the SDK in its header instead (see `src/puter-js/src/ui/components.md`). It opens
|
||||
this embed with the signed-in user named and closes it when the app's sign-in
|
||||
changes. The rest of this section is for a host that builds the iframe itself.
|
||||
|
||||
The iframe can be shown whether or not the user is signed in. Create it when
|
||||
“More apps” is opened, and remove it on close so reopening loads current apps
|
||||
and preferences. The host owns the modal and close control;
|
||||
“More apps” is opened, naming the signed-in user in its fragment, and remove it
|
||||
on close so reopening loads current apps and preferences. Remove it when the
|
||||
host's own sign-in changes too, such as on sign-out, so an open embed doesn't
|
||||
keep the previous user. The host owns the modal and close control;
|
||||
there is no parent messaging API and no app list or credential is sent to it.
|
||||
The app browser fills its iframe and responds to its size.
|
||||
The app browser fills its iframe and responds to its size. It shows a spinner until its apps load.
|
||||
|
||||
This example assumes `puterOrigin` comes from the host app's deployment
|
||||
settings. Translate the button and frame labels using the host app's
|
||||
@@ -84,10 +105,14 @@ localization system.
|
||||
const container = document.querySelector('#apps-frame');
|
||||
const trigger = document.querySelector('#more-apps');
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
trigger.addEventListener('click', async () => {
|
||||
const url = new URL('/embed/apps', puterOrigin);
|
||||
// Signed out, the embed shows the recommended apps.
|
||||
const user = puter.auth.isSignedIn() ? await puter.auth.getUser().catch(() => null) : null;
|
||||
if (user) url.hash = `user=${encodeURIComponent(user.uuid)}`;
|
||||
const frame = document.createElement('iframe');
|
||||
frame.title = 'Your apps';
|
||||
frame.src = new URL('/embed/apps', puterOrigin).href;
|
||||
frame.src = url.href;
|
||||
container.replaceChildren(frame);
|
||||
dialog.showModal();
|
||||
});
|
||||
@@ -99,22 +124,48 @@ localization system.
|
||||
</script>
|
||||
```
|
||||
|
||||
## Implementation and checks
|
||||
## Implementation
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
|
||||
injects desktop actions. `appsEmbed.js` supplies new-tab launching through
|
||||
`launchAppInTab` (signed in) and `guestAppLaunchUrl` (signed out) in
|
||||
`src/gui/src/helpers/appTabLaunch.js`, which build the same app URL as
|
||||
`launch_app`, and loads only the embed entry, its SDK, and shared styles.
|
||||
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
|
||||
`launch_app`, and loads only the embed entry, its SDK, and shared styles. `embedHostUser` in
|
||||
`src/gui/src/helpers/embedHostUser.js` reads the host's user from the fragment, and
|
||||
`embedHostTheme.js` beside it applies the host's theme.
|
||||
The normal GUI webpack build emits `bundle.min.js`, `apps-embed.min.js`, and `feedback-embed.min.js`.
|
||||
The `/embed/apps` route puts the recommended list in the page's config as
|
||||
`guestApps`. No puter.js API or app-list endpoint changes are needed.
|
||||
|
||||
## Feedback dialog
|
||||
|
||||
`GET /embed/feedback` is the app feedback dialog (`puter.ui.showFeedbackDialog()`)
|
||||
for a first-party page, with the same framing rules, query redirect, and
|
||||
host-user binding as `/embed/apps`. It sends the feedback for the framing
|
||||
page's origin, which the server reads from the referrer and pins with
|
||||
`frame-ancestors`; opened top-level, it does nothing. Its page is transparent
|
||||
so the dialog's own scrim covers the host.
|
||||
|
||||
puter.js uses it on a page one label under the GUI origin when the page is
|
||||
signed in, framing it in a modal `<dialog>` that stays hidden until the embed
|
||||
is ready. The embed posts `{ msg: 'feedbackEmbed', state }` to its parent
|
||||
(`loading`, then `ready` or `unavailable`) and finally
|
||||
`{ msg: 'feedbackDialogClosed', sent }`. It reports `unavailable` without the
|
||||
named user's session, or while that user's email still needs confirming, and
|
||||
puter.js then uses the `/action/send-feedback` popup, which can sign in. So does
|
||||
a frame that never answers, such as one the host's CSP blocks. `feedbackEmbed.js`
|
||||
is the entry, sharing `embedSession` in `src/gui/src/helpers/embedSession.js`
|
||||
with the app browser; `showFeedbackEmbed` in `src/puter-js/src/lib/guiEmbed.js`
|
||||
is the host side.
|
||||
|
||||
## Checks
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
Chromium installed), and the backend route tests:
|
||||
|
||||
```sh
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser
|
||||
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
|
||||
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
|
||||
```
|
||||
@@ -27,7 +27,7 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { embedFrameAncestors } from './HomepageController.js';
|
||||
import { embedFrameAncestors, embedHostOrigin } from './HomepageController.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -550,3 +550,26 @@ describe('embedFrameAncestors', () => {
|
||||
).toBe("'self'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedHostOrigin', () => {
|
||||
const config = {
|
||||
origin: 'https://example.test',
|
||||
domain: 'example.test',
|
||||
api_base_url: 'https://backend.example.test',
|
||||
static_hosting_domain: 'site.example.test',
|
||||
};
|
||||
|
||||
it.each([
|
||||
['https://sheets.example.test/doc/1', 'https://sheets.example.test'],
|
||||
['https://backend.example.test/x', null],
|
||||
['https://api.example.test/', null],
|
||||
['https://someone.site.example.test/', null],
|
||||
['https://a.sheets.example.test/', null],
|
||||
['http://sheets.example.test/', null],
|
||||
['https://example.test/', null],
|
||||
['not a url', null],
|
||||
[undefined, null],
|
||||
])('%s frames as %s', (referer, expected) => {
|
||||
expect(embedHostOrigin(config, referer)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -61,32 +61,34 @@ export function appMetaDescription(text: string): string {
|
||||
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}…`;
|
||||
}
|
||||
|
||||
type EmbedFramingConfig = Pick<
|
||||
IConfig,
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'api_base_url'
|
||||
| 'static_hosting_domain'
|
||||
| 'static_hosting_domain_alt'
|
||||
| 'private_app_hosting_domain'
|
||||
| 'private_app_hosting_domain_alt'
|
||||
>;
|
||||
|
||||
/**
|
||||
* `frame-ancestors` for the app browser embed. A wildcard over `*.<domain>`
|
||||
* The origin of a first-party page that may frame an `/embed/*` page, named
|
||||
* exactly from the request's referrer, or null. A wildcard over `*.<domain>`
|
||||
* would also admit user-hosted sites whenever the hosting domains sit under it,
|
||||
* so the embedding page is named exactly, from the request's referrer. Only a
|
||||
* single label under `<domain>` qualifies, and not one this server answers
|
||||
* itself (the API, WebDAV, SDK and native-app hosts).
|
||||
* so only a single label under `<domain>` qualifies, and not one this server
|
||||
* answers itself (the API, WebDAV, SDK and native-app hosts).
|
||||
*/
|
||||
export function embedFrameAncestors(
|
||||
config: Pick<
|
||||
IConfig,
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'api_base_url'
|
||||
| 'static_hosting_domain'
|
||||
| 'static_hosting_domain_alt'
|
||||
| 'private_app_hosting_domain'
|
||||
| 'private_app_hosting_domain_alt'
|
||||
>,
|
||||
export function embedHostOrigin(
|
||||
config: EmbedFramingConfig,
|
||||
referer: string | undefined,
|
||||
): string {
|
||||
): string | null {
|
||||
const gui = new URL(config.origin ?? `https://${config.domain}`);
|
||||
let parent: URL;
|
||||
try {
|
||||
parent = new URL(referer ?? '');
|
||||
} catch {
|
||||
return "'self'";
|
||||
return null;
|
||||
}
|
||||
const host = parent.hostname.toLowerCase();
|
||||
const label = subdomainLabelOf(host, config.domain);
|
||||
@@ -105,7 +107,16 @@ export function embedFrameAncestors(
|
||||
!isReservedSubdomain(label) &&
|
||||
host !== new URL(config.api_base_url).hostname &&
|
||||
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
|
||||
return allowed ? `'self' ${parent.origin}` : "'self'";
|
||||
return allowed ? parent.origin : null;
|
||||
}
|
||||
|
||||
/** `frame-ancestors` for an `/embed/*` page: the GUI and {@link embedHostOrigin}. */
|
||||
export function embedFrameAncestors(
|
||||
config: EmbedFramingConfig,
|
||||
referer: string | undefined,
|
||||
): string {
|
||||
const host = embedHostOrigin(config, referer);
|
||||
return host ? `'self' ${host}` : "'self'";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,12 +168,20 @@ export class HomepageController extends PuterController {
|
||||
|
||||
router.get('/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/embed/apps', {}, async (req, res) => {
|
||||
// SDK bootstrap parameters must not change this page's account or API origin.
|
||||
// Checked on the raw URL; `req.query` drops pairs past the parser's limit.
|
||||
/**
|
||||
* Headers shared by the `/embed/*` pages; false when the request was
|
||||
* redirected instead. SDK bootstrap parameters must not change an
|
||||
* embed's account or API origin, so a query string is dropped. Checked
|
||||
* on the raw URL; `req.query` drops pairs past the parser's limit.
|
||||
*/
|
||||
const prepareEmbed = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
path: string,
|
||||
): boolean => {
|
||||
if (req.originalUrl.includes('?')) {
|
||||
res.redirect('/embed/apps');
|
||||
return;
|
||||
res.redirect(path);
|
||||
return false;
|
||||
}
|
||||
res.removeHeader('X-Frame-Options');
|
||||
res.setHeader(
|
||||
@@ -170,6 +189,11 @@ export class HomepageController extends PuterController {
|
||||
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return true;
|
||||
};
|
||||
|
||||
router.get('/embed/apps', {}, async (req, res) => {
|
||||
if (!prepareEmbed(req, res, '/embed/apps')) return;
|
||||
// Shown to visitors without a session; a signed-in embed fetches
|
||||
// its own list, so a failure here only costs signed-out visitors.
|
||||
const guestApps = await this.services.recommendedApps
|
||||
@@ -178,6 +202,20 @@ export class HomepageController extends PuterController {
|
||||
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
|
||||
});
|
||||
|
||||
router.get('/embed/feedback', {}, (req, res) => {
|
||||
if (!prepareEmbed(req, res, '/embed/feedback')) return;
|
||||
// The feedback is recorded against this origin, which
|
||||
// frame-ancestors has just pinned to the actual framing page.
|
||||
res.send(
|
||||
homepage.renderFeedbackEmbed({
|
||||
hostOrigin: embedHostOrigin(
|
||||
this.config,
|
||||
req.get('referer'),
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/settings', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
|
||||
@@ -187,9 +187,9 @@ describe('createUserSubdomainNotFound', () => {
|
||||
});
|
||||
|
||||
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`,
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `about`, `apps`,
|
||||
// `puter-app-icons`, etc. all pass through.
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
for (const sub of ['api', 'js', 'docs', 'about', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
@@ -865,13 +865,13 @@ describe('createNativeAppStatic', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(nodePath.join(tmpdir(), 'native-apps-'));
|
||||
mkdirSync(nodePath.join(root, 'editor', 'assets'), { recursive: true });
|
||||
mkdirSync(nodePath.join(root, 'about', 'assets'), { recursive: true });
|
||||
writeFileSync(
|
||||
nodePath.join(root, 'editor', 'index.html'),
|
||||
'<h1>editor</h1>',
|
||||
nodePath.join(root, 'about', 'index.html'),
|
||||
'<h1>about</h1>',
|
||||
);
|
||||
writeFileSync(
|
||||
nodePath.join(root, 'editor', 'assets', 'app.js'),
|
||||
nodePath.join(root, 'about', 'assets', 'app.js'),
|
||||
'console.log(1);',
|
||||
);
|
||||
mkdirSync(nodePath.join(root, 'docs', 'dist'), { recursive: true });
|
||||
@@ -893,7 +893,7 @@ describe('createNativeAppStatic', () => {
|
||||
|
||||
it('leaves a native app name alone on a host outside our domains', async () => {
|
||||
const req = staticReq({
|
||||
subdomains: ['com', 'example', 'editor'],
|
||||
subdomains: ['com', 'example', 'about'],
|
||||
path: '/index.html',
|
||||
});
|
||||
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
|
||||
@@ -907,7 +907,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
createNativeAppStatic(config),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
@@ -915,17 +915,24 @@ describe('createNativeAppStatic', () => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes through subdomains that are not native apps', async () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'api'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// `editor`, `markus` and `pdf` are web apps on standard host routing,
|
||||
// even with a folder of their own under the native apps root.
|
||||
it.each(['api', 'editor', 'markus', 'pdf'])(
|
||||
'passes %s through, as it is not a native app',
|
||||
async (sub) => {
|
||||
mkdirSync(nodePath.join(root, sub), { recursive: true });
|
||||
writeFileSync(nodePath.join(root, sub, 'index.html'), sub);
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', sub],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('passes through when there is no subdomain at all', async () => {
|
||||
const { next } = await runStatic(
|
||||
@@ -939,13 +946,13 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toEqual({
|
||||
path: '/index.html',
|
||||
root: nodePath.join(root, 'editor'),
|
||||
root: nodePath.join(root, 'about'),
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -954,11 +961,11 @@ describe('createNativeAppStatic', () => {
|
||||
const { out } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'EDITOR'],
|
||||
subdomains: ['localhost', 'puter', 'ABOUT'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent?.root).toBe(nodePath.join(root, 'editor'));
|
||||
expect(out.sent?.root).toBe(nodePath.join(root, 'about'));
|
||||
});
|
||||
|
||||
it('serves docs out of its dist/ subdirectory, not the app root', async () => {
|
||||
@@ -979,7 +986,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/assets',
|
||||
originalUrl: '/assets?v=2',
|
||||
}),
|
||||
@@ -995,7 +1002,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/assets/',
|
||||
}),
|
||||
);
|
||||
@@ -1007,7 +1014,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/nope.html',
|
||||
}),
|
||||
);
|
||||
@@ -1019,7 +1026,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
new Error('send failed'),
|
||||
@@ -1032,7 +1039,7 @@ describe('createNativeAppStatic', () => {
|
||||
runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/../../etc/passwd',
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -27,15 +27,7 @@ import { HttpError, isHttpError } from '../HttpError';
|
||||
import { isLocalWorkerHost } from './localWorkerProxy';
|
||||
|
||||
/** Native-app subdomains served via `nativeAppStatic`. */
|
||||
const NATIVE_APP_SUBDOMAINS = [
|
||||
'about',
|
||||
'developer',
|
||||
'docs',
|
||||
'editor',
|
||||
'markus',
|
||||
'pdf',
|
||||
'apps',
|
||||
] as const;
|
||||
const NATIVE_APP_SUBDOMAINS = ['about', 'developer', 'docs', 'apps'] as const;
|
||||
|
||||
/** Subset served out of a `dist/` subdirectory rather than the app root. */
|
||||
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
|
||||
@@ -368,12 +368,52 @@ describe('PuterServer host header validation', () => {
|
||||
expect(api.status).toBe(404);
|
||||
});
|
||||
|
||||
it('lets a deployment subdomain frame the feedback embed and names it', async () => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
const res = await request('/embed/feedback', {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `${parent}/sheet/1`,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['x-frame-options']).toBeUndefined();
|
||||
expect(res.headers['content-security-policy']).toBe(
|
||||
`frame-ancestors 'self' ${parent}`,
|
||||
);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.body).toContain('/dist/feedback-embed.min.js');
|
||||
expect(res.body).not.toContain('/dist/bundle.min.js');
|
||||
const config = JSON.parse(
|
||||
res.body.match(/id="feedback-embed-config">(.*?)<\/script>/)[1],
|
||||
);
|
||||
expect(config).toEqual({
|
||||
apiOrigin: `http://api.puter.localhost:${port}`,
|
||||
hostOrigin: parent,
|
||||
});
|
||||
|
||||
const hosted = await request('/embed/feedback', {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `http://someone.site.puter.localhost:${port}/`,
|
||||
});
|
||||
expect(hosted.headers['content-security-policy']).toBe("frame-ancestors 'self'");
|
||||
expect(
|
||||
JSON.parse(
|
||||
hosted.body.match(/id="feedback-embed-config">(.*?)<\/script>/)[1],
|
||||
).hostOrigin,
|
||||
).toBeNull();
|
||||
|
||||
const redirected = await request('/embed/feedback?puter.api_origin=https://other.test', {
|
||||
host: `puter.localhost:${port}`,
|
||||
});
|
||||
expect(redirected.status).toBe(302);
|
||||
expect(redirected.headers.location).toBe('/embed/feedback');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
|
||||
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
|
||||
['the API host', 'http://api.puter.localhost:PORT/'],
|
||||
['the WebDAV host', 'http://dav.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://editor.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://about.puter.localhost:PORT/'],
|
||||
['a nested subdomain', 'http://a.spreadsheet.puter.localhost:PORT/'],
|
||||
['another site', 'http://evil.test:PORT/'],
|
||||
['another port', 'http://spreadsheet.puter.localhost:1/'],
|
||||
|
||||
@@ -107,6 +107,31 @@ describe('app browser embed shell', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedback embed shell', () => {
|
||||
it('carries the framing page and API origin, and loads only its own script', () => {
|
||||
const html = makeService({
|
||||
api_base_url: 'https://api.deployment.test',
|
||||
gui_bundle: '/static/bundle.min.js?v=42',
|
||||
}).renderFeedbackEmbed({ hostOrigin: 'https://notes.deployment.test' });
|
||||
const configJson = html.match(/id="feedback-embed-config">(.*?)<\/script>/)![1];
|
||||
expect(JSON.parse(configJson)).toEqual({
|
||||
apiOrigin: 'https://api.deployment.test',
|
||||
hostOrigin: 'https://notes.deployment.test',
|
||||
});
|
||||
expect(html).toContain('<script defer src="/static/feedback-embed.min.js?v=42"></script>');
|
||||
expect(html).toContain('<link rel="stylesheet" href="/css/feedback-embed.css">');
|
||||
expect(html).toContain('<main id="feedback-embed"></main>');
|
||||
expect(html).not.toContain('bundle.min.js');
|
||||
expect(html).not.toContain('apps-embed');
|
||||
});
|
||||
|
||||
it('carries no framing page when none is allowed', () => {
|
||||
const html = makeService({}).renderFeedbackEmbed({ hostOrigin: null });
|
||||
const configJson = html.match(/id="feedback-embed-config">(.*?)<\/script>/)![1];
|
||||
expect(JSON.parse(configJson).hostOrigin).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/** Capture what the service sends, and return the rendered HTML. */
|
||||
const render = async (
|
||||
service: PuterHomepageService,
|
||||
|
||||
@@ -171,15 +171,50 @@ export class PuterHomepageService extends PuterService {
|
||||
renderAppBrowserEmbed({
|
||||
guestApps = [],
|
||||
}: { guestApps?: Array<Record<string, unknown>> } = {}): string {
|
||||
const configJson = JSON.stringify({
|
||||
apiOrigin: this.config.api_base_url,
|
||||
sdkUrl:
|
||||
this.config.env === 'dev'
|
||||
? '/sdk/puter.dev.js'
|
||||
: (this.config.gui_puterjs_bundle ??
|
||||
'https://js.puter.com/v2/'),
|
||||
guestApps,
|
||||
}).replace(/</g, '\\u003c');
|
||||
return this.#renderEmbed({
|
||||
rootId: 'app-browser',
|
||||
rootClass: 'dashboard',
|
||||
stylesheet: 'apps-embed.css',
|
||||
script: 'apps-embed.min.js',
|
||||
config: {
|
||||
apiOrigin: this.config.api_base_url,
|
||||
sdkUrl:
|
||||
this.config.env === 'dev'
|
||||
? '/sdk/puter.dev.js'
|
||||
: (this.config.gui_puterjs_bundle ??
|
||||
'https://js.puter.com/v2/'),
|
||||
guestApps,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `hostOrigin` is the first-party page framing the dialog, which the
|
||||
* feedback is sent for; null when no allowed page frames it.
|
||||
*/
|
||||
renderFeedbackEmbed({ hostOrigin }: { hostOrigin: string | null }): string {
|
||||
return this.#renderEmbed({
|
||||
rootId: 'feedback-embed',
|
||||
stylesheet: 'feedback-embed.css',
|
||||
script: 'feedback-embed.min.js',
|
||||
config: { apiOrigin: this.config.api_base_url, hostOrigin },
|
||||
});
|
||||
}
|
||||
|
||||
#renderEmbed({
|
||||
rootId,
|
||||
rootClass,
|
||||
stylesheet,
|
||||
script,
|
||||
config,
|
||||
}: {
|
||||
rootId: string;
|
||||
rootClass?: string;
|
||||
stylesheet: string;
|
||||
script: string;
|
||||
config: Record<string, unknown>;
|
||||
}): string {
|
||||
const configJson = JSON.stringify(config).replace(/</g, '\\u003c');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -190,27 +225,27 @@ export class PuterHomepageService extends PuterService {
|
||||
<title>${encode(String(this.config.gui_params?.title ?? 'Puter'))}</title>
|
||||
<link rel="stylesheet" href="/css/normalize.css">
|
||||
<link rel="stylesheet" href="/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/css/apps-embed.css">
|
||||
<link rel="stylesheet" href="/css/${stylesheet}">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app-browser" class="dashboard"></main>
|
||||
<script type="application/json" id="app-browser-config">${configJson}</script>
|
||||
<script defer src="${encode(this.#appBrowserEmbedScript())}"></script>
|
||||
<main id="${rootId}"${rootClass ? ` class="${rootClass}"` : ''}></main>
|
||||
<script type="application/json" id="${rootId}-config">${configJson}</script>
|
||||
<script defer src="${encode(this.#embedScript(script))}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embed's script is built beside the GUI bundle, so it is served from
|
||||
* Embed scripts are built beside the GUI bundle, so they are served from
|
||||
* wherever `gui_bundle` is (a CDN in production), with the same query.
|
||||
*/
|
||||
#appBrowserEmbedScript(): string {
|
||||
#embedScript(name: string): string {
|
||||
const base = 'http://relative.invalid';
|
||||
const bundle = new URL(
|
||||
this.config.gui_bundle ?? '/dist/bundle.min.js',
|
||||
base,
|
||||
);
|
||||
const script = new URL('apps-embed.min.js', bundle);
|
||||
const script = new URL(name, bundle);
|
||||
script.search = bundle.search;
|
||||
return script.origin === base
|
||||
? `${script.pathname}${script.search}`
|
||||
|
||||
@@ -1102,8 +1102,9 @@ interface IConfigOptional {
|
||||
/** Force the bundled GUI even in dev. Default: false. */
|
||||
use_bundled_gui: boolean;
|
||||
/**
|
||||
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The app
|
||||
* browser embed loads `apps-embed.min.js` from the same directory.
|
||||
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The
|
||||
* `/embed/*` pages load `apps-embed.min.js` and `feedback-embed.min.js`
|
||||
* from the same directory.
|
||||
*/
|
||||
gui_bundle: string;
|
||||
/** Override the GUI CSS path when bundled. Default: `/dist/bundle.min.css`. */
|
||||
|
||||
@@ -8,7 +8,7 @@ Opens a dialog where the user can send you, the app's developer, feedback about
|
||||
|
||||
The message is delivered by Puter: it is stored and emailed to the email address on your Puter account. The feedback never passes through your app's code, and the dialog tells the user what is shared with you: their username, plus their email address (as the email's reply-to, so you can respond) when their email is verified.
|
||||
|
||||
Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog (signing the user in first if needed).
|
||||
Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog (signing the user in first if needed). Puter's own apps on puter.com subdomains show it in a frame over the page instead, when the user is signed in to both.
|
||||
|
||||
**Feedback is opt-in.** Users can only send feedback if it's enabled for your app. Apps created in the Dev Center have feedback enabled at creation — you can turn it off with the "User Feedback" toggle in the app's settings. Apps created through `puter.apps.create` default to off; enable it by setting `feedbackEnabled`:
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ export function pushAccountPickerScreen (flow) {
|
||||
const accounts = Array.isArray(window.logged_in_users) ? window.logged_in_users : [];
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('sign_in_with_puter', [], false));
|
||||
// Everyone here has signed in to Puter before; no need to explain it.
|
||||
h += authHeadHtml(flow, i18n('sign_in_with_puter', [], false), { openerNotice: false });
|
||||
h += `<ul class="auth-accounts" aria-label="${i18n('sign_in_with_puter')}">`;
|
||||
h += accounts.map(accountRowHtml).join('');
|
||||
h += '</ul>';
|
||||
|
||||
@@ -104,7 +104,8 @@ export function pushLoginScreen (flow, options = {}) {
|
||||
const id = `auth-login-${window.uuidv4?.() ?? Date.now()}`;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('log_in', [], false));
|
||||
// Logging in means already having a Puter account; no need to explain it.
|
||||
h += authHeadHtml(flow, i18n('log_in', [], false), { openerNotice: false });
|
||||
h += '<form class="auth-form login-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += formFieldHtml({
|
||||
@@ -152,6 +153,7 @@ export function pushLoginScreen (flow, options = {}) {
|
||||
|
||||
renderProviderButtons($screen.find('.auth-provider-list'), {
|
||||
kind: 'login',
|
||||
flow,
|
||||
referrer: () => flow.options.referrer ?? window.referrerStr ?? window.openerOrigin,
|
||||
}).then((offered) => {
|
||||
if ( offered ) $screen.find('.auth-providers').prop('hidden', false);
|
||||
|
||||
@@ -60,6 +60,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('login screen', () => {
|
||||
it('leaves out the opener notice in a sign-in popup', () => {
|
||||
window.embedded_in_popup = true;
|
||||
window.openerOrigin = 'https://builder.puter.com';
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
expect(screen().find('.auth-title')).toHaveLength(1);
|
||||
expect(screen().find('.auth-opener-notice')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('asks for each field before sending anything', () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
|
||||
@@ -36,11 +36,13 @@ import openAuthFlow, {
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.js';
|
||||
import { renderProviderButtons } from './authProviders.js';
|
||||
import { mayOfferProviders, renderProviderButtons } from './authProviders.js';
|
||||
import { pushLoginScreen } from './UIAuthLogin.js';
|
||||
|
||||
const SIGNUP_TIMEOUT_MS = 30000;
|
||||
const TURNSTILE_POLL_MS = 100;
|
||||
// How long the email form waits for the provider lookup before showing anyway.
|
||||
const PROVIDERS_WAIT_MS = 1500;
|
||||
|
||||
/**
|
||||
* @typedef {object} SignupScreenOptions
|
||||
@@ -187,6 +189,9 @@ export const signupRedirectUrl = (redirect_url) => {
|
||||
export function pushSignupScreen (flow, options = {}) {
|
||||
const id = `auth-signup-${window.uuidv4?.() ?? Date.now()}`;
|
||||
const turnstile_key = window.gui_params?.turnstileSiteKey;
|
||||
// The email form stays hidden until the providers are known, so it
|
||||
// doesn't show and then fold away behind "Sign up with email".
|
||||
let awaiting_providers = mayOfferProviders() && ! options.username && ! options.email;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('create_free_account', [], false));
|
||||
@@ -200,7 +205,7 @@ export function pushSignupScreen (flow, options = {}) {
|
||||
h += '<div class="signup-with-email-wrapper" hidden>';
|
||||
h += `<button type="button" class="dashboard-dialog-btn auth-provider-btn signup-with-email-btn">${i18n('sign_up_with_email')}</button>`;
|
||||
h += '</div>';
|
||||
h += '<form class="auth-form signup-form" novalidate>';
|
||||
h += `<form class="auth-form signup-form" novalidate${awaiting_providers ? ' hidden' : ''}>`;
|
||||
h += formFieldHtml({
|
||||
name: 'username',
|
||||
label: i18n('username'),
|
||||
@@ -307,11 +312,32 @@ export function pushSignupScreen (flow, options = {}) {
|
||||
})();
|
||||
}
|
||||
|
||||
// With focus on the card itself, nothing on the screen was focusable yet.
|
||||
const focusWasParked = () => document.activeElement === flow.$dialog.get(0);
|
||||
const stopAwaitingProviders = () => {
|
||||
const was_awaiting = awaiting_providers;
|
||||
awaiting_providers = false;
|
||||
clearTimeout(form_fallback);
|
||||
return was_awaiting;
|
||||
};
|
||||
const revealForm = () => {
|
||||
if ( ! stopAwaitingProviders() || flow.isClosed() ) return;
|
||||
showForm();
|
||||
if ( focusWasParked() ) flow.focus();
|
||||
};
|
||||
const form_fallback = awaiting_providers ? setTimeout(revealForm, PROVIDERS_WAIT_MS) : undefined;
|
||||
|
||||
renderProviderButtons($screen.find('.auth-provider-list'), {
|
||||
kind: 'signup',
|
||||
flow,
|
||||
referrer: () => flow.options.referrer ?? window.referrerStr,
|
||||
}).then((offered) => {
|
||||
if ( ! offered || flow.isClosed() ) return;
|
||||
if ( ! offered ) {
|
||||
revealForm();
|
||||
return;
|
||||
}
|
||||
stopAwaitingProviders();
|
||||
if ( flow.isClosed() ) return;
|
||||
offers_providers = true;
|
||||
$screen.find('.oidc-providers-wrapper').prop('hidden', false);
|
||||
if ( offered.signupOidcOnly ) {
|
||||
@@ -327,7 +353,7 @@ export function pushSignupScreen (flow, options = {}) {
|
||||
const had_focus = $form.get(0).contains(document.activeElement);
|
||||
$form.prop('hidden', true);
|
||||
$screen.find('.signup-with-email-wrapper').prop('hidden', false);
|
||||
if ( had_focus ) flow.focus();
|
||||
if ( had_focus || focusWasParked() ) flow.focus();
|
||||
});
|
||||
|
||||
$screen.find('.signup-with-email-btn').on('click', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ const { activeAuthFlow } = await import('./authFlow.js');
|
||||
const { default: UIAuthSignup, signupRedirectUrl } = await import('./UIAuthSignup.js');
|
||||
const { default: UIAuthAccountPicker } = await import('./UIAuthAccountPicker.js');
|
||||
const { default: UIAuthRecoverPassword } = await import('./UIAuthRecoverPassword.js');
|
||||
const { default: UIAuthLogin } = await import('./UIAuthLogin.js');
|
||||
|
||||
const providers = (body) => {
|
||||
globalThis.fetch = vi.fn(async () => reply(200, body));
|
||||
@@ -110,7 +111,12 @@ describe('signup when only OIDC signups are open', () => {
|
||||
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
void UIAuthSignup();
|
||||
// A slow lookup gives up the wait and shows the form.
|
||||
vi.advanceTimersByTime(1500);
|
||||
vi.useRealTimers();
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
$('[name="username"]').val('started');
|
||||
answer(reply(200, { providers: ['google'] }));
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
@@ -118,6 +124,50 @@ describe('signup when only OIDC signups are open', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('signup while the providers are looked up', () => {
|
||||
/** Lets promise callbacks run, but no timer, so nothing a paint could show in between. */
|
||||
const settlePromises = async () => {
|
||||
for ( let i = 0; i < 10; i++ ) await Promise.resolve();
|
||||
};
|
||||
|
||||
it('keeps the email form hidden until the providers are known', async () => {
|
||||
let answer;
|
||||
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
void UIAuthSignup();
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
answer(reply(200, { providers: ['google'] }));
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the email form when no providers are offered', async () => {
|
||||
void UIAuthSignup();
|
||||
await vi.waitFor(() => expect($('.signup-form').prop('hidden')).toBe(false));
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a prefilled email form straight away', () => {
|
||||
globalThis.fetch = vi.fn(() => new Promise(() => {}));
|
||||
void UIAuthSignup({ email: '[email protected]' });
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses the login screen\'s lookup, drawing the providers before any paint', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
await vi.waitFor(() => expect(screen().find('.oidc-google-btn')).toHaveLength(1));
|
||||
screen().find('.auth-to-signup').trigger('click');
|
||||
await settlePromises();
|
||||
expect(screen().attr('data-screen')).toBe('signup');
|
||||
expect(screen().find('.oidc-google-btn')).toHaveLength(1);
|
||||
expect(screen().find('.signup-with-email-wrapper').prop('hidden')).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signup form', () => {
|
||||
it.each([
|
||||
[{}, 'username_required', 'username'],
|
||||
|
||||
@@ -387,9 +387,12 @@ export const joinAuthFlow = () => {
|
||||
*
|
||||
* @param {AuthFlow} flow
|
||||
* @param {string} title Plain text.
|
||||
* @param {{ openerNotice?: boolean }} [opts] `openerNotice: false` leaves out
|
||||
* the line explaining that the site uses Puter, for screens shown to someone
|
||||
* who already has an account here.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const authHeadHtml = (flow, title) => {
|
||||
export const authHeadHtml = (flow, title, { openerNotice = true } = {}) => {
|
||||
const opener = window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '';
|
||||
let h = '<div class="auth-head">';
|
||||
h += authLogoHeader({
|
||||
@@ -399,7 +402,7 @@ export const authHeadHtml = (flow, title) => {
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
h += `<h1 class="auth-title">${html_encode(title)}</h1>`;
|
||||
if ( opener ) {
|
||||
if ( opener && openerNotice ) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(opener).hostname])}</p>`;
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
@@ -250,6 +250,16 @@ describe('authHeadHtml', () => {
|
||||
expect($dialog_head.find('a')).toHaveLength(0);
|
||||
expect($dialog_head.find('.auth-logo').attr('alt')).toBe('');
|
||||
});
|
||||
|
||||
it('explains the opener in a sign-in popup unless told not to', () => {
|
||||
window.embedded_in_popup = true;
|
||||
window.openerOrigin = 'https://builder.puter.com';
|
||||
const flow = openAuthFlow();
|
||||
const $notice = $(authHeadHtml(flow, 'Log in')).find('.auth-opener-notice');
|
||||
expect($notice).toHaveLength(1);
|
||||
expect($notice.text()).toContain('builder.puter.com');
|
||||
expect($(authHeadHtml(flow, 'Log in', { openerNotice: false })).find('.auth-opener-notice')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authFlowOptions', () => {
|
||||
|
||||
@@ -75,27 +75,46 @@ export const providerStartUrl = (provider, kind, referrer) => {
|
||||
return url;
|
||||
};
|
||||
|
||||
/** @type {WeakMap<import('./authFlow.js').AuthFlow, Promise<{ providers?: string[], signupOidcOnly?: boolean } | null>>} */
|
||||
const lookups = new WeakMap();
|
||||
|
||||
/**
|
||||
* Fetches the configured providers and fills `$list` with a button for each.
|
||||
* A self-hosted instance's own provider gets a generic button.
|
||||
* The configured providers, looked up once per auth flow so moving between
|
||||
* its screens draws them straight away instead of after another round trip.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
*/
|
||||
const lookUpProviders = (flow) => {
|
||||
let lookup = lookups.get(flow);
|
||||
if ( ! lookup ) {
|
||||
lookup = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
return res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
lookups.set(flow, lookup);
|
||||
}
|
||||
return lookup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills `$list` with a button for each configured provider. A self-hosted
|
||||
* instance's own provider gets a generic button.
|
||||
*
|
||||
* @param {jQuery} $list Where the buttons go.
|
||||
* @param {object} opts
|
||||
* @param {'login'|'signup'} opts.kind
|
||||
* @param {() => string | undefined} opts.referrer Read when a button is pressed.
|
||||
* @param {import('./authFlow.js').AuthFlow} opts.flow The flow showing `$list`.
|
||||
* @returns {Promise<{ providers: string[], signupOidcOnly: boolean } | null>}
|
||||
* Null when none are offered here or the lookup failed.
|
||||
*/
|
||||
export async function renderProviderButtons ($list, { kind, referrer }) {
|
||||
export async function renderProviderButtons ($list, { kind, referrer, flow }) {
|
||||
if ( ! mayOfferProviders() ) return null;
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
if ( ! res.ok ) return null;
|
||||
data = await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const data = await lookUpProviders(flow);
|
||||
if ( ! Array.isArray(data?.providers) || ! data.providers.length ) return null;
|
||||
|
||||
const ordered = [
|
||||
|
||||
@@ -16,6 +16,8 @@ const requests = [];
|
||||
const guiRoot = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const sessionToken = 'app-browser-test-session';
|
||||
const sessionCookie = 'app-browser-test-cookie';
|
||||
const sessionUser = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
const otherUser = '7d2e4f10-8a3b-4c5d-9e6f-0a1b2c3d4e5f';
|
||||
const appRows = Array.from({ length: 105 }, (_, i) => ({
|
||||
name: `app-${i}`, title: `App ${i}`, icon: null,
|
||||
}));
|
||||
@@ -62,6 +64,25 @@ beforeAll(async () => {
|
||||
if ( ! req.headers.cookie?.includes(`puter_auth_token=${sessionCookie}`) ) return res.sendStatus(401);
|
||||
res.json({ token: sessionToken });
|
||||
});
|
||||
// The GUI session's token and the tokens of apps signed in on a sibling subdomain.
|
||||
const whoamiByToken = {
|
||||
[sessionToken]: { uuid: sessionUser, username: 'session-user' },
|
||||
'app-token-session-user': { uuid: sessionUser, username: 'session-user' },
|
||||
'app-token-other-user': { uuid: otherUser, username: 'other-user' },
|
||||
};
|
||||
// The SDK on a sibling subdomain sends credentialed cross-origin requests.
|
||||
app.use(['/whoami', '/profile', '/rao'], (req, res, next) => {
|
||||
if ( req.headers.origin ) res.set('Access-Control-Allow-Origin', req.headers.origin);
|
||||
res.set('Access-Control-Allow-Credentials', 'true');
|
||||
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
||||
if ( req.method === 'OPTIONS' ) return res.sendStatus(204);
|
||||
next();
|
||||
});
|
||||
app.get('/whoami', (req, res) => {
|
||||
const user = whoamiByToken[req.headers.authorization?.replace(/^Bearer /, '')];
|
||||
if ( ! user ) return res.sendStatus(401);
|
||||
res.json(user);
|
||||
});
|
||||
app.get('/get-launch-apps', (_req, res) => res.json({ recommended: [{ name: 'removed', title: 'Removed' }] }));
|
||||
app.get('/cache/last-change-timestamp', (_req, res) => res.json({ timestamp: 0 }));
|
||||
app.post('/drivers/call', (req, res) => {
|
||||
@@ -81,9 +102,15 @@ beforeAll(async () => {
|
||||
app.get('/index', (_req, res) => res.send(`<title>App</title>
|
||||
<script>window.PUTER_ORIGIN = '${origin}'; window.PUTER_API_ORIGIN = '${origin}';</script>
|
||||
<script src="/sdk.js"></script>`));
|
||||
app.get('/host', (_req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0">
|
||||
<iframe title="More apps" src="${origin}/embed/apps" style="border:0;width:100vw;height:100vh"></iframe>
|
||||
// The host page names its signed-in user as `?user=`, as puter.js does in the fragment.
|
||||
app.get('/host', (req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0">
|
||||
<iframe title="More apps" src="${origin}/embed/apps${req.query.user ? `#user=${req.query.user}` : ''}" style="border:0;width:100vw;height:100vh"></iframe>
|
||||
</body></html>`));
|
||||
// An app on a sibling subdomain, signed in with puter.js, with the SDK's user menu.
|
||||
app.get('/menu-host', (_req, res) => res.send(`<!DOCTYPE html><html><head>
|
||||
<script>window.PUTER_ORIGIN = '${origin}'; window.PUTER_API_ORIGIN = '${origin}';</script>
|
||||
<script src="${origin}/sdk.js"></script></head>
|
||||
<body><header><puter-user-menu></puter-user-menu></header></body></html>`));
|
||||
app.get('/embed/apps', (_req, res) => {
|
||||
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://*.puter.localhost:${port}`);
|
||||
res.send(`<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -122,7 +149,7 @@ describe('embedded app browser', () => {
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
requests.length = 0;
|
||||
try {
|
||||
const parentUrl = `http://spreadsheet.puter.localhost:${port}/host`;
|
||||
const parentUrl = `http://spreadsheet.puter.localhost:${port}/host?user=${sessionUser}`;
|
||||
await page.goto(parentUrl);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.locator('.myapps-group-tile').waitFor();
|
||||
@@ -182,7 +209,7 @@ describe('embedded app browser', () => {
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/host`);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/host?user=${sessionUser}`);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.locator('.myapps-group-tile').waitFor();
|
||||
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(1);
|
||||
@@ -200,7 +227,7 @@ describe('embedded app browser', () => {
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.locator('.myapps-search').fill('App 104');
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
await page.locator('.myapps-tile').click();
|
||||
@@ -275,7 +302,7 @@ describe('embedded app browser', () => {
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.locator('[data-app-name="notes"]').waitFor();
|
||||
} finally {
|
||||
guestApps = [];
|
||||
@@ -290,7 +317,7 @@ describe('embedded app browser', () => {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.route('**/get-gui-token', route => route.fulfill({ status }));
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.getByRole('status').waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
|
||||
expect(await page.locator('.myapps-tile').count()).toBe(0);
|
||||
@@ -300,6 +327,351 @@ describe('embedded app browser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('only shows the session to the host\'s own user', () => {
|
||||
const notes = () => ({ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null });
|
||||
const signedInContext = () => browser.newContext({ storageState: {
|
||||
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
|
||||
} });
|
||||
|
||||
it.each([
|
||||
['names no user', ''],
|
||||
['names another user', `?user=${otherUser}`],
|
||||
['names a malformed user', '?user=session-user'],
|
||||
])('shows the signed-out list when the host %s', async (_label, query) => {
|
||||
guestApps = [notes()];
|
||||
const context = await signedInContext();
|
||||
requests.length = 0;
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/host${query}`);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.locator('[data-app-name="notes"]').waitFor();
|
||||
expect(await frame.locator('.myapps-tile').count()).toBe(1);
|
||||
expect(requests.some(req => ['/sdk.js', '/installedApps', '/drivers/call'].includes(req.path))).toBe(false);
|
||||
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
await frame.locator('[data-app-name="notes"]').click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForURL(/\/index\?/);
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
expect(requests.some(req => ['/auth/get-user-app-token', '/rao'].includes(req.path))).toBe(false);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the signed-out list when the API refuses the stored session', async () => {
|
||||
guestApps = [notes()];
|
||||
const context = await browser.newContext({ storageState: {
|
||||
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: 'revoked-session' }] }],
|
||||
} });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.locator('[data-app-name="notes"]').waitFor();
|
||||
expect(await page.locator('.myapps-tile').count()).toBe(1);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a spinner while it checks the session', async () => {
|
||||
const context = await signedInContext();
|
||||
let respond;
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.route('**/whoami', route => { respond = () => route.continue(); });
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await expect.poll(() => typeof respond).toBe('function');
|
||||
const spinner = () => page.locator('#app-browser').evaluate(root => getComputedStyle(root, '::after').animationName);
|
||||
expect(await spinner()).not.toBe('none');
|
||||
await respond();
|
||||
await page.locator('[data-app-name="app-104"]').waitFor();
|
||||
expect(await spinner()).toBe('none');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows an unavailable state when the session check fails', async () => {
|
||||
guestApps = [notes()];
|
||||
const context = await signedInContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.route('**/whoami', route => route.fulfill({ status: 503 }));
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.getByRole('status').waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
|
||||
expect(await page.locator('.myapps-tile').count()).toBe(0);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reloads for the user a changed fragment names', async () => {
|
||||
guestApps = [notes()];
|
||||
const context = await signedInContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.locator('[data-app-name="app-104"]').waitFor();
|
||||
await page.evaluate(user => { window.location.hash = `user=${user}`; }, otherUser);
|
||||
await page.locator('[data-app-name="notes"]').waitFor();
|
||||
expect(await page.locator('[data-app-name="app-104"]').count()).toBe(0);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('opened from the SDK\'s user menu', () => {
|
||||
const menuContext = async (appToken, options = {}) => {
|
||||
guestApps = [{ name: 'notes', title: 'Notes', uuid: 'uid-notes', index_url: `http://notes.puter.localhost:${port}/index`, icon: null }];
|
||||
const host = `http://spreadsheet.puter.localhost:${port}`;
|
||||
return browser.newContext({ ...options, storageState: {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] },
|
||||
{ origin: host, localStorage: appToken ? [
|
||||
{ name: 'puter.auth.token.v2', value: appToken },
|
||||
{ name: 'puter.auth.token.origin.v2', value: origin },
|
||||
] : [] },
|
||||
],
|
||||
} });
|
||||
};
|
||||
|
||||
it('shows the user\'s apps to the same user and the signed-out list once the app signs out', async () => {
|
||||
const context = await menuContext('app-token-session-user');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').waitFor();
|
||||
expect(await page.locator('puter-user-menu .avatar').textContent()).toBe('S');
|
||||
|
||||
await page.locator('puter-user-menu .apps-button').click();
|
||||
const iframe = page.locator('puter-user-menu iframe');
|
||||
expect(await iframe.getAttribute('src')).toBe(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.frameLocator('puter-user-menu iframe').locator('[data-app-name="app-104"]').waitFor();
|
||||
|
||||
// The panel hangs under its button, inside the viewport.
|
||||
const button = await page.locator('puter-user-menu .apps-button').boundingBox();
|
||||
const panel = await page.locator('puter-user-menu .apps-panel').boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
expect(panel.y).toBeGreaterThan(button.y + button.height);
|
||||
expect(panel.x).toBeGreaterThanOrEqual(0);
|
||||
expect(panel.x + panel.width).toBeLessThanOrEqual(viewport.width);
|
||||
|
||||
// Closing keeps the embed, so reopening shows the same page.
|
||||
const embed = page.frames().find(frame => frame.url().startsWith(`${origin}/embed/apps`));
|
||||
await embed.evaluate(() => { window.keptAcrossOpens = true; });
|
||||
await page.locator('puter-user-menu .apps-close').click();
|
||||
await page.locator('puter-user-menu .apps-panel').waitFor({ state: 'hidden' });
|
||||
await page.locator('puter-user-menu .apps-button').click();
|
||||
expect(await embed.evaluate(() => window.keptAcrossOpens)).toBe(true);
|
||||
|
||||
await page.evaluate(() => puter.auth.signOut());
|
||||
await iframe.waitFor({ state: 'detached' });
|
||||
await page.locator('puter-user-menu .sign-in').waitFor();
|
||||
await page.locator('puter-user-menu .apps-button').click();
|
||||
expect(await iframe.getAttribute('src')).toBe(`${origin}/embed/apps`);
|
||||
const frame = page.frameLocator('puter-user-menu iframe');
|
||||
await frame.locator('[data-app-name="notes"]').waitFor();
|
||||
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the signed-out list to an app signed in as another user', async () => {
|
||||
const context = await menuContext('app-token-other-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').waitFor();
|
||||
await page.locator('puter-user-menu .apps-button').click();
|
||||
const frame = page.frameLocator('puter-user-menu iframe');
|
||||
await frame.locator('[data-app-name="notes"]').waitFor();
|
||||
expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(0);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the account menu when Tab leaves it', 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.evaluate(() => document.body.insertAdjacentHTML('beforeend', '<button id="after">After</button>'));
|
||||
const menu = page.locator('puter-user-menu .menu');
|
||||
await page.locator('puter-user-menu .avatar').click();
|
||||
await menu.waitFor();
|
||||
await page.keyboard.press('Tab');
|
||||
await menu.waitFor({ state: 'hidden' });
|
||||
expect(await page.evaluate(() => document.activeElement.id)).toBe('after');
|
||||
|
||||
await page.locator('puter-user-menu .avatar').click();
|
||||
await menu.waitFor();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await menu.waitFor({ state: 'hidden' });
|
||||
expect(await page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className)).toBe('apps-button');
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('moves focus to Sign in after signing out from the keyboard', 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').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('puter-user-menu .menu').waitFor();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('puter-user-menu .sign-in').waitFor();
|
||||
await expect.poll(() => page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className)).toBe('sign-in');
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the app browser in a theme the page forces', async () => {
|
||||
const context = await menuContext('app-token-session-user');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
const menu = page.locator('puter-user-menu');
|
||||
await menu.evaluate(el => el.setAttribute('theme', 'dark'));
|
||||
await page.locator('puter-user-menu .apps-button').click();
|
||||
const frame = page.frameLocator('puter-user-menu iframe');
|
||||
await frame.locator('[data-app-name="app-104"]').waitFor();
|
||||
const background = () => frame.locator('body').evaluate(body => getComputedStyle(body).backgroundColor);
|
||||
expect(await background()).toBe('rgb(30, 30, 30)');
|
||||
|
||||
await menu.evaluate(el => el.setAttribute('theme', 'light'));
|
||||
await expect.poll(background).toBe('rgb(255, 255, 255)');
|
||||
await frame.locator('[data-app-name="app-104"]').waitFor();
|
||||
expect(await page.locator('puter-user-menu .apps-panel').isVisible()).toBe(true);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no hover shading on the Apps button once a tap closes it', async () => {
|
||||
const context = await menuContext('app-token-session-user', {
|
||||
viewport: { width: 390, height: 700 }, hasTouch: true, isMobile: true,
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
const button = page.locator('puter-user-menu .apps-button');
|
||||
await button.tap();
|
||||
await page.locator('puter-user-menu .apps-panel').waitFor();
|
||||
await button.tap();
|
||||
await page.locator('puter-user-menu .apps-panel').waitFor({ state: 'hidden' });
|
||||
expect(await button.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows Sign in pressed and the account hovered', async () => {
|
||||
const context = await menuContext(null);
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
const signIn = page.locator('puter-user-menu .sign-in');
|
||||
await signIn.hover();
|
||||
await page.mouse.down();
|
||||
expect(await signIn.evaluate(el => getComputedStyle(el).color)).toBe('rgb(190, 222, 245)');
|
||||
await page.mouse.move(0, 0);
|
||||
await page.mouse.up();
|
||||
|
||||
await page.evaluate(() => puter.setAuthToken('app-token-session-user'));
|
||||
const avatar = page.locator('puter-user-menu .avatar');
|
||||
await avatar.waitFor();
|
||||
expect(await avatar.evaluate(el => getComputedStyle(el).boxShadow)).toBe('none');
|
||||
await avatar.hover();
|
||||
expect(await avatar.evaluate(el => getComputedStyle(el).boxShadow)).not.toBe('none');
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the account before its picture arrives', async () => {
|
||||
const context = await menuContext('app-token-session-user');
|
||||
let respond;
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.route('**/profile', route => { respond = () => route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Access-Control-Allow-Origin': `http://spreadsheet.puter.localhost:${port}`, 'Access-Control-Allow-Credentials': 'true' },
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ picture: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' }),
|
||||
}); });
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').waitFor({ timeout: 2000 });
|
||||
expect(await page.locator('puter-user-menu .avatar').textContent()).toBe('S');
|
||||
expect(await page.locator('puter-user-menu .apps-button').isVisible()).toBe(true);
|
||||
await expect.poll(() => typeof respond).toBe('function');
|
||||
await respond();
|
||||
await page.locator('puter-user-menu .avatar img').waitFor();
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('names the user an app signs in as after loading', async () => {
|
||||
const context = await menuContext(null);
|
||||
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 .sign-in').waitFor();
|
||||
// What `puter.auth.signIn()` does with the token its popup hands back.
|
||||
await page.evaluate(() => puter.setAuthToken('app-token-session-user'));
|
||||
await page.locator('puter-user-menu .avatar').waitFor();
|
||||
await expect.poll(() => page.locator('puter-user-menu .avatar').textContent()).toBe('S');
|
||||
await page.locator('puter-user-menu .apps-button').click();
|
||||
expect(await page.locator('puter-user-menu iframe').getAttribute('src')).toBe(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.frameLocator('puter-user-menu iframe').locator('[data-app-name="app-104"]').waitFor();
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an unavailable state if the SDK cannot load', async () => {
|
||||
const context = await browser.newContext({ storageState: {
|
||||
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
|
||||
@@ -307,7 +679,7 @@ describe('embedded app browser', () => {
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.route('**/sdk.js', route => route.abort());
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.goto(`${origin}/embed/apps#user=${sessionUser}`);
|
||||
await page.getByRole('status').waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Apps are unavailable');
|
||||
} finally {
|
||||
|
||||
@@ -24,10 +24,10 @@ import UIContextMenu from '../UIContextMenu.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
|
||||
import UIAuthLogin from '../Auth/UIAuthLogin.js';
|
||||
import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
import apply_item_added_to_containers from '../../helpers/applyItemAddedToContainers.js';
|
||||
import { clear_shared_param } from '../../helpers/parseSharedPath.js';
|
||||
import UIDashboardNotifications from './UIDashboardNotifications.js';
|
||||
import UIDashboardContactDialog from './UIDashboardContactDialog.js';
|
||||
import {
|
||||
answerNotificationReceipts,
|
||||
notificationSocketAcks,
|
||||
@@ -900,16 +900,8 @@ async function UIDashboard (options) {
|
||||
// Contact Us
|
||||
{
|
||||
html: i18n('contact_us'),
|
||||
onClick: async function () {
|
||||
UIWindowFeedback({
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
onClick: function () {
|
||||
UIDashboardContactDialog({ $container: $el_window, returnFocusTo: $btn.get(0) });
|
||||
},
|
||||
},
|
||||
'-',
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The dashboard's Contact Us form: a dashboard form dialog with screenshots
|
||||
// and screen recordings attached by picking, dropping or pasting them. The
|
||||
// desktop keeps UIWindowFeedback.
|
||||
|
||||
import {
|
||||
ATTACHMENT_ACCEPT_ATTRIBUTE,
|
||||
MAX_ATTACHMENTS,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
MAX_TOTAL_ATTACHMENT_BYTES,
|
||||
checkAttachment,
|
||||
} from '../../helpers/contact_attachments.js';
|
||||
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import { ICONS, SVG } from './UIDashboardDialog.js';
|
||||
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
// Keep in sync with SystemController on the backend.
|
||||
const MESSAGE_MAX_LENGTH = 100_000;
|
||||
|
||||
// Numbers interpolated into each error message, taken from the enforced limits.
|
||||
const ERROR_VALUES = {
|
||||
contact_us_attachment_too_many: [MAX_ATTACHMENTS],
|
||||
contact_us_attachment_too_large: [MAX_ATTACHMENT_BYTES / MB],
|
||||
contact_us_attachment_total_too_large: [MAX_TOTAL_ATTACHMENT_BYTES / MB],
|
||||
};
|
||||
|
||||
const CONTACT_ICONS = {
|
||||
attach: SVG('<path d="M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/>'),
|
||||
video: SVG('<rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 10l6-3.5v11L16 14"/>'),
|
||||
};
|
||||
|
||||
const t = (key, values = []) => i18n(key, values, false);
|
||||
|
||||
/**
|
||||
* Whether a drag carries files, rather than text or a link from the page.
|
||||
*
|
||||
* @param {DragEvent} e
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const dragHasFiles = (e) => Array.from(e.originalEvent?.dataTransfer?.types ?? []).includes('Files');
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns {string}
|
||||
*/
|
||||
const attachmentHtml = (file) => {
|
||||
const name = html_encode(file.name);
|
||||
// The preview's URL is let go once it has drawn; the <img> keeps the pixels.
|
||||
const thumb = file.type.startsWith('image/')
|
||||
? `<img src="${html_encode(URL.createObjectURL(file))}" alt="" draggable="false">`
|
||||
: CONTACT_ICONS.video;
|
||||
const remove = html_encode(t('contact_us_attachment_remove_named', [file.name]));
|
||||
return '<li class="dashboard-contact-file">'
|
||||
+ `<span class="dashboard-contact-file-thumb" aria-hidden="true">${thumb}</span>`
|
||||
+ '<span class="dashboard-contact-file-text">'
|
||||
+ `<span class="dashboard-contact-file-name" title="${name}">${name}</span>`
|
||||
+ `<span class="dashboard-contact-file-size">${html_encode(window.byte_format(file.size))}</span>`
|
||||
+ '</span>'
|
||||
+ `<button type="button" class="dashboard-contact-file-remove" aria-label="${remove}" title="${i18n('contact_us_attachment_remove')}">${ICONS.close}</button>`
|
||||
+ '</li>';
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the Contact Us form; the message goes to support, with any
|
||||
* attachments as multipart form data. The checks here only fail fast: the
|
||||
* server enforces the limits.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {jQuery} [opts.$container] Where the overlay mounts.
|
||||
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
|
||||
* @returns {import('./UIDashboardFormDialog.js').FormDialog}
|
||||
*/
|
||||
export default function UIDashboardContactDialog ({ $container, returnFocusTo } = {}) {
|
||||
/** @type {File[]} */
|
||||
const attachments = [];
|
||||
let sending = false;
|
||||
// Once sent, the form is gone and a drop or paste has nothing to join.
|
||||
let sent = false;
|
||||
|
||||
const dialog = UIDashboardFormDialog({
|
||||
$container,
|
||||
returnFocusTo,
|
||||
title: t('contact_us'),
|
||||
intro: `<p>${i18n('feedback_c2a')}</p>`,
|
||||
submitLabel: t('send'),
|
||||
fields: [{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
label: i18n('contact_us_message'),
|
||||
placeholder: i18n('contact_us_message_placeholder'),
|
||||
attrs: { rows: 6, maxlength: MESSAGE_MAX_LENGTH },
|
||||
}],
|
||||
async onSubmit () {
|
||||
const { message } = dialog.values();
|
||||
if ( ! message.trim() ) {
|
||||
dialog.setStatus(t('contact_us_message_required'), { field: 'message' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('message', message);
|
||||
for ( const file of attachments ) body.append('attachments', file, file.name);
|
||||
|
||||
// No Content-Type: the browser sets it with the boundary.
|
||||
const resp = await fetch(`${window.api_origin}/contactUs`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${window.auth_token}` },
|
||||
body,
|
||||
});
|
||||
if ( ! resp.ok ) {
|
||||
dialog.setStatus(t(resp.status === 429 ? 'contact_us_rate_limited' : 'contact_us_error'));
|
||||
return;
|
||||
}
|
||||
sent = true;
|
||||
dialog.finish({ message: t('feedback_sent_confirmation') });
|
||||
} catch ( err ) {
|
||||
console.error('contact-us: submit failed', err);
|
||||
dialog.setStatus(t('contact_us_error'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { $dialog } = dialog;
|
||||
const $overlay = $dialog.parent();
|
||||
const hint_id = `${$dialog.attr('aria-labelledby')}-attach-hint`;
|
||||
|
||||
let h = '<div class="dashboard-contact-attachments">';
|
||||
h += `<input type="file" class="dashboard-contact-file-input" accept="${html_encode(ATTACHMENT_ACCEPT_ATTRIBUTE)}" multiple hidden tabindex="-1">`;
|
||||
h += `<button type="button" class="dashboard-contact-dropzone" aria-describedby="${hint_id}">`;
|
||||
h += `<span class="dashboard-contact-dropzone-icon" aria-hidden="true">${CONTACT_ICONS.attach}</span>`;
|
||||
h += '<span class="dashboard-contact-dropzone-text">';
|
||||
h += `<span class="dashboard-contact-dropzone-label">${i18n(isTouchPrimaryDevice() ? 'contact_us_attach' : 'contact_us_attach_or_drop')}</span>`;
|
||||
h += `<span class="dashboard-contact-dropzone-drop" aria-hidden="true">${i18n('contact_us_drop_to_attach')}</span>`;
|
||||
h += `<span class="dashboard-contact-dropzone-hint" id="${hint_id}">${i18n('contact_us_attach_hint', [MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES / MB])}</span>`;
|
||||
h += '</span>';
|
||||
h += '</button>';
|
||||
h += `<ul class="dashboard-contact-files" aria-label="${i18n('contact_us_attachments')}" hidden></ul>`;
|
||||
h += '</div>';
|
||||
$dialog.addClass('dashboard-contact-dialog')
|
||||
.find('.dashboard-form-field[data-field="message"]').after(h);
|
||||
|
||||
const $dropzone = $dialog.find('.dashboard-contact-dropzone');
|
||||
const $fileInput = $dialog.find('.dashboard-contact-file-input');
|
||||
const $list = $dialog.find('.dashboard-contact-files');
|
||||
|
||||
const syncControls = () => {
|
||||
$list.prop('hidden', attachments.length === 0);
|
||||
// Nothing left to add once the count cap is reached.
|
||||
$dropzone.prop('disabled', sending || attachments.length >= MAX_ATTACHMENTS);
|
||||
$list.find('.dashboard-contact-file-remove').prop('disabled', sending);
|
||||
};
|
||||
|
||||
function setSending (value) {
|
||||
sending = value;
|
||||
$dialog.toggleClass('dashboard-contact-sending', value);
|
||||
syncControls();
|
||||
}
|
||||
|
||||
/** @param {File[]} files */
|
||||
const addFiles = (files) => {
|
||||
if ( sending || sent || ! files.length ) return;
|
||||
let rejection = null;
|
||||
for ( const file of files ) {
|
||||
const verdict = checkAttachment(file, attachments);
|
||||
if ( ! verdict.ok ) {
|
||||
// Report the first rejection but keep the files that fit.
|
||||
rejection = rejection ?? verdict.error;
|
||||
continue;
|
||||
}
|
||||
attachments.push(file);
|
||||
const $item = $(attachmentHtml(file));
|
||||
$item.find('img').one('load error', function () {
|
||||
URL.revokeObjectURL(this.src);
|
||||
});
|
||||
$list.append($item);
|
||||
}
|
||||
dialog.setStatus(rejection ? t(rejection, ERROR_VALUES[rejection] ?? []) : '');
|
||||
syncControls();
|
||||
};
|
||||
|
||||
$dropzone.on('click', () => $fileInput.trigger('click'));
|
||||
$fileInput.on('change', function () {
|
||||
addFiles(Array.from(this.files ?? []));
|
||||
// Reset so re-picking the same file fires `change` again.
|
||||
this.value = '';
|
||||
});
|
||||
|
||||
$list.on('click', '.dashboard-contact-file-remove', function () {
|
||||
if ( sending ) return;
|
||||
const $item = $(this).closest('.dashboard-contact-file');
|
||||
const index = $list.children().index($item);
|
||||
attachments.splice(index, 1);
|
||||
// Focus stays in the list rather than falling back to the page.
|
||||
const $next = $item.next().length ? $item.next() : $item.prev();
|
||||
$item.remove();
|
||||
dialog.setStatus('');
|
||||
syncControls();
|
||||
($next.length ? $next.find('.dashboard-contact-file-remove') : $dropzone).get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// The whole overlay takes the drop, and keeps it from the window under it.
|
||||
let drag_depth = 0;
|
||||
const endDrag = () => {
|
||||
drag_depth = 0;
|
||||
$dialog.removeClass('dashboard-contact-dragging');
|
||||
};
|
||||
$overlay.on('dragenter', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drag_depth++;
|
||||
if ( ! sending && ! sent ) $dialog.addClass('dashboard-contact-dragging');
|
||||
});
|
||||
$overlay.on('dragover', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.originalEvent.dataTransfer.dropEffect = sending || sent ? 'none' : 'copy';
|
||||
});
|
||||
$overlay.on('dragleave', (e) => {
|
||||
// Matched to the dragenters counted, whatever this one says it carries.
|
||||
if ( drag_depth === 0 ) return;
|
||||
e.stopPropagation();
|
||||
if ( --drag_depth <= 0 ) endDrag();
|
||||
});
|
||||
$overlay.on('drop', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
endDrag();
|
||||
addFiles(Array.from(e.originalEvent.dataTransfer.files ?? []));
|
||||
});
|
||||
|
||||
// A screenshot pasted into the message is attached to it.
|
||||
$dialog.on('paste', '.dashboard-form-input[name="message"]', (e) => {
|
||||
const clipboard = e.originalEvent?.clipboardData;
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if ( ! files.length ) return;
|
||||
// Some apps put the image's name or markup alongside it; that text still pastes.
|
||||
if ( ! clipboard.getData('text/plain') ) e.preventDefault();
|
||||
addFiles(files);
|
||||
});
|
||||
|
||||
// Enter alone is a new line in the message; with Cmd or Ctrl, it sends.
|
||||
$dialog.on('keydown', '.dashboard-form-input[name="message"]', (e) => {
|
||||
if ( e.key !== 'Enter' || ! (e.metaKey || e.ctrlKey) ) return;
|
||||
e.preventDefault();
|
||||
$dialog.trigger('submit');
|
||||
});
|
||||
|
||||
syncControls();
|
||||
return dialog;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// @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';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
import { MAX_ATTACHMENTS } from '../../helpers/contact_attachments.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, values = []) => [key, ...values].join(' ');
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
window.matchMedia = () => ({ matches: false });
|
||||
window.byte_format = (bytes) => `${bytes} B`;
|
||||
URL.createObjectURL = vi.fn(() => 'blob:preview');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
|
||||
const { default: UIDashboardContactDialog } = await import('./UIDashboardContactDialog.js');
|
||||
|
||||
const settle = async () => {
|
||||
for ( let i = 0; i < 10; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
const message = () => $('.dashboard-form-input[name="message"]');
|
||||
const status = () => $('.dashboard-form-status').text();
|
||||
const fileNames = () => $('.dashboard-contact-file-name').map((_, el) => el.textContent).get();
|
||||
const png = (name = 'shot.png', size = 1024) => new File([new Uint8Array(size)], name, { type: 'image/png' });
|
||||
const mp4 = (name = 'clip.mp4') => new File([new Uint8Array(2048)], name, { type: 'video/mp4' });
|
||||
|
||||
/** Picks files through the hidden file input, as the system picker would. */
|
||||
const pick = (...files) => {
|
||||
const input = $('.dashboard-contact-file-input').get(0);
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
||||
$(input).trigger('change');
|
||||
};
|
||||
|
||||
/** A native-event stand-in for jQuery to wrap, carrying `fields`. */
|
||||
const nativeEvent = (fields) => ({ preventDefault () {}, stopPropagation () {}, ...fields });
|
||||
|
||||
/** A file drag event carrying `files`, as the browser sends it. */
|
||||
const fileDrag = (type, files = []) => $.Event(type, {
|
||||
originalEvent: nativeEvent({ dataTransfer: { types: ['Files'], files, dropEffect: 'none' } }),
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
await settle();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.fetch = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'tok-1';
|
||||
});
|
||||
|
||||
describe('Contact Us dialog', () => {
|
||||
it('opens as a dashboard form dialog with no attachments listed', () => {
|
||||
UIDashboardContactDialog();
|
||||
expect($('.dashboard-dialog-overlay .dashboard-contact-dialog').length).toBe(1);
|
||||
expect(message().length).toBe(1);
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('asks for a message before sending', async () => {
|
||||
UIDashboardContactDialog();
|
||||
message().val(' ');
|
||||
await submit();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(status()).toBe('contact_us_message_required');
|
||||
expect(message().attr('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
it('posts the message and attachments as multipart and confirms', async () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png(), mp4());
|
||||
message().val('It broke');
|
||||
await submit();
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://api.puter.test/contactUs');
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer tok-1' });
|
||||
expect(init.body.get('message')).toBe('It broke');
|
||||
expect(init.body.getAll('attachments').map(f => f.name)).toEqual(['shot.png', 'clip.mp4']);
|
||||
expect($('.dashboard-form-done').text()).toContain('feedback_sent_confirmation');
|
||||
expect($('.dashboard-contact-attachments').length).toBe(0);
|
||||
});
|
||||
|
||||
it('says when the sender is rate limited and keeps the form', async () => {
|
||||
fetch.mockResolvedValueOnce(new Response('{}', { status: 429 }));
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(status()).toBe('contact_us_rate_limited');
|
||||
expect(message().val()).toBe('Hello');
|
||||
expect(message().prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a generic error when the request fails', async () => {
|
||||
fetch.mockRejectedValueOnce(new TypeError('offline'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(status()).toBe('contact_us_error');
|
||||
});
|
||||
|
||||
it('locks the attachments while sending', async () => {
|
||||
let respond;
|
||||
fetch.mockReturnValueOnce(new Promise(resolve => {
|
||||
respond = resolve;
|
||||
}));
|
||||
UIDashboardContactDialog();
|
||||
pick(png());
|
||||
message().val('Hello');
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
await settle();
|
||||
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
|
||||
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(true);
|
||||
pick(png('late.png'));
|
||||
expect(fileNames()).toEqual(['shot.png']);
|
||||
|
||||
respond(new Response('{}', { status: 500 }));
|
||||
await settle();
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(false);
|
||||
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachments', () => {
|
||||
it('lists picked files with a preview for images', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png('a.png'), mp4('b.mp4'));
|
||||
expect(fileNames()).toEqual(['a.png', 'b.mp4']);
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(false);
|
||||
expect($('.dashboard-contact-file').eq(0).find('img').attr('src')).toBe('blob:preview');
|
||||
expect($('.dashboard-contact-file').eq(1).find('img').length).toBe(0);
|
||||
expect($('.dashboard-contact-file-remove').eq(0).attr('aria-label')).toBe('contact_us_attachment_remove_named a.png');
|
||||
});
|
||||
|
||||
it('keeps the files that fit and reports the first rejection', () => {
|
||||
UIDashboardContactDialog();
|
||||
const text = new File(['x'], 'notes.txt', { type: 'text/plain' });
|
||||
pick(png('a.png'), text, png('b.png'));
|
||||
expect(fileNames()).toEqual(['a.png', 'b.png']);
|
||||
expect(status()).toBe('contact_us_attachment_unsupported');
|
||||
});
|
||||
|
||||
it('stops offering to attach once the count cap is reached', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(...Array.from({ length: MAX_ATTACHMENTS }, (_, i) => png(`${i}.png`)));
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
|
||||
pick(png('extra.png'));
|
||||
expect(fileNames()).toHaveLength(MAX_ATTACHMENTS);
|
||||
expect(status()).toBe(`contact_us_attachment_too_many ${MAX_ATTACHMENTS}`);
|
||||
});
|
||||
|
||||
it('removes the file whose button is pressed and moves focus to the next one', async () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png('a.png'), png('b.png'), png('c.png'));
|
||||
$('.dashboard-contact-file-remove').eq(1).trigger('click');
|
||||
expect(fileNames()).toEqual(['a.png', 'c.png']);
|
||||
expect(document.activeElement).toBe($('.dashboard-contact-file-remove').get(1));
|
||||
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(fetch.mock.calls[0][1].body.getAll('attachments').map(f => f.name)).toEqual(['a.png', 'c.png']);
|
||||
});
|
||||
|
||||
it('returns focus to the attach button when the last file is removed', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png());
|
||||
$('.dashboard-contact-file-remove').trigger('click');
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
|
||||
expect(document.activeElement).toBe($('.dashboard-contact-dropzone').get(0));
|
||||
});
|
||||
|
||||
it('takes files dropped anywhere on the overlay, without letting the drop through', () => {
|
||||
const onWindowDrop = vi.fn();
|
||||
const $win = $('<div class="window"></div>').appendTo('body').on('drop', onWindowDrop);
|
||||
UIDashboardContactDialog({ $container: $win });
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
|
||||
$overlay.trigger(fileDrag('dragenter'));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
|
||||
const drop = fileDrag('drop', [png('dropped.png')]);
|
||||
$overlay.trigger(drop);
|
||||
|
||||
expect(drop.isDefaultPrevented()).toBe(true);
|
||||
expect(onWindowDrop).not.toHaveBeenCalled();
|
||||
expect(fileNames()).toEqual(['dropped.png']);
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the drop highlight when the drag leaves', () => {
|
||||
UIDashboardContactDialog();
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
$overlay.trigger(fileDrag('dragenter'));
|
||||
$overlay.find('.dashboard-contact-dropzone').trigger(fileDrag('dragenter'));
|
||||
$overlay.find('.dashboard-contact-dropzone').trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
|
||||
$overlay.trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores drags that carry no files', () => {
|
||||
UIDashboardContactDialog();
|
||||
const drag = $.Event('dragenter', { originalEvent: nativeEvent({ dataTransfer: { types: ['text/plain'] } }) });
|
||||
$('.dashboard-dialog-overlay').trigger(drag);
|
||||
expect(drag.isDefaultPrevented()).toBe(false);
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('attaches an image pasted into the message', () => {
|
||||
UIDashboardContactDialog();
|
||||
const paste = $.Event('paste', {
|
||||
originalEvent: nativeEvent({ clipboardData: { files: [png('pasted.png')], getData: () => '' } }),
|
||||
});
|
||||
message().trigger(paste);
|
||||
expect(fileNames()).toEqual(['pasted.png']);
|
||||
expect(paste.isDefaultPrevented()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard', () => {
|
||||
it('sends on Cmd/Ctrl+Enter but not on Enter alone', async () => {
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
message().trigger($.Event('keydown', { key: 'Enter' }));
|
||||
await settle();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
message().trigger($.Event('keydown', { key: 'Enter', ctrlKey: true }));
|
||||
await settle();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -17,13 +17,14 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The Files tab's publish flows, a folder as a website and a .js file as a
|
||||
// worker: dashboard form dialogs rather than desktop windows.
|
||||
// The Files tab's publish flows, a folder as a website (optionally on a
|
||||
// custom domain) and a .js file as a worker: dashboard form dialogs rather
|
||||
// than desktop windows.
|
||||
|
||||
import ensure_can_publish from '../../helpers/ensureCanPublish.js';
|
||||
import forget_parent_listing from '../../helpers/forgetParentListing.js';
|
||||
import { SVG } from './UIDashboardDialog.js';
|
||||
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
|
||||
import { openLinkHtml, publishDomainFields, setupPublishDomain } from './publishDomain.js';
|
||||
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
|
||||
|
||||
/**
|
||||
@@ -36,8 +37,6 @@ import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from '.
|
||||
|
||||
const t = (key) => i18n(key, [], false);
|
||||
|
||||
const OPEN_ICON = SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>');
|
||||
|
||||
/** Rows showing `path` or anything under it, on the dashboard or the desktop. */
|
||||
const each_item_at = (path, fn) => $('.item').each(function () {
|
||||
const item_path = this.dataset.path ?? '';
|
||||
@@ -54,6 +53,8 @@ const KINDS = {
|
||||
nameErrors: new Set(['conflict', 'subdomain_reserved']),
|
||||
suffix: () => `.${window.hosting_domain}`,
|
||||
requiresVerification: () => !! window.require_email_verification_to_publish_website,
|
||||
extraFields: publishDomainFields,
|
||||
setup: setupPublishDomain,
|
||||
async publish (name, path) {
|
||||
const site = await puter.hosting.create(name, path);
|
||||
// The server's address carries the deployment's real scheme (a
|
||||
@@ -136,6 +137,11 @@ const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
|
||||
}];
|
||||
fields.push(...(kind.extraFields?.() ?? []));
|
||||
|
||||
/** Set once the site is live; a retry for a step after it skips publishing. */
|
||||
let published = null;
|
||||
/** @type {import('./publishDomain.js').PublishDomain | undefined} */
|
||||
let extra;
|
||||
|
||||
const dialog = UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t(kind.title),
|
||||
@@ -144,30 +150,52 @@ const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
|
||||
fields,
|
||||
async onSubmit () {
|
||||
const values = dialog.values();
|
||||
const subdomain = finalizePublishName(values.name, kind.rule);
|
||||
dialog.field('name').val(subdomain);
|
||||
if ( ! subdomain ) {
|
||||
dialog.setStatus(t('publish_name_required'), { field: 'name' });
|
||||
return;
|
||||
const hostname = extra?.prepare(!! published) ?? null;
|
||||
if ( hostname === false ) return;
|
||||
if ( ! published ) {
|
||||
const subdomain = finalizePublishName(values.name, kind.rule);
|
||||
dialog.field('name').val(subdomain);
|
||||
if ( ! subdomain ) {
|
||||
dialog.setStatus(t('publish_name_required'), { field: 'name' });
|
||||
return;
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = await kind.publish(subdomain, path, values);
|
||||
} catch ( err ) {
|
||||
const code = err?.error?.code ?? err?.code;
|
||||
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
|
||||
return;
|
||||
}
|
||||
kind.markPublished(path, url);
|
||||
published = { name: subdomain, url };
|
||||
// The address is taken now; what's left to retry is the domain.
|
||||
dialog.field('name').prop('readOnly', true).attr('aria-readonly', 'true');
|
||||
update_submit_label();
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = await kind.publish(subdomain, path, values);
|
||||
} catch ( err ) {
|
||||
const code = err?.error?.code ?? err?.code;
|
||||
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
|
||||
return;
|
||||
}
|
||||
kind.markPublished(path, url);
|
||||
const open_link = `<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
|
||||
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${OPEN_ICON}</a>`;
|
||||
const domain = hostname ? await extra.attach(hostname, published) : null;
|
||||
if ( hostname && ! domain ) return;
|
||||
dialog.finish({
|
||||
message: t(kind.published),
|
||||
html: copyRowHtml({ value: url, label: t('publish_address'), compact: true, after: open_link }),
|
||||
html: copyRowHtml({ value: published.url, label: t('publish_address'), compact: true, after: openLinkHtml(published.url) })
|
||||
+ (domain ? extra.doneHtml(domain) : ''),
|
||||
// The DNS records are shown only here.
|
||||
keepOnBackdrop: !! domain,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Before the site is live the button publishes; after, it adds the domain.
|
||||
const update_submit_label = () => {
|
||||
let key = 'publish';
|
||||
if ( published ) {
|
||||
if ( extra?.claiming() ) key = 'custom_domain_verify_and_add';
|
||||
else key = extra?.addable() ? 'custom_domain_add' : 'done';
|
||||
}
|
||||
dialog.$dialog.find('.dashboard-form-submit-label').text(t(key));
|
||||
};
|
||||
extra = kind.setup?.(dialog, { onChange: update_submit_label });
|
||||
|
||||
// Shape the name as it's typed, keeping the caret where it was.
|
||||
const el_name = dialog.field('name').get(0);
|
||||
if ( document.activeElement === el_name ) el_name.select();
|
||||
|
||||
@@ -35,13 +35,59 @@ const { openPublishWebsiteDialog, openPublishWorkerDialog } = await import('./UI
|
||||
const { default: ensure_can_publish } = await import('../../helpers/ensureCanPublish.js');
|
||||
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const flush = async () => {
|
||||
for ( let i = 0; i < 5; i++ ) await settle();
|
||||
};
|
||||
const nameField = () => $('.dashboard-form-input[name="name"]');
|
||||
const status = () => $('.dashboard-form-status').text();
|
||||
|
||||
const submitWith = async (name) => {
|
||||
if ( name !== undefined ) nameField().val(name);
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
for ( let i = 0; i < 5; i++ ) await settle();
|
||||
await flush();
|
||||
};
|
||||
|
||||
const json = (code, data) => new Response(JSON.stringify(data), {
|
||||
status: code,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Stands in for the `/custom-domains` API. The list call answers with
|
||||
* `list`; anything else with its entry in `routes` (keyed `METHOD /path`),
|
||||
* or a 404.
|
||||
*/
|
||||
const serveDomains = ({ list = { items: [], canAdd: true, pricing: { amount: 0.5, currency: 'USD' } }, routes = {} } = {}) => {
|
||||
globalThis.fetch = vi.fn(async (url, opts = {}) => {
|
||||
const key = `${opts.method ?? 'GET'} ${String(url).split('/custom-domains')[1]}`;
|
||||
if ( key === 'GET ' ) return json(200, typeof list === 'function' ? list() : list);
|
||||
return routes[key]?.(opts.body ? JSON.parse(opts.body) : undefined) ?? json(404, {});
|
||||
});
|
||||
};
|
||||
const requests = (method) => fetch.mock.calls
|
||||
.filter(([, opts = {}]) => (opts.method ?? 'GET') === method)
|
||||
.map(([url, opts]) => ({ path: String(url).split('/custom-domains')[1], body: opts.body ? JSON.parse(opts.body) : undefined }));
|
||||
|
||||
const pendingDomain = (overrides = {}) => ({
|
||||
uuid: 'd1',
|
||||
hostname: 'www.example.com',
|
||||
site: 'my-site',
|
||||
status: 'pending',
|
||||
isApex: false,
|
||||
records: [{ type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing', required: true }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const domainSwitch = () => $('.dashboard-form-input[name="useDomain"]');
|
||||
const domainField = () => $('.dashboard-form-input[name="domain"]');
|
||||
const shown = (name) => $(`.dashboard-form-field[data-field="${name}"]`).prop('hidden') === false;
|
||||
const submitLabel = () => $('.dashboard-form-submit-label').text();
|
||||
const turnOnDomain = () => domainSwitch().prop('checked', true).trigger('change');
|
||||
|
||||
/** Opens the website dialog and waits for the custom domain list. */
|
||||
const openWebsite = async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await flush();
|
||||
};
|
||||
|
||||
const addRow = (path) => {
|
||||
@@ -59,6 +105,10 @@ beforeEach(() => {
|
||||
window.generate_identifier = () => 'clever-idea-42';
|
||||
window.update_sites_cache = vi.fn();
|
||||
window.require_email_verification_to_publish_website = false;
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
delete window.UIUpgradeAccount;
|
||||
// A Puter without custom domains, unless a test serves them.
|
||||
globalThis.fetch = vi.fn(async () => json(404, {}));
|
||||
globalThis.puter = {
|
||||
fsCacheKey: (kind, path) => `${kind}:${path}`,
|
||||
_cache: { del: vi.fn() },
|
||||
@@ -126,7 +176,7 @@ describe('publish website', () => {
|
||||
expect(nameField().val()).toBe('cafe');
|
||||
});
|
||||
|
||||
it('asks for an address when nothing usable was typed', async () => {
|
||||
it('asks for an address when nothing usable was typed', async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await submitWith('---');
|
||||
expect(status()).toBe('publish_name_required');
|
||||
@@ -164,6 +214,358 @@ describe('publish website', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish website on a custom domain', () => {
|
||||
it('leaves the option out where the server has no custom domains', async () => {
|
||||
await openWebsite();
|
||||
expect(shown('useDomain')).toBe(false);
|
||||
expect(shown('domain')).toBe(false);
|
||||
await submitWith('my-site');
|
||||
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
|
||||
expect(requests('POST')).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks for the domain once the switch is on, with what it costs', async () => {
|
||||
serveDomains();
|
||||
await openWebsite();
|
||||
expect(shown('useDomain')).toBe(true);
|
||||
expect(shown('domain')).toBe(false);
|
||||
|
||||
turnOnDomain();
|
||||
|
||||
expect(shown('domain')).toBe(true);
|
||||
expect(document.activeElement).toBe(domainField().get(0));
|
||||
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text())
|
||||
.toBe('publish_custom_domain_dns_hint custom_domain_price_note');
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes the site, adds the domain, and shows the records to set', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(201, pendingDomain()) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('https://WWW.Example.com/about');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
|
||||
expect(requests('POST')).toEqual([{ path: '', body: { site: 'my-site', hostname: 'www.example.com' } }]);
|
||||
expect($('.dashboard-form-done p').text()).toBe('website_published');
|
||||
expect($('.dashboard-copy-value').first().val()).toBe('https://my-site.puter.site');
|
||||
expect($('.dashboard-publish-domain-host').text()).toBe('www.example.com');
|
||||
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('pending');
|
||||
expect($('.dashboard-dns-record-type').text()).toBe('CNAME');
|
||||
expect($('.dashboard-dns-record .dashboard-copy-value').map((_, el) => el.value).get())
|
||||
.toEqual(['www', 'my-site.puter.site']);
|
||||
});
|
||||
|
||||
it('keeps the records up through a stray click outside the card', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(201, pendingDomain()) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
const $backdrop = $('.dashboard-dialog-overlay');
|
||||
$backdrop.trigger('mousedown').trigger('click');
|
||||
await flush();
|
||||
expect($backdrop.hasClass('dashboard-dialog-show')).toBe(true);
|
||||
expect($('.dashboard-dns-record')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('checks the domain again and shows its address once connected', async () => {
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain()),
|
||||
'GET /d1': () => json(200, pendingDomain({ status: 'active', records: [] })),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
$('.dashboard-publish-domain-check').trigger('click');
|
||||
await flush();
|
||||
|
||||
expect(requests('GET').map((r) => r.path)).toContain('/d1');
|
||||
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('active');
|
||||
expect($('.dashboard-publish-domain .dashboard-copy-value').val()).toBe('https://www.example.com');
|
||||
expect($('.dashboard-publish-domain-status').text()).toBe('custom_domain_success_note');
|
||||
expect($('.dashboard-publish-domain-check')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('announces a check in the status region already on screen', async () => {
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain()),
|
||||
'GET /d1': () => json(200, pendingDomain()),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
const el_status = $('.dashboard-publish-domain-status').get(0);
|
||||
|
||||
$('.dashboard-publish-domain-check').trigger('click');
|
||||
await flush();
|
||||
|
||||
expect($('.dashboard-publish-domain-status').get(0)).toBe(el_status);
|
||||
expect(el_status.textContent).toBe('custom_domain_status_pending');
|
||||
// The redrawn records are not read out again.
|
||||
expect($('.dashboard-dns-record').closest('[aria-live]').attr('aria-live')).toBe('off');
|
||||
});
|
||||
|
||||
it('runs one domain action at a time on the done screen', async () => {
|
||||
let answer_check;
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain({ hostname: 'example.com', isApex: true })),
|
||||
'GET /d1': () => new Promise((resolve) => {
|
||||
answer_check = () => resolve(json(200, pendingDomain({ hostname: 'example.com', isApex: true })));
|
||||
}),
|
||||
'POST /d1/use-www': () => json(200, pendingDomain({ redirectFrom: 'example.com' })),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
$('.dashboard-publish-domain-check').trigger('click');
|
||||
await flush();
|
||||
expect($('.dashboard-publish-domain-www').prop('disabled')).toBe(true);
|
||||
$('.dashboard-publish-domain-www').trigger('click');
|
||||
await flush();
|
||||
expect(requests('POST').map((r) => r.path)).not.toContain('/d1/use-www');
|
||||
|
||||
answer_check();
|
||||
await flush();
|
||||
expect($('.dashboard-publish-domain-www').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('stops before publishing when the domain is not one', async () => {
|
||||
serveDomains();
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('localhost');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(status()).toBe('custom_domain_error_invalid');
|
||||
expect(domainField().attr('aria-invalid')).toBe('true');
|
||||
expect(puter.hosting.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the live site and retries only the domain when adding it fails', async () => {
|
||||
const add = vi.fn()
|
||||
.mockReturnValueOnce(json(409, { code: 'custom_domain_taken', message: 'That hostname is already in use' }))
|
||||
.mockReturnValueOnce(json(201, pendingDomain({ hostname: 'www.example.org' })));
|
||||
serveDomains({ routes: { 'POST ': add } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(status()).toBe('publish_domain_failed\ncustom_domain_error_taken');
|
||||
expect(domainField().attr('aria-invalid')).toBe('true');
|
||||
expect(nameField().prop('readOnly')).toBe(true);
|
||||
expect(submitLabel()).toBe('custom_domain_add');
|
||||
// Published now, so the hint no longer speaks of "after publishing".
|
||||
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text()).toBe('custom_domain_price_note');
|
||||
|
||||
domainField().val('www.example.org');
|
||||
await submitWith();
|
||||
|
||||
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
|
||||
expect(add).toHaveBeenLastCalledWith({ site: 'my-site', hostname: 'www.example.org' });
|
||||
expect($('.dashboard-publish-domain-host').text()).toBe('www.example.org');
|
||||
});
|
||||
|
||||
it('finishes with just the site when the domain is turned off after a failure', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(409, { code: 'custom_domain_taken' }) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
domainSwitch().prop('checked', false).trigger('change');
|
||||
expect(submitLabel()).toBe('done');
|
||||
await submitWith();
|
||||
|
||||
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
|
||||
expect($('.dashboard-form-done p').text()).toBe('website_published');
|
||||
expect($('.dashboard-publish-domain')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('moves a domain already on the account instead of adding it again', async () => {
|
||||
serveDomains({
|
||||
list: { items: [pendingDomain({ site: 'old-site', status: 'active' })], canAdd: true },
|
||||
routes: { 'PATCH /d1': () => json(200, pendingDomain({ status: 'active' })) },
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
expect($(`#${domainField().attr('list')} option`).map((_, el) => el.value).get()).toEqual(['www.example.com']);
|
||||
domainField().val('www.example.com').trigger('input');
|
||||
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text()).toBe('publish_custom_domain_moves');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(requests('POST')).toEqual([]);
|
||||
expect(requests('PATCH')).toEqual([{ path: '/d1', body: { site: 'my-site' } }]);
|
||||
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('active');
|
||||
});
|
||||
|
||||
it('says the www hostname serves the site and the root domain forwards to it', async () => {
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain({
|
||||
redirectFrom: 'example.com',
|
||||
records: [
|
||||
{ type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing', required: true },
|
||||
{ type: 'A', name: '@', value: '192.0.2.1', purpose: 'redirect', required: true },
|
||||
],
|
||||
})),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
const i18n_stub = globalThis.i18n;
|
||||
globalThis.i18n = (key, replacements) => (Array.isArray(replacements) && replacements.length ? `${key}:${replacements.join('|')}` : key);
|
||||
try {
|
||||
await submitWith('my-site');
|
||||
} finally {
|
||||
globalThis.i18n = i18n_stub;
|
||||
}
|
||||
|
||||
expect($('.dashboard-publish-domain .dashboard-form-hint').last().text())
|
||||
.toBe('custom_domain_redirect_note:www.example.com|example.com');
|
||||
});
|
||||
|
||||
it('shows the ownership record for a domain held by another account', async () => {
|
||||
const add = vi.fn(() => json(409, {
|
||||
code: 'custom_domain_claim_required',
|
||||
verification: { name: '_puter.www.example.com', value: 'token-1' },
|
||||
}));
|
||||
serveDomains({ routes: { 'POST ': add } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect($('.dashboard-publish-claim').prop('hidden')).toBe(false);
|
||||
expect($('.dashboard-publish-claim .dashboard-dns-record-type').text()).toBe('TXT');
|
||||
expect($('.dashboard-publish-claim .dashboard-copy-value').map((_, el) => el.value).get())
|
||||
.toEqual(['_puter.www.example.com', 'token-1']);
|
||||
expect(submitLabel()).toBe('custom_domain_verify_and_add');
|
||||
expect(status()).toBe('publish_site_live');
|
||||
|
||||
await submitWith();
|
||||
expect(status()).toBe('publish_domain_failed\ncustom_domain_claim_not_found');
|
||||
|
||||
// A claim is for one hostname; editing it starts over.
|
||||
domainField().val('www.example.net').trigger('input');
|
||||
expect($('.dashboard-publish-claim').prop('hidden')).toBe(true);
|
||||
expect(submitLabel()).toBe('custom_domain_add');
|
||||
|
||||
// Back to the claimed hostname, its record and the verify step return together.
|
||||
domainField().val('www.example.com').trigger('input');
|
||||
expect($('.dashboard-publish-claim').prop('hidden')).toBe(false);
|
||||
expect(submitLabel()).toBe('custom_domain_verify_and_add');
|
||||
});
|
||||
|
||||
describe('on a plan without custom domains', () => {
|
||||
beforeEach(() => {
|
||||
window.UIUpgradeAccount = vi.fn(function () {
|
||||
this.open_as_window = vi.fn();
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the upgrade in place of the field, leaving focus on the switch', async () => {
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
await openWebsite();
|
||||
domainSwitch().get(0).focus();
|
||||
turnOnDomain();
|
||||
|
||||
expect(shown('domain')).toBe(false);
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(false);
|
||||
expect(document.activeElement).toBe(domainSwitch().get(0));
|
||||
$('.dashboard-publish-upgrade').trigger('click');
|
||||
expect(window.UIUpgradeAccount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not publish while the switch asks for a domain it cannot add', async () => {
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
await submitWith('my-site');
|
||||
expect(status()).toBe('publish_custom_domain_needs_plan');
|
||||
expect(puter.hosting.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unlocks the field once the upgrade goes through', async () => {
|
||||
let can_add = false;
|
||||
serveDomains({ list: () => ({ items: [], canAdd: can_add }) });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
$('.dashboard-publish-upgrade').trigger('click');
|
||||
|
||||
can_add = true;
|
||||
window.dispatchEvent(new CustomEvent('puter:subscription:changed'));
|
||||
await flush();
|
||||
|
||||
expect(shown('domain')).toBe(true);
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(true);
|
||||
expect(document.activeElement).toBe(domainField().get(0));
|
||||
});
|
||||
|
||||
it('only listens for a plan change once it has offered the upgrade', async () => {
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
const listen = vi.spyOn(window, 'addEventListener');
|
||||
try {
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
expect(listen.mock.calls.map(([type]) => type)).not.toContain('puter:subscription:changed');
|
||||
$('.dashboard-publish-upgrade').trigger('click');
|
||||
expect(listen.mock.calls.map(([type]) => type)).toContain('puter:subscription:changed');
|
||||
} finally {
|
||||
listen.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('explains the plan without a button where there is no upgrade flow', async () => {
|
||||
delete window.UIUpgradeAccount;
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
expect($('.dashboard-publish-upgrade')).toHaveLength(0);
|
||||
await submitWith('my-site');
|
||||
expect(status()).toBe('custom_domain_error_plan');
|
||||
});
|
||||
|
||||
it('finishes with the live site when adding the domain turns out to need a plan', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(402, { code: 'subscription_required' }) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(status()).toBe('publish_domain_failed\ncustom_domain_error_plan');
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(false);
|
||||
expect(submitLabel()).toBe('done');
|
||||
|
||||
await submitWith();
|
||||
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
|
||||
expect(requests('POST')).toHaveLength(1);
|
||||
expect($('.dashboard-form-done p').text()).toBe('website_published');
|
||||
expect($('.dashboard-publish-domain')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish worker', () => {
|
||||
it('always needs a verified account', async () => {
|
||||
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The custom domain part of the Publish Website dialog: a switch that asks
|
||||
// for a domain, an upgrade prompt where the plan doesn't include one, and,
|
||||
// once the site is live, the domain's DNS records with a status check.
|
||||
|
||||
import { isPlausibleHostname, normalizeHostnameInput } from '../../helpers/customDomainHostname.js';
|
||||
import {
|
||||
CUSTOM_DOMAIN_HOSTNAME_ERRORS,
|
||||
customDomainErrorText,
|
||||
customDomainsRequest,
|
||||
customDomainStatus,
|
||||
formatCustomDomainPrice,
|
||||
mergeCustomDomainRefresh,
|
||||
} from '../../helpers/customDomains.js';
|
||||
import { has_upgrade_flow, open_upgrade_flow } from '../../helpers/planGate.js';
|
||||
import { SVG } from './UIDashboardDialog.js';
|
||||
import { copyRowHtml } from './UIDashboardFormDialog.js';
|
||||
|
||||
/** @typedef {import('../../helpers/customDomains.js').CustomDomain} CustomDomain */
|
||||
/** @typedef {import('../../helpers/customDomains.js').CustomDomainRecord} CustomDomainRecord */
|
||||
/** @typedef {import('./UIDashboardFormDialog.js').FormDialog} FormDialog */
|
||||
/** @typedef {import('./UIDashboardFormDialog.js').FormDialogField} FormDialogField */
|
||||
|
||||
const t = (key, args = []) => i18n(key, args, false);
|
||||
|
||||
const ICONS = {
|
||||
open: SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>'),
|
||||
globe: SVG('<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/>'),
|
||||
sparkle: SVG('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"/>'),
|
||||
};
|
||||
|
||||
const BADGE_KEYS = {
|
||||
active: 'custom_domain_badge_active',
|
||||
pending: 'custom_domain_badge_pending',
|
||||
failed: 'custom_domain_badge_failed',
|
||||
};
|
||||
|
||||
/**
|
||||
* An icon link opening `url` in a new tab, beside a compact copy row.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export const openLinkHtml = (url) =>
|
||||
`<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
|
||||
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${ICONS.open}</a>`;
|
||||
|
||||
/**
|
||||
* DNS records as cards: the type, then the name and value, each with a Copy
|
||||
* button the form dialog wires.
|
||||
*
|
||||
* @param {CustomDomainRecord[]} records
|
||||
* @param {{ isApex?: boolean }} [domain] A root domain's routing record can
|
||||
* be any of the record types that alias a root.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const dnsRecordsHtml = (records, { isApex = false } = {}) => records.map((record) => {
|
||||
const type = isApex && record.purpose === 'routing' ? t('custom_domain_record_type_apex') : record.type;
|
||||
const field = (labelKey, value) => '<div class="dashboard-dns-record-field">'
|
||||
+ `<span class="dashboard-dns-record-label">${i18n(labelKey)}</span>`
|
||||
+ copyRowHtml({ value, label: `${type} ${t(labelKey)}`, compact: true })
|
||||
+ '</div>';
|
||||
return '<div class="dashboard-dns-record">'
|
||||
+ '<div class="dashboard-dns-record-head">'
|
||||
+ `<span class="dashboard-dns-record-type">${html_encode(type)}</span>`
|
||||
+ (record.required === false ? `<span class="dashboard-dns-record-optional">${i18n('custom_domain_optional')}</span>` : '')
|
||||
+ '</div>'
|
||||
+ field('name', record.name)
|
||||
+ field('custom_domain_record_value', record.value)
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
/**
|
||||
* The done screen's account of a domain: its status, and until it's
|
||||
* connected, the records still to set.
|
||||
*
|
||||
* @param {CustomDomain} domain
|
||||
* @returns {string}
|
||||
*/
|
||||
export const domainSummaryHtml = (domain) => {
|
||||
const status = customDomainStatus(domain);
|
||||
let h = '<div class="dashboard-publish-domain-head">'
|
||||
+ `<span class="dashboard-publish-domain-icon">${ICONS.globe}</span>`
|
||||
+ `<span class="dashboard-publish-domain-host">${html_encode(domain.hostname)}</span>`
|
||||
+ `<span class="dashboard-publish-domain-badge" data-status="${status}">${i18n(BADGE_KEYS[status])}</span>`
|
||||
+ '</div>';
|
||||
if ( status === 'active' ) {
|
||||
const url = `https://${domain.hostname}`;
|
||||
h += copyRowHtml({ value: url, label: domain.hostname, compact: true, after: openLinkHtml(url) });
|
||||
} else {
|
||||
if ( status === 'failed' && domain.statusDetail ) {
|
||||
h += `<p class="dashboard-publish-domain-detail">${html_encode(domain.statusDetail)}</p>`;
|
||||
}
|
||||
h += `<p class="dashboard-form-hint">${i18n('custom_domain_records_c2a')}</p>`;
|
||||
h += `<div class="dashboard-dns-records">${dnsRecordsHtml(domain.records ?? [], domain)}</div>`;
|
||||
if ( domain.isApex ) {
|
||||
h += `<p class="dashboard-form-hint">${i18n('custom_domain_apex_hint')}</p>`;
|
||||
h += `<button type="button" class="dashboard-publish-link-btn dashboard-publish-domain-www">${i18n('custom_domain_no_root_alias')}</button>`;
|
||||
}
|
||||
}
|
||||
if ( domain.redirectFrom ) {
|
||||
h += `<p class="dashboard-form-hint">${i18n('custom_domain_redirect_note', [domain.hostname, domain.redirectFrom])}</p>`;
|
||||
}
|
||||
if ( status !== 'active' ) {
|
||||
h += '<div class="dashboard-publish-domain-actions">'
|
||||
+ '<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-publish-domain-check">'
|
||||
+ '<span class="dashboard-form-spinner" aria-hidden="true"></span>'
|
||||
+ `<span>${i18n('custom_domain_check_status')}</span>`
|
||||
+ '</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
return h;
|
||||
};
|
||||
|
||||
/**
|
||||
* The switch and the domain field. Both start hidden: the switch shows once
|
||||
* the server answers that it has custom domains.
|
||||
*
|
||||
* @returns {FormDialogField[]}
|
||||
*/
|
||||
export const publishDomainFields = () => [
|
||||
{
|
||||
name: 'useDomain',
|
||||
type: 'switch',
|
||||
label: i18n('publish_custom_domain'),
|
||||
hint: i18n('publish_custom_domain_hint'),
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: 'domain',
|
||||
label: i18n('custom_domain_hostname_label'),
|
||||
placeholder: 'www.example.com',
|
||||
hint: i18n('publish_custom_domain_dns_hint'),
|
||||
hidden: true,
|
||||
attrs: {
|
||||
inputmode: 'url',
|
||||
enterkeyhint: 'go',
|
||||
autocomplete: 'off',
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
spellcheck: 'false',
|
||||
'data-gramm_editor': 'false',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @typedef {object} PublishDomain
|
||||
* @property {() => boolean} addable Whether the switch is on and the plan
|
||||
* lets a domain be added.
|
||||
* @property {(live: boolean) => string | null | false} prepare Before each
|
||||
* submit: null when no domain is to be added (including when the site is
|
||||
* `live` and the plan can't add one), false when the form can't go ahead
|
||||
* (the dialog says why), else the hostname to add.
|
||||
* @property {(hostname: string, site: { name: string, url: string }) => Promise<CustomDomain | null>} attach
|
||||
* Adds the hostname to the live site, or moves it there when it's already
|
||||
* on the account. Null when it didn't; the dialog says why.
|
||||
* @property {(domain: CustomDomain) => string} doneHtml The done screen's domain part.
|
||||
* @property {() => boolean} claiming Whether an ownership record for the
|
||||
* typed hostname is on screen, waiting to be verified.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wires the custom domain fields of a publish form dialog.
|
||||
*
|
||||
* @param {FormDialog} dialog
|
||||
* @param {object} opts
|
||||
* @param {() => void} opts.onChange Runs when what the submit button does may
|
||||
* have changed: the switch, the plan, or a claim to verify.
|
||||
* @returns {PublishDomain}
|
||||
*/
|
||||
export const setupPublishDomain = (dialog, { onChange }) => {
|
||||
const { $dialog } = dialog;
|
||||
const $switch = dialog.field('useDomain');
|
||||
const $input = dialog.field('domain');
|
||||
const $field = $input.closest('.dashboard-form-field');
|
||||
const $hint = $field.find('.dashboard-form-hint');
|
||||
const id = $input.attr('id');
|
||||
|
||||
const upgradable = has_upgrade_flow();
|
||||
$field.after(
|
||||
`<div class="dashboard-publish-upsell" hidden>`
|
||||
+ `<span class="dashboard-publish-upsell-icon">${ICONS.sparkle}</span>`
|
||||
+ '<div class="dashboard-publish-upsell-text">'
|
||||
+ `<p class="dashboard-publish-upsell-title">${i18n('publish_custom_domain_upsell_title')}</p>`
|
||||
+ (upgradable ? `<p class="dashboard-form-hint">${i18n('publish_custom_domain_upsell_text')}</p>` : '')
|
||||
+ '</div>'
|
||||
+ (upgradable ? `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-publish-upgrade">${i18n('billing.upgrade')}</button>` : '')
|
||||
+ '</div>'
|
||||
+ '<div class="dashboard-publish-claim" hidden>'
|
||||
+ `<p class="dashboard-publish-claim-text">${i18n('custom_domain_claim_c2a')}</p>`
|
||||
+ '<div class="dashboard-dns-records"></div>'
|
||||
+ '</div>'
|
||||
+ `<datalist id="${id}-owned"></datalist>`,
|
||||
);
|
||||
const $upsell = $dialog.find('.dashboard-publish-upsell');
|
||||
const $claim = $dialog.find('.dashboard-publish-claim');
|
||||
const $owned = $dialog.find(`#${id}-owned`);
|
||||
$input.attr('list', `${id}-owned`);
|
||||
|
||||
let available = false;
|
||||
let can_add = true;
|
||||
let price = null;
|
||||
/** @type {CustomDomain[]} */
|
||||
let owned = [];
|
||||
/** The hostname whose ownership record is on screen. */
|
||||
let claim_for = null;
|
||||
/** @type {CustomDomain | null} */
|
||||
let shown = null;
|
||||
/** Whether the site is published, so the domain is all that's left. */
|
||||
let live = false;
|
||||
|
||||
const typed = () => normalizeHostnameInput($input.val());
|
||||
const owned_named = (hostname) => owned.find((domain) => domain.hostname === hostname);
|
||||
const wanted = () => available && $switch.prop('checked');
|
||||
const addable = () => wanted() && can_add;
|
||||
|
||||
const update_hint = () => {
|
||||
const existing = owned_named(typed());
|
||||
$hint.text(existing
|
||||
? t('publish_custom_domain_moves', [`${existing.site}.${window.hosting_domain}`])
|
||||
: [! live && t('publish_custom_domain_dns_hint'), price && t('custom_domain_price_note', [price])].filter(Boolean).join(' '));
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const on = wanted();
|
||||
dialog.showField('domain', on && can_add);
|
||||
$upsell.prop('hidden', ! on || can_add);
|
||||
$claim.prop('hidden', ! on || ! can_add || claim_for !== typed());
|
||||
update_hint();
|
||||
onChange();
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
let res;
|
||||
try {
|
||||
res = await customDomainsRequest('GET');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// No custom domain API on this Puter, or none for this account:
|
||||
// the option stays out of the way.
|
||||
if ( ! res.ok || ! Array.isArray(res.data?.items) ) return;
|
||||
owned = res.data.items;
|
||||
can_add = res.data.canAdd !== false;
|
||||
price = formatCustomDomainPrice(res.data.pricing);
|
||||
$owned.html(owned.map((domain) => `<option value="${html_encode(domain.hostname)}"></option>`).join(''));
|
||||
if ( ! available ) {
|
||||
available = true;
|
||||
dialog.showField('useDomain', true);
|
||||
}
|
||||
sync();
|
||||
};
|
||||
load();
|
||||
|
||||
// The field takes focus as the switch reveals it. The upgrade prompt
|
||||
// doesn't: a second Space meant for the switch would open the upgrade.
|
||||
$switch.on('change', () => {
|
||||
sync();
|
||||
if ( addable() ) $input.get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
// A claim's record shows only while its hostname is the one typed.
|
||||
$input.on('input', sync);
|
||||
|
||||
// Once an upgrade lands, the field unlocks where the prompt was. The
|
||||
// dialog is modal, so only its own prompt can change the plan meanwhile.
|
||||
const on_plan_change = async () => {
|
||||
if ( ! document.contains($dialog.get(0)) ) {
|
||||
window.removeEventListener('puter:subscription:changed', on_plan_change);
|
||||
return;
|
||||
}
|
||||
const had = can_add;
|
||||
await load();
|
||||
if ( ! had && addable() ) $input.get(0)?.focus({ preventScroll: true });
|
||||
};
|
||||
$dialog.on('click', '.dashboard-publish-upgrade', () => {
|
||||
if ( ! open_upgrade_flow() ) return;
|
||||
window.addEventListener('puter:subscription:changed', on_plan_change);
|
||||
});
|
||||
|
||||
// -- Done screen --
|
||||
|
||||
/** Encoded HTML into the done screen's status line; empty clears it. */
|
||||
const set_done_status = (html, error = false) => {
|
||||
$dialog.find('.dashboard-publish-domain-status').html(html)
|
||||
.toggleClass('dashboard-publish-domain-status-error', error);
|
||||
};
|
||||
const render_done = (focus_selector) => {
|
||||
const $section = $dialog.find('.dashboard-publish-domain-summary');
|
||||
$section.html(domainSummaryHtml(shown));
|
||||
const el = $section.find(focus_selector).get(0)
|
||||
?? $section.find('.dashboard-copy-value').get(0)
|
||||
?? $dialog.find('.dashboard-form-done-btn').get(0);
|
||||
el?.focus({ preventScroll: true });
|
||||
};
|
||||
const DONE_ACTIONS = '.dashboard-publish-domain-check, .dashboard-publish-domain-www';
|
||||
let done_busy = false;
|
||||
/**
|
||||
* Runs a done-screen request with `$btn` busy and the other actions held,
|
||||
* since each answer redraws the domain; resolves with the response, or null.
|
||||
*/
|
||||
const done_request = async ($btn, method, path) => {
|
||||
done_busy = true;
|
||||
$dialog.find(DONE_ACTIONS).prop('disabled', true);
|
||||
$btn.addClass('dashboard-form-busy').attr('aria-busy', 'true');
|
||||
set_done_status('');
|
||||
try {
|
||||
const res = await customDomainsRequest(method, path);
|
||||
if ( res.ok ) return res;
|
||||
set_done_status(html_encode(customDomainErrorText(res)), true);
|
||||
} catch {
|
||||
set_done_status(i18n('custom_domain_error_generic'), true);
|
||||
} finally {
|
||||
done_busy = false;
|
||||
$dialog.find(DONE_ACTIONS).prop('disabled', false);
|
||||
$btn.removeClass('dashboard-form-busy').removeAttr('aria-busy');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$dialog.on('click', '.dashboard-publish-domain-check', async function () {
|
||||
if ( ! shown || done_busy ) return;
|
||||
const res = await done_request($(this), 'GET', `/${encodeURIComponent(shown.uuid)}`);
|
||||
if ( ! res ) return;
|
||||
shown = mergeCustomDomainRefresh(shown, res.data);
|
||||
render_done('.dashboard-publish-domain-check');
|
||||
const status = customDomainStatus(shown);
|
||||
set_done_status(status === 'active'
|
||||
? i18n('custom_domain_success_note', [shown.hostname, `${shown.site}.${window.hosting_domain}`])
|
||||
: i18n(status === 'failed' ? 'custom_domain_status_failed' : 'custom_domain_status_pending', [shown.hostname]));
|
||||
});
|
||||
// Same domain, so the month already paid covers the www hostname.
|
||||
$dialog.on('click', '.dashboard-publish-domain-www', async function () {
|
||||
if ( ! shown || done_busy ) return;
|
||||
const res = await done_request($(this), 'POST', `/${encodeURIComponent(shown.uuid)}/use-www`);
|
||||
if ( ! res ) return;
|
||||
shown = res.data;
|
||||
render_done('.dashboard-publish-domain-check');
|
||||
});
|
||||
|
||||
return {
|
||||
addable,
|
||||
prepare (live) {
|
||||
if ( ! wanted() || (live && ! can_add) ) return null;
|
||||
if ( ! can_add ) {
|
||||
dialog.setStatus(t(upgradable ? 'publish_custom_domain_needs_plan' : 'custom_domain_error_plan'));
|
||||
return false;
|
||||
}
|
||||
const hostname = typed();
|
||||
$input.val(hostname);
|
||||
if ( ! isPlausibleHostname(hostname) ) {
|
||||
dialog.setStatus(t('custom_domain_error_invalid'), { field: 'domain' });
|
||||
return false;
|
||||
}
|
||||
return hostname;
|
||||
},
|
||||
async attach (hostname, site) {
|
||||
if ( ! live ) {
|
||||
live = true;
|
||||
update_hint();
|
||||
}
|
||||
const existing = owned_named(hostname);
|
||||
const failed = (reason, field) => {
|
||||
dialog.setStatus(`${t('publish_domain_failed', [site.url])}\n${reason}`, field ? { field } : undefined);
|
||||
return null;
|
||||
};
|
||||
let res;
|
||||
try {
|
||||
res = existing
|
||||
? await customDomainsRequest('PATCH', `/${encodeURIComponent(existing.uuid)}`, { site: site.name })
|
||||
: await customDomainsRequest('POST', '', { site: site.name, hostname });
|
||||
} catch {
|
||||
return failed(t('custom_domain_error_generic'));
|
||||
}
|
||||
if ( res.ok ) {
|
||||
claim_for = null;
|
||||
return res.data;
|
||||
}
|
||||
// Held by another account: shown its ownership record the first
|
||||
// time; a retry that still can't see the record says so.
|
||||
const verification = res.data?.verification;
|
||||
if ( res.data?.code === 'custom_domain_claim_required' && verification ) {
|
||||
const retry = claim_for === hostname;
|
||||
claim_for = hostname;
|
||||
$claim.find('.dashboard-dns-records').html(dnsRecordsHtml([{ type: 'TXT', name: verification.name, value: verification.value }]));
|
||||
sync();
|
||||
if ( retry ) return failed(t('custom_domain_claim_not_found'));
|
||||
dialog.setStatus(t('publish_site_live', [site.url]), { kind: 'info' });
|
||||
return null;
|
||||
}
|
||||
if ( res.status === 402 && res.data?.code === 'subscription_required' ) {
|
||||
can_add = false;
|
||||
sync();
|
||||
}
|
||||
return failed(customDomainErrorText(res), CUSTOM_DOMAIN_HOSTNAME_ERRORS.has(res.data?.code) ? 'domain' : undefined);
|
||||
},
|
||||
doneHtml (domain) {
|
||||
shown = domain;
|
||||
// The summary is redrawn after each check; the status line stays,
|
||||
// so screen readers announce what changed and not the records again.
|
||||
return '<section class="dashboard-publish-domain">'
|
||||
+ `<div class="dashboard-publish-domain-summary" aria-live="off">${domainSummaryHtml(domain)}</div>`
|
||||
+ '<p class="dashboard-publish-domain-status" role="status" aria-live="polite"></p>'
|
||||
+ '</section>';
|
||||
},
|
||||
claiming: () => addable() && claim_for !== null && claim_for === typed(),
|
||||
};
|
||||
};
|
||||
@@ -3446,6 +3446,10 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
else if ( target_path !== '/' ) {
|
||||
try {
|
||||
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
|
||||
// the window may have moved on while this stat was in flight
|
||||
if ( $(el_window).attr('data-path') !== html_encode(target_path) ) {
|
||||
return;
|
||||
}
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
|
||||
@@ -54,7 +54,8 @@ async function UIWindowAppFeedback (options) {
|
||||
let sending = false;
|
||||
let closed = false;
|
||||
|
||||
const authToken = puter.authToken ?? window.auth_token;
|
||||
// The feedback embed runs without puter.js.
|
||||
const authToken = globalThis.puter?.authToken ?? window.auth_token;
|
||||
const target_params = options.app
|
||||
? { app: options.app }
|
||||
: { origin: options.origin };
|
||||
|
||||
@@ -18,31 +18,25 @@
|
||||
*/
|
||||
|
||||
import { isPlausibleHostname, normalizeHostnameInput } from '../helpers/customDomainHostname.js';
|
||||
import {
|
||||
customDomainErrorText,
|
||||
customDomainsRequest,
|
||||
customDomainStatus as statusOf,
|
||||
formatCustomDomainPrice,
|
||||
mergeCustomDomainRefresh,
|
||||
} from '../helpers/customDomains.js';
|
||||
|
||||
let attach_modal_seq = 0;
|
||||
|
||||
const SUCCESS_AUTOCLOSE_MS = 1800;
|
||||
const COPIED_RESET_MS = 1400;
|
||||
|
||||
/** API error code → message key; anything unlisted gets the generic error. */
|
||||
const ERROR_KEYS = {
|
||||
invalid_hostname: 'custom_domain_error_invalid',
|
||||
custom_domain_taken: 'custom_domain_error_taken',
|
||||
custom_domain_limit_reached: 'custom_domain_error_limit',
|
||||
custom_domain_rejected: 'custom_domain_error_rejected',
|
||||
site_not_found: 'custom_domain_error_site',
|
||||
custom_domain_not_found: 'custom_domain_error_not_found',
|
||||
custom_domains_not_enabled: 'custom_domain_not_enabled',
|
||||
custom_domains_permission_required: 'custom_domain_error_generic',
|
||||
};
|
||||
|
||||
/** Domain status → the badge shown for it; anything else reads as pending. */
|
||||
/** Domain status → the badge shown for it. */
|
||||
const STATUS_BADGE_KEYS = {
|
||||
active: 'custom_domain_badge_active',
|
||||
pending: 'custom_domain_badge_pending',
|
||||
failed: 'custom_domain_badge_failed',
|
||||
};
|
||||
const statusOf = (domain) => (domain?.status in STATUS_BADGE_KEYS ? domain.status : 'pending');
|
||||
|
||||
const svg = (body, strokeWidth = 2) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
@@ -55,19 +49,6 @@ const ICON_ALERT = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><pat
|
||||
const ICON_PLUS = svg('<path d="M12 5v14M5 12h14"/>', 2.2);
|
||||
const ICON_X = svg('<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>', 2.2);
|
||||
|
||||
const format_price = (pricing) => {
|
||||
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: pricing.currency || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(pricing.amount);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* "Connect a custom domain to this site" modal, behind
|
||||
* `puter.hosting.attach()`. The user picks one of their domains (moving it
|
||||
@@ -254,35 +235,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
focusedBeforeBusy = null;
|
||||
};
|
||||
|
||||
const api = async (method, path = '', body) => {
|
||||
const res = await fetch(`${window.api_origin}/custom-domains${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Non-JSON body; callers go by the status.
|
||||
}
|
||||
return { status: res.status, ok: res.ok, data };
|
||||
};
|
||||
// A known code gets our wording; anything else shows what the server
|
||||
// said, which beats a generic message when something is misconfigured.
|
||||
const errorFor = (res) => {
|
||||
if ( res.status === 402 ) return text('custom_domain_error_plan');
|
||||
if ( res.status === 429 ) return text('custom_domain_error_rate_limited');
|
||||
const key = ERROR_KEYS[res.data?.code];
|
||||
if ( key ) return text(key);
|
||||
if ( typeof res.data?.message === 'string' && res.data.message ) {
|
||||
return res.data.message;
|
||||
}
|
||||
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
|
||||
};
|
||||
const api = (method, path = '', body) => customDomainsRequest(method, path, body, authToken);
|
||||
|
||||
// Escape backs out of a pending confirmation first. Escape, the
|
||||
// backdrop and the close buttons all dismiss, except while a request
|
||||
@@ -503,7 +456,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('PATCH', `/${encodeURIComponent(item.uuid)}`, { site: subdomain });
|
||||
if ( ! res.ok ) {
|
||||
showError('list', errorFor(res));
|
||||
showError('list', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
settleOn(res.data);
|
||||
@@ -524,7 +477,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('DELETE', `/${encodeURIComponent(item.uuid)}`);
|
||||
if ( ! res.ok && res.status !== 404 ) {
|
||||
showError(pane, errorFor(res));
|
||||
showError(pane, customDomainErrorText(res));
|
||||
return false;
|
||||
}
|
||||
// A removed domain is no answer to hand the caller.
|
||||
@@ -611,7 +564,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
return;
|
||||
}
|
||||
if ( ! res.ok ) {
|
||||
showError('add', errorFor(res));
|
||||
showError('add', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
hideClaim();
|
||||
@@ -650,7 +603,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
.toggle(!!domain.statusDetail && status !== 'active');
|
||||
const $redirect = $overlay.find('.custom-domain-redirect').toggle(!!domain.redirectFrom);
|
||||
if ( domain.redirectFrom ) {
|
||||
$redirect.html(i18n('custom_domain_redirect_note', [domain.redirectFrom, domain.hostname]));
|
||||
$redirect.html(i18n('custom_domain_redirect_note', [domain.hostname, domain.redirectFrom]));
|
||||
}
|
||||
$overlay.find('.custom-domain-apex').toggle(domain.isApex === true);
|
||||
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && status !== 'active');
|
||||
@@ -670,22 +623,10 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('GET', `/${encodeURIComponent(chosen.uuid)}`);
|
||||
if ( ! res.ok ) {
|
||||
showError('records', errorFor(res));
|
||||
showError('records', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
// A domain that is no longer pending comes back with only its
|
||||
// routing record, and a GET never repeats the provider or the
|
||||
// redirect; keep what was shown when it was added.
|
||||
const known = chosen.records ?? [];
|
||||
const redirect = known.filter((r) => r.purpose === 'redirect');
|
||||
const fresh = (res.data.records?.length > 1 ? res.data.records : known)
|
||||
.filter((r) => r.purpose !== 'redirect');
|
||||
const next = {
|
||||
...res.data,
|
||||
dnsProvider: chosen.dnsProvider,
|
||||
redirectFrom: chosen.redirectFrom,
|
||||
records: [...fresh.slice(0, 1), ...redirect, ...fresh.slice(1)],
|
||||
};
|
||||
const next = mergeCustomDomainRefresh(chosen, res.data);
|
||||
if ( statusOf(next) === 'active' ) {
|
||||
showSuccess(next);
|
||||
return;
|
||||
@@ -706,7 +647,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('POST', `/${encodeURIComponent(chosen.uuid)}/use-www`);
|
||||
if ( ! res.ok ) {
|
||||
showError('records', errorFor(res));
|
||||
showError('records', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
chosen = res.data;
|
||||
@@ -758,7 +699,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
return;
|
||||
}
|
||||
renderList(res.data?.items ?? []);
|
||||
const price = format_price(res.data?.pricing);
|
||||
const price = formatCustomDomainPrice(res.data?.pricing);
|
||||
if ( price ) {
|
||||
$overlay.find('.custom-domain-price').text(text('custom_domain_price_note', [price])).show();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { createAppBrowser } from './UI/Dashboard/AppBrowser.js';
|
||||
import { appTileLink } from './UI/Dashboard/appLink.js';
|
||||
import { installAppIconFallback } from './helpers/appIcon.js';
|
||||
import { guestAppLaunchUrl, launchAppInTab } from './helpers/appTabLaunch.js';
|
||||
import { embedHostTheme, forceColorScheme } from './helpers/embedHostTheme.js';
|
||||
import { embedSession } from './helpers/embedSession.js';
|
||||
|
||||
window.$ = window.jQuery = jQuery;
|
||||
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
|
||||
@@ -45,6 +47,10 @@ try {
|
||||
window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
|
||||
} catch { /* Corrupt preferences must not prevent loading apps. */ }
|
||||
const localeReady = loadLocale(window.locale);
|
||||
// The host names its user and theme in the fragment, which can change without a reload.
|
||||
window.addEventListener('hashchange', () => window.location.reload());
|
||||
const hostTheme = embedHostTheme(window.location.hash);
|
||||
if ( hostTheme ) forceColorScheme(document, hostTheme);
|
||||
// Apps whose launch is still fetching; a second click would open a second tab.
|
||||
const launching = new Set();
|
||||
|
||||
@@ -98,31 +104,12 @@ function launchTile ({ appName, targetLink }) {
|
||||
.finally(() => launching.delete(appName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The GUI session, from local storage or else the session cookie. Safari keys a
|
||||
* frame's local storage by the top-level origin, so under a sibling subdomain
|
||||
* the GUI's stored token is out of reach while its cookie is still sent.
|
||||
*/
|
||||
async function readSession () {
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem('auth_token_v2');
|
||||
} catch { /* Storage the browser refuses holds no session; the cookie may. */ }
|
||||
if ( stored ) return stored;
|
||||
const response = await fetch('/get-gui-token', { credentials: 'include' });
|
||||
// Only a 401 means there is no session; a rate limit or server error
|
||||
// must not pass a signed-in user off as signed out.
|
||||
if ( response.status === 401 ) return null;
|
||||
if ( ! response.ok ) throw new Error(`Session lookup responded ${response.status}`);
|
||||
return (await response.json())?.token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed in, the user's own apps through the SDK. Without a session, the
|
||||
* recommended apps the page was rendered with, and no SDK.
|
||||
*/
|
||||
async function mount () {
|
||||
window.auth_token = await readSession();
|
||||
window.auth_token = (await embedSession(config.apiOrigin, window.location.hash))?.token ?? null;
|
||||
const guestApps = window.auth_token ? undefined : config.guestApps;
|
||||
if ( !window.auth_token && !guestApps?.length ) throw new Error('No apps to show without a session');
|
||||
|
||||
|
||||
@@ -15,6 +15,39 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Empty until the session check and the SDK finish; matches <puter-user-menu>'s spinner. */
|
||||
#app-browser:empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#app-browser:empty::after {
|
||||
content: '';
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(0, 0, 0, .12);
|
||||
border-top-color: #555;
|
||||
border-radius: 50%;
|
||||
animation: app-browser-spin .7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes app-browser-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#app-browser:empty::after { animation-duration: 1.6s; }
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#app-browser:empty::after {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
border-top-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
#app-browser .dashboard-section-apps {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -10590,6 +10590,253 @@ body.dashboard-mode .notifications-close-all {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Contact Us: a message with screenshots or recordings attached */
|
||||
.dashboard-dialog.dashboard-contact-dialog {
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
.dashboard-contact-dialog .dashboard-form-input[name='message'] {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.dashboard-contact-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-primary);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-label,
|
||||
.dashboard-contact-dropzone-drop {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-drop {
|
||||
display: none;
|
||||
color: var(--select-text);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-contact-dropzone:hover:not(:disabled) {
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-tint-weak);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--select-color);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Files dragged anywhere over the dialog land here. */
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone {
|
||||
border-style: solid;
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-tint);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone-drop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-contact-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-files[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-input-background);
|
||||
animation: dashboard-contact-file-in 160ms ease;
|
||||
}
|
||||
|
||||
@keyframes dashboard-contact-file-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
background: var(--dashboard-sidebar-background);
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-text {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dashboard-text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-size {
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-contact-file-remove:hover:not(:disabled) {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Sending: the files are on their way and no longer up for change. */
|
||||
.dashboard-contact-sending .dashboard-contact-file {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-contact-dropzone {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-contact-file {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* API token, shown once */
|
||||
.dashboard-token-warning {
|
||||
padding: 10px 12px;
|
||||
@@ -10681,6 +10928,287 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================
|
||||
Publish dialog: custom domain
|
||||
====================================== */
|
||||
|
||||
/* The address, once the site is live under it, reads as settled. */
|
||||
.dashboard-form-affix:has([readonly]) {
|
||||
background: var(--dashboard-sidebar-background);
|
||||
}
|
||||
|
||||
.dashboard-form-affix:has([readonly]):focus-within {
|
||||
border-color: var(--dashboard-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 12px;
|
||||
background: var(--select-tint-weak);
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell[hidden],
|
||||
.dashboard-publish-claim[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell .dashboard-dialog-btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dashboard-publish-upsell {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* The text keeps to the icon's row; only the button takes its own. */
|
||||
.dashboard-publish-upsell-text {
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell .dashboard-dialog-btn {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-publish-claim {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-publish-claim-text {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dashboard-warning-border);
|
||||
border-radius: 8px;
|
||||
background: var(--dashboard-warning-background);
|
||||
color: var(--dashboard-warning-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* DNS records: the type, then a copyable name and value. */
|
||||
.dashboard-dns-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One grid per card, so the label column fits the longest label and the
|
||||
name and value line up. */
|
||||
.dashboard-dns-record {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-sidebar-background);
|
||||
}
|
||||
|
||||
.dashboard-dns-record-head {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-dns-record-type {
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-dns-record-optional {
|
||||
font-size: 11px;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-dns-record-field {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.dashboard-dns-record-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
/* The done screen's domain, under the live address. */
|
||||
.dashboard-publish-domain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--dashboard-border);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-host {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--dashboard-warning-background);
|
||||
color: var(--dashboard-warning-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge[data-status='active'] {
|
||||
background: var(--dashboard-success-background);
|
||||
color: var(--dashboard-success-text);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge[data-status='failed'] {
|
||||
background: var(--dashboard-danger-background);
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-detail {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-danger-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-status {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
/* Empty, it stays in the a11y tree and gives back the gap it would claim. */
|
||||
.dashboard-publish-domain-status:empty {
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-status-error {
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-publish-link-btn {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dashboard-link);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-publish-link-btn:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-publish-link-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dashboard-publish-link-btn:focus-visible {
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
/* ======================================
|
||||
Auth dialog (login, signup, account picker, password recovery, 2FA)
|
||||
====================================== */
|
||||
@@ -11090,7 +11618,7 @@ body.dashboard-mode .notifications-close-all {
|
||||
|
||||
.auth-dialog .signup-terms {
|
||||
margin: 18px 0 0;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--dashboard-text-muted);
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Transparent, so the dialog's own scrim covers the framing page. */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
font-family: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* A scroll over the scrim must not scroll the framing page behind it. */
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
@@ -1925,6 +1925,60 @@ span.header-sort-icon img {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Dark mode: same surfaces as the dashboard's dark cards. The translucent
|
||||
background is also set in the backdrop-filter @supports block below. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.context-menu {
|
||||
color: #d4d4d4;
|
||||
background-color: rgb(38 38 38 / 85%);
|
||||
border-color: #3d3d3d;
|
||||
box-shadow: 0px 3px 20px #00000066;
|
||||
}
|
||||
|
||||
.context-menu .context-menu-divider>hr {
|
||||
border-top-color: #ffffff26;
|
||||
}
|
||||
|
||||
.context-menu .has-open-context-menu-submenu {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
/* `color: initial` resolves to black, unreadable on the dark surface. */
|
||||
.context-menu .context-menu-item-active-blurred {
|
||||
background-color: #404650;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.context-menu .context-menu-item-disabled, .context-menu .context-menu-item-disabled:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* The resting chevron is black; the active one is already white. */
|
||||
.context-menu .submenu-arrow:not(.submenu-arrow-active) {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Full-color icons (file types, app icons) keep their colors; only
|
||||
monochrome dark glyphs are inverted. */
|
||||
.context-menu .context-menu-item .ctx-item-icon-monochrome {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.context-menu.context-menu-sheet {
|
||||
background-color: rgba(38, 38, 38, 0.96) !important;
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.context-menu.context-menu-sheet .context-menu-item-active:not(.context-menu-divider) {
|
||||
background-color: rgba(10, 132, 255, 0.24) !important;
|
||||
color: #d4d4d4 !important;
|
||||
}
|
||||
|
||||
.context-menu.context-menu-sheet .context-menu-divider > hr {
|
||||
border-top-color: rgba(235, 235, 245, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.draggable-count-badge {
|
||||
background-color: red;
|
||||
border: 2px solid white;
|
||||
@@ -3925,6 +3979,12 @@ fieldset[name=number-code] {
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.context-menu {
|
||||
background-color: rgb(38 38 38 / 92%);
|
||||
}
|
||||
}
|
||||
|
||||
.popover:not(.device-phone .popover) {
|
||||
background-color: rgb(238, 243, 248);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium } from '@playwright/test';
|
||||
import express from 'express';
|
||||
import webpack from 'webpack';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
let browser;
|
||||
let server;
|
||||
let directory;
|
||||
let origin;
|
||||
let hostOrigin;
|
||||
let port;
|
||||
const requests = [];
|
||||
const guiRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
const sessionToken = 'feedback-embed-test-session';
|
||||
const unconfirmedToken = 'feedback-embed-test-unconfirmed';
|
||||
const sessionUser = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
const unconfirmedUser = '1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f';
|
||||
const otherUser = '7d2e4f10-8a3b-4c5d-9e6f-0a1b2c3d4e5f';
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'puter-feedback-embed-'));
|
||||
await new Promise((resolve, reject) => {
|
||||
const compiler = webpack({
|
||||
mode: 'development',
|
||||
entry: { 'feedback-embed': path.join(guiRoot, 'src/feedbackEmbed.js') },
|
||||
output: { path: directory, filename: '[name].min.js' },
|
||||
module: { rules: [{ test: /jquery-3\.6\.1\.min\.js$/, type: 'javascript/auto' }] },
|
||||
});
|
||||
compiler.run((error, stats) => compiler.close(closeError => {
|
||||
if ( error || closeError ) return reject(error || closeError);
|
||||
if ( stats.hasErrors() ) return reject(new Error(stats.toString()));
|
||||
resolve();
|
||||
}));
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
requests.push({ path: req.path, query: req.query, body: req.body, auth: req.headers.authorization });
|
||||
next();
|
||||
});
|
||||
app.use('/dist', express.static(directory));
|
||||
app.use('/css', express.static(path.join(guiRoot, 'src/css')));
|
||||
app.get('/get-gui-token', (_req, res) => res.sendStatus(401));
|
||||
const whoamiByToken = {
|
||||
[sessionToken]: { uuid: sessionUser, username: 'session-user', email: '[email protected]', email_confirmed: true },
|
||||
[unconfirmedToken]: {
|
||||
uuid: unconfirmedUser, username: 'new-user', email_confirmed: false, requires_email_confirmation: true,
|
||||
},
|
||||
};
|
||||
app.get('/whoami', (req, res) => {
|
||||
const user = whoamiByToken[req.headers.authorization?.replace(/^Bearer /, '')];
|
||||
if ( ! user ) return res.sendStatus(401);
|
||||
res.json(user);
|
||||
});
|
||||
app.get('/app-feedback/target', (req, res) => {
|
||||
if ( req.headers.authorization !== `Bearer ${sessionToken}` ) return res.sendStatus(401);
|
||||
if ( req.query.origin !== hostOrigin ) return res.json({ enabled: false });
|
||||
res.json({ enabled: true, app: { name: 'notes', title: 'Notes' } });
|
||||
});
|
||||
app.post('/app-feedback', (req, res) => {
|
||||
if ( req.headers.authorization !== `Bearer ${sessionToken}` ) return res.sendStatus(401);
|
||||
res.json({});
|
||||
});
|
||||
// A first-party page that frames the dialog as puter.js does, recording what the embed tells it.
|
||||
app.get('/host', (req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0;background:rgb(255,0,0)">
|
||||
<script>
|
||||
window.received = [];
|
||||
addEventListener('message', (e) => {
|
||||
if ( e.origin === '${origin}' ) window.received.push(e.data);
|
||||
});
|
||||
</script>
|
||||
<iframe referrerpolicy="origin" title="Send feedback" src="${origin}/embed/feedback#user=${req.query.user ?? ''}"
|
||||
style="display:block;border:0;width:100vw;height:100vh;color-scheme:normal"></iframe>
|
||||
<div style="height:3000px"></div>
|
||||
</body></html>`));
|
||||
app.get('/embed/feedback', (_req, res) => {
|
||||
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${hostOrigin}`);
|
||||
res.send(`<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/css/normalize.css"><link rel="stylesheet" href="/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/css/feedback-embed.css"></head><body>
|
||||
<main id="feedback-embed"></main>
|
||||
<script type="application/json" id="feedback-embed-config">${JSON.stringify({ apiOrigin: origin, hostOrigin })}</script>
|
||||
<script defer src="/dist/feedback-embed.min.js"></script></body></html>`);
|
||||
});
|
||||
server = await new Promise(resolve => {
|
||||
const listener = app.listen(0, '127.0.0.1', () => resolve(listener));
|
||||
});
|
||||
port = server.address().port;
|
||||
origin = `http://puter.localhost:${port}`;
|
||||
hostOrigin = `http://notes.puter.localhost:${port}`;
|
||||
browser = await chromium.launch();
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
if ( server ) await new Promise(resolve => server.close(resolve));
|
||||
if ( directory ) await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A browser whose GUI origin is signed in with `token`, if any. */
|
||||
const signedIn = token => browser.newContext({ storageState: {
|
||||
cookies: [],
|
||||
origins: token ? [{ origin, localStorage: [{ name: 'auth_token_v2', value: token }] }] : [],
|
||||
} });
|
||||
|
||||
const received = page => page.evaluate(() => window.received);
|
||||
|
||||
describe('feedback embed', () => {
|
||||
it('sends feedback for the framing page as the user it names, then tells it', async () => {
|
||||
const context = await signedIn(sessionToken);
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
requests.length = 0;
|
||||
await page.goto(`${hostOrigin}/host?user=${sessionUser}`);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.locator('.app-feedback-form').waitFor();
|
||||
expect(await frame.locator('.app-feedback-target-title').textContent()).toBe('Notes');
|
||||
expect(await received(page)).toEqual([
|
||||
{ msg: 'feedbackEmbed', state: 'loading' },
|
||||
{ msg: 'feedbackEmbed', state: 'ready' },
|
||||
]);
|
||||
// The page behind shows through around the dialog, under its scrim.
|
||||
expect(await frame.locator('body').evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||
await page.screenshot({ path: path.join(tmpdir(), 'puter-feedback-embed.png'), animations: 'disabled' });
|
||||
|
||||
await frame.locator('.app-feedback-message').fill('Love the new editor');
|
||||
await frame.locator('.app-feedback-send-btn').click();
|
||||
await frame.locator('.app-feedback-success').waitFor();
|
||||
await page.waitForFunction(() => window.received.some(m => m.msg === 'feedbackDialogClosed'));
|
||||
expect((await received(page)).at(-1)).toEqual({ msg: 'feedbackDialogClosed', sent: true });
|
||||
expect(await frame.locator('.app-feedback-overlay').count()).toBe(0);
|
||||
|
||||
const submit = requests.find(req => req.path === '/app-feedback');
|
||||
expect(submit.body).toEqual({ origin: hostOrigin, message: 'Love the new editor', context: 'web' });
|
||||
expect(submit.auth).toBe(`Bearer ${sessionToken}`);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('answers not sent when the user dismisses it', async () => {
|
||||
const context = await signedIn(sessionToken);
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${hostOrigin}/host?user=${sessionUser}`);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.locator('.app-feedback-form').waitFor();
|
||||
await frame.locator('.app-feedback-message').press('Escape');
|
||||
await page.waitForFunction(() => window.received.some(m => m.msg === 'feedbackDialogClosed'));
|
||||
expect((await received(page)).at(-1)).toEqual({ msg: 'feedbackDialogClosed', sent: false });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('keeps the page behind from scrolling under the scrim', async () => {
|
||||
const context = await signedIn(sessionToken);
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${hostOrigin}/host?user=${sessionUser}`);
|
||||
await page.frameLocator('iframe').locator('.app-feedback-form').waitFor();
|
||||
await page.mouse.move(10, 10);
|
||||
await page.mouse.wheel(0, 600);
|
||||
await page.waitForTimeout(300);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it.each([
|
||||
['no Puter session', null, sessionUser],
|
||||
['a session for another user', sessionToken, otherUser],
|
||||
['a host that names no user', sessionToken, ''],
|
||||
['an email still to confirm', unconfirmedToken, unconfirmedUser],
|
||||
])('tells the host it is unavailable with %s', async (_label, token, user) => {
|
||||
const context = await signedIn(token);
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
requests.length = 0;
|
||||
await page.goto(`${hostOrigin}/host?user=${user}`);
|
||||
await page.waitForFunction(() => window.received.length >= 2);
|
||||
expect(await received(page)).toEqual([
|
||||
{ msg: 'feedbackEmbed', state: 'loading' },
|
||||
{ msg: 'feedbackEmbed', state: 'unavailable' },
|
||||
]);
|
||||
expect(await page.frameLocator('iframe').locator('.app-feedback-overlay').count()).toBe(0);
|
||||
expect(requests.some(req => req.path.startsWith('/app-feedback'))).toBe(false);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('does nothing when opened on its own', async () => {
|
||||
const context = await signedIn(sessionToken);
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
requests.length = 0;
|
||||
await page.goto(`${origin}/embed/feedback#user=${sessionUser}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(await page.locator('.app-feedback-overlay').count()).toBe(0);
|
||||
expect(requests.some(req => req.path === '/whoami')).toBe(false);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
import { encode } from 'html-entities';
|
||||
import { loadLocale } from './i18n/embedI18n.js';
|
||||
import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js';
|
||||
import { embedSession } from './helpers/embedSession.js';
|
||||
|
||||
window.$ = window.jQuery = jQuery;
|
||||
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
|
||||
|
||||
const config = JSON.parse(document.querySelector('#feedback-embed-config').textContent);
|
||||
window.api_origin = config.apiOrigin;
|
||||
window.locale = 'en';
|
||||
try {
|
||||
window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
|
||||
} catch { /* Corrupt preferences must not prevent sending feedback. */ }
|
||||
const localeReady = loadLocale(window.locale);
|
||||
|
||||
/** Answers only the page frame-ancestors pinned, which the feedback is for. */
|
||||
const tellHost = (data) => window.parent.postMessage(data, config.hostOrigin);
|
||||
|
||||
/** Resolves once the dialog's overlay has left the page, after its success pane and exit. */
|
||||
const overlayGone = () => new Promise((resolve) => {
|
||||
const gone = () => !document.querySelector('.app-feedback-overlay');
|
||||
if ( gone() ) return resolve();
|
||||
const observer = new MutationObserver(() => {
|
||||
if ( ! gone() ) return;
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows the app feedback dialog for the framing page, as the signed-in user it
|
||||
* names. Without that user's session, or with an email still to confirm, the
|
||||
* host is told the embed is unavailable and uses the popup, which can sign in.
|
||||
*/
|
||||
async function run () {
|
||||
tellHost({ msg: 'feedbackEmbed', state: 'loading' });
|
||||
let session = null;
|
||||
try {
|
||||
session = await embedSession(config.apiOrigin, window.location.hash);
|
||||
} catch (e) {
|
||||
console.error('feedback embed: session check failed', e);
|
||||
}
|
||||
const user = session?.user;
|
||||
if ( !user || (user.requires_email_confirmation && !user.email_confirmed) ) {
|
||||
tellHost({ msg: 'feedbackEmbed', state: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
window.auth_token = session.token;
|
||||
window.user = user;
|
||||
await localeReady;
|
||||
|
||||
tellHost({ msg: 'feedbackEmbed', state: 'ready' });
|
||||
let sent = false;
|
||||
try {
|
||||
sent = await UIWindowAppFeedback({ origin: config.hostOrigin, source: 'web' });
|
||||
await overlayGone();
|
||||
} catch (e) {
|
||||
console.error('feedback embed: dialog failed', e);
|
||||
}
|
||||
tellHost({ msg: 'feedbackDialogClosed', sent: sent === true });
|
||||
}
|
||||
|
||||
// Opened on its own, there is no page to send feedback for or answer.
|
||||
if ( config.hostOrigin && window.parent !== window ) run();
|
||||
@@ -70,7 +70,7 @@ if ( window.logged_in_users.length === 0 && window.user !== null ) {
|
||||
let tuser = window.user;
|
||||
tuser.auth_token = window.auth_token;
|
||||
window.logged_in_users.push(tuser);
|
||||
localStorage.setItem('logged_in_users', window.logged_in_users);
|
||||
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users));
|
||||
}
|
||||
|
||||
window.last_window_zindex = 1;
|
||||
|
||||
+33
-23
@@ -663,33 +663,20 @@ window.update_auth_data = async (auth_token, user) => {
|
||||
$('.user-email').html(html_encode(user.email));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// load the user's profile
|
||||
// ----------------------------------------------------
|
||||
user.profile = {};
|
||||
puter.auth.getProfile().then((profile) => {
|
||||
window.user.profile.displayName = profile?.displayName ?? null;
|
||||
window.user.profile.bio = profile?.bio ?? null;
|
||||
if ( profile?.displayName ) {
|
||||
$('.dashboard-profile-display-name').text(profile.displayName);
|
||||
}
|
||||
if ( profile?.picture ) {
|
||||
window.user.profile.picture = html_encode(profile.picture);
|
||||
$('.profile-pic').css('background-image', `url(${window.user.profile.picture})`);
|
||||
// the account tab may have rendered before the profile arrived
|
||||
$('.dashboard-profile-remove-picture').prop('hidden', false);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error loading profile:', error);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Keep this account's last known profile until the fresh one loads below;
|
||||
// the account picker reads pictures from the stored copy.
|
||||
const known_profile = window.logged_in_users.find(known => known.uuid === user.uuid)?.profile;
|
||||
user.profile = { ...(known_profile ?? user.profile ?? {}) };
|
||||
|
||||
const to_storable_user = user => {
|
||||
const storable_user = { ...user };
|
||||
delete storable_user.taskbar_items;
|
||||
return storable_user;
|
||||
};
|
||||
const store_users = () => {
|
||||
localStorage.setItem('user', JSON.stringify(to_storable_user(window.user)));
|
||||
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users.map(to_storable_user)));
|
||||
};
|
||||
|
||||
// update this session's user data
|
||||
window.user = user;
|
||||
@@ -715,10 +702,33 @@ window.update_auth_data = async (auth_token, user) => {
|
||||
userobj.auth_token = window.auth_token;
|
||||
window.logged_in_users.push(userobj);
|
||||
}
|
||||
// update local storage
|
||||
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users.map(to_storable_user)));
|
||||
store_users();
|
||||
}
|
||||
|
||||
// Loaded only after puter.js holds this account's token, so it can't
|
||||
// return the previous account's profile.
|
||||
puter.auth.getProfile().then((profile) => {
|
||||
// Another account may have signed in while this was loading.
|
||||
if ( !profile || window.user !== user ) return;
|
||||
const shown_picture = user.profile.picture ?? null;
|
||||
user.profile = {
|
||||
displayName: profile.displayName ?? null,
|
||||
bio: profile.bio ?? null,
|
||||
picture: profile.picture ? html_encode(profile.picture) : null,
|
||||
};
|
||||
store_users();
|
||||
if ( profile.displayName ) {
|
||||
$('.dashboard-profile-display-name').text(profile.displayName);
|
||||
}
|
||||
// the account tab may have rendered before the profile arrived
|
||||
if ( user.profile.picture !== shown_picture ) {
|
||||
$('.profile-pic').css('background-image', `url(${user.profile.picture || window.icons['profile.svg']})`);
|
||||
$('.dashboard-profile-remove-picture').prop('hidden', !user.profile.picture);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error loading profile:', error);
|
||||
});
|
||||
|
||||
window.desktop_path = `/${ window.user.username }/Desktop`;
|
||||
window.trash_path = `/${ window.user.username }/Trash`;
|
||||
window.appdata_path = `/${ window.user.username }/AppData`;
|
||||
|
||||
@@ -51,8 +51,16 @@ describe('update_auth_data', () => {
|
||||
window.logged_in_users = [];
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.available_templates = vi.fn();
|
||||
window.icons = { 'profile.svg': '/profile.svg' };
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: true }));
|
||||
puter.setAuthToken.mockClear();
|
||||
puter.auth.getProfile.mockReset();
|
||||
puter.auth.getProfile.mockResolvedValue({});
|
||||
});
|
||||
|
||||
const storedUsers = () => JSON.parse(localStorage.getItem('logged_in_users'));
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
// Sign-in awaits this while its card is still up, over every ordinary window.
|
||||
it('raises a failed session-cookie sync above the sign-in card', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
@@ -62,4 +70,49 @@ describe('update_auth_data', () => {
|
||||
expect(UIAlert).toHaveBeenCalledOnce();
|
||||
expect(UIAlert.mock.calls[0][0]).toMatchObject({ stay_on_top: true });
|
||||
});
|
||||
|
||||
// The account picker shows pictures from the stored list, not from memory.
|
||||
it('stores the loaded profile picture with the signed-in account', async () => {
|
||||
puter.auth.getProfile.mockResolvedValue({ picture: 'data:image/png;base64,AAAA' });
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
await settle();
|
||||
expect(storedUsers()).toEqual([
|
||||
expect.objectContaining({ uuid: 'a', profile: expect.objectContaining({ picture: 'data:image/png;base64,AAAA' }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the stored picture while a refreshed profile is loading', async () => {
|
||||
window.logged_in_users = [{ uuid: 'a', username: 'a', profile: { picture: 'data:image/png;base64,AAAA' } }];
|
||||
puter.auth.getProfile.mockReturnValue(new Promise(() => {}));
|
||||
// a whoami refresh carries no profile
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
expect(storedUsers()[0].profile.picture).toBe('data:image/png;base64,AAAA');
|
||||
});
|
||||
|
||||
it('drops a stored picture the account no longer has', async () => {
|
||||
window.logged_in_users = [{ uuid: 'a', username: 'a', profile: { picture: 'data:image/png;base64,AAAA' } }];
|
||||
puter.auth.getProfile.mockResolvedValue({ picture: null });
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
await settle();
|
||||
expect(storedUsers()[0].profile.picture).toBeNull();
|
||||
});
|
||||
|
||||
it('loads the profile with the new account\'s token', async () => {
|
||||
await window.update_auth_data('token-b', { uuid: 'b', username: 'b' });
|
||||
expect(puter.setAuthToken.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(puter.auth.getProfile.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('ignores a profile that arrives after another account signed in', async () => {
|
||||
let resolveA;
|
||||
puter.auth.getProfile.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveA = resolve;
|
||||
}));
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
await window.update_auth_data('token-b', { uuid: 'b', username: 'b' });
|
||||
resolveA({ picture: 'data:image/png;base64,AAAA' });
|
||||
await settle();
|
||||
expect(storedUsers().find(stored => stored.uuid === 'b').profile.picture).toBeNull();
|
||||
expect(window.user.profile.picture).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The `/custom-domains` API as the GUI talks to it, with its own session:
|
||||
// the desktop's attach dialog and the dashboard's publish dialog share it.
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomDomainRecord
|
||||
* @property {string} type
|
||||
* @property {string} name
|
||||
* @property {string} value
|
||||
* @property {'routing'|'redirect'|string} [purpose]
|
||||
* @property {boolean} [required]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomDomain
|
||||
* @property {string} uuid
|
||||
* @property {string} hostname
|
||||
* @property {string} site Subdomain of the website it serves.
|
||||
* @property {'active'|'pending'|'failed'|string} status
|
||||
* @property {string} [statusDetail]
|
||||
* @property {boolean} [isApex]
|
||||
* @property {string} [redirectFrom] The root domain forwarding to `hostname`.
|
||||
* @property {string} [dnsProvider]
|
||||
* @property {CustomDomainRecord[]} [records]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomDomainResponse
|
||||
* @property {number} status
|
||||
* @property {boolean} ok
|
||||
* @property {any} data The parsed JSON body, or null.
|
||||
*/
|
||||
|
||||
/** API error code → message key; anything unlisted gets the server's message. */
|
||||
const ERROR_KEYS = {
|
||||
invalid_hostname: 'custom_domain_error_invalid',
|
||||
custom_domain_taken: 'custom_domain_error_taken',
|
||||
custom_domain_limit_reached: 'custom_domain_error_limit',
|
||||
custom_domain_rejected: 'custom_domain_error_rejected',
|
||||
site_not_found: 'custom_domain_error_site',
|
||||
custom_domain_not_found: 'custom_domain_error_not_found',
|
||||
custom_domains_not_enabled: 'custom_domain_not_enabled',
|
||||
custom_domains_permission_required: 'custom_domain_error_generic',
|
||||
};
|
||||
|
||||
/** Error codes that mean "try another hostname". */
|
||||
export const CUSTOM_DOMAIN_HOSTNAME_ERRORS = new Set([
|
||||
'invalid_hostname',
|
||||
'custom_domain_taken',
|
||||
'custom_domain_rejected',
|
||||
]);
|
||||
|
||||
const STATUSES = new Set(['active', 'pending', 'failed']);
|
||||
|
||||
/**
|
||||
* A domain's status as the GUI draws it; anything unknown reads as pending.
|
||||
*
|
||||
* @param {Partial<CustomDomain> | null | undefined} domain
|
||||
* @returns {'active'|'pending'|'failed'}
|
||||
*/
|
||||
export const customDomainStatus = (domain) => (STATUSES.has(domain?.status) ? domain.status : 'pending');
|
||||
|
||||
/**
|
||||
* The monthly price the list call reports, in the user's locale.
|
||||
*
|
||||
* @param {{ amount?: number, currency?: string } | null | undefined} pricing
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export const formatCustomDomainPrice = (pricing) => {
|
||||
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: pricing.currency || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(pricing.amount);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls `/custom-domains{path}`. Resolves with the status and parsed body,
|
||||
* even for an error status; rejects only when the request itself fails.
|
||||
*
|
||||
* @param {'GET'|'POST'|'PATCH'|'DELETE'} method
|
||||
* @param {string} [path] Starting with `/`, after `/custom-domains`.
|
||||
* @param {object} [body]
|
||||
* @param {string} [authToken] Defaults to the GUI's session.
|
||||
* @returns {Promise<CustomDomainResponse>}
|
||||
*/
|
||||
export const customDomainsRequest = async (method, path = '', body, authToken = puter.authToken ?? window.auth_token) => {
|
||||
const res = await fetch(`${window.api_origin}/custom-domains${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Non-JSON body; callers go by the status.
|
||||
}
|
||||
return { status: res.status, ok: res.ok, data };
|
||||
};
|
||||
|
||||
/**
|
||||
* Plain text for a failed response. A known code gets our wording; anything
|
||||
* else shows what the server said, which beats a generic message when
|
||||
* something is misconfigured.
|
||||
*
|
||||
* @param {CustomDomainResponse} res
|
||||
* @returns {string}
|
||||
*/
|
||||
export const customDomainErrorText = (res) => {
|
||||
const text = (key) => i18n(key, [], false);
|
||||
if ( res.status === 402 ) return text('custom_domain_error_plan');
|
||||
if ( res.status === 429 ) return text('custom_domain_error_rate_limited');
|
||||
const key = ERROR_KEYS[res.data?.code];
|
||||
if ( key ) return text(key);
|
||||
if ( typeof res.data?.message === 'string' && res.data.message ) {
|
||||
return res.data.message;
|
||||
}
|
||||
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* What to show after a status check. A domain that is no longer pending
|
||||
* comes back with only its routing record, and a GET never repeats the DNS
|
||||
* provider or the redirect, so those are kept from what was shown before.
|
||||
*
|
||||
* @param {CustomDomain} known What the add (or the last check) returned.
|
||||
* @param {CustomDomain} fresh What the GET returned.
|
||||
* @returns {CustomDomain}
|
||||
*/
|
||||
export const mergeCustomDomainRefresh = (known, fresh) => {
|
||||
const knownRecords = known.records ?? [];
|
||||
const redirect = knownRecords.filter((r) => r.purpose === 'redirect');
|
||||
const records = (fresh.records?.length > 1 ? fresh.records : knownRecords)
|
||||
.filter((r) => r.purpose !== 'redirect');
|
||||
return {
|
||||
...fresh,
|
||||
dnsProvider: known.dnsProvider,
|
||||
redirectFrom: known.redirectFrom,
|
||||
records: [...records.slice(0, 1), ...redirect, ...records.slice(1)],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
// @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';
|
||||
import {
|
||||
customDomainErrorText,
|
||||
customDomainsRequest,
|
||||
customDomainStatus,
|
||||
formatCustomDomainPrice,
|
||||
mergeCustomDomainRefresh,
|
||||
} from './customDomains.js';
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.puter = { authToken: 'session-token' };
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
});
|
||||
|
||||
describe('customDomainStatus', () => {
|
||||
it('keeps a known status and reads anything else as pending', () => {
|
||||
expect(customDomainStatus({ status: 'active' })).toBe('active');
|
||||
expect(customDomainStatus({ status: 'failed' })).toBe('failed');
|
||||
expect(customDomainStatus({ status: 'verifying' })).toBe('pending');
|
||||
expect(customDomainStatus(null)).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCustomDomainPrice', () => {
|
||||
it('formats the amount in its currency', () => {
|
||||
expect(formatCustomDomainPrice({ amount: 0.5, currency: 'USD' })).toMatch(/0[.,]50/);
|
||||
});
|
||||
|
||||
it('gives null without an amount or for a currency it cannot format', () => {
|
||||
expect(formatCustomDomainPrice(null)).toBeNull();
|
||||
expect(formatCustomDomainPrice({ currency: 'USD' })).toBeNull();
|
||||
expect(formatCustomDomainPrice({ amount: 1, currency: 'not-a-currency' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('customDomainsRequest', () => {
|
||||
it('sends the session token and a JSON body, and parses the reply', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ uuid: 'd1' }), { status: 201 }));
|
||||
const res = await customDomainsRequest('POST', '', { site: 'my-site', hostname: 'www.example.com' });
|
||||
expect(fetch).toHaveBeenCalledWith('https://api.puter.test/custom-domains', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer session-token', 'Content-Type': 'application/json' },
|
||||
body: '{"site":"my-site","hostname":"www.example.com"}',
|
||||
});
|
||||
expect(res).toEqual({ status: 201, ok: true, data: { uuid: 'd1' } });
|
||||
});
|
||||
|
||||
it('resolves an error status with a null body when it is not JSON', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response('Not Found', { status: 404 }));
|
||||
expect(await customDomainsRequest('GET', '/d1')).toEqual({ status: 404, ok: false, data: null });
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://api.puter.test/custom-domains/d1');
|
||||
expect(fetch.mock.calls[0][1].headers).toEqual({ 'Authorization': 'Bearer session-token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('customDomainErrorText', () => {
|
||||
it('words plan, rate limit and known codes itself', () => {
|
||||
expect(customDomainErrorText({ status: 402, data: { code: 'subscription_required' } })).toBe('custom_domain_error_plan');
|
||||
expect(customDomainErrorText({ status: 429, data: null })).toBe('custom_domain_error_rate_limited');
|
||||
expect(customDomainErrorText({ status: 409, data: { code: 'custom_domain_taken', message: 'x' } })).toBe('custom_domain_error_taken');
|
||||
});
|
||||
|
||||
it("falls back to the server's message, then to the status", () => {
|
||||
expect(customDomainErrorText({ status: 502, data: { message: 'Provider down' } })).toBe('Provider down');
|
||||
expect(customDomainErrorText({ status: 500, data: null })).toBe('custom_domain_error_generic (HTTP 500)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeCustomDomainRefresh', () => {
|
||||
const routing = { type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing' };
|
||||
const redirect = { type: 'A', name: '@', value: '192.0.2.1', purpose: 'redirect' };
|
||||
const ownership = { type: 'TXT', name: '_cf', value: 'abc', purpose: 'ownership' };
|
||||
const known = {
|
||||
uuid: 'd1',
|
||||
status: 'pending',
|
||||
dnsProvider: 'example-dns',
|
||||
redirectFrom: 'example.com',
|
||||
records: [routing, redirect, ownership],
|
||||
};
|
||||
|
||||
it('keeps the records shown before when the check returns only routing', () => {
|
||||
const merged = mergeCustomDomainRefresh(known, { uuid: 'd1', status: 'active', records: [routing] });
|
||||
expect(merged.status).toBe('active');
|
||||
expect(merged.records).toEqual([routing, redirect, ownership]);
|
||||
expect(merged.dnsProvider).toBe('example-dns');
|
||||
expect(merged.redirectFrom).toBe('example.com');
|
||||
});
|
||||
|
||||
it('takes fresh records, with the redirect after the routing one', () => {
|
||||
const fresh_ownership = { ...ownership, value: 'def' };
|
||||
const merged = mergeCustomDomainRefresh(known, { uuid: 'd1', status: 'pending', records: [routing, fresh_ownership] });
|
||||
expect(merged.records).toEqual([routing, redirect, fresh_ownership]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
const COLOR_SCHEME_FEATURE = /\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi;
|
||||
|
||||
/**
|
||||
* The theme an embedding page forces, from its `#theme=dark|light` fragment.
|
||||
* Null when it names none, so the embed follows the OS.
|
||||
*
|
||||
* @param {string} hash - `location.hash`
|
||||
* @returns {'dark' | 'light' | null}
|
||||
*/
|
||||
export const embedHostTheme = (hash) => {
|
||||
const value = new URLSearchParams(String(hash ?? '').replace(/^#/, '')).get('theme');
|
||||
return value === 'dark' || value === 'light' ? value : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes the document's `prefers-color-scheme` rules match `theme` whatever
|
||||
* the OS prefers. Chromium doesn't pass a frame's `color-scheme` on to the
|
||||
* document inside it, so the host's forced theme has to be applied here.
|
||||
*
|
||||
* @param {Document} doc - with its stylesheets loaded
|
||||
* @param {'dark' | 'light'} theme
|
||||
*/
|
||||
export const forceColorScheme = (doc, theme) => {
|
||||
doc.documentElement.style.colorScheme = theme;
|
||||
const force = (rules) => {
|
||||
for ( const rule of rules ) {
|
||||
const media = rule.media?.mediaText;
|
||||
// Always true for the forced scheme, never for the other.
|
||||
const forced = media?.replace(COLOR_SCHEME_FEATURE, (_, scheme) => (
|
||||
scheme.toLowerCase() === theme ? '(min-width: 0px)' : '(max-width: -1px)'
|
||||
));
|
||||
if ( forced !== media ) rule.media.mediaText = forced;
|
||||
if ( rule.cssRules ) force(rule.cssRules);
|
||||
}
|
||||
};
|
||||
for ( const sheet of doc.styleSheets ) {
|
||||
let rules;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch { continue; /* Another origin's stylesheet can't be read. */ }
|
||||
force(rules);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { embedHostTheme } from './embedHostTheme.js';
|
||||
|
||||
describe('embedHostTheme', () => {
|
||||
it('reads a forced theme from the fragment', () => {
|
||||
expect(embedHostTheme('#theme=dark')).toBe('dark');
|
||||
expect(embedHostTheme('#user=0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40&theme=light')).toBe('light');
|
||||
});
|
||||
|
||||
it.each(['', '#', '#theme=', '#theme=Dark', '#theme=auto', '#scheme=dark', undefined])(
|
||||
'is null for %j', hash => {
|
||||
expect(embedHostTheme(hash)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* The uuid of the user an embedding page is signed in as, from its
|
||||
* `#user=<uuid>` fragment, in lower case. Null without a well-formed one.
|
||||
*
|
||||
* @param {string} hash - `location.hash`
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export const embedHostUser = (hash) => {
|
||||
const value = new URLSearchParams(String(hash ?? '').replace(/^#/, '')).get('user');
|
||||
return value && UUID.test(value) ? value.toLowerCase() : null;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { embedHostUser } from './embedHostUser.js';
|
||||
|
||||
const UUID = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
|
||||
describe('embedHostUser', () => {
|
||||
it('reads the user uuid from the fragment', () => {
|
||||
expect(embedHostUser(`#user=${UUID}`)).toBe(UUID);
|
||||
expect(embedHostUser(`#other=1&user=${UUID}`)).toBe(UUID);
|
||||
});
|
||||
|
||||
it('lower-cases the uuid', () => {
|
||||
expect(embedHostUser(`#user=${UUID.toUpperCase()}`)).toBe(UUID);
|
||||
});
|
||||
|
||||
it.each(['', '#', '#user=', '#user=admin', `#user=${UUID}x`, `#user= ${UUID}`, `#uid=${UUID}`, undefined])(
|
||||
'is null for %j', hash => {
|
||||
expect(embedHostUser(hash)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 { embedHostUser } from './embedHostUser.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} EmbedSession
|
||||
* @property {string} token - the GUI session's token
|
||||
* @property {Record<string, unknown>} user - `/whoami` for that token
|
||||
*/
|
||||
|
||||
/**
|
||||
* The GUI session, from local storage or else the session cookie. Safari keys a
|
||||
* frame's local storage by the top-level origin, so under a sibling subdomain
|
||||
* the GUI's stored token is out of reach while its cookie is still sent.
|
||||
*
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function readSession () {
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem('auth_token_v2');
|
||||
} catch { /* Storage the browser refuses holds no session; the cookie may. */ }
|
||||
if ( stored ) return stored;
|
||||
const response = await fetch('/get-gui-token', { credentials: 'include' });
|
||||
// Only a 401 means there is no session; a rate limit or server error
|
||||
// must not pass a signed-in user off as signed out.
|
||||
if ( response.status === 401 ) return null;
|
||||
if ( ! response.ok ) throw new Error(`Session lookup responded ${response.status}`);
|
||||
return (await response.json())?.token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/whoami` for a GUI token, or null when the API refuses it.
|
||||
*
|
||||
* @param {string} token
|
||||
* @param {string} apiOrigin
|
||||
* @returns {Promise<Record<string, unknown> | null>}
|
||||
*/
|
||||
async function whoami (token, apiOrigin) {
|
||||
const response = await fetch(new URL('/whoami', apiOrigin), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if ( response.status === 401 ) return null;
|
||||
if ( ! response.ok ) throw new Error(`Session check responded ${response.status}`);
|
||||
const user = await response.json();
|
||||
return user && typeof user === 'object' ? user : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The GUI session, only when it belongs to the user the embedding page names
|
||||
* in its `#user=<uuid>` fragment. A host signed out, or signed in as someone
|
||||
* else, gets null. Rejects when the session can't be checked.
|
||||
*
|
||||
* @param {string} apiOrigin
|
||||
* @param {string} hash - `location.hash`
|
||||
* @returns {Promise<EmbedSession | null>}
|
||||
*/
|
||||
export async function embedSession (apiOrigin, hash) {
|
||||
const hostUser = embedHostUser(hash);
|
||||
if ( ! hostUser ) return null;
|
||||
const token = await readSession();
|
||||
if ( ! token ) return null;
|
||||
const user = await whoami(token, apiOrigin);
|
||||
const uuid = typeof user?.uuid === 'string' ? user.uuid.toLowerCase() : null;
|
||||
return uuid === hostUser ? { token, user } : null;
|
||||
}
|
||||
@@ -28,14 +28,21 @@ export const is_plan_gate_error = (error) =>
|
||||
Boolean(error && typeof error === 'object' && error.code === 'subscription_required');
|
||||
|
||||
/**
|
||||
* Open the upgrade flow, where the deployment has one. Hosted puter.com
|
||||
* attaches `UIUpgradeAccount`; a self-hosted install has no plan to offer,
|
||||
* and the caller explains the refusal in its own words instead.
|
||||
* Whether the deployment has an upgrade flow. Hosted puter.com attaches
|
||||
* `UIUpgradeAccount`; a self-hosted install has no plan to offer.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const has_upgrade_flow = () => typeof window.UIUpgradeAccount === 'function';
|
||||
|
||||
/**
|
||||
* Open the upgrade flow, where the deployment has one. Without one, the
|
||||
* caller explains the refusal in its own words instead.
|
||||
*
|
||||
* @returns {boolean} Whether the upgrade flow was opened.
|
||||
*/
|
||||
export const open_upgrade_flow = () => {
|
||||
if ( typeof window.UIUpgradeAccount !== 'function' ) return false;
|
||||
if ( ! has_upgrade_flow() ) return false;
|
||||
(new window.UIUpgradeAccount()).open_as_window();
|
||||
return true;
|
||||
};
|
||||
@@ -32,6 +32,13 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
options = options || {};
|
||||
|
||||
let container_path = $(el_item_container).attr('data-path');
|
||||
|
||||
// Only the newest refresh of a container may write to it or its window;
|
||||
// an older one's responses belong to a folder the window has since left.
|
||||
const refresh_id = ($(el_item_container).data('refreshId') ?? 0) + 1;
|
||||
$(el_item_container).data('refreshId', refresh_id);
|
||||
const is_current = () => $(el_item_container).data('refreshId') === refresh_id;
|
||||
|
||||
let el_window = $(el_item_container).closest('.window');
|
||||
let el_window_head_icon = $(el_window).find('.window-head-icon');
|
||||
const loading_spinner = $(el_item_container).find('.explorer-loading-spinner');
|
||||
@@ -60,9 +67,7 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
// A timeout that will show the loading spinner if the folder is not loaded
|
||||
// after 1000ms
|
||||
let loading_timeout = setTimeout(function () {
|
||||
// make sure the same folder is still loading
|
||||
if ( $(loading_spinner).closest('.item-container').attr('data-path') !== container_path )
|
||||
{
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,6 +94,9 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
}
|
||||
|
||||
if ( !is_shared_view ) puter.fs.stat({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(fsentry => {
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
if ( el_window ) {
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
@@ -156,10 +164,8 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
|
||||
|
||||
entries_promise.then(async (fsentries) => {
|
||||
// Check if the same folder is still loading since el_item_container's
|
||||
// data-path might have changed by other operations while waiting for the response to this `readdir`.
|
||||
if ( $(el_item_container).attr('data-path') !== container_path )
|
||||
{
|
||||
if ( ! is_current() ) {
|
||||
clearTimeout(loading_timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,6 +174,11 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
// clear loading timeout
|
||||
clearTimeout(loading_timeout);
|
||||
|
||||
// a newer refresh may have started during the spinner delay
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hide loading spinner
|
||||
$(loading_spinner).hide();
|
||||
|
||||
@@ -187,6 +198,10 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
|
||||
// add each item to window
|
||||
for ( let index = 0; index < fsentries.length; index++ ) {
|
||||
// item_icon() is awaited below, so a newer refresh can start mid-loop
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
const fsentry = fsentries[index];
|
||||
let is_disabled = false;
|
||||
|
||||
@@ -258,6 +273,9 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
if ( $(el_item_container).hasClass('desktop') ) {
|
||||
try {
|
||||
const trash = await puter.fs.stat({ path: window.trash_path, consistency: options.consistency ?? 'eventual' });
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
UIItem({
|
||||
appendTo: el_item_container,
|
||||
uid: trash.id,
|
||||
@@ -319,6 +337,11 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
// clear loading timeout
|
||||
clearTimeout(loading_timeout);
|
||||
|
||||
// don't show a folder's error in the folder the window moved to
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hide other messages/indicators
|
||||
$(loading_spinner).hide();
|
||||
$(empty_message).hide();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
@@ -30,6 +30,11 @@ vi.mock('./listAllShared.js', () => ({ default: vi.fn() }));
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) =>
|
||||
jQuery('<div>')
|
||||
.text(value ?? '')
|
||||
.html();
|
||||
globalThis.puter = { fs: { stat: vi.fn(), readdir: vi.fn() } };
|
||||
jQuery.fn.removeItems = function () {
|
||||
return this.remove();
|
||||
};
|
||||
@@ -46,6 +51,8 @@ Object.assign(window, {
|
||||
check_fsentry_against_allowed_file_types_string: () => true,
|
||||
sort_items: () => {},
|
||||
update_explorer_footer_item_count: () => {},
|
||||
update_window_layout: () => {},
|
||||
update_details_layout_sort_visuals: () => {},
|
||||
});
|
||||
|
||||
const { default: refresh_item_container } =
|
||||
@@ -53,6 +60,10 @@ const { default: refresh_item_container } =
|
||||
const { default: UIItem } = await import('../UI/UIItem.js');
|
||||
const { default: list_all_shared } = await import('./listAllShared.js');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('refresh_item_container in the Shared view', () => {
|
||||
it('flags rows shared through a team, and only those', async () => {
|
||||
list_all_shared.mockResolvedValue([
|
||||
@@ -97,3 +108,91 @@ describe('refresh_item_container in the Shared view', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const fileEntry = (dir, name) => ({
|
||||
uid: `${dir}/${name}`,
|
||||
name,
|
||||
path: `${dir}/${name}`,
|
||||
is_dir: false,
|
||||
});
|
||||
|
||||
// Points the window at a new folder the way update_window_path does, then refreshes it.
|
||||
const navigate = (path) => {
|
||||
$('.window').attr('data-path', path);
|
||||
$('.item-container').attr('data-path', path);
|
||||
refresh_item_container($('.item-container'));
|
||||
};
|
||||
|
||||
const renderedPaths = () => UIItem.mock.calls.map(([options]) => options.path);
|
||||
|
||||
describe('refresh_item_container after the window moves to another folder', () => {
|
||||
let pending;
|
||||
|
||||
beforeEach(() => {
|
||||
pending = { stat: {}, readdir: {} };
|
||||
puter.fs.stat.mockImplementation(({ path }) => {
|
||||
pending.stat[path] = deferred();
|
||||
return pending.stat[path].promise;
|
||||
});
|
||||
puter.fs.readdir.mockImplementation(({ path }) => {
|
||||
pending.readdir[path] ??= deferred();
|
||||
return pending.readdir[path].promise;
|
||||
});
|
||||
document.body.innerHTML =
|
||||
'<div class="window"><div class="item-container"></div></div>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ignores a stat for the folder it left', async () => {
|
||||
navigate('/alice');
|
||||
navigate('/alice/Pictures');
|
||||
|
||||
pending.stat['/alice/Pictures'].resolve({ id: 'pictures', name: 'Pictures' });
|
||||
pending.stat['/alice'].resolve({ id: 'home', name: 'alice' });
|
||||
await vi.waitFor(() => expect($('.window').attr('data-uid')).toBe('pictures'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect($('.window').attr('data-path')).toBe('/alice/Pictures');
|
||||
expect($('.window').attr('data-uid')).toBe('pictures');
|
||||
});
|
||||
|
||||
it('drops a listing that arrives just before the window moves on', async () => {
|
||||
vi.useFakeTimers();
|
||||
navigate('/alice/Documents');
|
||||
|
||||
// A listing slower than 1s waits another second before rendering.
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
pending.readdir['/alice/Documents'].resolve([fileEntry('/alice/Documents', 'a.txt')]);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
navigate('/alice/Pictures');
|
||||
pending.readdir['/alice/Pictures'].resolve([fileEntry('/alice/Pictures', 'b.png')]);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(renderedPaths()).toEqual(['/alice/Pictures/b.png']);
|
||||
});
|
||||
|
||||
it('renders a folder once when it is refreshed again before the first listing arrives', async () => {
|
||||
navigate('/alice/Documents');
|
||||
navigate('/alice/Pictures');
|
||||
navigate('/alice/Documents');
|
||||
|
||||
pending.readdir['/alice/Documents'].resolve([fileEntry('/alice/Documents', 'a.txt')]);
|
||||
pending.readdir['/alice/Pictures'].resolve([fileEntry('/alice/Pictures', 'b.png')]);
|
||||
await vi.waitFor(() => expect(UIItem).toHaveBeenCalled());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
|
||||
});
|
||||
});
|
||||
@@ -129,13 +129,19 @@ const en = {
|
||||
choose_publishing_option: 'Choose how you want to publish your website:',
|
||||
contact_us: 'Contact Us',
|
||||
contact_us_attach: 'Attach Files',
|
||||
contact_us_attach_or_drop: 'Attach files or drop them here',
|
||||
contact_us_attach_hint: 'Images and videos — up to %% files, %% MB each.',
|
||||
contact_us_attachment_remove: 'Remove attachment',
|
||||
contact_us_attachment_remove_named: 'Remove %%',
|
||||
contact_us_attachment_too_large: 'That file is too large. Each attachment can be up to %% MB.',
|
||||
contact_us_attachment_too_many: 'You can attach up to %% files.',
|
||||
contact_us_attachment_total_too_large: 'Those files add up to more than %% MB. Try attaching fewer of them, or smaller ones.',
|
||||
contact_us_attachments: 'Attachments',
|
||||
contact_us_attachment_unsupported: 'That file type is not supported. Attach an image (PNG, JPEG, GIF, WebP) or a video (MP4, MOV, WebM).',
|
||||
contact_us_drop_to_attach: 'Drop to attach',
|
||||
contact_us_error: 'Something went wrong. Please try again.',
|
||||
contact_us_message: 'Message',
|
||||
contact_us_message_placeholder: 'How can we help? For a bug, the steps that lead to it help us most.',
|
||||
contact_us_message_required: 'Please write a message before sending.',
|
||||
contact_us_rate_limited: 'You have sent a lot of messages recently. Please try again later.',
|
||||
contact_us_verification_required: 'You must have a verified email address to use this.',
|
||||
@@ -472,7 +478,16 @@ const en = {
|
||||
publish_as_website: 'Publish as website',
|
||||
publish_as_serverless_worker: 'Publish as Worker',
|
||||
publish_address: 'Address',
|
||||
publish_custom_domain: 'Use a custom domain',
|
||||
publish_custom_domain_dns_hint: 'After publishing, you\'ll get the DNS records to add at your domain provider.',
|
||||
publish_custom_domain_hint: 'Serve this website from a domain you own.',
|
||||
publish_custom_domain_moves: 'This domain is on your account. Publishing moves it here from %%, at no extra cost.',
|
||||
publish_custom_domain_needs_plan: 'Upgrade to use a custom domain, or turn it off to publish without one.',
|
||||
publish_custom_domain_upsell_text: 'Upgrade to serve this website from a domain you own.',
|
||||
publish_custom_domain_upsell_title: 'Custom domains come with a paid plan',
|
||||
publish_domain_failed: 'Your website is live at %%, but the domain wasn\'t added.',
|
||||
publish_name_required: 'Enter an address.',
|
||||
publish_site_live: 'Your website is live at %%.',
|
||||
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
|
||||
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
|
||||
push_app_turn_off_failed: "Couldn't turn that off. Please try again.",
|
||||
@@ -996,7 +1011,7 @@ const en = {
|
||||
sign_in_with_provider: 'Sign in with %%',
|
||||
sign_up_with_provider: 'Sign up with %%',
|
||||
sign_up_with_email: 'Sign up using email',
|
||||
popup_opener_uses_puter: '%strong% is powered by Puter for its AI and cloud features.\nUse your Puter account to continue.',
|
||||
popup_opener_uses_puter: '%strong% is powered by Puter',
|
||||
popup_opener_origin_unsupported: 'Puter cannot sign you in to this page because the browser gives it no identifiable address. Pages opened straight from a file (file://) and iframes sandboxed without allow-same-origin are not supported — serve the page over http://localhost or a real domain and try again.',
|
||||
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ module.exports = async (options = {}) => {
|
||||
...entries,
|
||||
],
|
||||
'apps-embed': './src/appsEmbed.js',
|
||||
'feedback-embed': './src/feedbackEmbed.js',
|
||||
};
|
||||
config.output = {
|
||||
path: path.resolve(__dirname, '../dist'),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Puter GUI pages a first-party site can frame, under `/embed/*` on the GUI
|
||||
* origin. The server lets only a page one label under that origin frame them.
|
||||
*/
|
||||
|
||||
// After the frame loads, how long to wait for the embed's first message
|
||||
// before treating it as refused (a CSP that blocks it still fires `load`).
|
||||
const LOAD_GRACE_MS = 1000;
|
||||
// How long the embed may take to check the session before the popup is used.
|
||||
const READY_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Whether a page at `pageUrl` may frame an embed served by `guiOrigin`:
|
||||
* exactly one label under its host, on the same scheme and port.
|
||||
*
|
||||
* @param {string | undefined} pageUrl
|
||||
* @param {string} guiOrigin
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const canFrameGuiEmbed = (pageUrl, guiOrigin) => {
|
||||
let page;
|
||||
let gui;
|
||||
try {
|
||||
page = new URL(pageUrl);
|
||||
gui = new URL(guiOrigin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = page.hostname.replace(/\.$/, '').toLowerCase();
|
||||
const suffix = `.${gui.hostname.toLowerCase()}`;
|
||||
if ( page.protocol !== gui.protocol || page.port !== gui.port || !host.endsWith(suffix) ) return false;
|
||||
const label = host.slice(0, -suffix.length);
|
||||
return label.length > 0 && !label.includes('.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the app feedback dialog from `/embed/feedback` in a modal frame over
|
||||
* the page, as the user named. Resolves `{ sent }` once the dialog closes, or
|
||||
* null without showing anything when the embed can't serve this user (no
|
||||
* matching Puter session, an email to confirm, or a refused frame), so the
|
||||
* caller can use the popup instead. Never rejects.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.guiOrigin
|
||||
* @param {string} opts.userUuid - the page's signed-in user
|
||||
* @returns {Promise<{ sent: boolean } | null>}
|
||||
*/
|
||||
export const showFeedbackEmbed = ({ guiOrigin, userUuid }) => new Promise((resolve) => {
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(guiOrigin).origin;
|
||||
} catch {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const url = new URL('/embed/feedback', origin);
|
||||
url.hash = new URLSearchParams({ user: userUuid }).toString();
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'puter-feedback-embed';
|
||||
dialog.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;background:transparent;overflow:hidden;';
|
||||
// The embed draws its own scrim.
|
||||
const style = document.createElement('style');
|
||||
style.textContent = 'dialog.puter-feedback-embed::backdrop{background:transparent}';
|
||||
const frame = document.createElement('iframe');
|
||||
frame.title = 'Send feedback';
|
||||
// The embed allows a framing page it can name from the referrer.
|
||||
frame.setAttribute('referrerpolicy', 'origin');
|
||||
// A frame whose color-scheme differs from its document's is painted
|
||||
// opaque; the embed declares none.
|
||||
frame.style.cssText = 'display:block;width:100%;height:100%;border:0;background:transparent;color-scheme:normal;';
|
||||
dialog.append(style, frame);
|
||||
|
||||
let settled = false;
|
||||
let heard = false;
|
||||
let shown = false;
|
||||
let timer = null;
|
||||
const restoreFocus = document.activeElement;
|
||||
const wait = (ms) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => finish(null), ms);
|
||||
};
|
||||
const finish = (result) => {
|
||||
if ( settled ) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('message', onMessage);
|
||||
dialog.remove();
|
||||
if ( shown ) restoreFocus?.focus?.({ preventScroll: true });
|
||||
resolve(result);
|
||||
};
|
||||
const onMessage = (event) => {
|
||||
if ( event.origin !== origin || event.source !== frame.contentWindow ) return;
|
||||
const { msg, state, sent } = event.data ?? {};
|
||||
if ( msg === 'feedbackDialogClosed' ) {
|
||||
finish({ sent: sent === true });
|
||||
} else if ( msg === 'feedbackEmbed' && state === 'loading' ) {
|
||||
heard = true;
|
||||
wait(READY_TIMEOUT_MS);
|
||||
} else if ( msg === 'feedbackEmbed' && state === 'ready' ) {
|
||||
heard = true;
|
||||
clearTimeout(timer);
|
||||
shown = true;
|
||||
dialog.showModal();
|
||||
frame.focus();
|
||||
} else if ( msg === 'feedbackEmbed' && state === 'unavailable' ) {
|
||||
finish(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
frame.addEventListener('load', () => {
|
||||
if ( ! heard ) wait(LOAD_GRACE_MS);
|
||||
});
|
||||
// Escape outside the frame would close the dialog with the embed's
|
||||
// dialog still open inside it; the embed owns dismissal.
|
||||
dialog.addEventListener('cancel', (event) => {
|
||||
event.preventDefault();
|
||||
frame.focus();
|
||||
});
|
||||
|
||||
try {
|
||||
frame.src = url.href;
|
||||
// Hidden until the embed is ready, so a refused session shows nothing.
|
||||
document.body.append(dialog);
|
||||
wait(READY_TIMEOUT_MS);
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { canFrameGuiEmbed, showFeedbackEmbed } from './guiEmbed.js';
|
||||
|
||||
const GUI_ORIGIN = 'https://puter.test';
|
||||
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
|
||||
// jsdom has no top-layer dialogs.
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
this.open = true;
|
||||
};
|
||||
|
||||
const frame = () => document.querySelector('dialog.puter-feedback-embed iframe');
|
||||
|
||||
/** A message from the embed, by default from its frame on the GUI origin. */
|
||||
const fromEmbed = (data, { origin = GUI_ORIGIN, source = frame().contentWindow } = {}) =>
|
||||
window.dispatchEvent(new MessageEvent('message', { data, origin, source }));
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('canFrameGuiEmbed', () => {
|
||||
it.each([
|
||||
['https://notes.puter.com/a', 'https://puter.com', true],
|
||||
['https://NOTES.puter.com./a', 'https://puter.com', true],
|
||||
['http://notes.puter.localhost:4100/', 'http://puter.localhost:4100', true],
|
||||
['https://puter.com/', 'https://puter.com', false],
|
||||
['https://a.b.puter.com/', 'https://puter.com', false],
|
||||
['https://notputer.com/', 'https://puter.com', false],
|
||||
['https://notes.puter.com.evil.com/', 'https://puter.com', false],
|
||||
['http://notes.puter.com/', 'https://puter.com', false],
|
||||
['https://notes.puter.com:8443/', 'https://puter.com', false],
|
||||
['https://notes.puter.site/', 'https://puter.com', false],
|
||||
[undefined, 'https://puter.com', false],
|
||||
])('%s under %s is %s', (page, gui, expected) => {
|
||||
expect(canFrameGuiEmbed(page, gui)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFeedbackEmbed', () => {
|
||||
it('loads the embed for the named user, hidden until it is ready', () => {
|
||||
showFeedbackEmbed({ guiOrigin: `${GUI_ORIGIN}/`, userUuid: USER });
|
||||
const url = new URL(frame().src);
|
||||
expect(url.origin + url.pathname).toBe(`${GUI_ORIGIN}/embed/feedback`);
|
||||
expect(url.search).toBe('');
|
||||
expect(new URLSearchParams(url.hash.slice(1)).get('user')).toBe(USER);
|
||||
expect(frame().getAttribute('referrerpolicy')).toBe('origin');
|
||||
expect(frame().closest('dialog').open).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the dialog once ready and resolves what it reports, restoring focus', async () => {
|
||||
const trigger = document.createElement('button');
|
||||
document.body.append(trigger);
|
||||
trigger.focus();
|
||||
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
|
||||
const dialog = frame().closest('dialog');
|
||||
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'loading' });
|
||||
expect(dialog.open).toBe(false);
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' });
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(document.activeElement).toBe(frame());
|
||||
|
||||
fromEmbed({ msg: 'feedbackDialogClosed', sent: true });
|
||||
await expect(result).resolves.toEqual({ sent: true });
|
||||
expect(dialog.isConnected).toBe(false);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('resolves null without showing anything when the embed is unavailable', async () => {
|
||||
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
|
||||
const dialog = frame().closest('dialog');
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'loading' });
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'unavailable' });
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(dialog.open).toBe(false);
|
||||
expect(dialog.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores messages from another origin or another window', async () => {
|
||||
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
|
||||
fromEmbed({ msg: 'feedbackDialogClosed', sent: true }, { origin: 'https://other.test' });
|
||||
fromEmbed({ msg: 'feedbackDialogClosed', sent: true }, { source: window });
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' }, { origin: 'https://notes.puter.test' });
|
||||
expect(frame().closest('dialog').open).toBe(false);
|
||||
|
||||
fromEmbed({ msg: 'feedbackDialogClosed', sent: false });
|
||||
await expect(result).resolves.toEqual({ sent: false });
|
||||
});
|
||||
|
||||
it('resolves null when the frame loads without the embed answering', async () => {
|
||||
vi.useFakeTimers();
|
||||
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
|
||||
frame().dispatchEvent(new Event('load'));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a loading embed time to check the session, then gives up', async () => {
|
||||
vi.useFakeTimers();
|
||||
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'loading' });
|
||||
frame().dispatchEvent(new Event('load'));
|
||||
let settled = false;
|
||||
result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(settled).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the dialog open on Escape outside the frame, since the embed owns dismissal', () => {
|
||||
showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' });
|
||||
const dialog = frame().closest('dialog');
|
||||
const cancel = new Event('cancel', { cancelable: true });
|
||||
dialog.dispatchEvent(cancel);
|
||||
expect(cancel.defaultPrevented).toBe(true);
|
||||
expect(dialog.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves null for an unusable GUI origin', async () => {
|
||||
await expect(showFeedbackEmbed({ guiOrigin: 'not a url', userUuid: USER })).resolves.toBeNull();
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import EventListener from '../lib/EventListener.js';
|
||||
import { PuterJSError } from '../lib/PuterJSError.js';
|
||||
import FSItem from './FSItem.js';
|
||||
import PuterDialog from './PuterDialog.js';
|
||||
import { canFrameGuiEmbed, showFeedbackEmbed } from '../lib/guiEmbed.js';
|
||||
import { checkPermissions } from './perms/lib/holds.js';
|
||||
|
||||
|
||||
@@ -528,6 +529,9 @@ export class UIModule extends EventListener {
|
||||
#guiFeatures = new URLSearchParams(globalThis.location?.search ?? '')
|
||||
.get('puter.gui_features')?.split(',') ?? [];
|
||||
|
||||
// Set while the framed feedback dialog is open on a first-party page.
|
||||
#feedbackEmbedOpen = false;
|
||||
|
||||
// Holds the callback functions for the various events
|
||||
// that are triggered when a watched item has changed.
|
||||
itemWatchCallbackFunctions = [];
|
||||
@@ -2249,9 +2253,12 @@ export class UIModule extends EventListener {
|
||||
* have opted in by setting `feedbackEnabled` on the app — otherwise the
|
||||
* dialog tells the user feedback is unavailable.
|
||||
*
|
||||
* In the `app` environment the Puter desktop renders the dialog; in the
|
||||
* `web` environment a puter.com popup hosts it (signing the user in first
|
||||
* if needed). Every other environment resolves `false`.
|
||||
* In the `app` environment the Puter desktop renders the dialog. In the
|
||||
* `web` environment a first-party page one label under the Puter origin
|
||||
* shows it in a frame over the page when the user's Puter session is the
|
||||
* one the page is signed in as; otherwise a puter.com popup hosts it
|
||||
* (signing the user in first if needed). Every other environment
|
||||
* resolves `false`.
|
||||
*
|
||||
* @returns {Promise<boolean>} `true` when the user submitted feedback,
|
||||
* `false` when the dialog was dismissed or feedback is unavailable.
|
||||
@@ -2276,6 +2283,10 @@ export class UIModule extends EventListener {
|
||||
if ( this.env !== 'web' ) {
|
||||
return false;
|
||||
}
|
||||
const embedded = await this.#showFeedbackEmbed();
|
||||
if ( embedded ) {
|
||||
return embedded.sent;
|
||||
}
|
||||
// The app's identity is deliberately NOT in the popup URL: the GUI
|
||||
// derives it from the browser-attested opener origin, so a link
|
||||
// can't open a feedback dialog in another app's name.
|
||||
@@ -2287,6 +2298,29 @@ export class UIModule extends EventListener {
|
||||
return result?.sent === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* The framed feedback dialog for a first-party page that is signed in, or
|
||||
* null when the popup has to host it. One at a time: a call while one is
|
||||
* open resolves `{ sent: false }`, as the desktop does.
|
||||
*
|
||||
* @returns {Promise<{ sent: boolean } | null>}
|
||||
*/
|
||||
async #showFeedbackEmbed () {
|
||||
if ( ! globalThis.document?.body ) return null;
|
||||
if ( ! canFrameGuiEmbed(globalThis.location?.href, puter.defaultGUIOrigin) ) return null;
|
||||
if ( ! puter.auth?.isSignedIn?.() ) return null;
|
||||
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;
|
||||
return await showFeedbackEmbed({ guiOrigin: puter.defaultGUIOrigin, userUuid: user.uuid });
|
||||
} finally {
|
||||
this.#feedbackEmbedOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens `/action/<action>` on the GUI origin in a popup and waits for
|
||||
* its single result message. Resolves the message's data, or `null` when
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
|
||||
import { 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;
|
||||
};
|
||||
|
||||
const GUI_ORIGIN = 'https://puter.test';
|
||||
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
|
||||
const makeUI = () => new UIModule(
|
||||
{ env: 'web', authToken: 'token-1', APIOrigin: 'https://api.puter.test', util: {} },
|
||||
{},
|
||||
);
|
||||
|
||||
const embedFrame = () => document.querySelector('dialog.puter-feedback-embed iframe');
|
||||
|
||||
const fromEmbed = (data) => window.dispatchEvent(new MessageEvent('message', {
|
||||
data, origin: GUI_ORIGIN, source: embedFrame().contentWindow,
|
||||
}));
|
||||
|
||||
let popup;
|
||||
|
||||
const fakePuter = ({ guiOrigin = GUI_ORIGIN, signedIn = true } = {}) => {
|
||||
globalThis.puter = {
|
||||
defaultGUIOrigin: guiOrigin,
|
||||
auth: {
|
||||
isSignedIn: () => signedIn,
|
||||
getUser: vi.fn(async () => ({ uuid: USER })),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
popup = { closed: false, close: vi.fn() };
|
||||
window.open = vi.fn(() => popup);
|
||||
Object.defineProperty(navigator, 'userActivation', {
|
||||
configurable: true,
|
||||
value: { hasBeenActive: true, isActive: true },
|
||||
});
|
||||
fakePuter();
|
||||
});
|
||||
|
||||
describe('ui.showFeedbackDialog on a first-party page', () => {
|
||||
it('shows the dialog in a frame instead of a popup', async () => {
|
||||
const result = makeUI().showFeedbackDialog();
|
||||
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' });
|
||||
fromEmbed({ msg: 'feedbackDialogClosed', sent: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the user puter.js already knows, without asking the API again', async () => {
|
||||
globalThis.puter.whoamiCache_ = Promise.resolve({ uuid: USER });
|
||||
const result = makeUI().showFeedbackDialog();
|
||||
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
|
||||
expect(new URLSearchParams(new URL(embedFrame().src).hash.slice(1)).get('user')).toBe(USER);
|
||||
expect(globalThis.puter.auth.getUser).not.toHaveBeenCalled();
|
||||
fromEmbed({ msg: 'feedbackDialogClosed', sent: false });
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('uses the popup when the embed cannot serve the user', async () => {
|
||||
const result = makeUI().showFeedbackDialog();
|
||||
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
|
||||
fromEmbed({ msg: 'feedbackEmbed', state: 'unavailable' });
|
||||
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
|
||||
const url = new URL(window.open.mock.calls[0][0]);
|
||||
expect(url.origin + url.pathname).toBe(`${GUI_ORIGIN}/action/send-feedback`);
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'feedbackDialogClosed', sent: true, original_msg_id: url.searchParams.get('msg_id') },
|
||||
origin: GUI_ORIGIN,
|
||||
source: popup,
|
||||
}));
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('answers false to a second call while the framed dialog is open', async () => {
|
||||
const ui = makeUI();
|
||||
const first = ui.showFeedbackDialog();
|
||||
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
|
||||
await expect(ui.showFeedbackDialog()).resolves.toBe(false);
|
||||
expect(document.querySelectorAll('dialog.puter-feedback-embed')).toHaveLength(1);
|
||||
fromEmbed({ msg: 'feedbackDialogClosed', sent: false });
|
||||
await expect(first).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('goes straight to the popup, which can sign in, when the page is signed out', async () => {
|
||||
fakePuter({ signedIn: false });
|
||||
makeUI().showFeedbackDialog();
|
||||
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
|
||||
expect(embedFrame()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.showFeedbackDialog on a third-party page', () => {
|
||||
it('uses the popup', async () => {
|
||||
fakePuter({ guiOrigin: 'https://other-puter.test' });
|
||||
makeUI().showFeedbackDialog();
|
||||
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
|
||||
expect(embedFrame()).toBeNull();
|
||||
expect(globalThis.puter.auth.getUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ All components use **Shadow DOM** so their styles won't leak into your page. The
|
||||
| [`<puter-spinner>`](#puter-spinner) | Loading overlay | `puter.ui.showSpinner()` / `hideSpinner()` |
|
||||
| [`<puter-color-picker>`](#puter-color-picker) | Color picker dialog | `puter.ui.showColorPicker()` |
|
||||
| [`<puter-font-picker>`](#puter-font-picker) | Font picker dialog | `puter.ui.showFontPicker()` |
|
||||
| [`<puter-user-menu>`](#puter-user-menu) | Account button and Puter's app browser, for first-party apps | none |
|
||||
|
||||
---
|
||||
|
||||
@@ -334,13 +335,62 @@ el.open();
|
||||
|
||||
---
|
||||
|
||||
### `<puter-user-menu>`
|
||||
|
||||
The account button and Puter's app browser for a first-party app on a Puter subdomain (`notes.puter.com`). It is internal and left out of the public docs, because the app browser only works one subdomain under the Puter origin. It follows the page's own puter.js sign-in, never the Puter session's.
|
||||
|
||||
- Signed out, it shows **Sign in**, which calls `puter.auth.signIn()`. A temporary account counts as signed out.
|
||||
- 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 username and **Sign out**, which calls `puter.auth.signOut()`.
|
||||
- On a page exactly one label under the Puter origin, with the same scheme and port, an **Apps** button opens the app browser (`/embed/apps`) in a panel under the button. It names the signed-in user as `#user=<uuid>`, so the app browser shows the Puter session's apps only to that user (see `doc/embedding-app-browser.md`), and passes a forced `theme` on as `theme=`. Elsewhere the button is hidden, because the server refuses the framing.
|
||||
- The menu and the Apps panel hang under their button with a caret pointing at it, and close on Escape, a press outside them, or their button. They're in the browser's top layer where it has one, so a host's `transform`, `overflow` or `z-index` can't clip or cover them. On screens up to 600px wide, the Apps panel spans the width below the button.
|
||||
- Closing the Apps panel keeps the app browser, so reopening it keeps its scroll and search. Any change to the page's sign-in discards it, so it never stays loaded for the previous user.
|
||||
- It renders nothing outside a top-level page (`puter.env !== 'web'`), such as inside the Puter desktop.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Attribute | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `apps-label` | `Apps` | Apps button label, panel title and iframe title |
|
||||
| `sign-in-label` | `Sign in` | Sign-in button text |
|
||||
| `sign-out-label` | `Sign out` | Sign-out menu item text |
|
||||
| `close-label` | `Close` | The Apps panel's close button label |
|
||||
| `account-label` | `Account` | Account button label, read as `Account: <username>` |
|
||||
| `theme` | follows the OS | `light` or `dark`, for the component and the app browser in it |
|
||||
|
||||
Changing an attribute updates the component in place.
|
||||
|
||||
**Parts**
|
||||
|
||||
`apps-button`, `avatar`, `sign-in`, `menu` and `apps-panel`, for styling from the host page with `puter-user-menu::part(avatar) { ... }`.
|
||||
|
||||
**Events**
|
||||
|
||||
| Event | Detail | When |
|
||||
|-------|--------|------|
|
||||
| `signin` | `{ user }`, the signed-in user or `null` | Sign in finished |
|
||||
| `signout` | none | The user signed out from the menu |
|
||||
|
||||
**Example**
|
||||
```html
|
||||
<header>
|
||||
<h1>Notes</h1>
|
||||
<puter-user-menu sign-in-label="Se connecter" sign-out-label="Se déconnecter"></puter-user-menu>
|
||||
</header>
|
||||
<script>
|
||||
document.querySelector('puter-user-menu')
|
||||
.addEventListener('signout', () => clearNotes());
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Styling
|
||||
|
||||
All components render inside Shadow DOM and match puter.com's native GUI appearance. Styles are encapsulated and cannot be overridden from outside the component.
|
||||
All components render inside Shadow DOM and match puter.com's native GUI appearance. Styles are encapsulated and cannot be overridden from outside the component, except through the parts `<puter-user-menu>` exposes.
|
||||
|
||||
## Dark mode
|
||||
|
||||
By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
|
||||
By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`, `<puter-user-menu>`.
|
||||
|
||||
To force a specific theme regardless of the OS preference, set the `theme` attribute on the component:
|
||||
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* <puter-user-menu> - The signed-in user's account button and Puter's app
|
||||
* browser, for a page that signs in with puter.js.
|
||||
*
|
||||
* 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 username 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
|
||||
* is kept between opens and dropped whenever the page's sign-in changes.
|
||||
* Renders nothing inside the Puter desktop.
|
||||
*
|
||||
* Attributes: theme, apps-label, sign-in-label, sign-out-label, close-label,
|
||||
* account-label
|
||||
* Parts: apps-button, avatar, sign-in, menu, apps-panel
|
||||
* Events: signin (detail.user), signout
|
||||
*/
|
||||
|
||||
import PuterWebComponent from '../PuterWebComponent.js';
|
||||
import { defaultFontFamily } from '../PuterDefaultStyles.js';
|
||||
import { canFrameGuiEmbed } from '../../lib/guiEmbed.js';
|
||||
|
||||
const APPS_ICON = `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">${
|
||||
[5, 12, 19].flatMap(y => [5, 12, 19].map(x => `<circle cx="${x}" cy="${y}" r="2"/>`)).join('')
|
||||
}</svg>`;
|
||||
const CLOSE_ICON = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
||||
|
||||
const LABELS = {
|
||||
'apps-label': 'Apps',
|
||||
'sign-in-label': 'Sign in',
|
||||
'sign-out-label': 'Sign out',
|
||||
'close-label': 'Close',
|
||||
'account-label': 'Account',
|
||||
};
|
||||
|
||||
// Gap between a panel and the viewport edge, and between its caret and corners.
|
||||
const EDGE = 8;
|
||||
const CARET_INSET = 20;
|
||||
|
||||
class PuterUserMenu extends PuterWebComponent {
|
||||
static get observedAttributes () {
|
||||
return Object.keys(LABELS);
|
||||
}
|
||||
|
||||
#unsubscribe = null;
|
||||
// Bumped on every load, so a slower earlier load can't overwrite a later one.
|
||||
#generation = 0;
|
||||
#ready = false;
|
||||
#token = null;
|
||||
#apiOrigin = null;
|
||||
#user = null;
|
||||
#picture = null;
|
||||
// Set while signing out from inside the component, whose focus then moves to Sign in.
|
||||
#focusSignIn = false;
|
||||
// Set while a sign-in popup is open, so another click doesn't open a second one.
|
||||
#signingIn = false;
|
||||
#onDocumentPointerDown = (event) => {
|
||||
if ( ! event.composedPath().includes(this) ) this._closePanels();
|
||||
};
|
||||
#onViewportChange = () => this._positionOpenPanels();
|
||||
|
||||
connectedCallback () {
|
||||
super.connectedCallback();
|
||||
// The SDK defines this element before it assigns `globalThis.puter`.
|
||||
queueMicrotask(() => this._start());
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
super.disconnectedCallback();
|
||||
this.#unsubscribe?.();
|
||||
this.#unsubscribe = null;
|
||||
this.#generation++;
|
||||
this._closePanels();
|
||||
this._dropApps();
|
||||
}
|
||||
|
||||
attributeChangedCallback () {
|
||||
if ( this.$('.bar') ) this._applyLabels();
|
||||
}
|
||||
|
||||
_applyTheme () {
|
||||
super._applyTheme();
|
||||
const frame = this.shadowRoot?.querySelector('.apps-body iframe');
|
||||
if ( ! frame ) return;
|
||||
frame.style.colorScheme = this._colorScheme();
|
||||
// A forced theme is part of the app browser's URL, so a new one reloads it.
|
||||
if ( frame.src === this._appsUrl() ) return;
|
||||
this._dropApps();
|
||||
if ( ! this.$('.apps-panel').hidden ) this._mountApps();
|
||||
}
|
||||
|
||||
_colorScheme () {
|
||||
return this.classList.contains('puter-theme-dark') ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/** The `theme` attribute's scheme, or null when the component follows the OS. */
|
||||
_forcedTheme () {
|
||||
const theme = this.getAttribute('theme');
|
||||
return theme === 'dark' || theme === 'light' ? theme : null;
|
||||
}
|
||||
|
||||
_label (name) {
|
||||
return this.getAttribute(name) || LABELS[name];
|
||||
}
|
||||
|
||||
/** Only a top-level page that signs in on its own; the desktop has its own menu. */
|
||||
_active () {
|
||||
return globalThis.puter?.env === 'web';
|
||||
}
|
||||
|
||||
_appsAllowed () {
|
||||
return canFrameGuiEmbed(globalThis.location?.href, globalThis.puter.defaultGUIOrigin);
|
||||
}
|
||||
|
||||
/** A temporary account has nothing to sign out of, so it gets Sign in. */
|
||||
_signedIn () {
|
||||
return Boolean(this.#token) && !this.#user?.is_temp;
|
||||
}
|
||||
|
||||
_start () {
|
||||
if ( !this.isConnected || this.#unsubscribe || !this._active() ) return;
|
||||
this.$('.bar').hidden = false;
|
||||
this.#unsubscribe = globalThis.puter.onAuthStateChanged(() => {
|
||||
const puter = globalThis.puter;
|
||||
if ( puter.authToken === this.#token && puter.APIOrigin === this.#apiOrigin ) return;
|
||||
this._closePanels();
|
||||
this._dropApps();
|
||||
this._load();
|
||||
});
|
||||
this._load();
|
||||
}
|
||||
|
||||
async _load () {
|
||||
const generation = ++this.#generation;
|
||||
const puter = globalThis.puter;
|
||||
this.#ready = false;
|
||||
this.#user = null;
|
||||
this.#picture = null;
|
||||
this.#token = puter.authToken;
|
||||
this.#apiOrigin = puter.APIOrigin;
|
||||
this._renderState();
|
||||
|
||||
// `setAuthToken` announces a token before it starts that token's whoami.
|
||||
await null;
|
||||
// A tab Puter opened for an app gets its token moments after loading.
|
||||
if ( puter.openerLaunch_ ) await puter.openerLaunch_.catch(() => {});
|
||||
const token = puter.authToken;
|
||||
const user = token ? await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null) : null;
|
||||
if ( generation !== this.#generation ) return;
|
||||
// Answers for a token since replaced; the newer token loads again.
|
||||
const current = token === puter.authToken;
|
||||
this.#token = puter.authToken;
|
||||
this.#apiOrigin = puter.APIOrigin;
|
||||
this.#user = current && user && typeof user === 'object' ? user : null;
|
||||
this.#ready = true;
|
||||
this._renderState();
|
||||
|
||||
// The picture follows, so a slow profile doesn't hold up the account.
|
||||
if ( !this.#user || this.#user.is_temp ) return;
|
||||
const picture = await puter.auth.getProfilePicture().catch(() => null);
|
||||
if ( generation !== this.#generation || token !== puter.authToken || typeof picture !== 'string' ) return;
|
||||
this.#picture = picture;
|
||||
this._renderState();
|
||||
}
|
||||
|
||||
getStyles () {
|
||||
return `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
font-family: ${defaultFontFamily};
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
[hidden] { display: none !important; }
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid #088ef0;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.apps-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #414650;
|
||||
fill: currentColor;
|
||||
}
|
||||
.apps-button[aria-expanded="true"] { background-color: rgba(0, 0, 0, .06); }
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(#34a5f8, #088ef0);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.avatar[aria-expanded="true"] { box-shadow: 0 0 0 3px rgba(8, 142, 240, .25); }
|
||||
.avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.sign-in {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #088ef0;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(#34a5f8, #088ef0);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sign-in:active {
|
||||
background-color: #2798eb;
|
||||
border-color: #2798eb;
|
||||
color: #bedef5;
|
||||
}
|
||||
|
||||
/* Panels hang under their button with a caret pointing at it. In
|
||||
the top layer where the browser has one, so no host transform,
|
||||
overflow or z-index can clip or cover them. */
|
||||
.panel {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
z-index: 2147483000;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 12px;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, .16), 0 2px 6px rgba(0, 0, 0, .06);
|
||||
font-family: ${defaultFontFamily};
|
||||
--caret-border: #ddd;
|
||||
--caret-fill: #fff;
|
||||
animation: panel-in .14s ease-out;
|
||||
}
|
||||
.panel:focus, .panel:focus-visible { outline: none; }
|
||||
.panel::before,
|
||||
.panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: var(--caret-x, calc(100% - ${CARET_INSET}px));
|
||||
transform: translateX(-50%);
|
||||
border: 8px solid transparent;
|
||||
border-top: none;
|
||||
border-bottom-color: var(--caret-border);
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel::after {
|
||||
top: -7px;
|
||||
border-bottom-color: var(--caret-fill);
|
||||
}
|
||||
@keyframes panel-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel { animation: none; }
|
||||
}
|
||||
|
||||
.menu {
|
||||
min-width: 200px;
|
||||
max-width: min(300px, calc(100vw - ${EDGE * 2}px));
|
||||
padding: 4px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.menu-user {
|
||||
padding: 8px 14px 10px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid #eee;
|
||||
color: #414650;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
.menu-item:focus-visible {
|
||||
outline: none;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.apps-panel {
|
||||
width: 400px;
|
||||
max-width: calc(100vw - ${EDGE * 2}px);
|
||||
height: min(580px, calc(100vh - var(--panel-top, 46px) - 12px));
|
||||
height: min(580px, calc(100dvh - var(--panel-top, 46px) - 12px));
|
||||
min-height: 240px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.apps-panel:not([hidden]) { display: flex; }
|
||||
.apps-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 14px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.apps-title {
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.apps-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #555;
|
||||
}
|
||||
.apps-body {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
.apps-body iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.apps-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.apps-body.is-loading .apps-loading { display: flex; }
|
||||
.apps-body.is-loading iframe { visibility: hidden; }
|
||||
.apps-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(0, 0, 0, .12);
|
||||
border-top-color: #555;
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.apps-spinner { animation-duration: 1.6s; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.apps-panel {
|
||||
width: calc(100vw - ${EDGE * 2}px);
|
||||
height: calc(100vh - var(--panel-top, 54px) - ${EDGE}px);
|
||||
height: calc(100dvh - var(--panel-top, 54px) - ${EDGE}px);
|
||||
}
|
||||
.apps-header { padding: 6px 6px 6px 16px; }
|
||||
.apps-title { font-size: 15px; }
|
||||
.apps-close { width: 40px; height: 40px; border-radius: 10px; }
|
||||
}
|
||||
|
||||
:host(.puter-theme-dark) .apps-button { color: #e6e6e6; }
|
||||
:host(.puter-theme-dark) .apps-button[aria-expanded="true"] { background-color: rgba(255, 255, 255, .1); }
|
||||
:host(.puter-theme-dark) .panel {
|
||||
border-color: #3a3a3a;
|
||||
background-color: #2a2a2a;
|
||||
color: #e6e6e6;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, .45);
|
||||
--caret-border: #3a3a3a;
|
||||
--caret-fill: #2a2a2a;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-user,
|
||||
:host(.puter-theme-dark) .apps-header { border-color: #3a3a3a; }
|
||||
:host(.puter-theme-dark) .menu-user,
|
||||
:host(.puter-theme-dark) .apps-title,
|
||||
:host(.puter-theme-dark) .menu-item { color: #e6e6e6; }
|
||||
:host(.puter-theme-dark) .menu-item:focus-visible { background-color: #3a3a3a; color: #fff; }
|
||||
:host(.puter-theme-dark) .apps-close { color: #ccc; }
|
||||
:host(.puter-theme-dark) .apps-spinner {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
border-top-color: #ccc;
|
||||
}
|
||||
|
||||
/* A tap leaves :hover on touch screens, which would shade a closed button. */
|
||||
@media (hover: hover) {
|
||||
.apps-button:hover { background-color: rgba(0, 0, 0, .06); }
|
||||
.avatar:hover { box-shadow: 0 0 0 3px rgba(8, 142, 240, .25); }
|
||||
.menu-item:hover { background-color: #f0f0f0; }
|
||||
.apps-close:hover { background-color: #f0f0f0; color: #222; }
|
||||
:host(.puter-theme-dark) .apps-button:hover { background-color: rgba(255, 255, 255, .1); }
|
||||
:host(.puter-theme-dark) .menu-item:hover,
|
||||
:host(.puter-theme-dark) .apps-close:hover { background-color: #3a3a3a; color: #fff; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
render () {
|
||||
return `
|
||||
<div class="bar" hidden>
|
||||
<button type="button" class="apps-button" part="apps-button" aria-haspopup="dialog" aria-expanded="false" hidden>${APPS_ICON}</button>
|
||||
<button type="button" class="sign-in" part="sign-in" hidden></button>
|
||||
<button type="button" class="avatar" part="avatar" aria-haspopup="menu" aria-expanded="false" hidden></button>
|
||||
</div>
|
||||
<div class="panel menu" part="menu" role="menu" tabindex="-1" hidden>
|
||||
<div class="menu-user" hidden></div>
|
||||
<button type="button" class="menu-item sign-out" role="menuitem"></button>
|
||||
</div>
|
||||
<div class="panel apps-panel" part="apps-panel" role="dialog" tabindex="-1" hidden>
|
||||
<div class="apps-header">
|
||||
<span class="apps-title"></span>
|
||||
<button type="button" class="apps-close">${CLOSE_ICON}</button>
|
||||
</div>
|
||||
<div class="apps-body">
|
||||
<div class="apps-loading" role="status"><span class="apps-spinner"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
onReady () {
|
||||
if ( typeof HTMLElement !== 'undefined' && 'popover' in HTMLElement.prototype ) {
|
||||
for ( const panel of this.$$('.panel') ) panel.setAttribute('popover', 'manual');
|
||||
}
|
||||
this._applyLabels();
|
||||
this.$('.apps-button').addEventListener('click', () => {
|
||||
if ( this.$('.apps-panel').hidden ) this._openApps();
|
||||
else this._closePanels();
|
||||
});
|
||||
this.$('.sign-in').addEventListener('click', () => this._signIn());
|
||||
this.$('.avatar').addEventListener('click', () => {
|
||||
if ( this.$('.menu').hidden ) this._openMenu();
|
||||
else this._closePanels();
|
||||
});
|
||||
this.$('.sign-out').addEventListener('click', () => this._signOut());
|
||||
this.$('.apps-close').addEventListener('click', () => this._closePanels({ returnFocus: true }));
|
||||
this.shadowRoot.addEventListener('keydown', (event) => {
|
||||
const open = this._openPanel();
|
||||
if ( ! open ) return;
|
||||
if ( event.key === 'Escape' ) {
|
||||
event.preventDefault();
|
||||
this._closePanels({ returnFocus: true });
|
||||
} else if ( event.key === 'Tab' && open.classList.contains('menu') ) {
|
||||
// Tab leaves the menu: it closes, and focus moves on from its button.
|
||||
this._closePanels({ returnFocus: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_applyLabels () {
|
||||
const apps = this._label('apps-label');
|
||||
const appsButton = this.$('.apps-button');
|
||||
appsButton.setAttribute('aria-label', apps);
|
||||
appsButton.title = apps;
|
||||
this.$('.apps-panel').setAttribute('aria-label', apps);
|
||||
this.$('.apps-title').textContent = apps;
|
||||
this.$('.apps-loading').setAttribute('aria-label', apps);
|
||||
const frame = this.$('.apps-body iframe');
|
||||
if ( frame ) frame.title = apps;
|
||||
const close = this.$('.apps-close');
|
||||
close.setAttribute('aria-label', this._label('close-label'));
|
||||
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._renderAvatarLabel();
|
||||
}
|
||||
|
||||
_username () {
|
||||
return typeof this.#user?.username === 'string' ? this.#user.username : '';
|
||||
}
|
||||
|
||||
_renderAvatarLabel () {
|
||||
const username = this._username();
|
||||
const account = this._label('account-label');
|
||||
this.$('.avatar').setAttribute('aria-label', username ? `${account}: ${username}` : account);
|
||||
}
|
||||
|
||||
_renderState () {
|
||||
const signedIn = this._signedIn();
|
||||
const username = this._username();
|
||||
this.$('.apps-button').hidden = !this.#ready || !this._appsAllowed();
|
||||
this.$('.sign-in').hidden = !this.#ready || signedIn;
|
||||
|
||||
const avatar = this.$('.avatar');
|
||||
avatar.hidden = !this.#ready || !signedIn;
|
||||
avatar.textContent = username ? username[0].toUpperCase() : '?';
|
||||
if ( this.#picture ) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
// The initial stays underneath for a picture the page's CSP refuses.
|
||||
img.addEventListener('error', () => img.remove());
|
||||
img.src = this.#picture;
|
||||
avatar.append(img);
|
||||
}
|
||||
this._renderAvatarLabel();
|
||||
const menuUser = this.$('.menu-user');
|
||||
menuUser.textContent = username;
|
||||
menuUser.hidden = !username;
|
||||
if ( avatar.hidden && !this.$('.menu').hidden ) this._closePanels();
|
||||
if ( this.#ready && this.#focusSignIn ) {
|
||||
this.#focusSignIn = false;
|
||||
if ( ! this.$('.sign-in').hidden ) this.$('.sign-in').focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// -- Panels --
|
||||
|
||||
_openPanel () {
|
||||
return [...this.$$('.panel')].find(panel => !panel.hidden) ?? null;
|
||||
}
|
||||
|
||||
_triggerOf (panel) {
|
||||
return this.$(panel.classList.contains('menu') ? '.avatar' : '.apps-button');
|
||||
}
|
||||
|
||||
_showPanel (panel) {
|
||||
this._closePanels();
|
||||
const trigger = this._triggerOf(panel);
|
||||
panel.hidden = false;
|
||||
if ( typeof panel.showPopover === 'function' && !panel.matches(':popover-open') ) panel.showPopover();
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
this._positionPanel(panel, trigger);
|
||||
document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
||||
window.addEventListener('resize', this.#onViewportChange);
|
||||
window.addEventListener('scroll', this.#onViewportChange, true);
|
||||
}
|
||||
|
||||
_closePanels ({ returnFocus = false } = {}) {
|
||||
const open = this._openPanel();
|
||||
for ( const panel of this.$$('.panel') ) {
|
||||
if ( typeof panel.hidePopover === 'function' && panel.matches(':popover-open') ) panel.hidePopover();
|
||||
panel.hidden = true;
|
||||
this._triggerOf(panel).setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
||||
window.removeEventListener('resize', this.#onViewportChange);
|
||||
window.removeEventListener('scroll', this.#onViewportChange, true);
|
||||
if ( returnFocus && open ) {
|
||||
const trigger = this._triggerOf(open);
|
||||
if ( ! trigger.hidden ) trigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_positionOpenPanels () {
|
||||
const panel = this._openPanel();
|
||||
if ( panel ) this._positionPanel(panel, this._triggerOf(panel));
|
||||
}
|
||||
|
||||
/** Right-aligns the panel under its trigger, inside the viewport, with the caret at the trigger. */
|
||||
_positionPanel (panel, trigger) {
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
// A trigger the page has hidden or laid out of view leaves nothing to hang from.
|
||||
if ( !rect.width && !rect.height ) {
|
||||
this._closePanels();
|
||||
return;
|
||||
}
|
||||
const top = Math.round(rect.bottom + 10);
|
||||
panel.style.setProperty('--panel-top', `${top}px`);
|
||||
panel.style.top = `${top}px`;
|
||||
const width = panel.offsetWidth;
|
||||
const viewport = document.documentElement.clientWidth || window.innerWidth;
|
||||
const left = Math.max(EDGE, Math.min(rect.right + EDGE - width, viewport - EDGE - width));
|
||||
panel.style.left = `${Math.round(left)}px`;
|
||||
const caret = rect.left + rect.width / 2 - left;
|
||||
panel.style.setProperty('--caret-x', `${Math.round(Math.min(width - CARET_INSET, Math.max(CARET_INSET, caret)))}px`);
|
||||
}
|
||||
|
||||
_openMenu () {
|
||||
const menu = this.$('.menu');
|
||||
this._showPanel(menu);
|
||||
this.$('.sign-out').focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
_openApps () {
|
||||
if ( ! this.#ready ) return;
|
||||
if ( ! this.$('.apps-body iframe') ) this._mountApps();
|
||||
const panel = this.$('.apps-panel');
|
||||
this._showPanel(panel);
|
||||
panel.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/** The app browser for the signed-in user, in the forced theme if any. */
|
||||
_appsUrl () {
|
||||
const url = new URL('/embed/apps', globalThis.puter.defaultGUIOrigin);
|
||||
const hash = new URLSearchParams();
|
||||
// The app browser shows the Puter session only to the user named here.
|
||||
const uuid = this._signedIn() && this.#token === globalThis.puter.authToken ? this.#user?.uuid : null;
|
||||
if ( typeof uuid === 'string' && uuid ) hash.set('user', uuid);
|
||||
const theme = this._forcedTheme();
|
||||
if ( theme ) hash.set('theme', theme);
|
||||
url.hash = hash.toString();
|
||||
return url.href;
|
||||
}
|
||||
|
||||
_mountApps () {
|
||||
const body = this.$('.apps-body');
|
||||
const frame = document.createElement('iframe');
|
||||
frame.title = this._label('apps-label');
|
||||
// The app browser allows a framing page it can name from the referrer.
|
||||
frame.setAttribute('referrerpolicy', 'origin');
|
||||
frame.style.colorScheme = this._colorScheme();
|
||||
body.classList.add('is-loading');
|
||||
frame.addEventListener('load', () => body.classList.remove('is-loading'), { once: true });
|
||||
frame.src = this._appsUrl();
|
||||
body.append(frame);
|
||||
}
|
||||
|
||||
/** Discards the app browser, so the next open loads it for whoever is signed in then. */
|
||||
_dropApps () {
|
||||
this.$('.apps-body iframe')?.remove();
|
||||
this.$('.apps-body').classList.remove('is-loading');
|
||||
}
|
||||
|
||||
// -- Sign-in --
|
||||
|
||||
_signIn () {
|
||||
if ( this.#signingIn ) return;
|
||||
const puter = globalThis.puter;
|
||||
// A cross-origin isolated page can't see its popup close, so it could never retry.
|
||||
this.#signingIn = !globalThis.crossOriginIsolated;
|
||||
// Called inside the click, so the sign-in popup isn't blocked.
|
||||
puter.auth.signIn().then(
|
||||
async () => {
|
||||
this.#signingIn = false;
|
||||
const user = await Promise.resolve(puter.whoamiCache_).catch(() => null);
|
||||
this.emitEvent('signin', { user: user && typeof user === 'object' ? user : null });
|
||||
},
|
||||
() => {
|
||||
// Closed or refused; the button stays for another try.
|
||||
this.#signingIn = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async _signOut () {
|
||||
this.#focusSignIn = Boolean(this.shadowRoot.activeElement);
|
||||
this._closePanels();
|
||||
try {
|
||||
await globalThis.puter.auth.signOut();
|
||||
} finally {
|
||||
this.#focusSignIn = false;
|
||||
// The token goes before the sign-out settles, even when it rejects.
|
||||
if ( ! globalThis.puter.authToken ) this.emitEvent('signout');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PuterUserMenu;
|
||||
@@ -0,0 +1,441 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url": "https://notes.puter.com/sheet"}
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import PuterUserMenu from './PuterUserMenu.js';
|
||||
|
||||
const USER = { username: 'jelveh', uuid: '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40' };
|
||||
const OTHER = { username: 'sam', uuid: '7d2e4f10-8a3b-4c5d-9e6f-0a1b2c3d4e5f' };
|
||||
const TEMP = { username: 'quiet_otter_42', uuid: '5c4b3a29-1807-4f6e-9d5c-4b3a29180716', is_temp: true };
|
||||
const PICTURE = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
// Where a trigger sits in the toolbar; jsdom lays nothing out.
|
||||
const TRIGGER_RECT = { left: 800, right: 836, top: 10, bottom: 46, width: 36, height: 36, x: 800, y: 10 };
|
||||
|
||||
/** The SDK instance the component reads, holding one signed-in state at a time. */
|
||||
const fakePuter = ({ token = null, user = null, picture = null } = {}) => {
|
||||
const listeners = new Set();
|
||||
let currentPicture = picture;
|
||||
const sdk = {
|
||||
env: 'web',
|
||||
defaultGUIOrigin: 'https://puter.com',
|
||||
APIOrigin: 'https://api.puter.com',
|
||||
authToken: token,
|
||||
whoamiCache_: Promise.resolve(user),
|
||||
onAuthStateChanged: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
auth: {
|
||||
getProfilePicture: vi.fn(async () => currentPicture),
|
||||
signIn: vi.fn(() => new Promise(() => {})),
|
||||
signOut: vi.fn(async () => sdk.signInAs(null)),
|
||||
},
|
||||
// In the SDK's order: listeners hear of the token before its whoami starts.
|
||||
signInAs (nextToken, nextUser = null, nextPicture = null) {
|
||||
sdk.authToken = nextToken;
|
||||
sdk.whoamiCache_ = null;
|
||||
currentPicture = nextPicture;
|
||||
for ( const listener of listeners ) listener();
|
||||
sdk.whoamiCache_ = Promise.resolve(nextUser);
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
return sdk;
|
||||
};
|
||||
|
||||
const settle = async () => {
|
||||
for ( let i = 0; i < 5; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const mount = async (attributes = {}) => {
|
||||
const el = document.createElement('puter-user-menu');
|
||||
for ( const [name, value] of Object.entries(attributes) ) el.setAttribute(name, value);
|
||||
document.body.appendChild(el);
|
||||
await settle();
|
||||
return el;
|
||||
};
|
||||
|
||||
const part = (el, selector) => el.shadowRoot.querySelector(selector);
|
||||
const shown = (el, selector) => {
|
||||
const node = part(el, selector);
|
||||
return Boolean(node) && !node.closest('[hidden]');
|
||||
};
|
||||
const frameOf = el => part(el, '.apps-body iframe');
|
||||
const appsOpen = el => shown(el, '.apps-panel');
|
||||
|
||||
beforeAll(() => {
|
||||
if ( ! customElements.get('puter-user-menu') ) customElements.define('puter-user-menu', PuterUserMenu);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(TRIGGER_RECT);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('<puter-user-menu>', () => {
|
||||
it.each(['app', 'gui'])('renders nothing in the %s environment', async (env) => {
|
||||
vi.stubGlobal('puter', { ...fakePuter({ token: 't', user: USER }), env });
|
||||
const el = await mount();
|
||||
expect(shown(el, '.bar')).toBe(false);
|
||||
expect(puter.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('offers sign-in and the signed-out app browser when signed out', async () => {
|
||||
vi.stubGlobal('puter', fakePuter());
|
||||
const el = await mount();
|
||||
expect(shown(el, '.sign-in')).toBe(true);
|
||||
expect(shown(el, '.avatar')).toBe(false);
|
||||
expect(shown(el, '.apps-button')).toBe(true);
|
||||
|
||||
part(el, '.apps-button').click();
|
||||
expect(appsOpen(el)).toBe(true);
|
||||
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
|
||||
expect(frameOf(el).getAttribute('referrerpolicy')).toBe('origin');
|
||||
});
|
||||
|
||||
it('treats a temporary account as signed out', async () => {
|
||||
const sdk = fakePuter({ token: 'temp-token', user: TEMP, picture: PICTURE });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
expect(shown(el, '.sign-in')).toBe(true);
|
||||
expect(shown(el, '.avatar')).toBe(false);
|
||||
expect(sdk.auth.getProfilePicture).not.toHaveBeenCalled();
|
||||
part(el, '.apps-button').click();
|
||||
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
|
||||
});
|
||||
|
||||
it('signs in from the button and reports the user', async () => {
|
||||
const sdk = fakePuter();
|
||||
sdk.auth.signIn.mockImplementation(async () => {
|
||||
sdk.signInAs('app-token', USER);
|
||||
return { success: true };
|
||||
});
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
const signins = [];
|
||||
el.addEventListener('signin', event => signins.push(event.detail));
|
||||
part(el, '.sign-in').click();
|
||||
expect(sdk.auth.signIn).toHaveBeenCalledTimes(1);
|
||||
await settle();
|
||||
expect(signins).toEqual([{ user: USER }]);
|
||||
expect(shown(el, '.avatar')).toBe(true);
|
||||
});
|
||||
|
||||
it('opens one sign-in at a time, and another once it is closed', async () => {
|
||||
const sdk = fakePuter();
|
||||
let close;
|
||||
sdk.auth.signIn.mockImplementation(() => new Promise((_resolve, reject) => {
|
||||
close = reject;
|
||||
}));
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
part(el, '.sign-in').click();
|
||||
part(el, '.sign-in').click();
|
||||
expect(sdk.auth.signIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
close({ error: 'auth_window_closed' });
|
||||
await settle();
|
||||
part(el, '.sign-in').click();
|
||||
expect(sdk.auth.signIn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lets a cross-origin isolated page start another sign-in, since it cannot see the popup close', async () => {
|
||||
vi.stubGlobal('crossOriginIsolated', true);
|
||||
const sdk = fakePuter();
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
part(el, '.sign-in').click();
|
||||
part(el, '.sign-in').click();
|
||||
expect(sdk.auth.signIn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows the signed-in user and names them to the app browser', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
expect(shown(el, '.sign-in')).toBe(false);
|
||||
expect(part(el, '.avatar').textContent).toBe('J');
|
||||
expect(part(el, '.avatar').querySelector('img')).toBeNull();
|
||||
expect(part(el, '.avatar').getAttribute('aria-label')).toBe('Account: jelveh');
|
||||
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.menu')).toBe(true);
|
||||
expect(part(el, '.avatar').getAttribute('aria-expanded')).toBe('true');
|
||||
expect(part(el, '.menu-user').textContent).toBe('jelveh');
|
||||
|
||||
part(el, '.apps-button').click();
|
||||
expect(shown(el, '.menu')).toBe(false);
|
||||
expect(appsOpen(el)).toBe(true);
|
||||
expect(part(el, '.apps-button').getAttribute('aria-expanded')).toBe('true');
|
||||
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}`);
|
||||
});
|
||||
|
||||
it('hangs the panel under its button with the caret pointing at it', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
const panel = part(el, '.apps-panel');
|
||||
expect(panel.style.top).toBe('56px');
|
||||
expect(panel.style.getPropertyValue('--panel-top')).toBe('56px');
|
||||
expect(panel.style.left).not.toBe('');
|
||||
expect(panel.style.getPropertyValue('--caret-x')).not.toBe('');
|
||||
});
|
||||
|
||||
it('closes the panel when its button is laid out of view', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
HTMLElement.prototype.getBoundingClientRect.mockReturnValue({ left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 });
|
||||
part(el, '.apps-button').click();
|
||||
expect(appsOpen(el)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a spinner until the app browser loads', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
expect(part(el, '.apps-body').classList.contains('is-loading')).toBe(true);
|
||||
frameOf(el).dispatchEvent(new Event('load'));
|
||||
expect(part(el, '.apps-body').classList.contains('is-loading')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the app browser between opens', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
const frame = frameOf(el);
|
||||
part(el, '.apps-close').click();
|
||||
expect(appsOpen(el)).toBe(false);
|
||||
expect(el.shadowRoot.activeElement).toBe(part(el, '.apps-button'));
|
||||
part(el, '.apps-button').click();
|
||||
expect(appsOpen(el)).toBe(true);
|
||||
expect(frameOf(el)).toBe(frame);
|
||||
});
|
||||
|
||||
it('shows the profile picture over the initial when there is one', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER, picture: PICTURE }));
|
||||
const el = await mount();
|
||||
const img = part(el, '.avatar img');
|
||||
expect(img.getAttribute('src')).toBe(PICTURE);
|
||||
expect(img.alt).toBe('');
|
||||
expect(part(el, '.avatar').textContent).toBe('J');
|
||||
});
|
||||
|
||||
it('shows the account while its picture is still loading', async () => {
|
||||
const sdk = fakePuter({ token: 'app-token', user: USER });
|
||||
let resolvePicture;
|
||||
sdk.auth.getProfilePicture.mockImplementation(() => new Promise(resolve => { resolvePicture = resolve; }));
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
expect(shown(el, '.avatar')).toBe(true);
|
||||
expect(shown(el, '.apps-button')).toBe(true);
|
||||
expect(part(el, '.avatar').textContent).toBe('J');
|
||||
expect(part(el, '.avatar img')).toBeNull();
|
||||
|
||||
resolvePicture(PICTURE);
|
||||
await settle();
|
||||
expect(part(el, '.avatar img').getAttribute('src')).toBe(PICTURE);
|
||||
});
|
||||
|
||||
it('drops a picture that arrives after the sign-in changed', async () => {
|
||||
const sdk = fakePuter({ token: 'app-token', user: USER });
|
||||
let resolvePicture;
|
||||
sdk.auth.getProfilePicture.mockImplementationOnce(() => new Promise(resolve => { resolvePicture = resolve; }));
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
sdk.signInAs('other-token', OTHER);
|
||||
await settle();
|
||||
resolvePicture(PICTURE);
|
||||
await settle();
|
||||
expect(part(el, '.avatar').textContent).toBe('S');
|
||||
expect(part(el, '.avatar img')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends no user when the signed-in user is unknown', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: null }));
|
||||
const el = await mount();
|
||||
expect(part(el, '.avatar').textContent).toBe('?');
|
||||
part(el, '.apps-button').click();
|
||||
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
|
||||
});
|
||||
|
||||
it('drops the app browser and reloads when the sign-in changes', async () => {
|
||||
const sdk = fakePuter({ token: 'app-token', user: USER });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
part(el, '.apps-close').click();
|
||||
|
||||
sdk.signInAs('other-token', OTHER);
|
||||
expect(appsOpen(el)).toBe(false);
|
||||
expect(frameOf(el)).toBeNull();
|
||||
await settle();
|
||||
expect(part(el, '.avatar').textContent).toBe('S');
|
||||
part(el, '.apps-button').click();
|
||||
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${OTHER.uuid}`);
|
||||
});
|
||||
|
||||
it('keeps the app browser open when the same token is set again', async () => {
|
||||
const sdk = fakePuter({ token: 'app-token', user: USER });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
const frame = frameOf(el);
|
||||
sdk.signInAs('app-token', USER);
|
||||
expect(appsOpen(el)).toBe(true);
|
||||
expect(frameOf(el)).toBe(frame);
|
||||
});
|
||||
|
||||
it('ignores a user that arrives after the token changed', async () => {
|
||||
const sdk = fakePuter({ token: 'app-token' });
|
||||
let resolveFirst;
|
||||
sdk.whoamiCache_ = new Promise(resolve => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
sdk.signInAs(null);
|
||||
resolveFirst(USER);
|
||||
await settle();
|
||||
expect(shown(el, '.sign-in')).toBe(true);
|
||||
part(el, '.apps-button').click();
|
||||
expect(frameOf(el).src).toBe('https://puter.com/embed/apps');
|
||||
});
|
||||
|
||||
it('signs out from the menu, reports it and drops the user', async () => {
|
||||
const sdk = fakePuter({ token: 'app-token', user: USER });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
const signouts = [];
|
||||
el.addEventListener('signout', () => signouts.push(true));
|
||||
part(el, '.apps-button').click();
|
||||
part(el, '.avatar').click();
|
||||
part(el, '.sign-out').click();
|
||||
await settle();
|
||||
expect(sdk.auth.signOut).toHaveBeenCalledTimes(1);
|
||||
expect(signouts).toHaveLength(1);
|
||||
expect(frameOf(el)).toBeNull();
|
||||
expect(shown(el, '.menu')).toBe(false);
|
||||
expect(shown(el, '.sign-in')).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the app browser on a page that may not frame it', async () => {
|
||||
vi.stubGlobal('puter', { ...fakePuter(), defaultGUIOrigin: 'https://example.com' });
|
||||
const el = await mount();
|
||||
expect(shown(el, '.sign-in')).toBe(true);
|
||||
expect(shown(el, '.apps-button')).toBe(false);
|
||||
});
|
||||
|
||||
it('follows the OS theme unless told otherwise, in the app browser too', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
expect(frameOf(el).style.colorScheme).toBe('light');
|
||||
|
||||
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}`);
|
||||
|
||||
// The forced theme is in the URL, since not every browser passes `color-scheme` on.
|
||||
el.setAttribute('theme', 'dark');
|
||||
await settle();
|
||||
expect(el.classList.contains('puter-theme-dark')).toBe(true);
|
||||
expect(frameOf(el).style.colorScheme).toBe('dark');
|
||||
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}&theme=dark`);
|
||||
expect(appsOpen(el)).toBe(true);
|
||||
expect(part(el, '.apps-body').classList.contains('is-loading')).toBe(true);
|
||||
});
|
||||
|
||||
it('opens a new app browser in the forced theme', async () => {
|
||||
vi.stubGlobal('puter', fakePuter());
|
||||
const el = await mount({ theme: 'light' });
|
||||
part(el, '.apps-button').click();
|
||||
expect(frameOf(el).style.colorScheme).toBe('light');
|
||||
expect(frameOf(el).src).toBe('https://puter.com/embed/apps#theme=light');
|
||||
});
|
||||
|
||||
it('keeps a closed app browser until the theme changes, then loads it in the new theme', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount({ theme: 'light' });
|
||||
part(el, '.apps-button').click();
|
||||
const frame = frameOf(el);
|
||||
part(el, '.apps-close').click();
|
||||
el.setAttribute('theme', 'light');
|
||||
await settle();
|
||||
expect(frameOf(el)).toBe(frame);
|
||||
|
||||
el.setAttribute('theme', 'dark');
|
||||
await settle();
|
||||
expect(frameOf(el)).toBeNull();
|
||||
expect(appsOpen(el)).toBe(false);
|
||||
part(el, '.apps-button').click();
|
||||
expect(frameOf(el).src).toBe(`https://puter.com/embed/apps#user=${USER.uuid}&theme=dark`);
|
||||
});
|
||||
|
||||
it('exposes parts for the host to style', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
const parts = [...el.shadowRoot.querySelectorAll('[part]')].map(node => node.getAttribute('part'));
|
||||
expect(parts.sort()).toEqual(['apps-button', 'apps-panel', 'avatar', 'menu', 'sign-in']);
|
||||
});
|
||||
|
||||
it('uses label attributes, including ones changed later', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount({ 'apps-label': 'Applis', 'sign-out-label': 'Déconnexion', 'account-label': 'Compte' });
|
||||
expect(part(el, '.apps-button').getAttribute('aria-label')).toBe('Applis');
|
||||
expect(part(el, '.apps-title').textContent).toBe('Applis');
|
||||
expect(part(el, '.apps-panel').getAttribute('aria-label')).toBe('Applis');
|
||||
expect(part(el, '.sign-out').textContent).toBe('Déconnexion');
|
||||
expect(part(el, '.avatar').getAttribute('aria-label')).toBe('Compte: jelveh');
|
||||
|
||||
el.setAttribute('close-label', 'Fermer');
|
||||
el.setAttribute('sign-in-label', 'Connexion');
|
||||
expect(part(el, '.apps-close').getAttribute('aria-label')).toBe('Fermer');
|
||||
expect(part(el, '.sign-in').textContent).toBe('Connexion');
|
||||
el.removeAttribute('apps-label');
|
||||
expect(part(el, '.apps-title').textContent).toBe('Apps');
|
||||
});
|
||||
|
||||
it.each([['.avatar', '.menu'], ['.apps-button', '.apps-panel']])(
|
||||
'closes %s\'s panel on Escape and returns focus to it', async (trigger, panel) => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, trigger).click();
|
||||
part(el, panel).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true }));
|
||||
expect(shown(el, panel)).toBe(false);
|
||||
expect(el.shadowRoot.activeElement).toBe(part(el, trigger));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([['.avatar', '.menu'], ['.apps-button', '.apps-panel']])(
|
||||
'closes %s\'s panel on a press outside it, and not on one inside', async (trigger, panel) => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, trigger).click();
|
||||
part(el, panel).dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true }));
|
||||
expect(shown(el, panel)).toBe(true);
|
||||
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true }));
|
||||
expect(shown(el, panel)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('toggles a panel closed from its own button', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
part(el, '.apps-button').click();
|
||||
expect(appsOpen(el)).toBe(false);
|
||||
expect(frameOf(el)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stops following the sign-in once removed', async () => {
|
||||
const sdk = fakePuter({ token: 'app-token', user: USER });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
part(el, '.apps-button').click();
|
||||
el.remove();
|
||||
expect(sdk.listenerCount()).toBe(0);
|
||||
expect(frameOf(el)).toBeNull();
|
||||
expect(appsOpen(el)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import PuterSpinner from './components/PuterSpinner.js';
|
||||
import PuterMenubar from './components/PuterMenubar.js';
|
||||
import PuterColorPicker from './components/PuterColorPicker.js';
|
||||
import PuterFontPicker from './components/PuterFontPicker.js';
|
||||
import PuterUserMenu from './components/PuterUserMenu.js';
|
||||
|
||||
const components = [
|
||||
['puter-alert', PuterAlert],
|
||||
@@ -21,6 +22,7 @@ const components = [
|
||||
['puter-menubar', PuterMenubar],
|
||||
['puter-color-picker', PuterColorPicker],
|
||||
['puter-font-picker', PuterFontPicker],
|
||||
['puter-user-menu', PuterUserMenu],
|
||||
];
|
||||
|
||||
export function registerComponents () {
|
||||
|
||||
@@ -30,6 +30,7 @@ const COMPONENT_TAGS = [
|
||||
'puter-menubar',
|
||||
'puter-color-picker',
|
||||
'puter-font-picker',
|
||||
'puter-user-menu',
|
||||
];
|
||||
|
||||
const raf = (): Promise<void> =>
|
||||
|
||||
Reference in new issue
Block a user