mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c2915c955 | ||
|
|
af35783df4 | ||
|
|
37e1bf35ca | ||
|
|
52fb91e8e2 | ||
|
|
67621d996a | ||
|
|
cb214f28cc | ||
|
|
8ed439e897 | ||
|
|
672a704193 | ||
|
|
35ae464d86 | ||
|
|
79f24ed3df | ||
|
|
f30d2134b1 | ||
|
|
c24c8589cb | ||
|
|
2f8f81e621 | ||
|
|
7d02c7f622 | ||
|
|
d2bbe2b5e2 | ||
|
|
3ff70ef97f | ||
|
|
f791024b2e | ||
|
|
cce9718b45 | ||
|
|
9758ada36e | ||
|
|
37dcee78f0 | ||
|
|
162398a827 |
No files matched your search
@@ -176,7 +176,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Missing when the PR leg failed; `coverage (pr)` already reports that.
|
||||
- name: Download PR coverage
|
||||
id: pr-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
@@ -186,6 +189,7 @@ jobs:
|
||||
# base run) — then report without the comparison column.
|
||||
- name: Download base coverage
|
||||
id: base-coverage
|
||||
if: steps.pr-coverage.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -193,7 +197,7 @@ jobs:
|
||||
path: coverage-base
|
||||
|
||||
- name: Report coverage on PR
|
||||
if: steps.base-coverage.outcome == 'success'
|
||||
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome == 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
@@ -202,7 +206,7 @@ jobs:
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
|
||||
- name: Report coverage on PR (no base comparison)
|
||||
if: steps.base-coverage.outcome != 'success'
|
||||
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome != 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
|
||||
@@ -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/'],
|
||||
|
||||
@@ -144,11 +144,22 @@ const createInline = async (
|
||||
return String(response.body.subId);
|
||||
};
|
||||
|
||||
const delivered = (count = 1): Promise<void> =>
|
||||
vi.waitFor(() => expect(sinkPosts.length).toBeGreaterThanOrEqual(count), {
|
||||
timeout: 30_000,
|
||||
interval: 50,
|
||||
});
|
||||
/**
|
||||
* The post each label made, in order, once all have arrived. Matched by label
|
||||
* since earlier tests' subscriptions stay live and post to the same sink.
|
||||
*/
|
||||
const delivered = async (...labels: string[]): Promise<SinkPost[]> => {
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(sinkPosts.map((post) => post.label)).toEqual(
|
||||
expect.arrayContaining(labels),
|
||||
),
|
||||
{ timeout: 30_000, interval: 50 },
|
||||
);
|
||||
return labels.map((label) =>
|
||||
sinkPosts.find((post) => post.label === label)!,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
@@ -255,14 +266,11 @@ describe('an inline handler in a user scope, end to end', () => {
|
||||
await env.server.services.fs.touch(holder.id, {
|
||||
path: `${holder.anchor}/note.txt`,
|
||||
});
|
||||
await delivered();
|
||||
const [post] = await delivered('inline');
|
||||
|
||||
expect(sinkPosts[0]).toMatchObject({
|
||||
label: 'inline',
|
||||
path: `${holder.anchor}/note.txt`,
|
||||
});
|
||||
expect(post.path).toBe(`${holder.anchor}/note.txt`);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
sinkPosts[0].token!,
|
||||
post.token!,
|
||||
);
|
||||
expect(actor!.user?.id).toBe(holder.id);
|
||||
expect(actor!.effectiveApp?.uid).toBe(appUid);
|
||||
@@ -297,14 +305,14 @@ describe('an inline handler in a user scope, end to end', () => {
|
||||
await env.server.services.fs.touch(holder.id, {
|
||||
path: `${holder.anchor}/shared-${i}.txt`,
|
||||
});
|
||||
await delivered(2);
|
||||
const runAs = new Set<number>();
|
||||
for (const post of sinkPosts)
|
||||
runAs.add(
|
||||
(await env.server.services.auth.authenticate(post.token!))
|
||||
.actor!.user!.id,
|
||||
// The first test's 'inline' handler also posts for holders[0].
|
||||
const posts = await delivered('first', 'second');
|
||||
for (const [i, post] of posts.entries()) {
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
post.token!,
|
||||
);
|
||||
expect(runAs).toEqual(new Set(holders.map((h) => h.id)));
|
||||
expect(actor!.user!.id).toBe(holders[i].id);
|
||||
}
|
||||
create.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -78,7 +78,10 @@ describe('LocalWorkerService shutdown disposal', () => {
|
||||
disposals[0].mockImplementationOnce(async () => {
|
||||
await held;
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
// The test server keeps arming timers from real callbacks while fakes
|
||||
// are installed, so only what shutdown arms synchronously is checked.
|
||||
const arm = vi.spyOn(globalThis, 'setTimeout');
|
||||
try {
|
||||
let finished = false;
|
||||
const draining = Promise.resolve(service.onServerShutdown()).then(
|
||||
@@ -86,12 +89,13 @@ describe('LocalWorkerService shutdown disposal', () => {
|
||||
finished = true;
|
||||
},
|
||||
);
|
||||
expect(arm.mock.calls.map(([, ms]) => ms)).toEqual([5_000]);
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(finished).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await draining;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
arm.mockRestore();
|
||||
release();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -2860,8 +2860,12 @@ describe('OpenAIRealtimeRelayService relay', () => {
|
||||
),
|
||||
).toHaveLength(64),
|
||||
);
|
||||
// Holds are reserved and usage recorded one at a time, which a loaded
|
||||
// runner takes past vi.waitFor's default second.
|
||||
const settled = (check: () => Promise<void> | void) =>
|
||||
vi.waitFor(check, { timeout: 5_000 });
|
||||
// The pool grew one item per commit, with nothing spare at the cap.
|
||||
await vi.waitFor(async () =>
|
||||
await settled(async () =>
|
||||
expect(
|
||||
await server.services.metering.getOutstandingHolds(actor),
|
||||
).toBe(64 * 10_896_000),
|
||||
@@ -2890,7 +2894,7 @@ describe('OpenAIRealtimeRelayService relay', () => {
|
||||
for (let batch = 0; batch < 8; batch++) {
|
||||
for (let i = Math.max(1, batch * 8); i < (batch + 1) * 8; i++)
|
||||
complete(i);
|
||||
await vi.waitFor(() =>
|
||||
await settled(() =>
|
||||
expect(
|
||||
recorded.mock.calls
|
||||
.flatMap(([, rows]) => rows)
|
||||
@@ -2906,13 +2910,13 @@ describe('OpenAIRealtimeRelayService relay', () => {
|
||||
.filter((row) => row.usageType.includes('transcription_'))
|
||||
.reduce((sum, row) => sum + row.costOverride!, 0),
|
||||
).toBe(640);
|
||||
await vi.waitFor(async () =>
|
||||
await settled(async () =>
|
||||
expect(
|
||||
await server.services.metering.getOutstandingHolds(actor),
|
||||
).toBe(2 * 10_896_000),
|
||||
);
|
||||
c.ws.close();
|
||||
await vi.waitFor(async () =>
|
||||
await settled(async () =>
|
||||
expect(
|
||||
await server.services.metering.getOutstandingHolds(actor),
|
||||
).toBe(0),
|
||||
|
||||
@@ -1542,18 +1542,22 @@ describe('AppStore detailed stats', () => {
|
||||
let db;
|
||||
|
||||
const APP_UID = `app-stats-${Date.now()}`;
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
// The windows come from the clock, so it is pinned: mid-week and
|
||||
// mid-month, and minutes clear of midnight in any time zone.
|
||||
const NOW = new Date('2026-06-17T12:07:30Z');
|
||||
const nowSeconds = Math.floor(NOW.getTime() / 1000);
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
appStore = server.stores.app;
|
||||
db = server.clients.db;
|
||||
await clearAppCache(server.clients.redis);
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: NOW });
|
||||
|
||||
// Two users; the repeat shares a timestamp so it is always same-hour.
|
||||
// Two users in one hour. Windows end at now, exclusive.
|
||||
for (const [userId, offset] of [
|
||||
[1, 0],
|
||||
[1, 0],
|
||||
[1, 60],
|
||||
[1, 60],
|
||||
[2, 120],
|
||||
]) {
|
||||
await db.write(
|
||||
@@ -1564,6 +1568,7 @@ describe('AppStore detailed stats', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.useRealTimers();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
|
||||
@@ -154,6 +154,9 @@ export const setupTestServer = async (
|
||||
redis: { useMock: true },
|
||||
// Suites run system jobs themselves, not on the poll timer.
|
||||
jobs: { enabled: false },
|
||||
// The health loop's timed `SELECT 1` would land in tests that
|
||||
// count queries.
|
||||
server_health: { disabled_checks: ['database-liveness'] },
|
||||
s3: { localConfig: { inMemory: true } },
|
||||
no_default_user: true,
|
||||
no_devwatch: true,
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -37,6 +37,9 @@ const repoRoot = path.resolve(backendDir, '../..');
|
||||
const isPgmockMode =
|
||||
(process.env.PUTER_TEST_DB_ENGINE ?? '').toLowerCase() === 'postgres';
|
||||
const pgmockTimeoutMs = 600_000;
|
||||
// Loaded CI runners stall timers well past the 5 s / 10 s defaults; a real
|
||||
// hang still fails, just later.
|
||||
const ciTimeouts = { testTimeout: 20_000, hookTimeout: 60_000 };
|
||||
|
||||
// Tests that always boot pgmock regardless of PUTER_TEST_DB_ENGINE. Too slow
|
||||
// for CI; run locally or via `npm run test:backend:postgres`.
|
||||
@@ -103,9 +106,11 @@ export const lowerDecoratorsPlugin = {
|
||||
loader: 'ts',
|
||||
target: 'es2024',
|
||||
sourcefile: id,
|
||||
sourcemap: 'inline',
|
||||
sourcemap: 'external',
|
||||
});
|
||||
return { code: result.code, map: null };
|
||||
// Without the map, coverage and stack traces report esbuild's
|
||||
// output lines instead of the source's.
|
||||
return { code: result.code, map: result.map };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -145,7 +150,9 @@ export default defineConfig(({ mode }) => ({
|
||||
hookTimeout: pgmockTimeoutMs,
|
||||
fileParallelism: false,
|
||||
}
|
||||
: {}),
|
||||
: isCi
|
||||
? ciTimeouts
|
||||
: {}),
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: isCi
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ vi.mock('./UI/UIWindowFontPicker.js', stub);
|
||||
vi.mock('./UI/UIPermissionDialog.js', () => ({ default: permissionDialog }));
|
||||
vi.mock('./UI/UIEnablePushDialog.js', () => ({ default: dialog }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', stub);
|
||||
vi.mock('./UI/UIWindowSignup.js', stub);
|
||||
vi.mock('./UI/Auth/UIAuthSignup.js', stub);
|
||||
vi.mock('./UI/UINotification.js', stub);
|
||||
vi.mock('./helpers/godmodeTokens.js', () => ({ renewGodmodeToken: vi.fn() }));
|
||||
vi.mock('./helpers/watchItems.js', () => ({ addItemWatcher: vi.fn() }));
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock('./UI/UIWindowEmailConfirmationRequired.js', stub);
|
||||
vi.mock('./UI/UIWindowFontPicker.js', stub);
|
||||
vi.mock('./UI/UIPermissionDialog.js', stub);
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', stub);
|
||||
vi.mock('./UI/UIWindowSignup.js', stub);
|
||||
vi.mock('./UI/Auth/UIAuthSignup.js', stub);
|
||||
vi.mock('./UI/UINotification.js', stub);
|
||||
vi.mock('./helpers/godmodeTokens.js', () => ({ renewGodmodeToken: vi.fn() }));
|
||||
vi.mock('./helpers/watchItems.js', () => ({ addItemWatcher: vi.fn() }));
|
||||
|
||||
+12
-12
@@ -36,7 +36,7 @@ import UIWindowFontPicker from './UI/UIWindowFontPicker.js';
|
||||
import UIPermissionDialog from './UI/UIPermissionDialog.js';
|
||||
import UIEnablePushDialog from './UI/UIEnablePushDialog.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
import UIWindowSignup from './UI/UIWindowSignup.js';
|
||||
import UIAuthSignup from './UI/Auth/UIAuthSignup.js';
|
||||
import UINotification from './UI/UINotification.js';
|
||||
|
||||
import { renewGodmodeToken } from './helpers/godmodeTokens.js';
|
||||
@@ -611,7 +611,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showOpenFilePicker' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -677,7 +677,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showDirectoryPicker' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -783,7 +783,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'mouseMoved' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -818,7 +818,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'contextMenu' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1384,7 +1384,7 @@ const ipc_listener = async (event, handled) => {
|
||||
|
||||
// auth
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
respond(false);
|
||||
return;
|
||||
@@ -1490,7 +1490,7 @@ const ipc_listener = async (event, handled) => {
|
||||
try {
|
||||
// auth
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
respond(false);
|
||||
return;
|
||||
@@ -1543,7 +1543,7 @@ const ipc_listener = async (event, handled) => {
|
||||
enable_push_dialog_guard.markOpened();
|
||||
try {
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
respond('unavailable');
|
||||
return;
|
||||
@@ -1592,7 +1592,7 @@ const ipc_listener = async (event, handled) => {
|
||||
custom_domain_dialog_guard.markOpened();
|
||||
try {
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) ) {
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) ) {
|
||||
respond(null);
|
||||
return;
|
||||
}
|
||||
@@ -1624,7 +1624,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showFontPicker' ) {
|
||||
// auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1657,7 +1657,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showColorPicker' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1699,7 +1699,7 @@ const ipc_listener = async (event, handled) => {
|
||||
}
|
||||
|
||||
//auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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 second factor of a login: a code from the user's authenticator app,
|
||||
// or one of their recovery codes. Verifies the challenge the first factor
|
||||
// (a password, or an identity provider) was answered with.
|
||||
|
||||
import { SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
import openAuthFlow, { keepCurrentSession, setAuthStatus } from './authFlow.js';
|
||||
|
||||
const CODE_LENGTH = 6;
|
||||
const RECOVERY_CODE_LENGTH = 8;
|
||||
// How long a wrong code stays on screen before the boxes clear for another try.
|
||||
const WRONG_CODE_MS = 1200;
|
||||
|
||||
const SHIELD_ICON = SVG('<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>');
|
||||
|
||||
/**
|
||||
* Sends a code for the challenge. Both routes are `guiOriginOnly` and carry
|
||||
* no `subdomain`, so they go to the root origin: on `api_origin` the
|
||||
* subdomain gate skips them and Express 404s.
|
||||
*
|
||||
* @param {'otp'|'recovery-code'} route
|
||||
* @param {string} token The first factor's challenge.
|
||||
* @param {string} code
|
||||
* @returns {Promise<{ ok: true, data: object } | { ok: false, error: string }>}
|
||||
* `error` is an i18n key.
|
||||
*/
|
||||
export const verifySecondFactor = async (route, token, code) => {
|
||||
try {
|
||||
const resp = await fetch(`${window.gui_origin}/login/${route}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, code }),
|
||||
});
|
||||
if ( resp.status === 429 ) return { ok: false, error: 'confirm_code_generic_too_many_requests' };
|
||||
const data = await resp.json();
|
||||
if ( ! data?.proceed ) return { ok: false, error: 'confirm_code_generic_incorrect' };
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, error: 'something_went_wrong' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A screen's head: the shield, a title and what to do.
|
||||
*
|
||||
* @param {string} title i18n key.
|
||||
* @param {string} instructions i18n key.
|
||||
* @returns {string}
|
||||
*/
|
||||
const headHtml = (title, instructions) => '<div class="auth-head">'
|
||||
+ `<div class="auth-badge" aria-hidden="true">${SHIELD_ICON}</div>`
|
||||
+ `<h1 class="auth-title">${i18n(title)}</h1>`
|
||||
+ `<p class="auth-subtitle">${i18n(instructions)}</p>`
|
||||
+ '</div>';
|
||||
|
||||
/**
|
||||
* Shows the recovery code screen over the code screen; Back returns to it.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {string} token
|
||||
* @param {(data: object) => void} onVerified
|
||||
*/
|
||||
const pushRecoveryScreen = (flow, token, onVerified) => {
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += headHtml('login2fa_recovery_title', 'login2fa_recovery_instructions');
|
||||
h += '<form class="auth-form login-2fa-recovery-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += `<input type="text" class="dashboard-dialog-input login-2fa-recovery-input" maxlength="${RECOVERY_CODE_LENGTH}"`;
|
||||
h += ` placeholder="${html_encode(i18n('login2fa_recovery_placeholder', [], false))}" aria-label="${i18n('login2fa_recovery_title')}"`;
|
||||
h += ' autocomplete="off" autocapitalize="characters" autocorrect="off" spellcheck="false">';
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({ name: 'recovery-code', html: h });
|
||||
const $input = $screen.find('.login-2fa-recovery-input');
|
||||
let verifying = false;
|
||||
|
||||
const verify = async () => {
|
||||
// Codes are 8-char base32; a pasted one usually carries the newline
|
||||
// it was copied with.
|
||||
const value = String($input.val() ?? '').trim().toUpperCase();
|
||||
if ( value.length !== RECOVERY_CODE_LENGTH || verifying ) return;
|
||||
verifying = true;
|
||||
flow.setBusy(true);
|
||||
setAuthStatus($screen, i18n('verifying', [], false), { kind: 'info' });
|
||||
const result = await verifySecondFactor('recovery-code', token, value);
|
||||
verifying = false;
|
||||
if ( flow.isClosed() ) {
|
||||
if ( result.ok ) keepCurrentSession();
|
||||
return;
|
||||
}
|
||||
if ( result.ok ) {
|
||||
onVerified(result.data);
|
||||
return;
|
||||
}
|
||||
flow.setBusy(false);
|
||||
setAuthStatus($screen, i18n(result.error, [], false), { field: '.login-2fa-recovery-input' });
|
||||
$input.get(0)?.select();
|
||||
};
|
||||
|
||||
$input.on('input', verify);
|
||||
$screen.find('form').on('submit', (e) => {
|
||||
e.preventDefault();
|
||||
verify();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the code screen in `flow`. Back leaves it unanswered.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {string} token The first factor's challenge.
|
||||
* @param {{ onBack?: () => void }} [opts] `onBack` makes Back available when
|
||||
* this is the flow's first screen.
|
||||
* @returns {Promise<object | null>} What the server answered a verified code
|
||||
* with (the session's token and user, and where to go); null when the user
|
||||
* went back or the flow closed.
|
||||
*/
|
||||
export function pushTwoFactorScreen (flow, token, { onBack } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value) => {
|
||||
if ( done ) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += headHtml('login2fa_otp_title', 'login2fa_otp_instructions');
|
||||
h += '<form class="auth-form login-2fa-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += `<div class="login-2fa-code-inputs" role="group" aria-label="${i18n('login2fa_otp_title')}">`;
|
||||
for ( let i = 0; i < CODE_LENGTH; i++ ) {
|
||||
// The first box takes a whole code from autofill or a paste.
|
||||
h += `<input type="text" class="dashboard-dialog-input" inputmode="numeric" pattern="[0-9]*" data-idx="${i}"`;
|
||||
h += ` aria-label="${i + 1}" autocomplete="${i === 0 ? 'one-time-code' : 'off'}">`;
|
||||
}
|
||||
h += '</div>';
|
||||
h += `<div class="auth-link-row"><button type="button" class="auth-link login-2fa-to-recovery">${i18n('login2fa_use_recovery_code')}</button></div>`;
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({
|
||||
name: 'two-factor',
|
||||
html: h,
|
||||
onBack: onBack ?? undefined,
|
||||
onLeave: () => finish(null),
|
||||
});
|
||||
const $inputs = $screen.find('.login-2fa-code-inputs input');
|
||||
let verifying = false;
|
||||
|
||||
const code = () => $inputs.map(function () {
|
||||
return $(this).val();
|
||||
}).get().join('');
|
||||
|
||||
const verified = (data) => finish(data);
|
||||
|
||||
const verify = async (value) => {
|
||||
if ( verifying || value.length !== CODE_LENGTH ) return;
|
||||
verifying = true;
|
||||
flow.setBusy(true);
|
||||
$inputs.prop('disabled', true);
|
||||
setAuthStatus($screen, i18n('verifying', [], false), { kind: 'info' });
|
||||
const result = await verifySecondFactor('otp', token, value);
|
||||
verifying = false;
|
||||
// Dismissed while the code was out: stay signed in as before.
|
||||
if ( flow.isClosed() ) {
|
||||
if ( result.ok ) keepCurrentSession();
|
||||
return;
|
||||
}
|
||||
if ( result.ok ) {
|
||||
verified(result.data);
|
||||
return;
|
||||
}
|
||||
flow.setBusy(false);
|
||||
$inputs.prop('disabled', false);
|
||||
setAuthStatus($screen, i18n(result.error, [], false));
|
||||
$inputs.addClass('login-2fa-wrong').attr('aria-invalid', 'true');
|
||||
setTimeout(() => {
|
||||
if ( verifying ) return;
|
||||
$inputs.val('').removeClass('login-2fa-wrong').removeAttr('aria-invalid');
|
||||
if ( ! $screen.prop('hidden') ) $inputs.first().trigger('focus');
|
||||
}, WRONG_CODE_MS);
|
||||
};
|
||||
|
||||
/** Spreads digits over the boxes from `start`, and verifies a full code. */
|
||||
const fill = (digits, start) => {
|
||||
for ( let i = 0; i < digits.length && start + i < CODE_LENGTH; i++ ) {
|
||||
$inputs.eq(start + i).val(digits[i]);
|
||||
}
|
||||
const next = Math.min(start + digits.length, CODE_LENGTH - 1);
|
||||
$inputs.eq(next).trigger('focus');
|
||||
verify(code());
|
||||
};
|
||||
|
||||
$inputs.on('input', function () {
|
||||
const idx = Number($(this).attr('data-idx'));
|
||||
const digits = String($(this).val() ?? '').replace(/\D/g, '');
|
||||
$inputs.removeClass('login-2fa-wrong').removeAttr('aria-invalid');
|
||||
if ( ! digits ) {
|
||||
$(this).val('');
|
||||
return;
|
||||
}
|
||||
// One digit typed, or a whole code autofilled into this box.
|
||||
$(this).val(digits[0]);
|
||||
if ( digits.length > 1 ) {
|
||||
fill(digits.slice(1), idx + 1);
|
||||
return;
|
||||
}
|
||||
if ( idx < CODE_LENGTH - 1 ) $inputs.eq(idx + 1).trigger('focus');
|
||||
verify(code());
|
||||
});
|
||||
|
||||
$inputs.on('keydown', function (e) {
|
||||
const idx = Number($(this).attr('data-idx'));
|
||||
if ( e.key === 'Backspace' && ! $(this).val() && idx > 0 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx - 1).val('').trigger('focus');
|
||||
} else if ( e.key === 'ArrowLeft' && idx > 0 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx - 1).trigger('focus');
|
||||
} else if ( e.key === 'ArrowRight' && idx < CODE_LENGTH - 1 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx + 1).trigger('focus');
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on('focus', function () {
|
||||
this.select();
|
||||
});
|
||||
|
||||
$inputs.on('paste', function (e) {
|
||||
e.preventDefault();
|
||||
const clipboard = e.originalEvent?.clipboardData ?? window.clipboardData;
|
||||
const pasted = String(clipboard?.getData('text') ?? '').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
||||
if ( ! pasted ) return;
|
||||
$inputs.val('');
|
||||
fill(pasted, 0);
|
||||
});
|
||||
|
||||
$screen.find('form').on('submit', (e) => {
|
||||
e.preventDefault();
|
||||
verify(code());
|
||||
});
|
||||
|
||||
$screen.find('.login-2fa-to-recovery').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
$inputs.val('').removeClass('login-2fa-wrong').removeAttr('aria-invalid');
|
||||
setAuthStatus($screen, '');
|
||||
pushRecoveryScreen(flow, token, verified);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the second factor of a login on a page of its own (an identity
|
||||
* provider's login that came back needing one). The card stays up, busy,
|
||||
* once the code is verified: the caller is expected to navigate.
|
||||
*
|
||||
* @param {string} token The first factor's challenge.
|
||||
* @returns {Promise<object | null>} The server's answer to a verified code;
|
||||
* null when the user chose to go back to the login.
|
||||
*/
|
||||
export default function UIAuth2FA (token) {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
const answer = pushTwoFactorScreen(flow, token, {
|
||||
onBack: () => flow.settle(null),
|
||||
});
|
||||
answer.then((data) => {
|
||||
if ( data ) flow.setBusy(true);
|
||||
flow.settle(data);
|
||||
});
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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 account picker: the accounts signed in on this browser, to continue
|
||||
// as one of them, log in to another, or create one. In a sign-in popup,
|
||||
// picking an account is the user's consent to sign the opener in.
|
||||
|
||||
import { SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
import openAuthFlow, { authHeadHtml, joinAuthFlow, wireAuthHead } from './authFlow.js';
|
||||
import { pushLoginScreen } from './UIAuthLogin.js';
|
||||
import { pushSignupScreen } from './UIAuthSignup.js';
|
||||
|
||||
const CHEVRON_ICON = SVG('<path d="M9 18l6-6-6-6"/>');
|
||||
|
||||
/**
|
||||
* One account's row.
|
||||
*
|
||||
* @param {{ uuid: string, username: string, email?: string, profile?: { picture?: string } }} account
|
||||
* @returns {string}
|
||||
*/
|
||||
const accountRowHtml = (account) => {
|
||||
const username = String(account.username ?? '');
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
let h = '<li>';
|
||||
h += `<button type="button" class="auth-account" data-uuid="${html_encode(account.uuid)}">`;
|
||||
h += `<span class="auth-account-avatar" aria-hidden="true">${html_encode(initial)}</span>`;
|
||||
h += '<span class="auth-account-text">';
|
||||
h += `<span class="auth-account-name">${html_encode(username)}</span>`;
|
||||
if ( account.email ) h += `<span class="auth-account-detail">${html_encode(account.email)}</span>`;
|
||||
h += '</span>';
|
||||
h += '<span class="dashboard-form-spinner auth-account-spinner" aria-hidden="true"></span>';
|
||||
h += `<span class="auth-account-chevron" aria-hidden="true">${CHEVRON_ICON}</span>`;
|
||||
h += '</button>';
|
||||
h += '</li>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lays each account's picture over its initial. Set as a property, never
|
||||
* through markup; a picture that fails to load leaves the initial.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @param {Array<{ uuid: string, profile?: { picture?: string } }>} accounts
|
||||
*/
|
||||
const paintPictures = ($screen, accounts) => {
|
||||
for ( const account of accounts ) {
|
||||
const picture = account.profile?.picture;
|
||||
if ( typeof picture !== 'string' || ! picture ) continue;
|
||||
const el_avatar = $screen.find('.auth-account').filter((_, el) => el.dataset.uuid === String(account.uuid))
|
||||
.find('.auth-account-avatar').get(0);
|
||||
if ( ! el_avatar ) continue;
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.draggable = false;
|
||||
img.addEventListener('error', () => img.remove(), { once: true });
|
||||
img.src = picture;
|
||||
el_avatar.appendChild(img);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the account picker in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
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">';
|
||||
// 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>';
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-login">${i18n('add_another_account')}</button>`;
|
||||
h += '<span class="auth-footer-sep" aria-hidden="true">·</span>';
|
||||
h += `<button type="button" class="auth-link auth-to-signup">${i18n('create_account')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
// Nothing to type here; focus lands on the card, not on an account.
|
||||
const $screen = flow.push({ name: 'accounts', html: h, autofocus: false });
|
||||
wireAuthHead($screen);
|
||||
paintPictures($screen, accounts);
|
||||
|
||||
$screen.find('.auth-to-login').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('login') ) pushLoginScreen(flow);
|
||||
});
|
||||
$screen.find('.auth-to-signup').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('signup') ) pushSignupScreen(flow);
|
||||
});
|
||||
|
||||
$screen.find('.auth-account').on('click', async function () {
|
||||
if ( flow.isBusy() ) return;
|
||||
const uuid = this.dataset.uuid;
|
||||
const account = accounts.find((candidate) => String(candidate.uuid) === uuid);
|
||||
if ( ! account ) return;
|
||||
flow.setBusy(true);
|
||||
$screen.find('.auth-account').prop('disabled', true);
|
||||
$(this).addClass('dashboard-form-busy').attr('aria-busy', 'true');
|
||||
|
||||
await window.update_auth_data(account.auth_token, account);
|
||||
if ( flow.options.reload_on_success ) {
|
||||
// Disable the browser's own "leave this page?" prompt.
|
||||
window.onbeforeunload = null;
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
flow.finish(true);
|
||||
});
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks which of the accounts signed in on this browser to continue as.
|
||||
*
|
||||
* @param {Partial<import('./authFlow.js').AuthFlowOptions> & { page?: boolean }} [options]
|
||||
* The picker can't be dismissed: it stands between a sign-in popup and
|
||||
* the token it hands out, and on a signed-out boot it is the page.
|
||||
* @returns {Promise<boolean>} True once an account is picked or signed in,
|
||||
* unless the page reloads first.
|
||||
*/
|
||||
export default function UIAuthAccountPicker (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({ page: options.page, dismissable: false, options });
|
||||
pushAccountPickerScreen(flow);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* 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 login screen: email or username and password, the identity provider
|
||||
// buttons, and the ways to the signup and password recovery screens.
|
||||
|
||||
import { bindPasswordReveal, formFieldHtml } from '../Dashboard/UIDashboardFormDialog.js';
|
||||
import openAuthFlow, {
|
||||
authHeadHtml,
|
||||
joinAuthFlow,
|
||||
keepCurrentSession,
|
||||
navigateAfterSignIn,
|
||||
setAuthStatus,
|
||||
setFormBusy,
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.js';
|
||||
import { renderProviderButtons } from './authProviders.js';
|
||||
import { pushTwoFactorScreen } from './UIAuth2FA.js';
|
||||
import { pushRecoverPasswordScreen } from './UIAuthRecoverPassword.js';
|
||||
import { pushSignupScreen } from './UIAuthSignup.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} LoginScreenOptions
|
||||
* @property {string} [email_or_username] Prefills the first field.
|
||||
* @property {string} [authError] Shown when the screen opens.
|
||||
* @property {string} [auth_id] Sent with the login when it follows a
|
||||
* `reauth_required` 401, so the backend re-attaches the same identity.
|
||||
* @property {string} [reauth_reason]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clears credentials a link may have put in the address bar.
|
||||
*/
|
||||
const clearCredentialsFromUrl = () => {
|
||||
const search = window.location.search;
|
||||
if ( search && (search.includes('password=') || search.includes('username=') || search.includes('email=')) ) {
|
||||
history.replaceState({}, document.title, window.location.origin + window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* What to tell the user about a failed login request.
|
||||
*
|
||||
* @param {{ status?: number, responseText?: string }} err
|
||||
* @returns {{ text?: string, html?: string }}
|
||||
*/
|
||||
export const loginErrorMessage = (err) => {
|
||||
const response_text = err?.responseText || '';
|
||||
try {
|
||||
const json = JSON.parse(response_text);
|
||||
if ( json?.message ) return { text: String(json.message) };
|
||||
} catch {
|
||||
// Not JSON.
|
||||
}
|
||||
if ( err?.status === 404 ) {
|
||||
// Not the whole 404 page.
|
||||
return { text: `Error 404: "${window.gui_origin}/login" not found` };
|
||||
}
|
||||
if ( response_text ) return { text: response_text };
|
||||
// No answer at all usually means the server couldn't be reached; on a
|
||||
// self-hosted instance that's most likely its configuration.
|
||||
if ( window.app_domain !== 'puter.com' ) {
|
||||
const origin = html_encode(window.gui_origin);
|
||||
return {
|
||||
html: '<div class="auth-status-help">'
|
||||
+ `<p>Error reaching "${origin}/login". This is likely to be a configuration issue.</p>`
|
||||
+ '<p>Make sure of the following:</p>'
|
||||
+ '<ul>'
|
||||
+ '<li><code>domain</code> in config.json is set to the domain you\'re using to access puter</li>'
|
||||
+ '<li>DNS resolves for the domain, and the <code>api.</code> subdomain on that domain</li>'
|
||||
+ '<li><code>http_port</code> is set to the port Puter is listening on (<code>auto</code> will use <code>4100</code> unless that port is in use)</li>'
|
||||
+ '<li><code>pub_port</code> is set to the external port (ex: <code>443</code> if you\'re using a reverse proxy that serves over https)</li>'
|
||||
+ '</ul></div>',
|
||||
};
|
||||
}
|
||||
return { text: `Failed to log in: Error ${err?.status ?? ''}`.trim() };
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the login screen in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {LoginScreenOptions} [options]
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
export function pushLoginScreen (flow, options = {}) {
|
||||
const id = `auth-login-${window.uuidv4?.() ?? Date.now()}`;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
// 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({
|
||||
name: 'email_or_username',
|
||||
label: i18n('email_or_username'),
|
||||
value: options.email_or_username ?? '',
|
||||
attrs: { autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
label: i18n('password'),
|
||||
attrs: { autocomplete: 'current-password' },
|
||||
}, id);
|
||||
h += `<div class="auth-forgot-row"><button type="button" class="auth-link forgot-password-link">${i18n('forgot_pass_c2a')}</button></div>`;
|
||||
h += submitButtonHtml(i18n('log_in', [], false), 'login-btn');
|
||||
h += '</form>';
|
||||
h += '<div class="auth-providers" hidden>';
|
||||
h += `<div class="auth-divider"><span>${i18n('or')}</span></div>`;
|
||||
h += '<div class="auth-provider-list"></div>';
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
if ( flow.options.show_signup_button ) {
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-signup">${i18n('create_free_account')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
const $screen = flow.push({
|
||||
name: 'login',
|
||||
html: h,
|
||||
autofocus: ! window.disable_login_autofocus,
|
||||
});
|
||||
const $form = $screen.find('.login-form');
|
||||
const $user = $form.find('[name="email_or_username"]');
|
||||
const $password = $form.find('[name="password"]');
|
||||
|
||||
wireAuthHead($screen);
|
||||
bindPasswordReveal($form);
|
||||
if ( options.authError ) setAuthStatus($screen, options.authError);
|
||||
|
||||
$form.on('input', '.dashboard-form-input', function () {
|
||||
$(this).removeAttr('aria-invalid');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
$screen.find('.forgot-password-link').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
pushRecoverPasswordScreen(flow, { email_or_username: String($user.val() ?? '').trim() });
|
||||
});
|
||||
|
||||
$screen.find('.auth-to-signup').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('signup') ) pushSignupScreen(flow);
|
||||
});
|
||||
|
||||
// The form first, so the end of busy can hand focus back to a field.
|
||||
const unlock = () => {
|
||||
setFormBusy($form, false);
|
||||
flow.setBusy(false);
|
||||
};
|
||||
|
||||
$form.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ( flow.isBusy() ) return;
|
||||
|
||||
const email_username = String($user.val() ?? '').trim();
|
||||
const password = String($password.val() ?? '');
|
||||
if ( ! email_username ) {
|
||||
setAuthStatus($screen, i18n('login_email_username_required', [], false), { field: '[name="email_or_username"]' });
|
||||
return;
|
||||
}
|
||||
if ( ! password ) {
|
||||
setAuthStatus($screen, i18n('login_password_required', [], false), { field: '[name="password"]' });
|
||||
return;
|
||||
}
|
||||
setAuthStatus($screen, '');
|
||||
|
||||
// After a `reauth_required` 401, the login re-attaches the same
|
||||
// stable identity.
|
||||
const reauth = options.auth_id ? { auth_id: options.auth_id, reauth_reason: options.reauth_reason } : {};
|
||||
const body = window.is_email(email_username)
|
||||
? { email: email_username, password, ...reauth }
|
||||
: { username: email_username, password, ...reauth };
|
||||
|
||||
flow.setBusy(true);
|
||||
setFormBusy($form, true);
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: `${window.gui_origin}/login`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: window.custom_headers ?? {},
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(body),
|
||||
success: resolve,
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
} catch ( err ) {
|
||||
clearCredentialsFromUrl();
|
||||
unlock();
|
||||
if ( flow.isClosed() ) return;
|
||||
const message = loginErrorMessage(err);
|
||||
setAuthStatus($screen, message.text ?? '', { html: message.html });
|
||||
// A refused password is typed again; a refused name is fixed there.
|
||||
$password.get(0)?.focus({ preventScroll: true });
|
||||
$password.get(0)?.select?.();
|
||||
return;
|
||||
}
|
||||
// Dismissed while the request was out: the caller already heard no,
|
||||
// so this browser stays signed in as it was.
|
||||
if ( flow.isClosed() ) {
|
||||
if ( data?.token ) keepCurrentSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( data?.next_step === 'otp' ) {
|
||||
// The second factor is a screen of its own; Back returns here.
|
||||
flow.setBusy(false);
|
||||
const verified = await pushTwoFactorScreen(flow, data.otp_jwt_token);
|
||||
if ( ! verified ) {
|
||||
setFormBusy($form, false);
|
||||
if ( ! flow.isClosed() ) flow.focus();
|
||||
return;
|
||||
}
|
||||
flow.setBusy(true);
|
||||
data = verified;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
} catch ( err ) {
|
||||
unlock();
|
||||
// Back from a verified code, whose screen would otherwise stay up, busy.
|
||||
if ( $screen.prop('hidden') ) flow.goTo('login');
|
||||
setAuthStatus($screen, err?.message || i18n('something_went_wrong', [], false));
|
||||
return;
|
||||
}
|
||||
|
||||
if ( flow.options.reload_on_success ) {
|
||||
// A clean URL, so no credentials stay in it. The card stays up,
|
||||
// busy, while the page goes.
|
||||
navigateAfterSignIn(flow.options.redirect_url || window.location.origin + window.location.pathname);
|
||||
return;
|
||||
}
|
||||
flow.finish(true);
|
||||
});
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to log in.
|
||||
*
|
||||
* @param {Partial<import('./authFlow.js').AuthFlowOptions> & LoginScreenOptions & {
|
||||
* page?: boolean, dismissable?: boolean, closeOnBackdrop?: boolean,
|
||||
* }} [options] `page`, `dismissable` and `closeOnBackdrop` as in `openAuthFlow`.
|
||||
* @returns {Promise<boolean>} True once signed in, unless the page reloads
|
||||
* first; false when the user dismisses it. Joins an auth flow that is
|
||||
* already open instead of opening another.
|
||||
*/
|
||||
export default function UIAuthLogin (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({
|
||||
page: options.page,
|
||||
dismissable: options.dismissable,
|
||||
closeOnBackdrop: options.closeOnBackdrop,
|
||||
options,
|
||||
});
|
||||
pushLoginScreen(flow, options);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// @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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reply, resetAuthGlobals, screen, tick } from './authTestSetup.js';
|
||||
|
||||
vi.mock('../UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
|
||||
const { activeAuthFlow } = await import('./authFlow.js');
|
||||
const { default: UIAuthLogin, loginErrorMessage } = await import('./UIAuthLogin.js');
|
||||
const { default: UIAuth2FA } = await import('./UIAuth2FA.js');
|
||||
|
||||
const completed = { proceed: true, next_step: 'complete', token: 'gui-token', user: { username: 'user' }, redirectUrl: 'https://puter.test/dashboard' };
|
||||
|
||||
/** Captures the login request; returns its callbacks. */
|
||||
const captureAjax = () => {
|
||||
const calls = [];
|
||||
vi.spyOn($, 'ajax').mockImplementation((opts) => {
|
||||
calls.push(opts);
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
const submitLogin = (user = 'user', password = 'correct-password') => {
|
||||
$('[name="email_or_username"]').val(user);
|
||||
$('[name="password"]').val(password);
|
||||
$('.login-form').trigger('submit');
|
||||
};
|
||||
|
||||
const enterOtp = (code) => {
|
||||
const inputs = $('.login-2fa-code-inputs input');
|
||||
[...code].forEach((digit, index) => inputs.eq(index).val(digit));
|
||||
inputs.last().trigger('input');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthGlobals();
|
||||
});
|
||||
afterEach(() => {
|
||||
activeAuthFlow()?.finish(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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 });
|
||||
$('.login-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('login_email_username_required');
|
||||
expect($('[name="email_or_username"]').attr('aria-invalid')).toBe('true');
|
||||
$('[name="email_or_username"]').val('user');
|
||||
$('.login-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('login_password_required');
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logs in by username or email, forwarding a reauth identity', async () => {
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false, auth_id: 'aid', reauth_reason: 'rotated' });
|
||||
submitLogin(' [email protected] ');
|
||||
expect(JSON.parse(calls[0].data)).toEqual({ email: '[email protected]', password: 'correct-password', auth_id: 'aid', reauth_reason: 'rotated' });
|
||||
expect($('.login-btn').hasClass('dashboard-form-busy')).toBe(true);
|
||||
calls[0].success({ token: 't', user: { username: 'someone' } });
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith('t', { username: 'someone' });
|
||||
expect(activeAuthFlow()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the server message, unlocks, and puts the user back on the password', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin('user', 'nope');
|
||||
calls[0].error({ status: 400, responseText: JSON.stringify({ message: '<b>Wrong</b> password' }) });
|
||||
await tick();
|
||||
// Text, never markup from the server.
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('<b>Wrong</b> password');
|
||||
expect(screen().find('.dashboard-form-status b')).toHaveLength(0);
|
||||
expect($('[name="password"]').prop('disabled')).toBe(false);
|
||||
expect(activeAuthFlow().isBusy()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays signed in as before when dismissed while the login is out', async () => {
|
||||
window.auth_token = 'current-token';
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin();
|
||||
submitLogin();
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(result).resolves.toBe(false);
|
||||
calls[0].success({ token: 't', user: { username: 'other' } });
|
||||
await tick();
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
// The server answered by moving the session cookie; it comes back.
|
||||
expect(fetch).toHaveBeenCalledWith('https://puter.test/session/sync-cookie', expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer current-token' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('leaves the session cookie alone when a dismissed login only asked for a code', async () => {
|
||||
window.auth_token = 'current-token';
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin();
|
||||
submitLogin();
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await tick();
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).endsWith('/session/sync-cookie'))).toBe(false);
|
||||
});
|
||||
|
||||
it('encodes a prefilled username', () => {
|
||||
void UIAuthLogin({ email_or_username: '"><img src=x onerror=alert(1)>' });
|
||||
expect($('.auth-dialog img[src="x"]')).toHaveLength(0);
|
||||
expect($('[name="email_or_username"]').val()).toBe('"><img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('hides the signup link when asked to', () => {
|
||||
void UIAuthLogin({ show_signup_button: false });
|
||||
expect($('.auth-to-signup')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens forgot password in place, prefilled, and comes back', () => {
|
||||
void UIAuthLogin();
|
||||
$('[name="email_or_username"]').val('someone');
|
||||
$('.forgot-password-link').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('recover-password');
|
||||
expect(screen().find('[name="email_or_username"]').val()).toBe('someone');
|
||||
screen().find('.auth-to-login').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
expect($('.auth-screen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('switches to signup and back without stacking screens', () => {
|
||||
void UIAuthLogin();
|
||||
$('.auth-to-signup').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('signup');
|
||||
screen().find('.auth-to-login').trigger('click');
|
||||
expect($('.auth-screen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('joins a login that is already open', async () => {
|
||||
const first = UIAuthLogin({ reload_on_success: false });
|
||||
const second = UIAuthLogin({ reload_on_success: false });
|
||||
expect($('.auth-dialog')).toHaveLength(1);
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(second).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('explains an unreachable self-hosted server', () => {
|
||||
window.app_domain = 'my.host';
|
||||
expect(loginErrorMessage({ status: 0 }).html).toContain('config.json');
|
||||
window.app_domain = 'puter.com';
|
||||
expect(loginErrorMessage({ status: 0 }).text).toBe('Failed to log in: Error 0');
|
||||
expect(loginErrorMessage({ status: 404, responseText: '<html>' }).text).toContain('404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC buttons on the login and signup screens', () => {
|
||||
const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers';
|
||||
const asPopup = (query) => {
|
||||
window.embedded_in_popup = true;
|
||||
window.gui_action = 'sign-in';
|
||||
window.url_query_params = new URLSearchParams(query);
|
||||
};
|
||||
const askedForProviders = () => fetch.mock.calls.some(([url]) => url === PROVIDERS_URL);
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn(async () => reply(200, { providers: ['google'] }));
|
||||
});
|
||||
|
||||
it.each(['login', 'signup'])('hides them in a cross-origin-isolated relay popup (%s)', async (kind) => {
|
||||
asPopup('embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s');
|
||||
void UIAuthLogin();
|
||||
if ( kind === 'signup' ) $('.auth-to-signup').trigger('click');
|
||||
await tick();
|
||||
expect(askedForProviders()).toBe(false);
|
||||
expect(screen().find('.oidc-google-btn')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(['login', 'signup'])('keeps them in a regular sign-in popup (%s)', async (kind) => {
|
||||
asPopup('embedded_in_popup=true&msg_id=1');
|
||||
void UIAuthLogin();
|
||||
if ( kind === 'signup' ) $('.auth-to-signup').trigger('click');
|
||||
await vi.waitFor(() => expect(screen().find('.oidc-google-btn')).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('starts the provider login with where to come back to', async () => {
|
||||
void UIAuthLogin({ referrer: 'ref' });
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
expect($('.auth-providers').prop('hidden')).toBe(false);
|
||||
const assign = vi.fn();
|
||||
const original = window.location;
|
||||
delete window.location;
|
||||
window.location = { ...original, set href (url) {
|
||||
assign(url);
|
||||
}, get href () {
|
||||
return original.href;
|
||||
} };
|
||||
$('.oidc-google-btn').trigger('click');
|
||||
window.location = original;
|
||||
expect(assign.mock.calls[0][0]).toBe('https://puter.test/auth/oidc/google/start?flow=login&referrer=ref');
|
||||
});
|
||||
});
|
||||
|
||||
describe('second factor', () => {
|
||||
it('passes leading-zero codes to the verifier and keeps the continuation data', async () => {
|
||||
fetch.mockResolvedValue(reply(200, completed));
|
||||
const result = UIAuth2FA('first-factor-challenge');
|
||||
enterOtp('001234');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch).toHaveBeenCalledWith('https://puter.test/login/otp', expect.objectContaining({
|
||||
body: JSON.stringify({ token: 'first-factor-challenge', code: '001234' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('takes a whole code autofilled into the first box', async () => {
|
||||
fetch.mockResolvedValue(reply(200, completed));
|
||||
const result = UIAuth2FA('t');
|
||||
$('.login-2fa-code-inputs input').first().val('987654').trigger('input');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body).code).toBe('987654');
|
||||
});
|
||||
|
||||
it('keeps a failed challenge open for a recovery-code retry', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { proceed: false })).mockResolvedValueOnce(reply(200, completed));
|
||||
const result = UIAuth2FA('first-factor-challenge');
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(screen().find('.dashboard-form-status').text()).toBe('confirm_code_generic_incorrect'));
|
||||
$('.login-2fa-to-recovery').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('recovery-code');
|
||||
$('.login-2fa-recovery-input').val(' abcdefg2\n').trigger('input');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch.mock.calls[1][0]).toBe('https://puter.test/login/recovery-code');
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ token: 'first-factor-challenge', code: 'ABCDEFG2' });
|
||||
});
|
||||
|
||||
it('reports throttling without completing', async () => {
|
||||
fetch.mockResolvedValue(reply(429, {}));
|
||||
const result = UIAuth2FA('t');
|
||||
let settled = false;
|
||||
result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(screen().find('.dashboard-form-status').text()).toBe('confirm_code_generic_too_many_requests'));
|
||||
expect(settled).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores incomplete recovery codes', () => {
|
||||
void UIAuth2FA('t');
|
||||
$('.login-2fa-to-recovery').trigger('click');
|
||||
$('.login-2fa-recovery-input').val('ABC').trigger('input');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves null when the user goes back from a standalone challenge', async () => {
|
||||
const result = UIAuth2FA('t');
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('password login with a second factor', () => {
|
||||
it('completes only after the challenge succeeds', async () => {
|
||||
fetch.mockImplementation(async (url) => (String(url).endsWith('/providers') ? reply(200, { providers: [] }) : reply(200, completed)));
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
const submitted = calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'password-challenge' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
enterOtp('001234');
|
||||
await submitted;
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith(completed.token, completed.user);
|
||||
expect(JSON.parse(fetch.mock.calls.at(-1)[1].body).token).toBe('password-challenge');
|
||||
});
|
||||
|
||||
it('shows a failure to finish signing in on the login screen, not a stuck code screen', async () => {
|
||||
fetch.mockImplementation(async (url) => (String(url).endsWith('/providers') ? reply(200, { providers: [] }) : reply(200, completed)));
|
||||
window.update_auth_data = vi.fn(async () => {
|
||||
throw new Error('Storage is full');
|
||||
});
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('login'));
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('Storage is full');
|
||||
expect($('.login-btn').prop('disabled')).toBe(false);
|
||||
expect(activeAuthFlow().isBusy()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays signed in as before when dismissed while the code is out', async () => {
|
||||
window.auth_token = 'current-token';
|
||||
let answer;
|
||||
fetch.mockImplementation(async (url) => {
|
||||
if ( String(url).endsWith('/providers') ) return reply(200, { providers: [] });
|
||||
if ( String(url).endsWith('/login/otp') ) return new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
return reply(204, null);
|
||||
});
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
const submitted = calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(answer).toBeTypeOf('function'));
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(result).resolves.toBe(false);
|
||||
answer(reply(200, completed));
|
||||
await submitted;
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith('https://puter.test/session/sync-cookie', expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer current-token' },
|
||||
})));
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns to the enabled password form when the user goes back', async () => {
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
const submitted = calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
await submitted;
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
expect($('.login-btn').prop('disabled')).toBe(false);
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 password recovery screen: mails a link to set a new password.
|
||||
|
||||
import { formFieldHtml } from '../Dashboard/UIDashboardFormDialog.js';
|
||||
import { SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
import openAuthFlow, {
|
||||
authHeadHtml,
|
||||
joinAuthFlow,
|
||||
setAuthStatus,
|
||||
setFormBusy,
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.js';
|
||||
import { pushLoginScreen } from './UIAuthLogin.js';
|
||||
|
||||
const SENT_ICON = SVG('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/>');
|
||||
|
||||
/**
|
||||
* The message of a failed request.
|
||||
*
|
||||
* @param {{ responseText?: string }} err
|
||||
* @returns {string}
|
||||
*/
|
||||
const errorMessage = (err) => {
|
||||
const text = err?.responseText || '';
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json.message || json.error || text;
|
||||
} catch {
|
||||
return text || i18n('something_went_wrong', [], false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the password recovery screen in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {{ email_or_username?: string }} [opts] Prefills the field.
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
export function pushRecoverPasswordScreen (flow, { email_or_username = '' } = {}) {
|
||||
const id = `auth-recover-${window.uuidv4?.() ?? Date.now()}`;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('recover_password', [], false));
|
||||
h += '<form class="auth-form pass-recovery-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += formFieldHtml({
|
||||
name: 'email_or_username',
|
||||
label: i18n('email_or_username'),
|
||||
value: email_or_username,
|
||||
attrs: { autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' },
|
||||
}, id);
|
||||
h += submitButtonHtml(i18n('send_password_recovery_email', [], false), 'send-recovery-email');
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-login">${i18n('log_in')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({ name: 'recover-password', html: h });
|
||||
const $form = $screen.find('.pass-recovery-form');
|
||||
const $input = $form.find('[name="email_or_username"]');
|
||||
wireAuthHead($screen);
|
||||
|
||||
$screen.find('.auth-to-login').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('login') ) pushLoginScreen(flow, { email_or_username: String($input.val() ?? '').trim() });
|
||||
});
|
||||
|
||||
$form.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ( flow.isBusy() ) return;
|
||||
const value = String($input.val() ?? '').trim();
|
||||
if ( ! value ) {
|
||||
setAuthStatus($screen, i18n('login_email_username_required', [], false), { field: '[name="email_or_username"]' });
|
||||
return;
|
||||
}
|
||||
setAuthStatus($screen, '');
|
||||
flow.setBusy(true);
|
||||
setFormBusy($form, true);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: `${window.api_origin}/send-pass-recovery-email`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(window.is_email(value) ? { email: value } : { username: value }),
|
||||
statusCode: {
|
||||
401: (xhr) => window.handle401(xhr),
|
||||
},
|
||||
success: resolve,
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
} catch ( err ) {
|
||||
flow.setBusy(false);
|
||||
setFormBusy($form, false);
|
||||
setAuthStatus($screen, errorMessage(err), { field: '[name="email_or_username"]' });
|
||||
return;
|
||||
}
|
||||
flow.setBusy(false);
|
||||
|
||||
// The form gives way to what the server said happened; the way back
|
||||
// to the login stays below it.
|
||||
$form.replaceWith('<div class="dashboard-form-done auth-done" role="status">'
|
||||
+ `${SENT_ICON}<p>${html_encode(res?.message ?? '')}</p></div>`);
|
||||
$screen.find('.auth-to-login').get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the account whose password to reset, on its own.
|
||||
*
|
||||
* @param {{ page?: boolean, dismissable?: boolean }} [options]
|
||||
* @returns {Promise<unknown>} Settles only if the user dismisses the card or
|
||||
* signs in from it without the page reloading.
|
||||
*/
|
||||
export default function UIAuthRecoverPassword (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({ page: options.page, dismissable: options.dismissable });
|
||||
pushRecoverPasswordScreen(flow);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/*
|
||||
* 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 signup screen: the identity provider buttons, the email form behind
|
||||
// them, and the account checks (email, phone, card) a new account may have
|
||||
// to pass once created.
|
||||
|
||||
import check_password_strength from '../../helpers/checkPasswordStrength.js';
|
||||
import { bonusRequirementsKey, checkSignupBonusCode } from '../../helpers/signupBonusCode.js';
|
||||
import { bindPasswordReveal, formFieldHtml } from '../Dashboard/UIDashboardFormDialog.js';
|
||||
import UIWindowCardVerificationRequired from '../UIWindowCardVerificationRequired.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowPhoneVerificationRequired from '../UIWindowPhoneVerificationRequired.js';
|
||||
import openAuthFlow, {
|
||||
authHeadHtml,
|
||||
joinAuthFlow,
|
||||
navigateAfterSignIn,
|
||||
setAuthStatus,
|
||||
setFormBusy,
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.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
|
||||
* @property {string} [username] Prefills the field.
|
||||
* @property {string} [email] Prefills the field.
|
||||
* @property {string} [authError] Shown when the screen opens.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What a checked bonus code grants, above the form and the provider buttons.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @param {{ valid: boolean, display?: { title: string, description: string }, requirements?: object } | null} result
|
||||
*/
|
||||
const renderBonusNotice = ($screen, result) => {
|
||||
const $notice = $screen.find('.signup-bonus-notice');
|
||||
if ( ! result ) {
|
||||
$notice.prop('hidden', true).empty();
|
||||
return;
|
||||
}
|
||||
if ( ! result.valid ) {
|
||||
$notice.addClass('signup-bonus-notice-invalid')
|
||||
.html(`<p>${i18n('signup_bonus_code_invalid_retry')}</p>`)
|
||||
.prop('hidden', false);
|
||||
return;
|
||||
}
|
||||
const requirements_key = bonusRequirementsKey(result.requirements);
|
||||
let h = `<strong>${html_encode(result.display.title)}</strong>`;
|
||||
h += `<p>${html_encode(result.display.description)}</p>`;
|
||||
if ( requirements_key ) h += `<p class="signup-bonus-requirements">${i18n(requirements_key)}</p>`;
|
||||
$notice.removeClass('signup-bonus-notice-invalid').html(h).prop('hidden', false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Covers the page with the reason signups are refused here.
|
||||
*
|
||||
* @param {string} message Plain text.
|
||||
*/
|
||||
const showSignupBlocked = (message) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('signup-blocked-overlay');
|
||||
overlay.innerHTML = `
|
||||
<div class="signup-blocked-content">
|
||||
<img src="${html_encode(window.icons['logo.svg'] || window.icons['logo-white.svg'] || '')}" style="width:64px;margin-bottom:24px;" alt="" />
|
||||
<p>${html_encode(message || 'Signup Not Allowed')}</p>
|
||||
<p>If you already have an account, try <a href="/action/login">logging in</a>. Otherwise, contact <a href="mailto:[email protected]">[email protected]</a> for assistance.</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the checks a new account has to pass, in the server's order (see
|
||||
* assertVerifiedAccount): email → phone (SMS) → card, or card → phone →
|
||||
* email when the account clears email last.
|
||||
*
|
||||
* @param {object} user The new account, as the signup answered.
|
||||
* @param {boolean} show_email Ask for the emailed code here.
|
||||
* @param {boolean} reload_on_success
|
||||
* @param {boolean} page Signup was a page, so the checks cover it too
|
||||
* rather than float over whatever was under it.
|
||||
* @returns {Promise<boolean>} Whether the email was confirmed (true when not asked).
|
||||
*/
|
||||
const runVerificationGates = async (user, show_email, reload_on_success, page) => {
|
||||
const window_options = page ? { cover_page: true, has_head: false } : {};
|
||||
let email_verified = true;
|
||||
const emailGate = async () => {
|
||||
if ( ! show_email ) return;
|
||||
// Its ✕ would close it without an answer, leaving the caller
|
||||
// waiting on a card that is already gone.
|
||||
email_verified = await UIWindowEmailConfirmationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
reload_on_success,
|
||||
window_options,
|
||||
});
|
||||
};
|
||||
// A user the SMS path keeps failing may take the card fallback instead;
|
||||
// that resolves 'card' and clears the card gate along with the phone
|
||||
// gate (see UIWindowPhoneVerificationRequired).
|
||||
let card_cleared_by_fallback = false;
|
||||
const phoneGate = async () => {
|
||||
if ( ! user?.requires_phone_verification ) return;
|
||||
let phone_ok = false;
|
||||
do {
|
||||
phone_ok = await UIWindowPhoneVerificationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options,
|
||||
});
|
||||
} while ( ! phone_ok );
|
||||
card_cleared_by_fallback = phone_ok === 'card';
|
||||
};
|
||||
const cardGate = async () => {
|
||||
if ( ! user?.requires_card_verification || card_cleared_by_fallback ) return;
|
||||
let card_ok = false;
|
||||
do {
|
||||
card_ok = await UIWindowCardVerificationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options,
|
||||
});
|
||||
} while ( ! card_ok );
|
||||
};
|
||||
if ( user?.email_gate_last ) {
|
||||
await cardGate();
|
||||
await phoneGate();
|
||||
await emailGate();
|
||||
} else {
|
||||
await emailGate();
|
||||
await phoneGate();
|
||||
await cardGate();
|
||||
}
|
||||
return email_verified;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where to go after a signup that reloads. Signup can interrupt a direct app
|
||||
* landing (the login cover page's "Sign up", the account picker's "Create
|
||||
* Account", the must_login_or_signup fallback); landing on '/' afterwards
|
||||
* would drop the app the URL asked for, so app landings are kept and the
|
||||
* reloaded boot replays them. The query string is left behind, as login
|
||||
* does, so no credentials stay in it.
|
||||
*
|
||||
* @param {string | undefined} redirect_url
|
||||
* @returns {string}
|
||||
*/
|
||||
export const signupRedirectUrl = (redirect_url) => {
|
||||
if ( redirect_url ) return redirect_url;
|
||||
const on_app_landing = /^\/(?:desktop\/)?app\/[^/]+\/?$/.test(window.location.pathname);
|
||||
return on_app_landing ? window.location.origin + window.location.pathname : '/';
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the signup screen in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {SignupScreenOptions} [options]
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
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));
|
||||
// Above the provider buttons, not in the form: the form hides behind
|
||||
// them, and an identity provider's refusal shows here too.
|
||||
h += '<div class="dashboard-form-status auth-screen-status" role="status" aria-live="polite"></div>';
|
||||
h += '<div class="signup-bonus-notice" hidden></div>';
|
||||
h += '<div class="auth-providers oidc-providers-wrapper" hidden>';
|
||||
h += '<div class="auth-provider-list"></div>';
|
||||
h += '</div>';
|
||||
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${awaiting_providers ? ' hidden' : ''}>`;
|
||||
h += formFieldHtml({
|
||||
name: 'username',
|
||||
label: i18n('username'),
|
||||
value: options.username ?? '',
|
||||
attrs: { autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false', 'data-gramm_editor': 'false' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: i18n('email'),
|
||||
value: options.email ?? '',
|
||||
attrs: { autocomplete: 'email', inputmode: 'email', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false', 'data-gramm_editor': 'false' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
label: i18n('password'),
|
||||
attrs: { autocomplete: 'new-password' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'confirm_password',
|
||||
type: 'password',
|
||||
label: i18n('signup_confirm_password'),
|
||||
attrs: { autocomplete: 'new-password' },
|
||||
}, id);
|
||||
// Bot trap: the server ignores a request that fills it in.
|
||||
h += '<input type="text" name="p102xyzname" class="p102xyzname" value="" tabindex="-1" autocomplete="off" aria-hidden="true">';
|
||||
if ( turnstile_key ) {
|
||||
h += `<div class="auth-turnstile"><div class="cf-turnstile" data-sitekey="${html_encode(turnstile_key)}" data-appearance="interaction-only"></div></div>`;
|
||||
}
|
||||
h += submitButtonHtml(i18n('create_free_account', [], false), 'signup-btn');
|
||||
h += '</form>';
|
||||
h += `<p class="signup-terms">${i18n('tos_fineprint', [], false)}</p>`;
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-login">${i18n('log_in')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({
|
||||
name: 'signup',
|
||||
html: h,
|
||||
autofocus: ! window.disable_signup_autofocus,
|
||||
});
|
||||
const $form = $screen.find('.signup-form');
|
||||
const field = (name) => $form.find(`[name="${name}"]`);
|
||||
const $turnstile = $form.find('.cf-turnstile');
|
||||
let turnstile_widget = null;
|
||||
let offers_providers = false;
|
||||
|
||||
wireAuthHead($screen);
|
||||
bindPasswordReveal($form);
|
||||
if ( options.authError ) setAuthStatus($screen, options.authError);
|
||||
|
||||
$form.on('input', '.dashboard-form-input', function () {
|
||||
$(this).removeAttr('aria-invalid');
|
||||
});
|
||||
|
||||
const showForm = () => {
|
||||
$form.prop('hidden', false);
|
||||
$screen.find('.signup-with-email-wrapper').prop('hidden', true);
|
||||
};
|
||||
|
||||
// Form signups are closed here, so the providers are the only way in.
|
||||
const showOidcOnly = () => {
|
||||
$form.prop('hidden', true);
|
||||
$screen.find('.signup-with-email-wrapper').prop('hidden', true);
|
||||
$screen.find('.oidc-providers-wrapper').prop('hidden', false);
|
||||
};
|
||||
|
||||
// Turnstile renders into the form once its script has loaded.
|
||||
const initTurnstile = () => {
|
||||
if ( ! turnstile_key || ! $turnstile.length || flow.isClosed() ) return;
|
||||
if ( ! window.turnstile ) {
|
||||
setTimeout(initTurnstile, TURNSTILE_POLL_MS);
|
||||
return;
|
||||
}
|
||||
turnstile_widget = window.turnstile.render($turnstile.get(0), {
|
||||
sitekey: turnstile_key,
|
||||
callback: (token) => {
|
||||
$turnstile.attr('data-token', token).addClass('captcha-completed');
|
||||
},
|
||||
'expired-callback': () => {
|
||||
$turnstile.removeAttr('data-token').removeClass('captcha-completed');
|
||||
},
|
||||
});
|
||||
};
|
||||
initTurnstile();
|
||||
|
||||
if ( window.signup_bonus_code ) {
|
||||
(async () => {
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint = await window.getDeviceFingerprint?.();
|
||||
} catch {
|
||||
// The check works without device signals.
|
||||
}
|
||||
const result = await checkSignupBonusCode(window.signup_bonus_code, {
|
||||
origin: window.gui_origin,
|
||||
fingerprint,
|
||||
});
|
||||
// Unknown (null) keeps the code; signup has the final say.
|
||||
if ( result && ! result.valid ) window.signup_bonus_code = null;
|
||||
renderBonusNotice($screen, result);
|
||||
})();
|
||||
}
|
||||
|
||||
// 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 ) {
|
||||
revealForm();
|
||||
return;
|
||||
}
|
||||
stopAwaitingProviders();
|
||||
if ( flow.isClosed() ) return;
|
||||
offers_providers = true;
|
||||
$screen.find('.oidc-providers-wrapper').prop('hidden', false);
|
||||
if ( offered.signupOidcOnly ) {
|
||||
showOidcOnly();
|
||||
return;
|
||||
}
|
||||
// The email form waits behind its own button, unless the user has
|
||||
// already typed into it.
|
||||
const started = $form.find('.dashboard-form-input').filter(function () {
|
||||
return !! $(this).val();
|
||||
}).length > 0;
|
||||
if ( started ) return;
|
||||
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 || focusWasParked() ) flow.focus();
|
||||
});
|
||||
|
||||
$screen.find('.signup-with-email-btn').on('click', () => {
|
||||
showForm();
|
||||
field('username').get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
$screen.find('.auth-to-login').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('login') ) pushLoginScreen(flow);
|
||||
});
|
||||
|
||||
const resetTurnstile = () => {
|
||||
try {
|
||||
if ( window.turnstile && turnstile_widget !== null ) {
|
||||
window.turnstile.reset(turnstile_widget);
|
||||
$turnstile.removeAttr('data-token').removeClass('captcha-completed');
|
||||
}
|
||||
} catch ( e ) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (text, field_name) => {
|
||||
setAuthStatus($screen, text, field_name ? { field: `[name="${field_name}"]` } : {});
|
||||
};
|
||||
|
||||
$form.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ( flow.isBusy() ) return;
|
||||
|
||||
const username = String(field('username').val() ?? '').trim();
|
||||
const email = String(field('email').val() ?? '').trim();
|
||||
const password = String(field('password').val() ?? '');
|
||||
const confirm_password = String(field('confirm_password').val() ?? '');
|
||||
|
||||
if ( ! username ) return fail(i18n('username_required', [], false), 'username');
|
||||
if ( ! email ) return fail(i18n('email_required', [], false), 'email');
|
||||
if ( ! window.is_email(email) ) return fail(i18n('email_invalid', [], false), 'email');
|
||||
if ( ! password ) return fail(i18n('password_required', [], false), 'password');
|
||||
if ( ! check_password_strength(password).overallPass ) return fail(i18n('password_strength_error', [], false), 'password');
|
||||
if ( ! confirm_password ) return fail(i18n('confirm_password_required', [], false), 'confirm_password');
|
||||
if ( password !== confirm_password ) return fail(i18n('passwords_do_not_match', [], false), 'confirm_password');
|
||||
|
||||
let turnstile_token = null;
|
||||
if ( window.turnstile && turnstile_key ) {
|
||||
turnstile_token = $turnstile.attr('data-token');
|
||||
if ( ! turnstile_token ) return fail(i18n('captcha_required', [], false));
|
||||
}
|
||||
setAuthStatus($screen, '');
|
||||
|
||||
flow.setBusy(true);
|
||||
setFormBusy($form, true);
|
||||
|
||||
// A device signal for abuse prevention; signup never waits on or
|
||||
// fails because of it.
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint = await window.getDeviceFingerprint?.();
|
||||
} catch {
|
||||
// Omitted.
|
||||
}
|
||||
|
||||
const request = {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
referrer: flow.options.referrer ?? window.referrerStr,
|
||||
send_confirmation_code: flow.options.send_confirmation_code,
|
||||
p102xyzname: String($form.find('.p102xyzname').val() ?? ''),
|
||||
'cf-turnstile-response': turnstile_token,
|
||||
};
|
||||
if ( fingerprint ) request.fingerprint = fingerprint;
|
||||
if ( window.signup_bonus_code ) request.bonusCode = window.signup_bonus_code;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: `${window.gui_origin}/signup`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: window.custom_headers ?? {},
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(request),
|
||||
success: resolve,
|
||||
error: reject,
|
||||
timeout: SIGNUP_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
} catch ( err ) {
|
||||
setFormBusy($form, false);
|
||||
flow.setBusy(false);
|
||||
resetTurnstile();
|
||||
handleError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
} catch ( err ) {
|
||||
setFormBusy($form, false);
|
||||
flow.setBusy(false);
|
||||
fail(err?.message || i18n('signup_error', [], false));
|
||||
return;
|
||||
}
|
||||
|
||||
const reload_on_success = flow.options.reload_on_success;
|
||||
const show_email = ! reload_on_success
|
||||
&& !! (flow.options.send_confirmation_code || data.user?.requires_email_confirmation);
|
||||
let email_verified = true;
|
||||
if ( data.user?.requires_phone_verification || data.user?.requires_card_verification || show_email ) {
|
||||
// The checks are windows of their own; the card makes way for them.
|
||||
flow.close();
|
||||
email_verified = await runVerificationGates(data.user, show_email, reload_on_success, flow.page);
|
||||
}
|
||||
|
||||
if ( reload_on_success ) {
|
||||
navigateAfterSignIn(signupRedirectUrl(flow.options.redirect_url));
|
||||
return;
|
||||
}
|
||||
flow.finish(email_verified);
|
||||
});
|
||||
|
||||
const handleError = (err) => {
|
||||
const error_text = err?.responseText || '';
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(error_text);
|
||||
} catch {
|
||||
// Not JSON.
|
||||
}
|
||||
|
||||
if ( json?.code === 'signup_blocked' ) {
|
||||
showSignupBlocked(json.message);
|
||||
return;
|
||||
}
|
||||
// Form signups closed after this screen loaded. Without provider
|
||||
// buttons (some popups), the server's message below says so instead.
|
||||
if ( json?.code === 'signup_oidc_only' && offers_providers ) {
|
||||
showOidcOnly();
|
||||
return;
|
||||
}
|
||||
if ( json?.code === 'bonus_code_invalid' ) {
|
||||
// The next attempt signs up without it; the offer no longer applies.
|
||||
window.signup_bonus_code = null;
|
||||
renderBonusNotice($screen, null);
|
||||
fail(i18n('signup_bonus_code_invalid_retry', [], false));
|
||||
return;
|
||||
}
|
||||
if ( json?.code === 'response_timeout' || err?.statusText === 'timeout' || error_text.includes('timeout') ) {
|
||||
fail(i18n('server_timeout', [], false));
|
||||
return;
|
||||
}
|
||||
if ( json?.message ) {
|
||||
fail(String(json.message));
|
||||
return;
|
||||
}
|
||||
fail(error_text || i18n('signup_error', [], false));
|
||||
};
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to create an account.
|
||||
*
|
||||
* @param {Partial<import('./authFlow.js').AuthFlowOptions> & SignupScreenOptions & {
|
||||
* page?: boolean, dismissable?: boolean, closeOnBackdrop?: boolean,
|
||||
* }} [options] `page`, `dismissable` and `closeOnBackdrop` as in `openAuthFlow`.
|
||||
* @returns {Promise<boolean>} True once signed up (or false if a requested
|
||||
* email confirmation wasn't completed), unless the page reloads first;
|
||||
* false when the user dismisses it. Joins an auth flow that is already
|
||||
* open instead of opening another.
|
||||
*/
|
||||
export default function UIAuthSignup (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({
|
||||
page: options.page,
|
||||
dismissable: options.dismissable,
|
||||
closeOnBackdrop: options.closeOnBackdrop,
|
||||
options,
|
||||
});
|
||||
pushSignupScreen(flow, options);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// @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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reply, resetAuthGlobals, screen, tick } from './authTestSetup.js';
|
||||
|
||||
const gates = vi.hoisted(() => ({ email: vi.fn(), phone: vi.fn(), card: vi.fn() }));
|
||||
vi.mock('../UIWindowEmailConfirmationRequired.js', () => ({ default: gates.email }));
|
||||
vi.mock('../UIWindowPhoneVerificationRequired.js', () => ({ default: gates.phone }));
|
||||
vi.mock('../UIWindowCardVerificationRequired.js', () => ({ default: gates.card }));
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
const fillForm = () => {
|
||||
$('[name="username"]').val('someone');
|
||||
$('[name="email"]').val('[email protected]');
|
||||
$('[name="password"]').val('a-Strong-pass-1');
|
||||
$('[name="confirm_password"]').val('a-Strong-pass-1');
|
||||
};
|
||||
|
||||
const captureAjax = () => {
|
||||
const calls = [];
|
||||
vi.spyOn($, 'ajax').mockImplementation((opts) => {
|
||||
calls.push(opts);
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthGlobals();
|
||||
gates.email.mockReset();
|
||||
gates.phone.mockReset();
|
||||
gates.card.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
activeAuthFlow()?.finish(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('signup when only OIDC signups are open', () => {
|
||||
const open = async () => {
|
||||
void UIAuthSignup({ reload_on_success: false });
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
};
|
||||
|
||||
it('offers the email form behind a button normally', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(false);
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
$('.signup-with-email-btn').trigger('click');
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the email form', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: true });
|
||||
await open();
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('switches to the providers when a form signup is refused for it', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
const calls = captureAjax();
|
||||
$('.signup-with-email-btn').trigger('click');
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: JSON.stringify({ code: 'signup_oidc_only', message: 'closed' }) });
|
||||
await tick();
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])('keeps an error it opened with in view (OIDC only: %s)', async (signupOidcOnly) => {
|
||||
providers({ providers: ['google'], signupOidcOnly });
|
||||
void UIAuthSignup({ authError: 'Your account could not be created.' });
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
const $status = screen().find('.dashboard-form-status');
|
||||
expect($status.text()).toBe('Your account could not be created.');
|
||||
expect($status.closest('[hidden]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves a form the user already typed into', async () => {
|
||||
let answer;
|
||||
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));
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
[{ username: 'a' }, 'email_required', 'email'],
|
||||
[{ username: 'a', email: 'nope' }, 'email_invalid', 'email'],
|
||||
[{ username: 'a', email: '[email protected]' }, 'password_required', 'password'],
|
||||
[{ username: 'a', email: '[email protected]', password: 'weak' }, 'password_strength_error', 'password'],
|
||||
[{ username: 'a', email: '[email protected]', password: 'a-Strong-pass-1' }, 'confirm_password_required', 'confirm_password'],
|
||||
[{ username: 'a', email: '[email protected]', password: 'a-Strong-pass-1', confirm_password: 'other' }, 'passwords_do_not_match', 'confirm_password'],
|
||||
])('checks %j before sending', (values, message, field) => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
for ( const [name, value] of Object.entries(values) ) $(`[name="${name}"]`).val(value);
|
||||
$('.signup-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe(message);
|
||||
expect($(`[name="${field}"]`).attr('aria-invalid')).toBe('true');
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('asks for the CAPTCHA when Turnstile is on', async () => {
|
||||
window.gui_params = { turnstileSiteKey: 'key' };
|
||||
window.turnstile = { render: vi.fn(() => 'widget'), reset: vi.fn() };
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
expect(window.turnstile.render).toHaveBeenCalledWith($('.cf-turnstile').get(0), expect.objectContaining({ sitekey: 'key' }));
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('captcha_required');
|
||||
window.turnstile.render.mock.calls[0][1].callback('tok');
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(JSON.parse(calls[0].data)['cf-turnstile-response']).toBe('tok');
|
||||
calls[0].error({ responseText: '{"message":"no"}' });
|
||||
await tick();
|
||||
expect(window.turnstile.reset).toHaveBeenCalledWith('widget');
|
||||
});
|
||||
|
||||
it('sends the honeypot, referrer, bonus code and confirmation setting', async () => {
|
||||
window.signup_bonus_code = 'BONUS';
|
||||
globalThis.fetch = vi.fn(async () => reply(200, { valid: true, display: { title: 'T', description: 'D' } }));
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup({ referrer: 'app-x', send_confirmation_code: true });
|
||||
fillForm();
|
||||
$('.p102xyzname').val('bot');
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(JSON.parse(calls[0].data)).toMatchObject({
|
||||
username: 'someone',
|
||||
email: '[email protected]',
|
||||
referrer: 'app-x',
|
||||
send_confirmation_code: true,
|
||||
p102xyzname: 'bot',
|
||||
bonusCode: 'BONUS',
|
||||
});
|
||||
expect(calls[0].timeout).toBe(30000);
|
||||
});
|
||||
|
||||
it('drops a refused bonus code and says so', async () => {
|
||||
window.signup_bonus_code = 'BAD';
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: JSON.stringify({ code: 'bonus_code_invalid' }) });
|
||||
await tick();
|
||||
expect(window.signup_bonus_code).toBeNull();
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('signup_bonus_code_invalid_retry');
|
||||
expect($('.signup-btn').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('puts focus back in the form after the server refuses, so Enter can retry', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
fillForm();
|
||||
$('[name="confirm_password"]').get(0).focus();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: '{"message":"This username is not available."}' });
|
||||
await tick();
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('This username is not available.');
|
||||
expect(document.activeElement).toBe($('[name="confirm_password"]').get(0));
|
||||
});
|
||||
|
||||
it('covers the page when signups are blocked here', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: JSON.stringify({ code: 'signup_blocked', message: '<i>No</i>' }) });
|
||||
await tick();
|
||||
expect($('.signup-blocked-overlay').text()).toContain('<i>No</i>');
|
||||
expect($('.signup-blocked-overlay i')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('runs the account checks after the card makes way, then settles', async () => {
|
||||
gates.email.mockResolvedValue(true);
|
||||
gates.phone.mockResolvedValueOnce(false).mockResolvedValueOnce('card');
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthSignup({ reload_on_success: false, send_confirmation_code: true });
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].success({ token: 't', user: { requires_phone_verification: true, requires_card_verification: true } });
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect($('.auth-dialog-overlay.dashboard-dialog-show')).toHaveLength(0);
|
||||
// email → phone (retried until passed) → card, which the phone's card fallback cleared.
|
||||
expect(gates.email).toHaveBeenCalledOnce();
|
||||
// Closing a check without an answer would strand the caller.
|
||||
for ( const gate of [gates.email, gates.phone] ) {
|
||||
expect(gate.mock.calls[0][0].show_close_button).toBe(false);
|
||||
}
|
||||
expect(gates.phone).toHaveBeenCalledTimes(2);
|
||||
expect(gates.card).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, { cover_page: true, has_head: false }],
|
||||
[false, {}],
|
||||
])('covers the page with the account checks when signup was a page (%s)', async (page, window_options) => {
|
||||
gates.email.mockResolvedValue(true);
|
||||
gates.phone.mockResolvedValue(true);
|
||||
gates.card.mockResolvedValue(true);
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthSignup({ page, reload_on_success: false, send_confirmation_code: true });
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].success({ token: 't', user: { requires_phone_verification: true, requires_card_verification: true } });
|
||||
await result;
|
||||
for ( const gate of [gates.email, gates.phone, gates.card] ) {
|
||||
expect(gate.mock.calls[0][0].window_options).toEqual(window_options);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an app landing after a signup that reloads', () => {
|
||||
expect(signupRedirectUrl('/x')).toBe('/x');
|
||||
expect(signupRedirectUrl(undefined)).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('account picker', () => {
|
||||
beforeEach(() => {
|
||||
window.logged_in_users = [
|
||||
{ uuid: 'u1', username: 'alice', email: '[email protected]', auth_token: 'ta', profile: { picture: 'data:image/png;base64,AA' } },
|
||||
{ uuid: 'u2', username: '<b>bob</b>', auth_token: 'tb' },
|
||||
];
|
||||
});
|
||||
|
||||
it('lists the accounts, encoded, and can not be dismissed', () => {
|
||||
void UIAuthAccountPicker({ reload_on_success: false, page: true });
|
||||
expect($('.auth-account')).toHaveLength(2);
|
||||
expect($('.auth-account b')).toHaveLength(0);
|
||||
expect($('.auth-account-avatar img').attr('src')).toBe('data:image/png;base64,AA');
|
||||
expect($('.dashboard-dialog-close')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('signs in as the picked account', async () => {
|
||||
const result = UIAuthAccountPicker({ reload_on_success: false });
|
||||
$('.auth-account[data-uuid="u2"]').trigger('click');
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith('tb', window.logged_in_users[1]);
|
||||
});
|
||||
|
||||
it('logs in to another account with a way back to the list', async () => {
|
||||
captureAjax();
|
||||
void UIAuthAccountPicker({ reload_on_success: false });
|
||||
$('.auth-to-login').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('accounts');
|
||||
$('.auth-to-signup').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('signup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password recovery', () => {
|
||||
it('asks for the account, then shows what the server said', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthRecoverPassword({ page: true });
|
||||
$('.pass-recovery-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('login_email_username_required');
|
||||
$('[name="email_or_username"]').val('[email protected]');
|
||||
$('.pass-recovery-form').trigger('submit');
|
||||
expect(calls[0].url).toBe('https://api.puter.test/send-pass-recovery-email');
|
||||
expect(JSON.parse(calls[0].data)).toEqual({ email: '[email protected]' });
|
||||
calls[0].success({ message: 'Sent <b>it</b>' });
|
||||
await tick();
|
||||
expect($('.auth-done').text()).toBe('Sent <b>it</b>');
|
||||
expect($('.pass-recovery-form')).toHaveLength(0);
|
||||
// The page has a way on to the login.
|
||||
$('.auth-to-login').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
});
|
||||
|
||||
it('shows a refusal on the form', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthRecoverPassword();
|
||||
$('[name="email_or_username"]').val('someone');
|
||||
$('.pass-recovery-form').trigger('submit');
|
||||
expect(JSON.parse(calls[0].data)).toEqual({ username: 'someone' });
|
||||
calls[0].error({ responseText: JSON.stringify({ message: 'Too many' }) });
|
||||
await tick();
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('Too many');
|
||||
expect($('[name="email_or_username"]').prop('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
* 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 card that sign-in, sign-up, the account picker, password recovery and
|
||||
// the second factor share: a dashboard dialog whose screens replace each
|
||||
// other in place, with a Back button, instead of opening windows over each
|
||||
// other. One flow is open at a time.
|
||||
//
|
||||
// Two presentations. A dialog dims what is under it, and docks as a bottom
|
||||
// sheet on phones. A page (a cover page, or any sign-in popup) has nothing
|
||||
// to go back to: an opaque page under a centered card, and the whole screen
|
||||
// on phones and in popups.
|
||||
|
||||
import { isAttestedOrigin } from '../../helpers/attestedOrigin.js';
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../../helpers/authLogoHeader.js';
|
||||
import { get_auth_redirect_url } from '../../helpers/authRedirect.js';
|
||||
import { syncSessionCookie } from '../../util/sessionAuth.js';
|
||||
import { isTouchPrimaryDevice } from '../Dashboard/ContextMenu/ContextMenu.js';
|
||||
import mountDashboardOverlay from '../Dashboard/dashboardOverlay.js';
|
||||
import { ICONS, SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
|
||||
const BACK_ICON = SVG('<path d="M15 18l-6-6 6-6"/>');
|
||||
|
||||
/** @type {AuthFlow | null} */
|
||||
let active_flow = null;
|
||||
|
||||
/**
|
||||
* @typedef {object} AuthScreen
|
||||
* @property {string} name Matched by `goTo`.
|
||||
* @property {string} html The screen's markup. A `.auth-title` names the
|
||||
* dialog while the screen shows.
|
||||
* @property {boolean} [autofocus] Focus the first field on show; default true.
|
||||
* @property {() => void} [onBack] Makes Back available on the first screen,
|
||||
* where it runs this instead of returning to an earlier screen.
|
||||
* @property {() => void} [onLeave] Runs when the screen is taken off the
|
||||
* stack: by Back, `goTo`, or the flow closing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What every screen of a flow does once the user is signed in. Set from the
|
||||
* options of the call that opened the flow; screens it leads to share them.
|
||||
*
|
||||
* @typedef {object} AuthFlowOptions
|
||||
* @property {boolean} reload_on_success Navigate to `redirect_url` on success,
|
||||
* rather than settle the flow with `true`.
|
||||
* @property {string} [redirect_url] Defaults to the page the flow started on.
|
||||
* @property {string} [referrer] Sent with a signup, for user acquisition.
|
||||
* @property {boolean} send_confirmation_code Ask a new account to confirm its
|
||||
* email with a code.
|
||||
* @property {boolean} show_signup_button Offer to create an account from
|
||||
* the login screen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Partial<AuthFlowOptions>} [options]
|
||||
* @returns {AuthFlowOptions}
|
||||
*/
|
||||
export const authFlowOptions = (options = {}) => ({
|
||||
reload_on_success: options.reload_on_success ?? true,
|
||||
// Stay on the page the flow started from (e.g. an /app/<name> landing);
|
||||
// standalone auth pages go to the root dashboard.
|
||||
redirect_url: options.redirect_url === undefined ? get_auth_redirect_url() : options.redirect_url,
|
||||
referrer: options.referrer,
|
||||
send_confirmation_code: options.send_confirmation_code ?? false,
|
||||
show_signup_button: options.show_signup_button ?? true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Leaves for `url` once signed in. A URL that differs from this page's only
|
||||
* in its hash (a dashboard `#tab`) would just scroll, never reload, so the
|
||||
* page reloads at it instead.
|
||||
*
|
||||
* @param {string} url
|
||||
*/
|
||||
export const navigateAfterSignIn = (url) => {
|
||||
window.onbeforeunload = null;
|
||||
const target = new URL(url, window.location.href);
|
||||
const here = new URL(window.location.href);
|
||||
target.hash = '';
|
||||
here.hash = '';
|
||||
if ( target.href !== here.href ) {
|
||||
window.location.replace(url);
|
||||
return;
|
||||
}
|
||||
history.replaceState(history.state, '', new URL(url, window.location.href).href);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
/**
|
||||
* Hands the session cookie back to the account this page is signed in as.
|
||||
* A sign-in the server completes after the user dismissed the card has
|
||||
* already moved the cookie to the other account, and the endpoints that
|
||||
* read the cookie (password, 2FA) would act on that one.
|
||||
*/
|
||||
export const keepCurrentSession = () => {
|
||||
void syncSessionCookie({ origin: window.gui_origin || window.location.origin, authToken: window.auth_token });
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} AuthFlow
|
||||
* @property {AuthFlowOptions} options
|
||||
* @property {jQuery} $overlay
|
||||
* @property {jQuery} $dialog
|
||||
* @property {boolean} page Whether it shows as a page rather than a dialog.
|
||||
* @property {boolean} dismissable
|
||||
* @property {Promise<unknown>} result Settles once, with `settle`/`finish`'s
|
||||
* value, or `false` when the user dismisses the flow.
|
||||
* @property {(screen: AuthScreen) => jQuery} push Shows a screen over the
|
||||
* current one; returns its element.
|
||||
* @property {() => void} back
|
||||
* @property {(name: string) => boolean} goTo Returns to an earlier screen by
|
||||
* name, if there is one.
|
||||
* @property {(busy: boolean) => void} setBusy While busy, only ✕ dismisses
|
||||
* the flow and Back is disabled. Ending it returns focus to where it was,
|
||||
* once that control is enabled again.
|
||||
* @property {() => boolean} isBusy
|
||||
* @property {() => void} close Takes the card down without settling.
|
||||
* @property {(value: unknown) => void} settle
|
||||
* @property {(value: unknown) => void} finish `close`, then `settle`.
|
||||
* @property {() => boolean} isClosed
|
||||
* @property {() => void} focus Brings focus back to the current screen.
|
||||
*/
|
||||
|
||||
/** The open flow, if any. */
|
||||
export const activeAuthFlow = () => (active_flow && ! active_flow.isClosed() ? active_flow : null);
|
||||
|
||||
/**
|
||||
* The first enabled, visible field of a screen.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @returns {HTMLElement | undefined}
|
||||
*/
|
||||
const firstField = ($screen) => $screen
|
||||
.find('input:not([type=hidden]):not(.p102xyzname), select, textarea')
|
||||
.filter(':visible:not(:disabled):not([readonly])')
|
||||
.get(0);
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.page] Show as a page; see the top of this file.
|
||||
* Sign-in popups always are.
|
||||
* @param {boolean} [opts.dismissable] Offer ✕ and Escape; defaults to
|
||||
* `!page`.
|
||||
* @param {boolean} [opts.closeOnBackdrop] A press on the dimmed area
|
||||
* dismisses it too. Off by default: a stray click must not lose typed
|
||||
* credentials.
|
||||
* @param {Partial<AuthFlowOptions>} [opts.options]
|
||||
* @returns {AuthFlow}
|
||||
*/
|
||||
export default function openAuthFlow ({ page = false, dismissable = ! page, closeOnBackdrop = false, options } = {}) {
|
||||
// A newer flow wins; the old one's caller hears it was dismissed.
|
||||
activeAuthFlow()?.finish(false);
|
||||
|
||||
const id = `auth-${window.uuidv4?.() ?? Date.now()}`;
|
||||
const as_page = !! page || !! window.embedded_in_popup;
|
||||
|
||||
let h = '<div class="dashboard-dialog auth-dialog" role="dialog" aria-modal="true" tabindex="-1">';
|
||||
h += '<div class="auth-dialog-bar">';
|
||||
h += `<button type="button" class="auth-dialog-back" hidden aria-label="${i18n('back')}" title="${i18n('back')}">${BACK_ICON}</button>`;
|
||||
if ( dismissable ) {
|
||||
h += `<button type="button" class="dashboard-dialog-close" aria-label="${i18n('close')}" title="${i18n('close')}">${ICONS.close}</button>`;
|
||||
}
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
|
||||
let resolve;
|
||||
const result = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
let busy = false;
|
||||
/** @type {HTMLElement | null} */
|
||||
let el_busy_return = null;
|
||||
let settled = false;
|
||||
let torn_down = false;
|
||||
/** @type {Array<AuthScreen & { $el: jQuery, el_return?: Element | null }>} */
|
||||
const stack = [];
|
||||
|
||||
const teardown = () => {
|
||||
if ( torn_down ) return;
|
||||
torn_down = true;
|
||||
if ( active_flow === flow ) active_flow = null;
|
||||
// Screens waiting on the user (the second factor) hear they won't get an answer.
|
||||
while ( stack.length ) stack.pop().onLeave?.();
|
||||
};
|
||||
|
||||
const { $overlay, $dialog, close: closeOverlay, isClosed } = mountDashboardOverlay({
|
||||
html: h,
|
||||
// A page already fills the screen. Resizing it under iOS's own pan to
|
||||
// the focused field makes the form jump up and down as the keyboard opens.
|
||||
fitKeyboard: ! as_page,
|
||||
canDismiss: (via) => dismissable
|
||||
&& (via === 'close' || (! busy && (via !== 'backdrop' || closeOnBackdrop))),
|
||||
onDismiss: () => {
|
||||
teardown();
|
||||
flow.settle(false);
|
||||
},
|
||||
});
|
||||
$overlay.addClass('auth-dialog-overlay');
|
||||
if ( as_page ) $overlay.addClass('auth-dialog-page');
|
||||
|
||||
const $back = $dialog.find('.auth-dialog-back');
|
||||
const top = () => stack[stack.length - 1];
|
||||
|
||||
const focusScreen = (screen) => {
|
||||
if ( ! screen || isClosed() ) return;
|
||||
// A keyboard popping up mid-animation on a phone hides half the
|
||||
// card; there the user taps the field they want.
|
||||
const el_field = screen.autofocus !== false && ! isTouchPrimaryDevice()
|
||||
? firstField(screen.$el) ?? screen.$el.find('.auth-provider-btn').filter(':visible').get(0)
|
||||
: undefined;
|
||||
(el_field ?? $dialog.get(0))?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
// Back and ✕ float over the top of the screen. Once it scrolls, the bar
|
||||
// under them turns opaque, so what passes beneath neither shows through
|
||||
// nor takes their taps.
|
||||
const syncScrolled = () => {
|
||||
const el_body = top()?.$el.find('.auth-body').get(0);
|
||||
$dialog.toggleClass('auth-dialog-scrolled', !! el_body && el_body.scrollTop > 0);
|
||||
};
|
||||
$dialog.get(0).addEventListener('scroll', syncScrolled, { capture: true, passive: true });
|
||||
|
||||
const refresh = () => {
|
||||
const screen = top();
|
||||
syncScrolled();
|
||||
$back.prop('hidden', ! (stack.length > 1 || screen?.onBack));
|
||||
$back.prop('disabled', busy);
|
||||
const el_title = screen?.$el.find('.auth-title').get(0);
|
||||
$dialog.attr('aria-labelledby', el_title?.id || null);
|
||||
};
|
||||
|
||||
/** Takes the top screen off the stack and shows the one under it. */
|
||||
const pop = () => {
|
||||
const screen = stack.pop();
|
||||
screen.$el.remove();
|
||||
screen.onLeave?.();
|
||||
const under = top();
|
||||
under.$el.prop('hidden', false);
|
||||
return under;
|
||||
};
|
||||
|
||||
/** @type {AuthFlow} */
|
||||
const flow = {
|
||||
options: authFlowOptions(options),
|
||||
$overlay,
|
||||
$dialog,
|
||||
page: as_page,
|
||||
dismissable,
|
||||
result,
|
||||
push (screen) {
|
||||
const under = top();
|
||||
if ( under ) {
|
||||
under.el_return = $dialog.get(0).contains(document.activeElement) ? document.activeElement : null;
|
||||
under.$el.prop('hidden', true);
|
||||
}
|
||||
const $el = $(`<section class="auth-screen" data-screen="${html_encode(screen.name)}">${screen.html}</section>`);
|
||||
$el.find('.auth-title').first().attr('id', `${id}-title-${stack.length}`);
|
||||
$dialog.append($el);
|
||||
const record = { ...screen, $el };
|
||||
stack.push(record);
|
||||
refresh();
|
||||
focusScreen(record);
|
||||
return $el;
|
||||
},
|
||||
back () {
|
||||
if ( busy || isClosed() ) return;
|
||||
if ( stack.length > 1 ) {
|
||||
const under = pop();
|
||||
refresh();
|
||||
const el_return = under.el_return;
|
||||
if ( el_return && under.$el.get(0).contains(el_return) && ! el_return.disabled ) {
|
||||
el_return.focus({ preventScroll: true });
|
||||
} else {
|
||||
focusScreen(under);
|
||||
}
|
||||
return;
|
||||
}
|
||||
top()?.onBack?.();
|
||||
},
|
||||
goTo (name) {
|
||||
const index = stack.findIndex((screen) => screen.name === name);
|
||||
if ( index === -1 ) return false;
|
||||
while ( stack.length > index + 1 ) pop();
|
||||
refresh();
|
||||
focusScreen(top());
|
||||
return true;
|
||||
},
|
||||
setBusy (value) {
|
||||
const was_busy = busy;
|
||||
busy = !! value;
|
||||
// Busy screens lock their fields, and a disabled field drops focus
|
||||
// to the page, where the desktop's shortcuts would take the keys.
|
||||
const el_dialog = $dialog.get(0);
|
||||
const el_active = document.activeElement;
|
||||
if ( busy && ! was_busy ) {
|
||||
el_busy_return = el_active !== el_dialog && el_dialog.contains(el_active) ? el_active : null;
|
||||
}
|
||||
if ( busy && el_dialog.contains(el_active) ) el_dialog.focus({ preventScroll: true });
|
||||
$dialog.toggleClass('auth-dialog-busy', busy).attr('aria-busy', busy ? 'true' : null);
|
||||
refresh();
|
||||
if ( busy || ! was_busy ) return;
|
||||
// A failed request hands the keys back to where the user was, so
|
||||
// Enter can retry. A screen that already moved focus keeps it.
|
||||
const el_return = el_busy_return;
|
||||
el_busy_return = null;
|
||||
if ( el_return && document.activeElement === el_dialog && el_dialog.contains(el_return)
|
||||
&& ! el_return.disabled && ! el_return.closest('[hidden]') ) {
|
||||
el_return.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
isBusy: () => busy,
|
||||
close () {
|
||||
if ( isClosed() ) return;
|
||||
teardown();
|
||||
closeOverlay();
|
||||
},
|
||||
settle (value) {
|
||||
if ( settled ) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
},
|
||||
finish (value) {
|
||||
flow.close();
|
||||
flow.settle(value);
|
||||
},
|
||||
isClosed,
|
||||
focus: () => focusScreen(top()),
|
||||
};
|
||||
|
||||
$back.on('click', () => flow.back());
|
||||
|
||||
// Keys typed here are the card's. The desktop's shortcuts listen on the
|
||||
// document (Enter opens the selected item, Delete trashes it, letters
|
||||
// search the desktop), so they must not reach it. Escape is handled here
|
||||
// for the same reason.
|
||||
$overlay.on('keydown keyup keypress', function (e) {
|
||||
e.stopPropagation();
|
||||
if ( e.type !== 'keydown' || e.key !== 'Escape' ) return;
|
||||
e.preventDefault();
|
||||
if ( dismissable && ! busy ) $dialog.find('.dashboard-dialog-close').trigger('click');
|
||||
});
|
||||
// A press on the backdrop (or a page's background) would move focus to
|
||||
// the page, and with it the keys; it stays where it was.
|
||||
$overlay.on('mousedown', function (e) {
|
||||
if ( e.target === this ) e.preventDefault();
|
||||
});
|
||||
|
||||
active_flow = flow;
|
||||
return flow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the flow that is already open, if any: a second sign-in prompt
|
||||
* (an app asking while the user is mid-login) waits on the first rather than
|
||||
* stacking another card over it.
|
||||
*
|
||||
* @returns {Promise<unknown> | null}
|
||||
*/
|
||||
export const joinAuthFlow = () => {
|
||||
const open = activeAuthFlow();
|
||||
if ( ! open ) return null;
|
||||
// Bring focus back to the card, without taking it from a field the
|
||||
// user is typing in there.
|
||||
if ( ! open.$dialog.get(0).contains(document.activeElement) ) open.focus();
|
||||
return open.result;
|
||||
};
|
||||
|
||||
/**
|
||||
* The top of a screen: the Puter logo (beside the opener's, in a sign-in
|
||||
* popup), the screen's title, and, in a popup, which site sent the user.
|
||||
*
|
||||
* @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, { openerNotice = true } = {}) => {
|
||||
const opener = window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '';
|
||||
let h = '<div class="auth-head">';
|
||||
h += authLogoHeader({
|
||||
logoSrc: window.icons['logo-white.svg'],
|
||||
logoHref: logoHref(flow),
|
||||
openerOrigin: opener,
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
h += `<h1 class="auth-title">${html_encode(title)}</h1>`;
|
||||
if ( opener && openerNotice ) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(opener).hostname])}</p>`;
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/**
|
||||
* On a cover page, the logo is a way out: desktop users stay on the desktop,
|
||||
* everyone else goes to the root dashboard.
|
||||
*
|
||||
* @param {AuthFlow} flow
|
||||
* @returns {string} Empty where the logo leads nowhere.
|
||||
*/
|
||||
const logoHref = (flow) => {
|
||||
if ( ! flow.page || window.embedded_in_popup ) return '';
|
||||
return window.location.pathname === '/desktop' ? '/desktop' : '/';
|
||||
};
|
||||
|
||||
/**
|
||||
* Wires a screen's head once it is on the page.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
*/
|
||||
export const wireAuthHead = ($screen) => {
|
||||
wireAuthLogoHeader($screen.get(0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets a screen's status line. Plain text unless `html` is given, which the
|
||||
* caller has already encoded.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @param {string} text Empty clears it.
|
||||
* @param {{ kind?: 'error'|'info'|'success', html?: string, field?: string }} [opts]
|
||||
* `field` is a selector for the control to mark invalid and focus.
|
||||
*/
|
||||
export const setAuthStatus = ($screen, text, { kind = 'error', html, field } = {}) => {
|
||||
const $status = $screen.find('.dashboard-form-status').first();
|
||||
if ( html !== undefined ) $status.html(html);
|
||||
else $status.text(text ?? '');
|
||||
const shown = !! (text || html);
|
||||
$status
|
||||
.toggleClass('dashboard-form-status-error', shown && kind === 'error')
|
||||
.toggleClass('dashboard-form-status-info', shown && kind === 'info')
|
||||
.toggleClass('auth-status-success', shown && kind === 'success');
|
||||
$screen.find('[aria-invalid]').removeAttr('aria-invalid');
|
||||
if ( ! shown ) return;
|
||||
const el_status = $status.get(0);
|
||||
el_status?.scrollIntoView?.({ block: 'nearest' });
|
||||
if ( field ) {
|
||||
const el_field = $screen.find(field).attr('aria-invalid', 'true').get(0);
|
||||
if ( el_field && ! el_field.disabled ) el_field.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a form's submit button as working, and locks the form's fields,
|
||||
* while `busy`.
|
||||
*
|
||||
* @param {jQuery} $form
|
||||
* @param {boolean} busy
|
||||
*/
|
||||
export const setFormBusy = ($form, busy) => {
|
||||
$form.find('input, select, textarea, button').prop('disabled', busy);
|
||||
$form.find('.auth-submit')
|
||||
.toggleClass('dashboard-form-busy', busy)
|
||||
.attr('aria-busy', busy ? 'true' : null);
|
||||
};
|
||||
|
||||
/**
|
||||
* The submit button of an auth form: full width, with a spinner while busy.
|
||||
*
|
||||
* @param {string} label Plain text.
|
||||
* @param {string} [extraClass]
|
||||
* @returns {string}
|
||||
*/
|
||||
export const submitButtonHtml = (label, extraClass = '') =>
|
||||
`<button type="submit" class="dashboard-dialog-btn dashboard-dialog-btn-primary auth-submit ${extraClass}">`
|
||||
+ '<span class="dashboard-form-spinner" aria-hidden="true"></span>'
|
||||
+ `<span class="dashboard-form-submit-label">${html_encode(label)}</span>`
|
||||
+ '</button>';
|
||||
@@ -0,0 +1,315 @@
|
||||
// @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 { resetAuthGlobals, tick } from './authTestSetup.js';
|
||||
|
||||
const { default: openAuthFlow, activeAuthFlow, authFlowOptions, authHeadHtml, joinAuthFlow, navigateAfterSignIn } = await import('./authFlow.js');
|
||||
|
||||
const key = (target, k) => target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
|
||||
const backdropClick = () => {
|
||||
const el = $('.auth-dialog-overlay').last().get(0);
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, detail: 1 }));
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthGlobals();
|
||||
activeAuthFlow()?.finish(false);
|
||||
});
|
||||
|
||||
describe('openAuthFlow', () => {
|
||||
it('mounts one card over everything, not a window', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<h1 class="auth-title">A</h1>' });
|
||||
await tick();
|
||||
expect($('.auth-dialog-overlay.dashboard-dialog-overlay')).toHaveLength(1);
|
||||
expect($('.window')).toHaveLength(0);
|
||||
expect($('.auth-dialog').attr('aria-labelledby')).toBe($('.auth-title').attr('id'));
|
||||
});
|
||||
|
||||
it('shows as a page, without ✕, for cover pages and every popup', () => {
|
||||
openAuthFlow({ page: true }).finish(false);
|
||||
window.embedded_in_popup = true;
|
||||
const flow = openAuthFlow();
|
||||
expect(flow.page).toBe(true);
|
||||
expect($('.auth-dialog-overlay').last().hasClass('auth-dialog-page')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets ✕ and Escape dismiss a dialog, settling false', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
key($('.auth-dialog input').get(0), 'Escape');
|
||||
await expect(flow.result).resolves.toBe(false);
|
||||
expect(activeAuthFlow()).toBeNull();
|
||||
|
||||
const second = openAuthFlow();
|
||||
second.push({ name: 'a', html: '' });
|
||||
$('.dashboard-dialog-close').last().trigger('click');
|
||||
await expect(second.result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a page up: no ✕, Escape and backdrop do nothing', async () => {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
await tick();
|
||||
expect($('.dashboard-dialog-close')).toHaveLength(0);
|
||||
key($('.auth-dialog input').get(0), 'Escape');
|
||||
backdropClick();
|
||||
expect(flow.isClosed()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the backdrop unless asked, so a stray click keeps typed credentials', async () => {
|
||||
const kept = openAuthFlow();
|
||||
kept.push({ name: 'a', html: '' });
|
||||
await tick();
|
||||
backdropClick();
|
||||
expect(kept.isClosed()).toBe(false);
|
||||
kept.finish(false);
|
||||
|
||||
const closing = openAuthFlow({ closeOnBackdrop: true });
|
||||
closing.push({ name: 'a', html: '' });
|
||||
await tick();
|
||||
backdropClick();
|
||||
await expect(closing.result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('holds Escape while busy; only ✕ dismisses then', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
flow.setBusy(true);
|
||||
key($('.auth-dialog input').get(0), 'Escape');
|
||||
expect(flow.isClosed()).toBe(false);
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(flow.result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps typed keys from reaching the desktop shortcuts on the document', () => {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
const seen = vi.fn();
|
||||
document.addEventListener('keydown', seen);
|
||||
key($('.auth-dialog input').get(0), 'Delete');
|
||||
key($('.auth-dialog input').get(0), 'Enter');
|
||||
document.removeEventListener('keydown', seen);
|
||||
expect(seen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps focus in the card while busy, since a locked field would drop it to the page', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
const el_input = $('.auth-dialog input').get(0);
|
||||
el_input.focus();
|
||||
flow.setBusy(true);
|
||||
el_input.disabled = true;
|
||||
expect(document.activeElement).toBe($('.auth-dialog').get(0));
|
||||
});
|
||||
|
||||
it('hands focus back to where it was once busy ends, so Enter can retry', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input class="one"><input class="two">' });
|
||||
const el_two = $('.auth-dialog .two').get(0);
|
||||
el_two.focus();
|
||||
flow.setBusy(true);
|
||||
el_two.disabled = true;
|
||||
el_two.disabled = false;
|
||||
flow.setBusy(false);
|
||||
expect(document.activeElement).toBe(el_two);
|
||||
|
||||
// A screen that moved focus on its own keeps it there.
|
||||
flow.setBusy(true);
|
||||
$('.auth-dialog .one').get(0).focus();
|
||||
flow.setBusy(false);
|
||||
expect(document.activeElement).toBe($('.auth-dialog .one').get(0));
|
||||
});
|
||||
|
||||
it('keeps focus in the card when the backdrop is pressed', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 1 });
|
||||
$('.auth-dialog-overlay').get(0).dispatchEvent(press);
|
||||
expect(press.defaultPrevented).toBe(true);
|
||||
$('.auth-dialog-overlay').get(0).dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||||
expect(flow.isClosed()).toBe(false);
|
||||
|
||||
const inside = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 1 });
|
||||
$('.auth-dialog input').get(0).dispatchEvent(inside);
|
||||
expect(inside.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('stacks screens, keeping what was typed, with Back and goTo', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'login', html: '<input class="typed">' });
|
||||
$('.typed').val('kept');
|
||||
expect($('.auth-dialog-back').prop('hidden')).toBe(true);
|
||||
|
||||
const left = vi.fn();
|
||||
flow.push({ name: 'signup', html: '<p>signup</p>', onLeave: left });
|
||||
expect($('[data-screen="login"]').prop('hidden')).toBe(true);
|
||||
expect($('.auth-dialog-back').prop('hidden')).toBe(false);
|
||||
|
||||
flow.back();
|
||||
expect(left).toHaveBeenCalledOnce();
|
||||
expect($('[data-screen="signup"]')).toHaveLength(0);
|
||||
expect($('[data-screen="login"]').prop('hidden')).toBe(false);
|
||||
expect($('.typed').val()).toBe('kept');
|
||||
|
||||
flow.push({ name: 'signup', html: '' });
|
||||
flow.push({ name: 'recover', html: '' });
|
||||
expect(flow.goTo('login')).toBe(true);
|
||||
expect($('.auth-screen')).toHaveLength(1);
|
||||
expect(flow.goTo('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the card scrolled while the showing screen is, so the bar covers what passes under it', () => {
|
||||
const flow = openAuthFlow();
|
||||
const scroll = (screen, top) => {
|
||||
const el = $(`[data-screen="${screen}"] .auth-body`).get(0);
|
||||
el.scrollTop = top;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
};
|
||||
flow.push({ name: 'a', html: '<div class="auth-body"></div>' });
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(false);
|
||||
scroll('a', 40);
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(true);
|
||||
flow.push({ name: 'b', html: '<div class="auth-body"></div>' });
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(false);
|
||||
flow.back();
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(true);
|
||||
scroll('a', 0);
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(false);
|
||||
});
|
||||
|
||||
it('offers Back on a first screen that asks for it', () => {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
const back = vi.fn();
|
||||
flow.push({ name: 'only', html: '', onBack: back });
|
||||
expect($('.auth-dialog-back').prop('hidden')).toBe(false);
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
expect(back).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('tells waiting screens when the flow closes', () => {
|
||||
const flow = openAuthFlow();
|
||||
const left = vi.fn();
|
||||
flow.push({ name: 'a', html: '', onLeave: left });
|
||||
flow.finish(true);
|
||||
expect(left).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('lets a second prompt join the open flow instead of stacking another card', async () => {
|
||||
expect(joinAuthFlow()).toBeNull();
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '' });
|
||||
const joined = joinAuthFlow();
|
||||
expect($('.auth-dialog')).toHaveLength(1);
|
||||
flow.finish(true);
|
||||
await expect(joined).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the field being typed in when a second prompt joins', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input class="first"><input class="second">' });
|
||||
$('.second').get(0).focus();
|
||||
joinAuthFlow();
|
||||
expect(document.activeElement).toBe($('.second').get(0));
|
||||
|
||||
// Focus that wandered off the card is brought back to it.
|
||||
$('.second').get(0).blur();
|
||||
joinAuthFlow();
|
||||
expect($('.auth-dialog').get(0).contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authHeadHtml', () => {
|
||||
it('makes the logo a keyboard-reachable way out only on a cover page', () => {
|
||||
const page = openAuthFlow({ page: true });
|
||||
const $page_head = $(authHeadHtml(page, 'Log in'));
|
||||
expect($page_head.find('a.auth-logo-link').attr('href')).toBe('/');
|
||||
expect($page_head.find('.auth-logo').attr('alt')).toBe('Puter');
|
||||
page.finish(false);
|
||||
|
||||
const dialog = openAuthFlow();
|
||||
const $dialog_head = $(authHeadHtml(dialog, 'Log in'));
|
||||
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', () => {
|
||||
it('defaults to reloading on success, to the page the flow started on', () => {
|
||||
const options = authFlowOptions();
|
||||
expect(options.reload_on_success).toBe(true);
|
||||
expect(options.redirect_url).toBe(window.location.href);
|
||||
expect(options.show_signup_button).toBe(true);
|
||||
expect(options.send_confirmation_code).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an explicit empty redirect', () => {
|
||||
expect(authFlowOptions({ redirect_url: '' }).redirect_url).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigateAfterSignIn', () => {
|
||||
const withLocation = (href, run) => {
|
||||
const original = window.location;
|
||||
const replace = vi.fn();
|
||||
const reload = vi.fn();
|
||||
delete window.location;
|
||||
window.location = { href, replace, reload };
|
||||
const spy = vi.spyOn(history, 'replaceState').mockImplementation(() => {});
|
||||
let states;
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
window.location = original;
|
||||
states = spy.mock.calls.map((call) => call[2]);
|
||||
spy.mockRestore();
|
||||
}
|
||||
return { replace, reload, states };
|
||||
};
|
||||
|
||||
it('goes to another page by replacing this one', () => {
|
||||
const { replace, reload } = withLocation('https://puter.test/action/login', () => {
|
||||
navigateAfterSignIn('/');
|
||||
});
|
||||
expect(replace).toHaveBeenCalledWith('/');
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Replacing with a hash-only change would scroll, never reload.
|
||||
it('reloads when only the hash would change, as on a dashboard tab', () => {
|
||||
const { replace, reload, states } = withLocation('https://puter.test/#files', () => {
|
||||
navigateAfterSignIn('https://puter.test/#files');
|
||||
});
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
expect(states).toEqual(['https://puter.test/#files']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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 "Sign in with Google" (and other identity provider) buttons of the
|
||||
// login and signup screens. A button navigates to the backend's OIDC start
|
||||
// route, which comes back to the page the flow started from.
|
||||
|
||||
import { get_oidc_return_to } from '../../helpers/authRedirect.js';
|
||||
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../../util/openid.js';
|
||||
import { offersFederatedSignInInPopup } from '../../util/popupAuth.js';
|
||||
|
||||
const GOOGLE_ICON = '<img alt="" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA">';
|
||||
const MICROSOFT_ICON = '<svg aria-hidden="true" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>';
|
||||
const APPLE_ICON = '<svg aria-hidden="true" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>';
|
||||
|
||||
const KNOWN = {
|
||||
google: { icon: GOOGLE_ICON, login: 'sign_in_with_google', signup: 'sign_up_with_google' },
|
||||
microsoft: { icon: MICROSOFT_ICON, login: 'sign_in_with_microsoft', signup: 'sign_up_with_microsoft' },
|
||||
apple: { icon: APPLE_ICON, login: 'sign_in_with_apple', signup: 'sign_up_with_apple' },
|
||||
};
|
||||
|
||||
// Shown in this order whatever order the server lists them in.
|
||||
const KNOWN_ORDER = ['google', 'microsoft', 'apple'];
|
||||
|
||||
/**
|
||||
* Whether this page may offer a federated sign-in at all. A federated hop
|
||||
* navigates a popup away and the provider returns it as a plain sign-in
|
||||
* popup, losing whatever the popup was opened to do (and, for a permission
|
||||
* prompt, handing the opener a token instead of a decision). A
|
||||
* cross-origin-isolated opener can only get the token through the session
|
||||
* relay, which the provider's return doesn't keep.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const mayOfferProviders = () => ! (window.embedded_in_popup
|
||||
&& ( ! offersFederatedSignInInPopup(window.gui_action)
|
||||
|| window.url_query_params?.get('cross_origin_isolated') === 'true'));
|
||||
|
||||
/**
|
||||
* The URL that starts a provider's sign-in.
|
||||
*
|
||||
* @param {string} provider
|
||||
* @param {'login'|'signup'} kind
|
||||
* @param {string | undefined} referrer
|
||||
* @returns {string}
|
||||
*/
|
||||
export const providerStartUrl = (provider, kind, referrer) => {
|
||||
let url = `${window.gui_origin}/auth/oidc/${encodeURIComponent(provider)}/start?flow=${kind}`;
|
||||
// Return to the interface the flow started from (the backend whitelists the path).
|
||||
const return_to = get_oidc_return_to();
|
||||
if ( return_to ) url += `&return_to=${encodeURIComponent(return_to)}`;
|
||||
if ( referrer ) url += `&referrer=${encodeURIComponent(referrer)}`;
|
||||
// A provider sign-in can create the account, so a signup link's code rides along.
|
||||
if ( window.signup_bonus_code ) url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if ( window.openerOrigin ) url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/** @type {WeakMap<import('./authFlow.js').AuthFlow, Promise<{ providers?: string[], signupOidcOnly?: boolean } | null>>} */
|
||||
const lookups = new WeakMap();
|
||||
|
||||
/**
|
||||
* 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, flow }) {
|
||||
if ( ! mayOfferProviders() ) return null;
|
||||
const data = await lookUpProviders(flow);
|
||||
if ( ! Array.isArray(data?.providers) || ! data.providers.length ) return null;
|
||||
|
||||
const ordered = [
|
||||
...KNOWN_ORDER.filter((provider) => data.providers.includes(provider)),
|
||||
...data.providers.filter((provider) => ! KNOWN_OIDC_PROVIDERS.includes(provider)),
|
||||
];
|
||||
let h = '';
|
||||
for ( const provider of ordered ) {
|
||||
const known = KNOWN[provider];
|
||||
const label = known
|
||||
? i18n(known[kind])
|
||||
: i18n(kind === 'login' ? 'sign_in_with_provider' : 'sign_up_with_provider', [humanizeOidcProviderId(provider)]);
|
||||
const icon = known ? known.icon : OIDC_GENERIC_PROVIDER_ICON;
|
||||
h += `<button type="button" class="dashboard-dialog-btn auth-provider-btn oidc-${known ? provider : 'custom'}-btn" data-provider="${html_encode(provider)}">`;
|
||||
h += `<span class="auth-provider-icon" aria-hidden="true">${icon}</span><span>${label}</span>`;
|
||||
h += '</button>';
|
||||
}
|
||||
$list.html(h);
|
||||
$list.on('click', '.auth-provider-btn', function () {
|
||||
window.location.href = providerStartUrl($(this).attr('data-provider'), kind, referrer());
|
||||
});
|
||||
return { providers: ordered, signupOidcOnly: data.signupOidcOnly === true };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// Shared jsdom setup for the auth dialog tests: the globals the GUI
|
||||
// installs at boot, reset between tests.
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, args) => (Array.isArray(args) && args.length ? `${key}:${args.join(',')}` : key);
|
||||
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 });
|
||||
|
||||
export const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A JSON fetch response. */
|
||||
export const reply = (status, data) => new Response(JSON.stringify(data), { status });
|
||||
|
||||
/** Resets the boot globals the screens read. */
|
||||
export const resetAuthGlobals = () => {
|
||||
document.body.innerHTML = '';
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.app_domain = 'puter.com';
|
||||
window.gui_params = {};
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => `id-${Math.random().toString(36).slice(2)}`;
|
||||
window.is_email = (value) => String(value).includes('@');
|
||||
window.embedded_in_popup = false;
|
||||
window.gui_action = undefined;
|
||||
window.openerOrigin = undefined;
|
||||
window.referrerStr = undefined;
|
||||
window.signup_bonus_code = null;
|
||||
window.url_query_params = new URLSearchParams('');
|
||||
window.custom_headers = undefined;
|
||||
window.turnstile = undefined;
|
||||
window.logged_in_users = [];
|
||||
window.auth_token = undefined;
|
||||
window.update_auth_data = vi.fn(async () => {});
|
||||
globalThis.fetch = vi.fn(async () => reply(200, { providers: [] }));
|
||||
};
|
||||
|
||||
/** The screen showing in the auth card. */
|
||||
export const screen = () => $('.auth-dialog .auth-screen:not([hidden])');
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,11 +23,11 @@ import UIWindow from '../UIWindow.js';
|
||||
import UIContextMenu from '../UIContextMenu.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
|
||||
import UIWindowLogin from '../UIWindowLogin.js';
|
||||
import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
import UIAuthLogin from '../Auth/UIAuthLogin.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,
|
||||
@@ -514,9 +514,9 @@ async function UIDashboard (options) {
|
||||
|
||||
if ( sharedLinkNeedsLogin ) {
|
||||
// Not awaited: the dashboard is already up underneath the cover page.
|
||||
UIWindowLogin({
|
||||
UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
window_options: { cover_page: true, has_head: false },
|
||||
page: true,
|
||||
}).then(() => {
|
||||
// Dismissed without signing in: drop the link rather than loop.
|
||||
if ( window.user?.is_temp ) clear_shared_param();
|
||||
@@ -875,21 +875,10 @@ async function UIDashboard (options) {
|
||||
items.push({
|
||||
html: i18n('add_another_account'),
|
||||
onClick: async function () {
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: false,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
// Phones/tablets pin every .window to one z-index
|
||||
// (style.css), which puts the fullpage dashboard
|
||||
// above this dialog's backdrop wrapper; stay_on_top
|
||||
// lifts the dialog into the 99999999+ band like
|
||||
// every other backdropped dashboard dialog.
|
||||
stay_on_top: true,
|
||||
},
|
||||
closeOnBackdrop: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -911,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);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,14 @@ const attrs_html = (attrs) => Object.entries(attrs ?? {})
|
||||
.map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${html_encode(String(value))}"`))
|
||||
.join('');
|
||||
|
||||
const field_html = (field, id) => {
|
||||
/**
|
||||
* A labelled form field's markup, as the form dialog draws it.
|
||||
*
|
||||
* @param {FormDialogField} field
|
||||
* @param {string} id Prefix for the control's id, unique to the dialog.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formFieldHtml = (field, id) => {
|
||||
const control_id = `${id}-${field.name}`;
|
||||
const hint_id = `${control_id}-hint`;
|
||||
const suffix_id = `${control_id}-suffix`;
|
||||
@@ -127,6 +134,25 @@ const field_html = (field, id) => {
|
||||
+ '</div>';
|
||||
};
|
||||
|
||||
/**
|
||||
* Wires the show/hide toggles of the password fields under `$root`.
|
||||
*
|
||||
* @param {jQuery} $root
|
||||
*/
|
||||
export const bindPasswordReveal = ($root) => {
|
||||
// Keep the field focused (and a phone's keyboard up) while toggling. The
|
||||
// label names the action, so the button carries no aria-pressed as well.
|
||||
$root.on('pointerdown mousedown', '.dashboard-form-reveal', (e) => e.preventDefault());
|
||||
$root.on('click', '.dashboard-form-reveal', function () {
|
||||
const $input = $(this).siblings('input');
|
||||
const reveal = $input.attr('type') === 'password';
|
||||
const label = i18n(reveal ? 'hide_password' : 'show_password', [], false);
|
||||
$input.attr('type', reveal ? 'text' : 'password');
|
||||
$(this).attr({ 'aria-label': label, title: label })
|
||||
.html(reveal ? FORM_ICONS.hide : FORM_ICONS.show);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A read-only value with a Copy button, for a `finish` screen. The dialog
|
||||
* wires the button.
|
||||
@@ -179,7 +205,7 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
if ( danger ) h += `<div class="dashboard-dialog-icon">${ICONS.danger}</div>`;
|
||||
h += '<div class="dashboard-dialog-content">';
|
||||
if ( intro ) h += `<div class="dashboard-dialog-message" id="${id}-intro">${intro}</div>`;
|
||||
h += fields.map((field) => field_html(field, id)).join('');
|
||||
h += fields.map((field) => formFieldHtml(field, id)).join('');
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += '</div></div>';
|
||||
h += '<div class="dashboard-dialog-footer">';
|
||||
@@ -317,17 +343,7 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
});
|
||||
|
||||
// Keep the field focused (and a phone's keyboard up) while toggling. The
|
||||
// label names the action, so the button carries no aria-pressed as well.
|
||||
$dialog.on('pointerdown mousedown', '.dashboard-form-reveal', (e) => e.preventDefault());
|
||||
$dialog.on('click', '.dashboard-form-reveal', function () {
|
||||
const $input = $(this).siblings('input');
|
||||
const reveal = $input.attr('type') === 'password';
|
||||
const label = i18n(reveal ? 'hide_password' : 'show_password', [], false);
|
||||
$input.attr('type', reveal ? 'text' : 'password');
|
||||
$(this).attr({ 'aria-label': label, title: label })
|
||||
.html(reveal ? FORM_ICONS.hide : FORM_ICONS.show);
|
||||
});
|
||||
bindPasswordReveal($dialog);
|
||||
|
||||
$dialog.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -72,9 +72,11 @@ export function bindBackdropDismiss ($overlay, isBackdrop, dismiss) {
|
||||
* overlay, after it has closed. Not called for `close()`.
|
||||
* @param {(via: 'escape'|'close'|'backdrop') => boolean} [opts.canDismiss]
|
||||
* Consulted before each dismissal; returning false keeps the overlay up.
|
||||
* @param {boolean} [opts.fitKeyboard] Shrink to the part of the screen an
|
||||
* on-screen keyboard leaves visible. Defaults to true.
|
||||
* @returns {{ $overlay: jQuery, $dialog: jQuery, close: () => void, isClosed: () => boolean }}
|
||||
*/
|
||||
export default function mountDashboardOverlay ({ html, $container, returnFocusTo, onDismiss, canDismiss }) {
|
||||
export default function mountDashboardOverlay ({ html, $container, returnFocusTo, onDismiss, canDismiss, fitKeyboard = true }) {
|
||||
const $root = $container && $container.length ? $container : $('body');
|
||||
const ns = `.dashboard-overlay-${++overlay_seq}`;
|
||||
const el_previous_focus = document.activeElement;
|
||||
@@ -87,7 +89,7 @@ export default function mountDashboardOverlay ({ html, $container, returnFocusTo
|
||||
// iOS lays its keyboard over the page rather than resizing it, which
|
||||
// would leave a bottom sheet's fields and buttons underneath. Pin the
|
||||
// overlay to the part of the screen that is still visible.
|
||||
const viewport = window.visualViewport;
|
||||
const viewport = fitKeyboard ? window.visualViewport : null;
|
||||
const fitToViewport = () => {
|
||||
const covered = window.innerHeight - viewport.height - viewport.offsetTop;
|
||||
const pinned = covered > 1 || viewport.offsetTop > 1;
|
||||
|
||||
@@ -150,6 +150,13 @@ describe('mountDashboardOverlay', () => {
|
||||
close();
|
||||
expect(window.visualViewport.removeEventListener).toHaveBeenCalledWith('resize', listeners.resize);
|
||||
});
|
||||
|
||||
it('leaves its size alone under a keyboard when told not to fit it', () => {
|
||||
window.visualViewport = { height: window.innerHeight, offsetTop: 0, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const { close } = mountDashboardOverlay({ html: card, fitKeyboard: false });
|
||||
expect(window.visualViewport.addEventListener).not.toHaveBeenCalled();
|
||||
close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindBackdropDismiss', () => {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -27,7 +27,7 @@ import UIWindowSaveAccount from './UIWindowSaveAccount.js';
|
||||
import UIWindowDesktopBGSettings from './UIWindowDesktopBGSettings.js';
|
||||
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
|
||||
import UIWindowFeedback from './UIWindowFeedback.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowQR from './UIWindowQR.js';
|
||||
|
||||
import UIWindowProgress from './UIWindowProgress.js';
|
||||
@@ -1901,9 +1901,9 @@ async function UIDesktop (options) {
|
||||
await open_path_target(stat.path ?? shared_path, stat);
|
||||
return;
|
||||
}
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
window_options: { cover_page: true, has_head: false },
|
||||
page: true,
|
||||
});
|
||||
// Dismissed without signing in: drop the link rather than loop.
|
||||
if ( window.user?.is_temp ) clear_shared_param();
|
||||
@@ -2235,12 +2235,9 @@ $(document).on('click', '.user-options-menu-btn', async function (e) {
|
||||
html: i18n('add_another_account'),
|
||||
// icon: l_user.username === user.username ? '✓' : '',
|
||||
onClick: async function (val) {
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: false,
|
||||
window_options: {
|
||||
has_head: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -2423,16 +2420,12 @@ $(document).on('click', '.user-options-login-btn', async function (e) {
|
||||
});
|
||||
if ( saved )
|
||||
{
|
||||
UIWindowLogin({ show_signup_button: false, reload_on_success: true });
|
||||
UIAuthLogin({ show_signup_button: false, reload_on_success: true });
|
||||
}
|
||||
} else if ( alert_resp === 'login' ) {
|
||||
UIWindowLogin({
|
||||
UIAuthLogin({
|
||||
show_signup_button: false,
|
||||
reload_on_success: true,
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import UIAlert from './UIAlert.js';
|
||||
import UIContextMenu from './UIContextMenu.js';
|
||||
import path from '../lib/path.js';
|
||||
import UITaskbarItem from './UITaskbarItem.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
@@ -1288,7 +1288,7 @@ async function UIWindow (options) {
|
||||
// directory content
|
||||
// --------------------------------------------------------
|
||||
//auth
|
||||
if ( !window.is_auth() && !(await UIWindowLogin()) )
|
||||
if ( !window.is_auth() && !(await UIAuthLogin()) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindowLogin2FA from './UIWindowLogin2FA.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowRecoverPassword from './UIWindowRecoverPassword.js';
|
||||
import UIWindowSignup from './UIWindowSignup.js';
|
||||
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
|
||||
import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
|
||||
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js';
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
|
||||
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
|
||||
|
||||
async function UIWindowLogin (options) {
|
||||
options = options ?? {};
|
||||
|
||||
if ( options.reload_on_success === undefined )
|
||||
{
|
||||
options.reload_on_success = true;
|
||||
}
|
||||
|
||||
if ( options.redirect_url === undefined )
|
||||
{
|
||||
// stay on the page the login started from (e.g. an /app/<name>
|
||||
// landing), with standalone auth pages going to the root dashboard
|
||||
options.redirect_url = get_auth_redirect_url();
|
||||
}
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const internal_id = window.uuidv4();
|
||||
|
||||
let h = '';
|
||||
h += '<div style="max-width:100%; width:100%; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; justify-content:center; align-items:stretch; padding:0; overflow:auto; color:var(--color-text);">';
|
||||
// logo
|
||||
const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup;
|
||||
h += '<div class="logo-wrapper" style="display:flex; justify-content:center; padding:20px 20px 0 20px; margin-bottom: 0;">';
|
||||
h += authLogoHeader({
|
||||
logoSrc: window.icons['logo-white.svg'],
|
||||
logoClickable: logo_clickable,
|
||||
openerOrigin: window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '',
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
h += '</div>';
|
||||
// title
|
||||
h += '<div style="padding:10px 20px; text-align:center; margin-bottom:0;">';
|
||||
h += `<h1 class="login-form-title">${i18n('log_in')}</h1>`;
|
||||
// In a sign-in popup, say which site brought the user here.
|
||||
if (window.embedded_in_popup && isAttestedOrigin(window.openerOrigin)) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(window.openerOrigin).hostname])}</p>`;
|
||||
}
|
||||
h += '</div>';
|
||||
// form
|
||||
h += '<div style="padding:20px; overflow-y:auto; overflow-x:hidden;">';
|
||||
h += '<form class="login-form" style="width:100%; width: 100%; max-width: 400px; margin: 0 auto;">';
|
||||
// server messages
|
||||
h += '<div class="login-error-msg" style="color:#e74c3c; display:none; margin-bottom:10px; line-height:15px; font-size:13px;"></div>';
|
||||
// email or username
|
||||
h += '<div style="position: relative; margin-bottom: 20px;">';
|
||||
h += `<label style="display:block; margin-bottom:5px;">${i18n('email_or_username')}</label>`;
|
||||
if ( options.email_or_username ) {
|
||||
h += `<input type="text" class="email_or_username" value="${options.email_or_username}" autocomplete="username"/>`;
|
||||
} else {
|
||||
h += '<input type="text" class="email_or_username" autocomplete="username"/>';
|
||||
}
|
||||
h += '</div>';
|
||||
// password
|
||||
h += '<div style="position: relative; margin-bottom: 20px;">';
|
||||
h += '<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;">';
|
||||
h += `<label style="margin-bottom:0;">${i18n('password')}</label>`;
|
||||
h += `<span class="forgot-password-link" style="font-size:13px;">${i18n('forgot_pass_c2a')}</span>`;
|
||||
h += '</div>';
|
||||
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? 'text' : 'password'}" name="password" autocomplete="current-password"/>`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']}" width="20" height="20">
|
||||
</span>`;
|
||||
h += '</div>';
|
||||
// login
|
||||
h += `<button type="submit" class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
|
||||
// OIDC
|
||||
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 0 0 10px 0;">';
|
||||
h += `<div style="display:flex; align-items:center; gap:10px; margin: 10px 0; font-size:13px; color:#888;"><div style="flex:1; height:1px; background-color:#d0d0d0;"></div><span>${ i18n('or') }</span><div style="flex:1; height:1px; background-color:#d0d0d0;"></div></div>`;
|
||||
h += `<button type="button" class="oidc-google-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_in_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-microsoft-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_in_with_microsoft')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
|
||||
h += '<div class="oidc-custom-providers"></div>';
|
||||
h += '</div>';
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
// create account link
|
||||
|
||||
// If show_signup_button is undefined, the default behavior is to show it.
|
||||
// If show_signup_button is set to false, the button will not be shown.
|
||||
if ( options.show_signup_button === undefined || options.show_signup_button ) {
|
||||
h += '<div class="c2a-wrapper" style="padding:20px;">';
|
||||
h += `<button class="signup-c2a-clickable">${i18n('create_free_account')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: null,
|
||||
app: 'login',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: true,
|
||||
selectable_body: false,
|
||||
draggable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_draggable: options.is_draggable ?? true,
|
||||
is_droppable: false,
|
||||
is_resizable: false,
|
||||
stay_on_top: false,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
...options.window_options,
|
||||
width: 350,
|
||||
dominant: true,
|
||||
on_close: () => {
|
||||
resolve(false);
|
||||
},
|
||||
onAppend: function (this_window) {
|
||||
if ( options.authError ) {
|
||||
$(this_window).find('.login-error-msg').html(html_encode(options.authError)).fadeIn();
|
||||
}
|
||||
if ( ! window.disable_login_autofocus )
|
||||
{
|
||||
$(this_window).find('.email_or_username').get(0).focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
window_class: 'window-login',
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
padding: '0',
|
||||
'background-color': 'rgb(255 255 255)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'center',
|
||||
'align-items': 'center',
|
||||
},
|
||||
});
|
||||
|
||||
wireAuthLogoHeader(el_window);
|
||||
|
||||
if ( logo_clickable ) {
|
||||
$(el_window).find('.auth-logo').on('click', function () {
|
||||
// keep desktop users on the desktop; everyone else goes to the root dashboard
|
||||
window.location.href = window.location.pathname === '/desktop' ? '/desktop' : '/';
|
||||
});
|
||||
}
|
||||
|
||||
$(el_window).find('.forgot-password-link').on('click', function (e) {
|
||||
UIWindowRecoverPassword({
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
// A stay-on-top login window (dashboard mode) sits in the
|
||||
// 99999999+ band; this dialog must join it there or it
|
||||
// opens buried under the login window's backdrop.
|
||||
stay_on_top: isMobile.phone || !!options.window_options?.stay_on_top,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// A federated hop navigates this popup away and the provider
|
||||
// returns it as a plain sign-in popup, losing whatever the popup
|
||||
// was opened to do — and, for a permission prompt, handing the
|
||||
// opener a token instead of a decision. Don't offer it there.
|
||||
// A cross-origin-isolated opener can only get the token through
|
||||
// the session relay, which the provider's return doesn't keep.
|
||||
if ( window.embedded_in_popup
|
||||
&& ( ! offersFederatedSignInInPopup(window.gui_action)
|
||||
|| window.url_query_params?.get('cross_origin_isolated') === 'true' ) ) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
if ( ! res.ok ) return;
|
||||
const data = await res.json();
|
||||
if ( ! data.providers ) return;
|
||||
|
||||
const wireOidcBtn = (provider, btnClass) => {
|
||||
$(el_window).find(btnClass).css('display', 'flex');
|
||||
$(el_window).find(btnClass).on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=login`;
|
||||
|
||||
// return to the interface the login started from (backend whitelists the path)
|
||||
const return_to = get_oidc_return_to();
|
||||
if ( return_to ) {
|
||||
url += `&return_to=${encodeURIComponent(return_to)}`;
|
||||
}
|
||||
|
||||
const referrer = options.referrer ?? window.referrerStr ?? window.openerOrigin;
|
||||
if ( referrer ) {
|
||||
url += `&referrer=${encodeURIComponent(referrer)}`;
|
||||
}
|
||||
// A provider login can create the account, so a signup link's code rides along.
|
||||
if ( window.signup_bonus_code ) {
|
||||
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
}
|
||||
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if ( window.openerOrigin ) {
|
||||
url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
|
||||
}
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
};
|
||||
|
||||
let hasProvider = false;
|
||||
if ( data.providers.includes('google') ) { hasProvider = true; wireOidcBtn('google', '.oidc-google-btn'); }
|
||||
if ( data.providers.includes('apple') ) { hasProvider = true; wireOidcBtn('apple', '.oidc-apple-btn'); }
|
||||
if ( data.providers.includes('microsoft') ) { hasProvider = true; wireOidcBtn('microsoft', '.oidc-microsoft-btn'); }
|
||||
|
||||
// Any other configured provider (e.g. a self-hosted admin's
|
||||
// custom OIDC IdP) gets a generic button.
|
||||
data.providers
|
||||
.filter(provider => !KNOWN_OIDC_PROVIDERS.includes(provider))
|
||||
.forEach((provider, idx) => {
|
||||
hasProvider = true;
|
||||
const btnClass = `oidc-custom-btn-${idx}`;
|
||||
const label = humanizeOidcProviderId(provider);
|
||||
$(el_window).find('.oidc-custom-providers').append(
|
||||
`<button type="button" class="${btnClass} button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:8px;">${OIDC_GENERIC_PROVIDER_ICON}${i18n('sign_in_with_provider', [label])}</button>`,
|
||||
);
|
||||
wireOidcBtn(provider, `.${btnClass}`);
|
||||
});
|
||||
|
||||
if ( hasProvider ) $(el_window).find('.oidc-providers-wrapper').show();
|
||||
} catch (_) {
|
||||
}
|
||||
})();
|
||||
|
||||
$(el_window).find('.login-btn').on('click', function (e) {
|
||||
// Prevent default button behavior (important for async requests)
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous error states
|
||||
$(el_window).find('.login-error-msg').hide();
|
||||
|
||||
const email_username = $(el_window).find('.email_or_username').val();
|
||||
const password = $(el_window).find('.password').val();
|
||||
|
||||
// Basic validation for email/username and password
|
||||
if ( ! email_username ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('login_email_username_required'));
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! password ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('login_password_required'));
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data for the request
|
||||
// When this login was triggered by a 401 reauth_required,
|
||||
// forward `auth_id` (and the originating reason) so the
|
||||
// backend can re-attach the user/session to the same
|
||||
// stable identity.
|
||||
const reauth_payload = options.auth_id
|
||||
? { auth_id: options.auth_id, reauth_reason: options.reauth_reason }
|
||||
: {};
|
||||
let data;
|
||||
if ( window.is_email(email_username) ) {
|
||||
data = JSON.stringify({
|
||||
email: email_username,
|
||||
password: password,
|
||||
...reauth_payload,
|
||||
});
|
||||
} else {
|
||||
data = JSON.stringify({
|
||||
username: email_username,
|
||||
password: password,
|
||||
...reauth_payload,
|
||||
});
|
||||
}
|
||||
|
||||
let headers = {};
|
||||
if ( window.custom_headers )
|
||||
{
|
||||
headers = window.custom_headers;
|
||||
}
|
||||
|
||||
// Disable the login button to prevent multiple submissions
|
||||
$(el_window).find('.login-btn').prop('disabled', true);
|
||||
|
||||
$.ajax({
|
||||
url: `${window.gui_origin }/login`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: headers,
|
||||
contentType: 'application/json',
|
||||
data: data,
|
||||
success: async function (data) {
|
||||
// Keep the button disabled on success since we're redirecting or closing
|
||||
if ( data.next_step === 'otp' ) {
|
||||
const verified = await UIWindowLogin2FA(data.otp_jwt_token);
|
||||
if ( ! verified ) {
|
||||
$(el_window).find('.login-btn').prop('disabled', false);
|
||||
return;
|
||||
}
|
||||
data = verified;
|
||||
}
|
||||
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
|
||||
if ( options.reload_on_success ) {
|
||||
window.onbeforeunload = null;
|
||||
// Replace with a clean URL to prevent password leakage
|
||||
const cleanUrl = options.redirect_url || window.location.origin + window.location.pathname;
|
||||
window.location.replace(cleanUrl);
|
||||
} else
|
||||
{
|
||||
resolve(true);
|
||||
}
|
||||
$(el_window).close();
|
||||
},
|
||||
error: function (err) {
|
||||
// First, ensure URL is clean in case of error (prevent password leakage)
|
||||
if ( window.location.search && (
|
||||
window.location.search.includes('password=') ||
|
||||
window.location.search.includes('username=') ||
|
||||
window.location.search.includes('email=')
|
||||
) ) {
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
history.replaceState({}, document.title, cleanUrl);
|
||||
}
|
||||
|
||||
// Enable 'Log In' button
|
||||
$(el_window).find('.login-btn').prop('disabled', false);
|
||||
|
||||
// Handle captcha-specific errors
|
||||
const errorText = err.responseText || '';
|
||||
|
||||
// Try to parse error as JSON
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
|
||||
// If it's a message in the JSON, use that
|
||||
if ( errorJson.message ) {
|
||||
$(el_window).find('.login-error-msg').html(errorJson.message);
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue with text analysis
|
||||
}
|
||||
|
||||
// Fall back to original error handling
|
||||
const $errorMessage = $(el_window).find('.login-error-msg');
|
||||
if ( err.status === 404 ) {
|
||||
// Don't include the whole 404 page
|
||||
$errorMessage.html(`Error 404: "${window.gui_origin}/login" not found`);
|
||||
} else if ( err.responseText ) {
|
||||
$errorMessage.html(html_encode(err.responseText));
|
||||
} else {
|
||||
// No message was returned. *Probably* this means we couldn't reach the server.
|
||||
// If this is a self-hosted instance, it's probably a configuration issue.
|
||||
if ( window.app_domain !== 'puter.com' ) {
|
||||
$errorMessage.html(`<div style="text-align: left;">
|
||||
<p>Error reaching "${window.gui_origin}/login". This is likely to be a configuration issue.</p>
|
||||
<p>Make sure of the following:</p>
|
||||
<ul style="padding-left: 2em;">
|
||||
<li><code>domain</code> in config.json is set to the domain you're using to access puter</li>
|
||||
<li>DNS resolves for the domain, and the <code>api.</code> subdomain on that domain</li>
|
||||
<li><code>http_port</code> is set to the port Puter is listening on (<code>auto</code> will use <code>4100</code> unless that port is in use)</li>
|
||||
<li><code>pub_port</code> is set to the external port (ex: <code>443</code> if you're using a reverse proxy that serves over https)</li>
|
||||
</ul>
|
||||
</div>`);
|
||||
} else {
|
||||
$errorMessage.html(`Failed to log in: Error ${html_encode(err.status)}`);
|
||||
}
|
||||
}
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$(el_window).find('.login-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Instead of triggering the click event, process the login directly
|
||||
const email_username = $(el_window).find('.email_or_username').val();
|
||||
const password = $(el_window).find('.password').val();
|
||||
|
||||
// Basic validation
|
||||
if ( ! email_username ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('login_email_username_required'));
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! password ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process login using the same function as the button click
|
||||
$(el_window).find('.login-btn').click();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$(el_window).find('.signup-c2a-clickable').on('click', async function (e) {
|
||||
//destroy this window
|
||||
$(el_window).close();
|
||||
// create Signup window
|
||||
const signup = await UIWindowSignup({
|
||||
referrer: options.referrer,
|
||||
show_close_button: options.show_close_button,
|
||||
reload_on_success: options.reload_on_success,
|
||||
redirect_url: options.redirect_url,
|
||||
window_options: options.window_options,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
});
|
||||
if ( signup )
|
||||
{
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
$(el_window).find(`#toggle-show-password-${internal_id}`).on('click', function (e) {
|
||||
options.show_password = !options.show_password;
|
||||
// hide/show password and update icon
|
||||
$(el_window).find('.password').attr('type', options.show_password ? 'text' : 'password');
|
||||
$(el_window).find('.toggle-show-password-icon').attr('src', options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowLogin;
|
||||
@@ -1,118 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null }));
|
||||
vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/authLogoHeader.js', () => ({
|
||||
authLogoHeader: () => '',
|
||||
wireAuthLogoHeader: vi.fn(),
|
||||
}));
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: async (options) => {
|
||||
state.el = document.createElement('div');
|
||||
state.el.innerHTML = options.body_content;
|
||||
document.body.append(state.el);
|
||||
options.onAppend?.(state.el);
|
||||
return state.el;
|
||||
},
|
||||
}));
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
const { default: UIWindowLogin } = await import('./UIWindowLogin.js');
|
||||
const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
|
||||
|
||||
const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers';
|
||||
const RELAY_POPUP =
|
||||
'embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s';
|
||||
const PLAIN_POPUP = 'embedded_in_popup=true&msg_id=1';
|
||||
|
||||
const asPopup = (query) => {
|
||||
window.embedded_in_popup = true;
|
||||
window.gui_action = 'sign-in';
|
||||
window.url_query_params = new URLSearchParams(query);
|
||||
};
|
||||
|
||||
const askedForProviders = () =>
|
||||
fetch.mock.calls.some(([url]) => url === PROVIDERS_URL);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = vi.fn();
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response(JSON.stringify({ providers: ['google'] })),
|
||||
);
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.gui_params = { turnstileSiteKey: 'site-key' };
|
||||
window.turnstile = { render: vi.fn() };
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'oidc-popup-test';
|
||||
window.embedded_in_popup = false;
|
||||
window.gui_action = undefined;
|
||||
window.url_query_params = new URLSearchParams('');
|
||||
state.el = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
[
|
||||
'login',
|
||||
() => UIWindowLogin({ reload_on_success: false, redirect_url: '/' }),
|
||||
'.login-btn',
|
||||
],
|
||||
[
|
||||
'signup',
|
||||
() => UIWindowSignup({ reload_on_success: false, redirect_url: '/' }),
|
||||
'.signup-btn',
|
||||
],
|
||||
])('OIDC buttons in the %s window', (_name, open, formButton) => {
|
||||
const rendered = async () => {
|
||||
void open();
|
||||
await vi.waitFor(() =>
|
||||
expect(jQuery(state.el).find(formButton)).toHaveLength(1),
|
||||
);
|
||||
};
|
||||
|
||||
// The provider's return drops the relay that hands the token to an
|
||||
// isolated opener, so only password sign-in can finish there.
|
||||
it('hides them in a cross-origin-isolated relay popup', async () => {
|
||||
asPopup(RELAY_POPUP);
|
||||
await rendered();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(askedForProviders()).toBe(false);
|
||||
expect(jQuery(state.el).find('.oidc-google-btn').css('display')).toBe(
|
||||
'none',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps them in a regular sign-in popup', async () => {
|
||||
asPopup(PLAIN_POPUP);
|
||||
await rendered();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
jQuery(state.el).find('.oidc-google-btn').css('display'),
|
||||
).toBe('flex'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps them outside a popup', async () => {
|
||||
await rendered();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
jQuery(state.el).find('.oidc-google-btn').css('display'),
|
||||
).toBe('flex'),
|
||||
);
|
||||
expect(askedForProviders()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,474 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import TeePromise from '../util/TeePromise.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
|
||||
// ── 2FA Login CSS (injected once) ───────────────────────────────────────────
|
||||
const LOGIN_2FA_CSS = `
|
||||
.login-2fa {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #1a2233;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.login-2fa-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: #eff6ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Screens ──────────────────────────────────────────────────────────── */
|
||||
.login-2fa-screen {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
animation: login-2fa-fade-in 0.25s ease;
|
||||
}
|
||||
.login-2fa-screen.active {
|
||||
display: flex;
|
||||
}
|
||||
@keyframes login-2fa-fade-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-2fa-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a2233;
|
||||
text-align: center;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.login-2fa-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
margin: 0 0 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── OTP code inputs ──────────────────────────────────────────────────── */
|
||||
.login-2fa-code-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
.login-2fa-code-inputs input {
|
||||
width: 44px;
|
||||
height: 52px;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #1a2233;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
caret-color: #3b82f6;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.login-2fa-code-inputs input::-webkit-outer-spin-button,
|
||||
.login-2fa-code-inputs input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.login-2fa-code-inputs input:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.login-2fa-code-inputs input.error {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
animation: login-2fa-shake 0.4s ease;
|
||||
}
|
||||
@keyframes login-2fa-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
/* ── Error message ────────────────────────────────────────────────────── */
|
||||
.login-2fa-error {
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Spinner ──────────────────────────────────────────────────────────── */
|
||||
.login-2fa-spinner {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.login-2fa-spinner.visible {
|
||||
display: flex;
|
||||
}
|
||||
.login-2fa-spinner-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: login-2fa-spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes login-2fa-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Link button ──────────────────────────────────────────────────────── */
|
||||
.login-2fa-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
margin-top: 8px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.login-2fa-link-btn:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Recovery input ───────────────────────────────────────────────────── */
|
||||
.login-2fa-recovery-input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
box-sizing: border-box;
|
||||
height: 52px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
letter-spacing: 2px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #1a2233;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
caret-color: #3b82f6;
|
||||
}
|
||||
.login-2fa-recovery-input:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* ── Recovery error ───────────────────────────────────────────────────── */
|
||||
.login-2fa-recovery-error {
|
||||
display: none;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
padding: 8px 14px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────────── */
|
||||
@media (max-width: 420px) {
|
||||
.login-2fa-code-inputs {
|
||||
gap: 6px;
|
||||
}
|
||||
.login-2fa-code-inputs input {
|
||||
width: 38px;
|
||||
height: 46px;
|
||||
font-size: 19px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.login-2fa-recovery-input {
|
||||
height: 46px;
|
||||
font-size: 19px;
|
||||
}
|
||||
.login-2fa-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.login-2fa-desc {
|
||||
font-size: 13px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let login2faCssInjected = false;
|
||||
const injectLogin2faCss = () => {
|
||||
if ( login2faCssInjected ) return;
|
||||
login2faCssInjected = true;
|
||||
$('<style/>').text(LOGIN_2FA_CSS).appendTo('head');
|
||||
};
|
||||
|
||||
/** Verify an existing first-factor challenge with TOTP or a recovery code. */
|
||||
export default async function UIWindowLogin2FA (challengeToken) {
|
||||
injectLogin2faCss();
|
||||
const p = new TeePromise();
|
||||
let win;
|
||||
|
||||
// ── Build 2FA verification HTML ──
|
||||
let h2fa = '';
|
||||
h2fa += '<div class="login-2fa">';
|
||||
|
||||
// ── Shield icon ──
|
||||
h2fa += '<div class="login-2fa-icon">';
|
||||
h2fa += '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
||||
h2fa += '</div>';
|
||||
|
||||
// ── OTP screen ──
|
||||
h2fa += '<div class="login-2fa-screen active" data-screen="otp">';
|
||||
h2fa += `<h3 class="login-2fa-title">${i18n('login2fa_otp_title')}</h3>`;
|
||||
h2fa += `<p class="login-2fa-desc">${i18n('login2fa_otp_instructions')}</p>`;
|
||||
h2fa += '<div class="login-2fa-code-inputs">';
|
||||
for ( let i = 0; i < 6; i++ ) {
|
||||
h2fa += `<input type="text" inputmode="numeric" maxlength="1" autocomplete="off" data-idx="${i}" />`;
|
||||
}
|
||||
h2fa += '</div>';
|
||||
h2fa += '<div class="login-2fa-error"></div>';
|
||||
h2fa += `<div class="login-2fa-spinner"><div class="login-2fa-spinner-icon"></div><span>${i18n('verifying')}</span></div>`;
|
||||
h2fa += `<button type="button" class="login-2fa-link-btn login-2fa-to-recovery">${i18n('login2fa_use_recovery_code')}</button>`;
|
||||
h2fa += '</div>';
|
||||
|
||||
// ── Recovery screen ──
|
||||
h2fa += '<div class="login-2fa-screen" data-screen="recovery">';
|
||||
h2fa += `<h3 class="login-2fa-title">${i18n('login2fa_recovery_title')}</h3>`;
|
||||
h2fa += `<p class="login-2fa-desc">${i18n('login2fa_recovery_instructions')}</p>`;
|
||||
h2fa += '<div class="login-2fa-recovery-error"></div>';
|
||||
h2fa += `<input type="text" class="login-2fa-recovery-input" placeholder="${html_encode(i18n('login2fa_recovery_placeholder'))}" maxlength="8" autocomplete="off" />`;
|
||||
h2fa += `<button type="button" class="login-2fa-link-btn login-2fa-to-otp">${i18n('login2fa_recovery_back')}</button>`;
|
||||
h2fa += '</div>';
|
||||
|
||||
h2fa += '</div>';
|
||||
|
||||
win = await UIWindow({
|
||||
title: null,
|
||||
app: 'login-2fa',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h2fa,
|
||||
has_head: false,
|
||||
selectable_body: false,
|
||||
draggable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_resizable: false,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: false,
|
||||
allow_user_select: false,
|
||||
width: Math.min(400, window.innerWidth - 24),
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
is_draggable: false,
|
||||
backdrop: true,
|
||||
stay_on_top: true,
|
||||
window_class: 'window-login-2fa',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': '#f8fafc',
|
||||
padding: '32px 28px 24px',
|
||||
},
|
||||
on_close: () => p.resolve(null),
|
||||
});
|
||||
|
||||
const $w = $(win);
|
||||
|
||||
// ── Screen navigation ──
|
||||
function showScreen (name) {
|
||||
$w.find('.login-2fa-screen').removeClass('active');
|
||||
$w.find(`.login-2fa-screen[data-screen="${name}"]`).addClass('active');
|
||||
if ( name === 'otp' ) {
|
||||
setTimeout(() => $w.find('.login-2fa-code-inputs input').first().focus(), 80);
|
||||
} else {
|
||||
setTimeout(() => $w.find('.login-2fa-recovery-input').focus(), 80);
|
||||
}
|
||||
}
|
||||
|
||||
$w.find('.login-2fa-to-recovery').on('click', () => {
|
||||
$w.find('.login-2fa-code-inputs input').val('').removeClass('error');
|
||||
$w.find('.login-2fa-error').text('');
|
||||
showScreen('recovery');
|
||||
});
|
||||
$w.find('.login-2fa-to-otp').on('click', () => {
|
||||
$w.find('.login-2fa-recovery-input').val('');
|
||||
$w.find('.login-2fa-recovery-error').text('').hide();
|
||||
showScreen('otp');
|
||||
});
|
||||
|
||||
// ── OTP code input handling ──
|
||||
const $inputs = $w.find('.login-2fa-code-inputs input');
|
||||
let is_verifying = false;
|
||||
|
||||
$inputs.on('input', function () {
|
||||
const val = $(this).val().replace(/\D/g, '');
|
||||
$(this).val(val.slice(0, 1));
|
||||
$(this).removeClass('error');
|
||||
$w.find('.login-2fa-error').text('');
|
||||
|
||||
if ( val && $(this).data('idx') < 5 ) {
|
||||
$inputs.eq($(this).data('idx') + 1).focus();
|
||||
}
|
||||
|
||||
const code = $inputs.map(function () { return $(this).val(); }).get().join('');
|
||||
if ( code.length === 6 && ! is_verifying ) {
|
||||
verifyOtp(code);
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on('keydown', function (e) {
|
||||
const idx = $(this).data('idx');
|
||||
if ( e.key === 'Backspace' && ! $(this).val() && idx > 0 ) {
|
||||
$inputs.eq(idx - 1).focus().val('');
|
||||
}
|
||||
if ( e.key === 'ArrowLeft' && idx > 0 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx - 1).focus();
|
||||
}
|
||||
if ( e.key === 'ArrowRight' && idx < 5 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx + 1).focus();
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on('paste', function (e) {
|
||||
e.preventDefault();
|
||||
const pasted = (e.originalEvent.clipboardData || window.clipboardData)
|
||||
.getData('text').replace(/\D/g, '').slice(0, 6);
|
||||
if ( ! pasted ) return;
|
||||
for ( let i = 0; i < 6; i++ ) {
|
||||
$inputs.eq(i).val(pasted[i] || '');
|
||||
}
|
||||
$inputs.eq(Math.min(pasted.length, 6) - 1).focus();
|
||||
if ( pasted.length === 6 && ! is_verifying ) {
|
||||
verifyOtp(pasted);
|
||||
}
|
||||
});
|
||||
|
||||
async function verifyOtp (code) {
|
||||
is_verifying = true;
|
||||
$inputs.attr('disabled', true);
|
||||
$w.find('.login-2fa-spinner').addClass('visible');
|
||||
$w.find('.login-2fa-error').text('');
|
||||
let error_i18n_key = 'something_went_wrong';
|
||||
try {
|
||||
const resp = await fetch(`${window.gui_origin}/login/otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: challengeToken,
|
||||
code: code,
|
||||
}),
|
||||
});
|
||||
if ( resp.status === 429 ) {
|
||||
error_i18n_key = 'confirm_code_generic_too_many_requests';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
const next_data = await resp.json();
|
||||
if ( ! next_data.proceed ) {
|
||||
error_i18n_key = 'confirm_code_generic_incorrect';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
$w.find('.login-2fa-spinner').removeClass('visible');
|
||||
p.resolve(next_data);
|
||||
$(win).close();
|
||||
} catch (e) {
|
||||
$w.find('.login-2fa-spinner').removeClass('visible');
|
||||
$w.find('.login-2fa-error').text(i18n(error_i18n_key));
|
||||
$inputs.addClass('error').attr('disabled', false);
|
||||
setTimeout(() => {
|
||||
$inputs.val('').removeClass('error');
|
||||
$inputs.first().focus();
|
||||
}, 1200);
|
||||
}
|
||||
is_verifying = false;
|
||||
}
|
||||
|
||||
// ── Recovery code handling ──
|
||||
$w.find('.login-2fa-recovery-input').on('input', async function () {
|
||||
// Codes are 8-char base32; a pasted one usually
|
||||
// carries the newline it was copied with.
|
||||
const value = $(this).val().trim().toUpperCase();
|
||||
if ( value.length !== 8 ) return;
|
||||
let error_i18n_key = 'something_went_wrong';
|
||||
try {
|
||||
// Root origin, like `/login/otp` above: the
|
||||
// route is `guiOriginOnly` and carries no
|
||||
// `subdomain`, so on `api_origin` the
|
||||
// subdomain gate skips it and Express 404s.
|
||||
const resp = await fetch(`${window.gui_origin}/login/recovery-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: challengeToken,
|
||||
code: value,
|
||||
}),
|
||||
});
|
||||
if ( resp.status === 429 ) {
|
||||
error_i18n_key = 'confirm_code_generic_too_many_requests';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
const next_data = await resp.json();
|
||||
if ( ! next_data.proceed ) {
|
||||
error_i18n_key = 'confirm_code_generic_incorrect';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
p.resolve(next_data);
|
||||
$(win).close();
|
||||
} catch (e) {
|
||||
$w.find('.login-2fa-recovery-error').text(i18n(error_i18n_key)).show();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus first OTP input
|
||||
setTimeout(() => $inputs.first().focus(), 150);
|
||||
return p;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null, options: null }));
|
||||
vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowSignup.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/authLogoHeader.js', () => ({ authLogoHeader: () => '', wireAuthLogoHeader: vi.fn() }));
|
||||
vi.mock('./UIWindow.js', () => ({ default: async options => {
|
||||
state.options = options;
|
||||
state.el = document.createElement('div');
|
||||
state.el.innerHTML = options.body_content;
|
||||
document.body.append(state.el);
|
||||
options.onAppend?.(state.el);
|
||||
return state.el;
|
||||
} }));
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
const { default: UIWindowLogin2FA } = await import('./UIWindowLogin2FA.js');
|
||||
const { default: UIWindowLogin } = await import('./UIWindowLogin.js');
|
||||
const reply = (status, data) => new Response(JSON.stringify(data), { status });
|
||||
const completed = { proceed: true, next_step: 'complete', token: 'gui-token', user: { username: 'user' }, redirectUrl: 'https://puter.test/dashboard' };
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = vi.fn(function () { state.options.on_close?.(); });
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.fetch = vi.fn();
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'login-test';
|
||||
window.is_email = () => false;
|
||||
window.update_auth_data = vi.fn();
|
||||
state.el = null;
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
const open = async () => {
|
||||
const result = UIWindowLogin2FA('first-factor-challenge');
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-code-inputs input')).toHaveLength(6));
|
||||
return { result };
|
||||
};
|
||||
const enterOtp = code => {
|
||||
const inputs = jQuery(state.el).find('.login-2fa-code-inputs input');
|
||||
[...code].forEach((digit, index) => inputs.eq(index).val(digit));
|
||||
inputs.last().trigger('input');
|
||||
};
|
||||
|
||||
describe('shared login second-factor dialog', () => {
|
||||
it('passes leading-zero codes to the existing verifier and preserves continuation data', async () => {
|
||||
fetch.mockResolvedValue(reply(200, completed));
|
||||
const { result } = await open();
|
||||
enterOtp('001234');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch).toHaveBeenCalledWith('https://puter.test/login/otp', expect.objectContaining({ body: JSON.stringify({ token: 'first-factor-challenge', code: '001234' }) }));
|
||||
expect(jQuery.fn.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps failed challenges available for a recovery-code retry', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { proceed: false })).mockResolvedValueOnce(reply(200, completed));
|
||||
const { result } = await open();
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-error').text()).toBe('confirm_code_generic_incorrect'));
|
||||
expect(jQuery.fn.close).not.toHaveBeenCalled();
|
||||
jQuery(state.el).find('.login-2fa-to-recovery').trigger('click');
|
||||
jQuery(state.el).find('.login-2fa-recovery-input').val(' abcdefg2\n').trigger('input');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch.mock.calls[1][0]).toBe('https://puter.test/login/recovery-code');
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ token: 'first-factor-challenge', code: 'ABCDEFG2' });
|
||||
});
|
||||
|
||||
it('reports throttling without closing or completing login', async () => {
|
||||
fetch.mockResolvedValue(reply(429, {}));
|
||||
const { result } = await open();
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-error').text()).toBe('confirm_code_generic_too_many_requests'));
|
||||
expect(jQuery.fn.close).not.toHaveBeenCalled();
|
||||
state.options.on_close();
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('closing the challenge cancels without authenticating', async () => {
|
||||
const { result } = await open();
|
||||
state.options.on_close();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores incomplete recovery codes', async () => {
|
||||
const { result } = await open();
|
||||
jQuery(state.el).find('.login-2fa-recovery-input').val('ABC').trigger('input');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
state.options.on_close();
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('password login using the shared second-factor dialog', () => {
|
||||
it('completes password login only after its challenge succeeds', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { providers: [] })).mockResolvedValueOnce(reply(200, completed));
|
||||
let success;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation(options => { success = options.success; });
|
||||
const result = UIWindowLogin({ reload_on_success: false, redirect_url: '/' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-btn')).toHaveLength(1));
|
||||
const login = state.el;
|
||||
jQuery(login).find('.email_or_username').val('user');
|
||||
jQuery(login).find('.password').val('correct-password');
|
||||
jQuery(login).find('.login-btn').trigger('click');
|
||||
expect(success).toBeTypeOf('function');
|
||||
const submitted = success({ proceed: true, next_step: 'otp', otp_jwt_token: 'password-challenge' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-code-inputs input')).toHaveLength(6));
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
enterOtp('001234');
|
||||
await submitted;
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith(completed.token, completed.user);
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body).token).toBe('password-challenge');
|
||||
});
|
||||
|
||||
it('returns to the enabled password form when the second-factor dialog is dismissed', async () => {
|
||||
fetch.mockResolvedValue(reply(200, { providers: [] }));
|
||||
let success;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation(options => { success = options.success; });
|
||||
const result = UIWindowLogin({ reload_on_success: false, redirect_url: '/' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-btn')).toHaveLength(1));
|
||||
const login = state.el;
|
||||
const loginOptions = state.options;
|
||||
jQuery(login).find('.email_or_username').val('user');
|
||||
jQuery(login).find('.password').val('correct-password');
|
||||
jQuery(login).find('.login-btn').trigger('click');
|
||||
const submitted = success({ proceed: true, next_step: 'otp', otp_jwt_token: 'password-challenge' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-code-inputs input')).toHaveLength(6));
|
||||
state.options.on_close();
|
||||
await submitted;
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
expect(jQuery(login).find('.login-btn').prop('disabled')).toBe(false);
|
||||
loginOptions.on_close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import check_password_strength from '../helpers/checkPasswordStrength.js';
|
||||
|
||||
async function UIWindowNewPassword (options) {
|
||||
@@ -183,11 +183,9 @@ async function UIWindowNewPassword (options) {
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
},
|
||||
dismissable: false,
|
||||
});
|
||||
},
|
||||
error: function (err) {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
|
||||
function UIWindowRecoverPassword (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
|
||||
let h = '';
|
||||
h += '<div style="-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #3e5362;">';
|
||||
h += `<h3 style="text-align:center; font-weight: 400; font-size: 20px;">${i18n('recover_password')}</h3>`;
|
||||
h += '<form class="pass-recovery-form">';
|
||||
h += '<p style="text-align:center; padding: 0 20px;"></p>';
|
||||
h += '<div class="error"></div>';
|
||||
h += `<label>${i18n('email_or_username')}</label>`;
|
||||
h += '<input class="pass-recovery-username-or-email" type="text"/>';
|
||||
h += `<button type="submit" class="send-recovery-email button button-block button-primary" style="margin-top:10px;">${i18n('send_password_recovery_email')}</button>`;
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: null,
|
||||
backdrop: options.backdrop ?? false,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: options.has_head ?? true,
|
||||
selectable_body: false,
|
||||
draggable_body: true,
|
||||
allow_context_menu: false,
|
||||
is_draggable: options.is_draggable ?? true,
|
||||
is_droppable: false,
|
||||
is_resizable: false,
|
||||
stay_on_top: options.stay_on_top ?? false,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
width: 350,
|
||||
dominant: true,
|
||||
...options.window_options,
|
||||
onAppend: function (el_window) {
|
||||
$(el_window).find('.pass-recovery-username-or-email').first().focus();
|
||||
},
|
||||
window_class: 'window-recover-password',
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
body_css: {
|
||||
padding: '10px',
|
||||
width: 'initial',
|
||||
height: 'initial',
|
||||
'background-color': 'rgba(231, 238, 245)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
},
|
||||
});
|
||||
$(el_window).find('.pass-recovery-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
// Send recovery email
|
||||
$(el_window).find('.send-recovery-email').on('click', function (e) {
|
||||
let email, username;
|
||||
let input = $(el_window).find('.pass-recovery-username-or-email').val();
|
||||
if ( window.is_email(input) )
|
||||
{
|
||||
email = input;
|
||||
}
|
||||
else
|
||||
{
|
||||
username = input;
|
||||
}
|
||||
|
||||
// todo validation before sending
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/send-pass-recovery-email`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
email: email,
|
||||
username: username,
|
||||
}),
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: async function (res) {
|
||||
$(el_window).close();
|
||||
await UIAlert({
|
||||
message: res.message,
|
||||
body_icon: window.icons['c-check.svg'],
|
||||
stay_on_top: true,
|
||||
backdrop: true,
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
error: function (err) {
|
||||
const errorText = err.responseText || '';
|
||||
let msg = errorText;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
msg = errorJson.message || errorJson.error || errorText;
|
||||
} catch (_) { /* not JSON, use responseText */ }
|
||||
$(el_window).find('.error').html(html_encode(msg));
|
||||
$(el_window).find('.error').fadeIn();
|
||||
},
|
||||
complete: function () {
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowRecoverPassword;
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIWindowSignup from './UIWindowSignup.js';
|
||||
|
||||
async function UIWindowSessionList (options) {
|
||||
options = options ?? {};
|
||||
options.reload_on_success = options.reload_on_success ?? true;
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
let h = '';
|
||||
h += '<div style="margin:10px;">';
|
||||
// loading indicator with spinner
|
||||
h += `<div class="loading" style="z-index: 999999999;">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 15px;">
|
||||
<div style="
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
"></div>
|
||||
<div style="
|
||||
font-size: 15px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
">${i18n('signing_in')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// session list
|
||||
h += '<div class="hide-scrollbar" style="overflow-y: scroll; max-width: 400px; margin: 0 auto;">';
|
||||
h += `<h1 style="text-align: center; font-size: 18px; font-weight: normal; color: #757575; margin-bottom: 30px;"><img src="${window.icons['logo-white.svg']}" style="padding: 4px; background-color: blue; border-radius: 5px; width: 25px; box-sizing: border-box; margin-bottom: -6px; margin-right: 6px;">${i18n('sign_in_with_puter')}</h1>`;
|
||||
for ( let index = 0; index < window.logged_in_users.length; index++ ) {
|
||||
const l_user = window.logged_in_users[index];
|
||||
h += `<div data-uuid="${l_user.uuid}" class="session-entry" style="display: flex; padding: 15px 10px;">`;
|
||||
// profile picture
|
||||
h += `<div class="profile-picture" style="background-color: #cbced1; width: 30px; height: 30px; margin:0; margin-right: 10px; background-image: url('${l_user.profile.picture ?? window.icons['profile.svg']}');"></div>`;
|
||||
h += `<div style="display: flex; align-items: center;">${l_user.username}</div>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
// c2a
|
||||
h += `<div style="margin-top: 20px; margin-bottom: 20px; text-align:center;"><span class="login-c2a-session-list">Log Into Another Account</span> • <span class="signup-c2a-session-list">${i18n('create_account')}</span></div>`;
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: i18n('window_title_session_list'),
|
||||
app: 'session-list',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: false,
|
||||
selectable_body: false,
|
||||
draggable_body: options.draggable_body ?? true,
|
||||
allow_context_menu: false,
|
||||
is_resizable: false,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: false,
|
||||
allow_user_select: false,
|
||||
width: 350,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
update_window_url: false,
|
||||
cover_page: options.cover_page ?? false,
|
||||
window_class: 'window-session-list',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'center',
|
||||
},
|
||||
});
|
||||
$(el_window).find('.login-c2a-session-list').on('click', async function (e) {
|
||||
// The login window is a centered cover and this picker would
|
||||
// otherwise float on top of it, hiding its username field. Only
|
||||
// the reload flows close it: in the no-reload (popup) flows the
|
||||
// picker doubles as the fallback UI when the login window is
|
||||
// abandoned, and the resolve() below still needs a live promise
|
||||
// chain either way.
|
||||
if ( options.reload_on_success ) $(el_window).close();
|
||||
const login = await UIWindowLogin({
|
||||
referrer: options.referrer,
|
||||
reload_on_success: options.reload_on_success,
|
||||
cover_page: options.cover_page ?? false,
|
||||
has_head: options.has_head,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: options.cover_page ?? false,
|
||||
},
|
||||
});
|
||||
if ( login ) {
|
||||
if ( options.reload_on_success ) {
|
||||
// disable native browser exit confirmation
|
||||
window.onbeforeunload = null;
|
||||
// refresh
|
||||
location.reload();
|
||||
} else {
|
||||
resolve(login);
|
||||
}
|
||||
}
|
||||
});
|
||||
$(el_window).find('.signup-c2a-session-list').on('click', async function (e) {
|
||||
// Same picker-over-cover overlap as the login c2a above, same
|
||||
// reload-flows-only close. (The selector this replaces —
|
||||
// '.signup-c2a-clickable', the LOGIN window's c2a class —
|
||||
// matched nothing in this window and closed nothing.)
|
||||
if ( options.reload_on_success ) $(el_window).close();
|
||||
// create Signup window
|
||||
const signup = await UIWindowSignup({
|
||||
referrer: options.referrer,
|
||||
reload_on_success: options.reload_on_success,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: options.cover_page ?? false,
|
||||
},
|
||||
|
||||
});
|
||||
if ( signup ) {
|
||||
if ( options.reload_on_success ) {
|
||||
// disable native browser exit confirmation
|
||||
window.onbeforeunload = null;
|
||||
// refresh
|
||||
location.reload();
|
||||
} else {
|
||||
resolve(signup);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(el_window).find('.session-entry').on('click', function (e) {
|
||||
$(el_window).find('.loading').css({ display: 'flex' });
|
||||
|
||||
setTimeout(async () => {
|
||||
let selected_uuid = $(this).attr('data-uuid');
|
||||
let selected_user;
|
||||
for ( let index = 0; index < window.logged_in_users.length; index++ ) {
|
||||
const l_user = window.logged_in_users[index];
|
||||
if ( l_user.uuid === selected_uuid ) {
|
||||
selected_user = l_user;
|
||||
}
|
||||
}
|
||||
|
||||
// new logged in user
|
||||
await window.update_auth_data(selected_user.auth_token, selected_user);
|
||||
if ( options.reload_on_success ) {
|
||||
// disable native browser exit confirmation
|
||||
window.onbeforeunload = null;
|
||||
// refresh
|
||||
location.reload();
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowSessionList;
|
||||
@@ -1,861 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import check_password_strength from '../helpers/checkPasswordStrength.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowPhoneVerificationRequired from './UIWindowPhoneVerificationRequired.js';
|
||||
import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
|
||||
import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
|
||||
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js';
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
|
||||
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
|
||||
import { bonusRequirementsKey, checkSignupBonusCode } from '../helpers/signupBonusCode.js';
|
||||
|
||||
// What a checked bonus code grants, above the form (and the provider buttons).
|
||||
function renderBonusNotice(el_window, result) {
|
||||
const $notice = $(el_window).find('.signup-bonus-notice');
|
||||
if (!result) {
|
||||
$notice.hide().empty();
|
||||
return;
|
||||
}
|
||||
if (!result.valid) {
|
||||
$notice
|
||||
.addClass('signup-bonus-notice-invalid')
|
||||
.html(`<p>${i18n('signup_bonus_code_invalid_retry')}</p>`)
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
const requirementsKey = bonusRequirementsKey(result.requirements);
|
||||
let h = `<strong>${html_encode(result.display.title)}</strong>`;
|
||||
h += `<p>${html_encode(result.display.description)}</p>`;
|
||||
if (requirementsKey) {
|
||||
h += `<p class="signup-bonus-requirements">${i18n(requirementsKey)}</p>`;
|
||||
}
|
||||
$notice.removeClass('signup-bonus-notice-invalid').html(h).show();
|
||||
}
|
||||
|
||||
function UIWindowSignup(options) {
|
||||
options = options ?? {};
|
||||
options.reload_on_success = options.reload_on_success ?? true;
|
||||
options.has_head = options.has_head ?? true;
|
||||
options.send_confirmation_code = options.send_confirmation_code ?? false;
|
||||
options.show_close_button = options.show_close_button ?? true;
|
||||
if (options.redirect_url === undefined) {
|
||||
// stay on the page the signup started from (e.g. an /app/<name>
|
||||
// landing), with standalone auth pages going to the root dashboard
|
||||
options.redirect_url = get_auth_redirect_url();
|
||||
}
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const internal_id = window.uuidv4();
|
||||
|
||||
let offersProviders = false;
|
||||
// Form signups are closed, so the providers are the only way in.
|
||||
const showOidcOnly = (el_window) => {
|
||||
$(el_window).find('.signup-form').hide();
|
||||
$(el_window).find('.signup-with-email-wrapper').hide();
|
||||
$(el_window).find('.oidc-providers-wrapper').show();
|
||||
$(el_window).find('.signup-oidc-only-notice').show();
|
||||
};
|
||||
|
||||
const logo_clickable =
|
||||
!!options.window_options?.cover_page && !window.embedded_in_popup;
|
||||
|
||||
let h = '';
|
||||
h +=
|
||||
'<div style="margin: 0 auto;">';
|
||||
// logo
|
||||
h += authLogoHeader({
|
||||
logoSrc: window.icons['logo-white.svg'],
|
||||
logoClickable: logo_clickable,
|
||||
openerOrigin: window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '',
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
// close button
|
||||
if (!options.has_head && options.show_close_button !== false) {
|
||||
h += '<div class="generic-close-window-button"> × </div>';
|
||||
}
|
||||
|
||||
// Form
|
||||
h += '<div style="padding: 15px;">';
|
||||
|
||||
// title
|
||||
h += `<h1 class="signup-form-title">${i18n('create_free_account')}</h1>`;
|
||||
// In a sign-in popup, say which site brought the user here.
|
||||
if (window.embedded_in_popup && isAttestedOrigin(window.openerOrigin)) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(window.openerOrigin).hostname])}</p>`;
|
||||
}
|
||||
h += '<div class="signup-bonus-notice" style="display:none;"></div>';
|
||||
// signup form
|
||||
h += '<form class="signup-form">';
|
||||
// error msg
|
||||
h += '<div class="signup-error-msg"></div>';
|
||||
// username
|
||||
h += '<div style="overflow: hidden;">';
|
||||
h += `<label for="username-${internal_id}">${i18n('username')}</label>`;
|
||||
h += `<input id="username-${internal_id}" value="${html_encode(options.username ?? '')}" class="username" type="text" autocomplete="username" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false"/>`;
|
||||
h += '</div>';
|
||||
// email
|
||||
h += '<div style="overflow: hidden; margin-top: 10px;">';
|
||||
h += `<label for="email-${internal_id}">${i18n('email')}</label>`;
|
||||
h += `<input id="email-${internal_id}" value="${html_encode(options.email ?? '')}" class="email" type="email" autocomplete="email" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false"/>`;
|
||||
h += '</div>';
|
||||
// password
|
||||
h +=
|
||||
'<div style="overflow: hidden; margin-top: 10px; position: relative;">';
|
||||
h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
|
||||
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? 'text' : 'password'}" name="password" autocomplete="new-password" />`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']}" width="20" height="20">
|
||||
</span>`;
|
||||
h += '</div>';
|
||||
// confirm password
|
||||
h +=
|
||||
'<div style="overflow: hidden; margin-top: 10px; margin-bottom: 10px; position: relative">';
|
||||
h += `<label for="confirm-password-${internal_id}">${i18n('signup_confirm_password')}</label>`;
|
||||
h += `<input id="confirm-password-${internal_id}" class="confirm-password" type="${options.show_password ? 'text' : 'password'}" name="confirm-password" autocomplete="new-password" />`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']}" width="20" height="20">
|
||||
</span>`;
|
||||
h += '</div>';
|
||||
// bot trap - if this value is submitted server will ignore the request
|
||||
h +=
|
||||
'<input type="text" name="p102xyzname" class="p102xyzname" value="">';
|
||||
|
||||
// Turnstile widget (only when enabled)
|
||||
if (window.gui_params?.turnstileSiteKey) {
|
||||
h +=
|
||||
'<div style="min-height: 20px; display: flex; justify-content: center;">';
|
||||
// appearance: always/execute/interaction-only
|
||||
// docs: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/?utm_source=chatgpt.com#appearance-modes
|
||||
h += `<div class="cf-turnstile" data-sitekey="${window.gui_params.turnstileSiteKey}" data-appearance="interaction-only"></div>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
// Create Account
|
||||
h += `<button class="signup-btn button button-primary button-block button-normal">${i18n('create_free_account')}</button>`;
|
||||
h += '</form>';
|
||||
h +=
|
||||
'<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
|
||||
h += `<p class="signup-oidc-only-notice" style="display:none;">${i18n('signup_oidc_only')}</p>`;
|
||||
h += `<button type="button" class="oidc-google-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_up_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-microsoft-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_up_with_microsoft')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
|
||||
h += '<div class="oidc-custom-providers"></div>';
|
||||
h += '</div>';
|
||||
// "Sign up with email" button, shown only when OIDC providers are available
|
||||
h += `<div class="signup-with-email-wrapper" style="display:none;">`;
|
||||
h += `<button class="signup-with-email-btn oidc-btn button button-block button-normal">${i18n('sign_up_with_email')}</button>`;
|
||||
h += `</div>`;
|
||||
// terms and privacy
|
||||
h += `<p class="signup-terms">${i18n('tos_fineprint', [], false)}</p>`;
|
||||
h += '</div>';
|
||||
// login link
|
||||
// create account link
|
||||
h += '<div class="c2a-wrapper" style="padding:15px;">';
|
||||
h += `<button class="login-c2a-clickable">${i18n('log_in')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: null,
|
||||
app: 'signup',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
draggable_body: false,
|
||||
has_head: true,
|
||||
selectable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_draggable: true,
|
||||
is_droppable: false,
|
||||
is_resizable: false,
|
||||
stay_on_top: false,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
...options.window_options,
|
||||
dominant: true,
|
||||
center: true,
|
||||
onAppend: function (el_window) {
|
||||
if (options.authError) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(html_encode(options.authError))
|
||||
.fadeIn();
|
||||
}
|
||||
if (!window.disable_signup_autofocus) {
|
||||
$(el_window)
|
||||
.find('.username')
|
||||
.get(0)
|
||||
.focus({ preventScroll: true });
|
||||
}
|
||||
wireAuthLogoHeader(el_window);
|
||||
if (logo_clickable) {
|
||||
$(el_window)
|
||||
.find('.auth-logo')
|
||||
.on('click', function () {
|
||||
// keep desktop users on the desktop; everyone else goes to the root dashboard
|
||||
window.location.href =
|
||||
window.location.pathname === '/desktop'
|
||||
? '/desktop'
|
||||
: '/';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Turnstile widget with callback to capture token
|
||||
const initTurnstile = () => {
|
||||
if (
|
||||
window.turnstile &&
|
||||
window.gui_params?.turnstileSiteKey
|
||||
) {
|
||||
window.turnstile.render('.cf-turnstile', {
|
||||
sitekey: window.gui_params.turnstileSiteKey,
|
||||
callback: function (token) {
|
||||
// Store the token for the signup request
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.attr('data-token', token);
|
||||
// Enable the signup button once CAPTCHA is completed
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.prop('disabled', false);
|
||||
// Add visual feedback
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.addClass('captcha-completed');
|
||||
},
|
||||
'expired-callback': function () {
|
||||
// Reset when token expires
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeAttr('data-token');
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeClass('captcha-completed');
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.prop('disabled', true);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If Turnstile isn't loaded yet, wait for it
|
||||
setTimeout(initTurnstile, 100);
|
||||
}
|
||||
};
|
||||
|
||||
initTurnstile();
|
||||
|
||||
if (window.signup_bonus_code) {
|
||||
(async () => {
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint =
|
||||
await window.getDeviceFingerprint?.();
|
||||
} catch (_) {
|
||||
// the check works without device signals
|
||||
}
|
||||
const result = await checkSignupBonusCode(
|
||||
window.signup_bonus_code,
|
||||
{ origin: window.gui_origin, fingerprint },
|
||||
);
|
||||
// Unknown (null) keeps the code; signup has the final say.
|
||||
if (result && !result.valid) {
|
||||
window.signup_bonus_code = null;
|
||||
}
|
||||
renderBonusNotice(el_window, result);
|
||||
})();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// A federated hop navigates this popup away and the
|
||||
// provider returns it as a plain sign-in popup, losing
|
||||
// whatever the popup was opened to do — and, for a
|
||||
// permission prompt, handing the opener a token instead
|
||||
// of a decision. Don't offer it there. A
|
||||
// cross-origin-isolated opener can only get the token
|
||||
// through the session relay, which the provider's
|
||||
// return doesn't keep.
|
||||
if (
|
||||
window.embedded_in_popup &&
|
||||
(!offersFederatedSignInInPopup(window.gui_action) ||
|
||||
window.url_query_params?.get(
|
||||
'cross_origin_isolated',
|
||||
) === 'true')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
`${window.api_origin}/auth/oidc/providers`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data.providers) return;
|
||||
|
||||
const wireOidcBtn = (provider, btnClass) => {
|
||||
$(el_window).find(btnClass).css('display', 'flex');
|
||||
$(el_window)
|
||||
.find(btnClass)
|
||||
.on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=signup`;
|
||||
// return to the interface the signup started from (backend whitelists the path)
|
||||
const return_to = get_oidc_return_to();
|
||||
if (return_to) {
|
||||
url += `&return_to=${encodeURIComponent(return_to)}`;
|
||||
}
|
||||
const referrer =
|
||||
options.referrer ?? window.referrerStr;
|
||||
if (referrer) {
|
||||
url += `&referrer=${encodeURIComponent(referrer)}`;
|
||||
}
|
||||
if (window.signup_bonus_code) {
|
||||
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
}
|
||||
if (
|
||||
window.embedded_in_popup &&
|
||||
window.url_query_params?.get('msg_id')
|
||||
) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if (window.openerOrigin) {
|
||||
url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
|
||||
}
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
};
|
||||
|
||||
let hasProvider = false;
|
||||
if (data.providers.includes('google')) {
|
||||
hasProvider = true;
|
||||
wireOidcBtn('google', '.oidc-google-btn');
|
||||
}
|
||||
if (data.providers.includes('apple')) {
|
||||
hasProvider = true;
|
||||
wireOidcBtn('apple', '.oidc-apple-btn');
|
||||
}
|
||||
if (data.providers.includes('microsoft')) {
|
||||
hasProvider = true;
|
||||
wireOidcBtn('microsoft', '.oidc-microsoft-btn');
|
||||
}
|
||||
|
||||
// Any other configured provider (e.g. a self-hosted
|
||||
// admin's custom OIDC IdP) gets a generic button.
|
||||
data.providers
|
||||
.filter(
|
||||
provider =>
|
||||
!KNOWN_OIDC_PROVIDERS.includes(provider),
|
||||
)
|
||||
.forEach((provider, idx) => {
|
||||
hasProvider = true;
|
||||
const btnClass = `oidc-custom-btn-${idx}`;
|
||||
const label =
|
||||
humanizeOidcProviderId(provider);
|
||||
$(el_window)
|
||||
.find('.oidc-custom-providers')
|
||||
.append(
|
||||
`<button type="button" class="${btnClass} button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:8px;">${OIDC_GENERIC_PROVIDER_ICON}${i18n('sign_up_with_provider', [label])}</button>`,
|
||||
);
|
||||
wireOidcBtn(provider, `.${btnClass}`);
|
||||
});
|
||||
|
||||
if (hasProvider) {
|
||||
offersProviders = true;
|
||||
$(el_window).find('.oidc-providers-wrapper').show();
|
||||
// Hide the signup form and show the "Sign up with email" button
|
||||
$(el_window).find('.signup-form').hide();
|
||||
$(el_window).find('.signup-form-title').hide();
|
||||
$(el_window)
|
||||
.find('.signup-with-email-wrapper')
|
||||
.show();
|
||||
$(el_window)
|
||||
.find('.signup-with-email-btn')
|
||||
.on('click', function () {
|
||||
$(el_window).find('.signup-form').show();
|
||||
$(el_window)
|
||||
.find('.signup-form-title')
|
||||
.show();
|
||||
$(el_window)
|
||||
.find('.signup-with-email-wrapper')
|
||||
.hide();
|
||||
$(el_window)
|
||||
.find('.username')
|
||||
.get(0)
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (data.signupOidcOnly === true && hasProvider) {
|
||||
showOidcOnly(el_window);
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
},
|
||||
window_class: 'window-signup',
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
'background-color': 'white',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'center',
|
||||
'align-items': 'center',
|
||||
padding: '20px 10px 10px 10px',
|
||||
},
|
||||
// Add custom CSS for CAPTCHA states
|
||||
custom_css: `
|
||||
.cf-turnstile.captcha-completed {
|
||||
border: 2px solid #4CAF50;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
.signup-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find('.login-c2a-clickable')
|
||||
.on('click', async function (e) {
|
||||
$('.login-c2a-clickable').parents('.window').close();
|
||||
const login = await UIWindowLogin({
|
||||
referrer: options.referrer,
|
||||
reload_on_success: options.reload_on_success,
|
||||
redirect_url: options.redirect_url,
|
||||
window_options: options.window_options,
|
||||
show_close_button: options.show_close_button,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
show_password: false,
|
||||
});
|
||||
if (login) {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.on('click', async function (e) {
|
||||
// Clear previous error states
|
||||
$(el_window).find('.signup-error-msg').hide();
|
||||
|
||||
//Username
|
||||
let username = $(el_window).find('.username').val();
|
||||
|
||||
if (!username) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('username_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
//Email
|
||||
let email = $(el_window).find('.email').val();
|
||||
|
||||
// must have an email
|
||||
if (!email) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('email_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// must be a valid email
|
||||
else if (!window.is_email(email)) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('email_invalid'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
//Password
|
||||
let password = $(el_window).find('.password').val();
|
||||
|
||||
// must have a password
|
||||
if (!password) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('password_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// check password strength
|
||||
const pass_strength = check_password_strength(password);
|
||||
if (!pass_strength.overallPass) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('password_strength_error'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// get confirm password value
|
||||
const confirmPassword = $(el_window)
|
||||
.find('.confirm-password')
|
||||
.val();
|
||||
if (!confirmPassword) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('confirm_password_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// check if passwords match
|
||||
if (password !== confirmPassword) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('passwords_do_not_match'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Cloudflare Turnstile CAPTCHA was completed
|
||||
let turnstileToken = null;
|
||||
if (window.turnstile && window.gui_params?.turnstileSiteKey) {
|
||||
turnstileToken = $(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.attr('data-token');
|
||||
if (!turnstileToken) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(
|
||||
i18n('captcha_required') ||
|
||||
'Please complete the CAPTCHA verification',
|
||||
);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//xyzname
|
||||
let p102xyzname = $(el_window).find('.p102xyzname').val();
|
||||
|
||||
// disable 'Create Account' button
|
||||
$(el_window).find('.signup-btn').prop('disabled', true);
|
||||
|
||||
let headers = {};
|
||||
if (window.custom_headers) {
|
||||
headers = window.custom_headers;
|
||||
}
|
||||
|
||||
// Device signal for abuse prevention; omitted when unavailable
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint = await window.getDeviceFingerprint?.();
|
||||
} catch (_) {
|
||||
// signup must never block or fail because of device signals
|
||||
}
|
||||
|
||||
// Include captcha in request only if required
|
||||
const requestData = {
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
referrer: options.referrer ?? window.referrerStr,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
p102xyzname: p102xyzname,
|
||||
'cf-turnstile-response': turnstileToken,
|
||||
};
|
||||
if (fingerprint) {
|
||||
requestData.fingerprint = fingerprint;
|
||||
}
|
||||
if (window.signup_bonus_code) {
|
||||
requestData.bonusCode = window.signup_bonus_code;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: `${window.gui_origin}/signup`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: headers,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(requestData),
|
||||
success: async function (data) {
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
|
||||
let email_verified = true;
|
||||
const show_email_dialog =
|
||||
!options.reload_on_success &&
|
||||
(options.send_confirmation_code ||
|
||||
data.user?.requires_email_confirmation);
|
||||
if (
|
||||
data.user?.requires_phone_verification ||
|
||||
data.user?.requires_card_verification ||
|
||||
show_email_dialog
|
||||
) {
|
||||
$(el_window).close();
|
||||
// Verification gates run in the server's order
|
||||
// (see assertVerifiedAccount): email → phone
|
||||
// (SMS) → card, or card → phone → email when the
|
||||
// account clears email last.
|
||||
const emailGate = async () => {
|
||||
if (show_email_dialog) {
|
||||
email_verified =
|
||||
await UIWindowEmailConfirmationRequired({
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
reload_on_success:
|
||||
options.reload_on_success,
|
||||
window_options:
|
||||
options.window_options ?? {},
|
||||
});
|
||||
}
|
||||
};
|
||||
// A user the SMS path keeps failing may take the
|
||||
// card fallback instead; that resolves 'card' and
|
||||
// clears the card gate along with the phone gate
|
||||
// (see UIWindowPhoneVerificationRequired).
|
||||
let card_gate_cleared_by_fallback = false;
|
||||
const phoneGate = async () => {
|
||||
if (!data.user?.requires_phone_verification)
|
||||
return;
|
||||
let phone_ok = false;
|
||||
do {
|
||||
phone_ok =
|
||||
await UIWindowPhoneVerificationRequired(
|
||||
{
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options:
|
||||
options.window_options ??
|
||||
{},
|
||||
},
|
||||
);
|
||||
} while (!phone_ok);
|
||||
card_gate_cleared_by_fallback =
|
||||
phone_ok === 'card';
|
||||
};
|
||||
const cardGate = async () => {
|
||||
if (
|
||||
!data.user?.requires_card_verification ||
|
||||
card_gate_cleared_by_fallback
|
||||
)
|
||||
return;
|
||||
let card_ok = false;
|
||||
do {
|
||||
card_ok =
|
||||
await UIWindowCardVerificationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options:
|
||||
options.window_options ?? {},
|
||||
});
|
||||
} while (!card_ok);
|
||||
};
|
||||
if (data.user?.email_gate_last) {
|
||||
await cardGate();
|
||||
await phoneGate();
|
||||
await emailGate();
|
||||
} else {
|
||||
await emailGate();
|
||||
await phoneGate();
|
||||
await cardGate();
|
||||
}
|
||||
}
|
||||
|
||||
if (options.reload_on_success) {
|
||||
window.onbeforeunload = null;
|
||||
// Signup can interrupt a direct app landing (the
|
||||
// login cover page's "Sign up", the session
|
||||
// picker's "Create Account", the
|
||||
// must_login_or_signup fallback) — landing on '/'
|
||||
// afterwards would silently drop the app the URL
|
||||
// asked for, so app landings are preserved and
|
||||
// the reloaded boot replays them, launch and
|
||||
// dashboard intro included (the query string is
|
||||
// deliberately left behind, mirroring login's
|
||||
// credential-leak hygiene). Every other route
|
||||
// keeps the historical '/' — notably
|
||||
// /action/signup, where coming back to the same
|
||||
// path would just show this window again.
|
||||
const on_app_landing =
|
||||
/^\/(?:desktop\/)?app\/[^/]+\/?$/.test(
|
||||
window.location.pathname,
|
||||
);
|
||||
const redirectUrl =
|
||||
options.redirect_url ||
|
||||
(on_app_landing
|
||||
? window.location.origin +
|
||||
window.location.pathname
|
||||
: '/');
|
||||
window.location.replace(redirectUrl);
|
||||
} else {
|
||||
resolve(email_verified);
|
||||
}
|
||||
},
|
||||
error: function (err) {
|
||||
// re-enable 'Create Account' button so user can try again
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.prop('disabled', false);
|
||||
|
||||
// Reset Turnstile widget for retry
|
||||
try {
|
||||
if (window.turnstile) {
|
||||
window.turnstile?.reset('.cf-turnstile');
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeAttr('data-token');
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeClass('captcha-completed');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// Process error response
|
||||
const errorText = err.responseText || '';
|
||||
|
||||
// Handle JSON error response
|
||||
try {
|
||||
// Try to parse error as JSON
|
||||
const errorJson = JSON.parse(errorText);
|
||||
|
||||
// Handle signup blocked with full-screen overlay
|
||||
if (errorJson?.code === 'signup_blocked') {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('signup-blocked-overlay');
|
||||
const blockedMsg =
|
||||
errorJson.message || 'Signup Not Allowed';
|
||||
overlay.innerHTML = `
|
||||
<div class="signup-blocked-content">
|
||||
<img src="${window.icons['logo.svg'] || window.icons['logo-white.svg'] || ''}" style="width:64px;margin-bottom:24px;" />
|
||||
<p>${html_encode(blockedMsg)}</p>
|
||||
<p>If you already have an account, try <a href="/action/login">logging in</a>. Otherwise, contact <a href="mailto:support@puter.com">support@puter.com</a> for assistance.</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
return;
|
||||
}
|
||||
|
||||
// Form signups closed after this window loaded.
|
||||
// Without provider buttons (some popups), the
|
||||
// server's message below says it instead.
|
||||
if (
|
||||
errorJson?.code === 'signup_oidc_only' &&
|
||||
offersProviders
|
||||
) {
|
||||
showOidcOnly(el_window);
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the code so the next attempt signs up without it.
|
||||
if (errorJson?.code === 'bonus_code_invalid') {
|
||||
window.signup_bonus_code = null;
|
||||
// The error below says it; the offer no longer applies.
|
||||
renderBonusNotice(el_window, null);
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('signup_bonus_code_invalid_retry'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle timeout specifically
|
||||
if (
|
||||
errorJson?.code === 'response_timeout' ||
|
||||
errorText.includes('timeout')
|
||||
) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(
|
||||
i18n('server_timeout') ||
|
||||
'The server took too long to respond. Please try again.',
|
||||
);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a message in the JSON, use that
|
||||
if (errorJson.message) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(errorJson.message);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// Not JSON, continue with text analysis
|
||||
}
|
||||
|
||||
// Default general error handling
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(
|
||||
errorText ||
|
||||
i18n('signup_error') ||
|
||||
'An error occurred during signup. Please try again.',
|
||||
);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
},
|
||||
timeout: 30000, // Add a reasonable timeout
|
||||
});
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find('.signup-form')
|
||||
.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find(
|
||||
`#toggle-show-password-${internal_id}, #toggle-show-password-${internal_id}-confirm`,
|
||||
)
|
||||
.on('click', function (e) {
|
||||
// hide/show password/confirm password and update icon
|
||||
let inputField = $(this).siblings('input');
|
||||
let isPasswordVisible = inputField.attr('type') === 'text';
|
||||
inputField.attr(
|
||||
'type',
|
||||
isPasswordVisible ? 'password' : 'text',
|
||||
);
|
||||
$(this)
|
||||
.find('.toggle-show-password-icon')
|
||||
.attr(
|
||||
'src',
|
||||
isPasswordVisible
|
||||
? window.icons['eye-open.svg']
|
||||
: window.icons['eye-closed.svg'],
|
||||
);
|
||||
});
|
||||
|
||||
//remove login window
|
||||
$('.signup-c2a-clickable').parents('.window').close();
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowSignup;
|
||||
@@ -1,100 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null }));
|
||||
vi.mock('./UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/authLogoHeader.js', () => ({
|
||||
authLogoHeader: () => '',
|
||||
wireAuthLogoHeader: vi.fn(),
|
||||
}));
|
||||
vi.mock('../helpers/checkPasswordStrength.js', () => ({
|
||||
default: () => ({ overallPass: true }),
|
||||
}));
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: async (options) => {
|
||||
state.el = document.createElement('div');
|
||||
state.el.innerHTML = options.body_content;
|
||||
document.body.append(state.el);
|
||||
options.onAppend?.(state.el);
|
||||
return state.el;
|
||||
},
|
||||
}));
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
|
||||
|
||||
const providers = (body) => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(body)));
|
||||
};
|
||||
const find = (selector) => jQuery(state.el).find(selector);
|
||||
const shown = (selector) => find(selector).css('display') !== 'none';
|
||||
|
||||
const open = async () => {
|
||||
void UIWindowSignup({ reload_on_success: false, redirect_url: '/' });
|
||||
await vi.waitFor(() => expect(find('.oidc-google-btn').css('display')).toBe('flex'));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = vi.fn();
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.gui_params = {};
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'oidc-only-test';
|
||||
window.is_email = () => true;
|
||||
window.embedded_in_popup = false;
|
||||
window.url_query_params = new URLSearchParams('');
|
||||
state.el = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('signup window when only OIDC signups are open', () => {
|
||||
it('offers the email form behind a button normally', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
|
||||
expect(shown('.signup-with-email-wrapper')).toBe(true);
|
||||
expect(shown('.signup-oidc-only-notice')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the email form and says why', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: true });
|
||||
await open();
|
||||
|
||||
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
|
||||
expect(shown('.signup-form')).toBe(false);
|
||||
expect(shown('.signup-with-email-wrapper')).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to the providers when a form signup is refused for it', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
jQuery.ajax = vi.fn((opts) =>
|
||||
opts.error({
|
||||
responseText: JSON.stringify({
|
||||
code: 'signup_oidc_only',
|
||||
message: 'closed',
|
||||
}),
|
||||
}));
|
||||
|
||||
find('.signup-with-email-btn').trigger('click');
|
||||
find('.username').val('someone');
|
||||
find('.email').val('[email protected]');
|
||||
find('.password').val('a-Strong-pass-1');
|
||||
find('.confirm-password').val('a-Strong-pass-1');
|
||||
find('.signup-btn').trigger('click');
|
||||
|
||||
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
|
||||
expect(shown('.signup-form')).toBe(false);
|
||||
expect(shown('.signup-with-email-wrapper')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
+60
-60
@@ -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;
|
||||
@@ -2260,13 +2314,6 @@ label {
|
||||
color: #611a15;
|
||||
}
|
||||
|
||||
.signup-oidc-only-notice {
|
||||
text-align: center;
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
/* -- Logo strip above the login / signup forms -- */
|
||||
.auth-logo-header {
|
||||
display: flex;
|
||||
@@ -2662,32 +2709,6 @@ label {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.session-entry {
|
||||
cursor: pointer;
|
||||
padding: 20px;
|
||||
border: 1px solid #CCC;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 10px;
|
||||
background-color: white;
|
||||
font-weight: 500;
|
||||
color: #394d5c;
|
||||
}
|
||||
|
||||
.session-entry:hover {
|
||||
border-color: #00a6ff;
|
||||
}
|
||||
|
||||
.login-c2a-session-list, .signup-c2a-session-list {
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: #636363;
|
||||
}
|
||||
|
||||
.login-c2a-session-list:hover, .signup-c2a-session-list:hover {
|
||||
text-decoration: underline;
|
||||
;
|
||||
}
|
||||
|
||||
/*****************************************************
|
||||
* Taskbar
|
||||
*****************************************************/
|
||||
@@ -3958,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);
|
||||
@@ -6139,13 +6166,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.device-phone .window.window-login-2fa {
|
||||
left: 50% !important;
|
||||
transform: translate(-50%) !important;
|
||||
height: initial !important;
|
||||
max-width: calc(100% - 30px);
|
||||
}
|
||||
|
||||
.device-phone .window.window-explorer {
|
||||
transform: none;
|
||||
width: 100%;
|
||||
@@ -6165,15 +6185,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.device-phone .window-signup {
|
||||
transform: none;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.device-phone .send-feedback-btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -6404,17 +6415,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.window-recover-password.window-cover-page .window-body{
|
||||
background-color: white !important;
|
||||
}
|
||||
.window-recover-password .window-body{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
/* -------------------------------------------------------------------------
|
||||
Permission Request Dialog (UIPermissionDialog)
|
||||
Standalone top-layer <dialog>; follows the OS light/dark scheme.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,7 +23,7 @@ import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
|
||||
+41
-33
@@ -36,7 +36,7 @@ import mime from './lib/mime.js';
|
||||
import path from './lib/path.js';
|
||||
import UIAlert from './UI/UIAlert.js';
|
||||
import UIItem from './UI/UIItem.js';
|
||||
import UIWindowLogin from './UI/UIWindowLogin.js';
|
||||
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
|
||||
import UIWindowProgress from './UI/UIWindowProgress.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
|
||||
@@ -67,7 +67,7 @@ window.is_auth = () => {
|
||||
* 1. Snapshot enough state to land back where we were after sign-in.
|
||||
* 2. Clear the token key (and the retired one, if a value lingers).
|
||||
* 3. Show a soft modal explaining what happened.
|
||||
* 4. Re-open UIWindowLogin, forwarding `auth_id` so temp users can
|
||||
* 4. Re-open the login dialog, forwarding `auth_id` so temp users can
|
||||
* re-attach to the same identity.
|
||||
*
|
||||
* Idempotent: parallel 401s while the modal is already open are dropped.
|
||||
@@ -106,12 +106,12 @@ window.handleReauthRequired = async (signal = {}) => {
|
||||
});
|
||||
} catch ( e ) {
|
||||
// If the alert surface isn't available (very early in boot),
|
||||
// fall through to UIWindowLogin directly.
|
||||
// fall through to the login dialog directly.
|
||||
}
|
||||
|
||||
// Open the login window, forwarding auth_id.
|
||||
try {
|
||||
const login = await UIWindowLogin({
|
||||
const login = await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
auth_id: signal.auth_id,
|
||||
reauth_reason: signal.reason,
|
||||
@@ -643,8 +643,11 @@ window.update_auth_data = async (auth_token, user) => {
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to sync session cookie:', e);
|
||||
// Sign-in waits on this with its card still up, above every
|
||||
// ordinary window.
|
||||
await UIAlert({
|
||||
message: `Failed to sync session cookie: ${ e.message}`,
|
||||
stay_on_top: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -660,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;
|
||||
@@ -712,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`;
|
||||
@@ -1077,15 +1090,10 @@ window.show_save_account_notice_if_needed = function (message) {
|
||||
});
|
||||
|
||||
} else if ( alert_resp === 'login' ) {
|
||||
let login_result = await UIWindowLogin({
|
||||
let login_result = await UIAuthLogin({
|
||||
show_signup_button: false,
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: false,
|
||||
window_options: {
|
||||
show_in_taskbar: false,
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
// FIXME: Report login error.
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @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';
|
||||
|
||||
const UIAlert = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: UIAlert }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./helpers/updateUsernameInGui.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
globalThis.use = globalThis.use ?? (() => class {});
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.puter = {
|
||||
_cache: { flushall: vi.fn() },
|
||||
setAuthToken: vi.fn(),
|
||||
auth: { getProfile: vi.fn(async () => ({})) },
|
||||
};
|
||||
|
||||
await import('./helpers.js');
|
||||
|
||||
describe('update_auth_data', () => {
|
||||
beforeEach(() => {
|
||||
UIAlert.mockClear();
|
||||
localStorage.clear();
|
||||
window.user = { uuid: 'old', username: 'old' };
|
||||
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 () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
await window.update_auth_data('token', { uuid: 'new', username: 'new' });
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -81,16 +81,18 @@ function openerLogoTile(origin, fallbackSrc) {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.logoSrc - URL of the Puter logo.
|
||||
* @param {boolean} [options.logoClickable] - Adds the pointer cursor; the
|
||||
* caller wires the click.
|
||||
* @param {string} [options.logoHref] - Makes the logo a link there, named
|
||||
* for Puter; without it the logo is decorative.
|
||||
* @param {string} [options.openerOrigin] - When set, the opener's favicon is
|
||||
* rendered beside the Puter logo with a dashed connector between them.
|
||||
* @param {string} [options.openerFallbackSrc] - Generic website icon shown
|
||||
* when the favicon is missing.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function authLogoHeader({ logoSrc, logoClickable = false, openerOrigin = '', openerFallbackSrc = '' }) {
|
||||
const puterLogo = `<img src="${html_encode(logoSrc)}" class="auth-logo"${logoClickable ? ' style="cursor: pointer;"' : ''}>`;
|
||||
export function authLogoHeader({ logoSrc, logoHref = '', openerOrigin = '', openerFallbackSrc = '' }) {
|
||||
const puterLogo = logoHref
|
||||
? `<a class="auth-logo-link" href="${html_encode(logoHref)}"><img src="${html_encode(logoSrc)}" class="auth-logo" alt="Puter"></a>`
|
||||
: `<img src="${html_encode(logoSrc)}" class="auth-logo" alt="">`;
|
||||
|
||||
let h = '';
|
||||
h += `<div class="auth-logo-header${openerOrigin ? ' auth-logo-header-connected' : ''}">`;
|
||||
|
||||
@@ -51,11 +51,15 @@ describe('authLogoHeader', () => {
|
||||
expect(h).toContain('class="auth-logo"');
|
||||
expect(h).not.toContain('auth-opener-logo');
|
||||
expect(h).not.toContain('auth-logo-connector');
|
||||
expect(h).not.toContain('cursor: pointer');
|
||||
expect(h).not.toContain('<a ');
|
||||
// Decorative: the title beside it names the screen.
|
||||
expect(h).toContain('alt=""');
|
||||
});
|
||||
|
||||
it('adds the pointer cursor when the logo is clickable', () => {
|
||||
expect(authLogoHeader({ logoSrc: LOGO, logoClickable: true })).toContain('cursor: pointer');
|
||||
it('makes the logo a named link when it leads somewhere', () => {
|
||||
const h = authLogoHeader({ logoSrc: LOGO, logoHref: '/desktop' });
|
||||
expect(h).toContain('<a class="auth-logo-link" href="/desktop">');
|
||||
expect(h).toContain('alt="Puter"');
|
||||
});
|
||||
|
||||
it('pairs the opener favicon with the Puter logo through a connector', () => {
|
||||
|
||||
@@ -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,8 +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',
|
||||
signup_oidc_only: 'New accounts can only be created with one of the options below.',
|
||||
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.',
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('the permission popup and browser push', () => {
|
||||
});
|
||||
|
||||
describe('the account pickers in a popup', () => {
|
||||
const PICKER = 'picked_a_user_for_sdk_login = await UIWindowSessionList(';
|
||||
const PICKER = 'picked_a_user_for_sdk_login = await UIAuthAccountPicker(';
|
||||
|
||||
it('record that they were shown', () => {
|
||||
// A dismissed picker has to outrank an existing relationship, so the
|
||||
|
||||
+46
-66
@@ -33,17 +33,17 @@ import UIWindowPasswordChangeRequired from './UI/UIWindowPasswordChangeRequired.
|
||||
import UIWindowPhoneVerificationRequired from './UI/UIWindowPhoneVerificationRequired.js';
|
||||
import UIWindowCardVerificationRequired from './UI/UIWindowCardVerificationRequired.js';
|
||||
import { openVerificationGateWindow } from './helpers/verification_gates.js';
|
||||
import UIWindowLogin from './UI/UIWindowLogin.js';
|
||||
import UIWindowLogin2FA from './UI/UIWindowLogin2FA.js';
|
||||
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
|
||||
import UIAuth2FA from './UI/Auth/UIAuth2FA.js';
|
||||
import { takeOidcLoginChallenge } from './helpers/authRedirect.js';
|
||||
import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js';
|
||||
import UIWindowNewPassword from './UI/UIWindowNewPassword.js';
|
||||
import UIPermissionDialog from './UI/UIPermissionDialog.js';
|
||||
import UIEnablePushDialog from './UI/UIEnablePushDialog.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
import UIWindowSessionList from './UI/UIWindowSessionList.js';
|
||||
import UIWindowSignup from './UI/UIWindowSignup.js';
|
||||
import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js';
|
||||
import UIAuthAccountPicker from './UI/Auth/UIAuthAccountPicker.js';
|
||||
import UIAuthSignup from './UI/Auth/UIAuthSignup.js';
|
||||
import UIAuthRecoverPassword from './UI/Auth/UIAuthRecoverPassword.js';
|
||||
import { PROCESS_RUNNING } from './definitions.js';
|
||||
import { isAttestedOrigin } from './helpers/attestedOrigin.js';
|
||||
import confirm_before_unload from './helpers/confirmBeforeUnload.js';
|
||||
@@ -140,7 +140,7 @@ const follow_share_recipient = async (action) => {
|
||||
});
|
||||
if ( choice !== 'sign_in' ) return false;
|
||||
// Reloads on success; closing it carries on as the current account.
|
||||
return Boolean(await UIWindowLogin({
|
||||
return Boolean(await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: true,
|
||||
}));
|
||||
@@ -1521,7 +1521,7 @@ window.initgui = async function (options) {
|
||||
window.gui_action = action;
|
||||
|
||||
if (action === 'oidc-2fa') {
|
||||
const result = oidcChallenge?.token && await UIWindowLogin2FA(oidcChallenge.token);
|
||||
const result = oidcChallenge?.token && await UIAuth2FA(oidcChallenge.token);
|
||||
if (result?.redirectUrl) {
|
||||
const target = new URL(result.redirectUrl, window.location.origin);
|
||||
if (target.origin === window.location.origin) {
|
||||
@@ -1736,14 +1736,10 @@ window.initgui = async function (options) {
|
||||
) {
|
||||
// show signup window
|
||||
if (
|
||||
await UIWindowSignup({
|
||||
await UIAuthSignup({
|
||||
reload_on_success: false,
|
||||
send_confirmation_code: true,
|
||||
show_close_button: false,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
},
|
||||
page: true,
|
||||
})
|
||||
) {
|
||||
// Completing signup in a sign-in popup is the user asking to
|
||||
@@ -1802,11 +1798,9 @@ window.initgui = async function (options) {
|
||||
} else {
|
||||
// Show session list so user can pick which account to use
|
||||
showed_account_picker = true;
|
||||
picked_a_user_for_sdk_login = await UIWindowSessionList({
|
||||
picked_a_user_for_sdk_login = await UIAuthAccountPicker({
|
||||
reload_on_success: false,
|
||||
draggable_body: false,
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
page: true,
|
||||
});
|
||||
|
||||
if (picked_a_user_for_sdk_login) {
|
||||
@@ -1880,9 +1874,13 @@ window.initgui = async function (options) {
|
||||
|
||||
//--------------------------------------------------------------------------------------
|
||||
// Desktop background (early)
|
||||
// Set before action=login/signup so OIDC error redirects show the background behind the form.
|
||||
// Set before action=login/signup so the signed-in dialog has the desktop behind it.
|
||||
// Skipped for the full-page form, which it would only flash behind before covering.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
|
||||
const authActionIsPage =
|
||||
action === 'password-recovery' ||
|
||||
(!window.is_auth() && (action === 'login' || action === 'signup'));
|
||||
if (!window.is_fullpage_mode && !window.embedded_in_popup && !authActionIsPage) {
|
||||
window.refresh_desktop_background();
|
||||
}
|
||||
|
||||
@@ -1912,20 +1910,15 @@ window.initgui = async function (options) {
|
||||
? { authError: authErrorDisplayMessage() }
|
||||
: {};
|
||||
if (!window.is_auth()) {
|
||||
opts.window_options = { cover_page: true, has_head: false };
|
||||
opts.page = true;
|
||||
}
|
||||
await UIWindowLogin(Object.keys(opts).length ? opts : undefined);
|
||||
await UIAuthLogin(opts);
|
||||
}
|
||||
//--------------------------------------------------------------------------------------
|
||||
// Action: Password recovery
|
||||
//--------------------------------------------------------------------------------------
|
||||
else if (action === 'password-recovery') {
|
||||
await UIWindowRecoverPassword({
|
||||
window_options: {
|
||||
cover_page: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
await UIAuthRecoverPassword({ page: true });
|
||||
}
|
||||
//--------------------------------------------------------------------------------------
|
||||
// Action: Signup
|
||||
@@ -1935,10 +1928,10 @@ window.initgui = async function (options) {
|
||||
? { authError: authErrorDisplayMessage() }
|
||||
: {};
|
||||
if (!window.is_auth()) {
|
||||
opts.window_options = { cover_page: true, has_head: false };
|
||||
opts.page = true;
|
||||
}
|
||||
opts.send_confirmation_code = true;
|
||||
await UIWindowSignup(Object.keys(opts).length ? opts : undefined);
|
||||
await UIAuthSignup(opts);
|
||||
}
|
||||
// Which URL parameter (if any) is carrying a token to sign in with —
|
||||
// `auth_token`, or `token` when returning from a remote backend's AuthMe.
|
||||
@@ -1965,11 +1958,9 @@ window.initgui = async function (options) {
|
||||
window.url_query_params.get('request_auth'))
|
||||
) {
|
||||
showed_account_picker = true;
|
||||
picked_a_user_for_sdk_login = await UIWindowSessionList({
|
||||
picked_a_user_for_sdk_login = await UIAuthAccountPicker({
|
||||
reload_on_success: false,
|
||||
draggable_body: false,
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
page: true,
|
||||
});
|
||||
if (picked_a_user_for_sdk_login) {
|
||||
window.popup_signin_consent = true;
|
||||
@@ -2439,12 +2430,23 @@ window.initgui = async function (options) {
|
||||
window.update_sites_cache();
|
||||
}
|
||||
}
|
||||
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
|
||||
// real account even on a first visit — never a temp user. So does a share
|
||||
// link: a share only ever reaches a real account, so a temporary one could
|
||||
// never see what it points at.
|
||||
const is_app_landing_page = window.url_paths[0] === 'app' && !!window.url_paths[1];
|
||||
const is_share_link = window.url_query_params.has('shared');
|
||||
const needsSignIn =
|
||||
!window.is_auth() &&
|
||||
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page || is_share_link);
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Desktop Background
|
||||
// If we're in fullpage/emebedded/Auth Popup mode, we don't want to load the custom background
|
||||
// because it's not visible anyway and it's a waste of bandwidth
|
||||
// because it's not visible anyway and it's a waste of bandwidth. Same behind the sign-in page.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
|
||||
const showsSignInPage = needsSignIn && window.logged_in_users.length === 0;
|
||||
if (!window.is_fullpage_mode && !window.embedded_in_popup && !showsSignInPage) {
|
||||
window.refresh_desktop_background();
|
||||
}
|
||||
// -------------------------------------------------------------------------------------
|
||||
@@ -2455,16 +2457,7 @@ window.initgui = async function (options) {
|
||||
// then failed to authenticate.
|
||||
if (!window.is_auth()) reveal_anonymous_markup(true);
|
||||
|
||||
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
|
||||
// real account even on a first visit — never a temp user. So does a share
|
||||
// link: a share only ever reaches a real account, so a temporary one could
|
||||
// never see what it points at.
|
||||
const is_app_landing_page = window.url_paths[0] === 'app' && !!window.url_paths[1];
|
||||
const is_share_link = window.url_query_params.has('shared');
|
||||
if (
|
||||
!window.is_auth() &&
|
||||
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page || is_share_link)
|
||||
) {
|
||||
if (needsSignIn) {
|
||||
// `npm start --server=<remote>` serves this GUI locally while pointing
|
||||
// `gui_origin` at a remote Puter. There is nothing here to log into:
|
||||
// `/login` accepts only its own origin, because it answers with a full
|
||||
@@ -2509,26 +2502,24 @@ window.initgui = async function (options) {
|
||||
const needs_action = action === 'authme' || action === 'copyauth';
|
||||
const reload_on_success = needs_action;
|
||||
if (window.logged_in_users.length > 0) {
|
||||
// dashboard mode skips the wallpaper (fullpage), but the session list
|
||||
// has no cover page — restore the wallpaper so it isn't on a blank page
|
||||
// dashboard mode skips the wallpaper (fullpage), but the account
|
||||
// picker is a dialog, not a page — restore the wallpaper so it
|
||||
// isn't on a blank page
|
||||
if (window.is_dashboard_mode) {
|
||||
window.refresh_desktop_background();
|
||||
}
|
||||
await UIWindowSessionList({
|
||||
await UIAuthAccountPicker({
|
||||
redirect_url: needs_action ? window.location.href : undefined,
|
||||
});
|
||||
} else {
|
||||
const resp = await fetch(`${window.gui_origin}/whoarewe`);
|
||||
const whoarewe = await resp.json();
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: !window.embedded_in_popup,
|
||||
send_confirmation_code: true,
|
||||
show_signup_button: !whoarewe.disable_user_signup,
|
||||
redirect_url: needs_action ? window.location.href : undefined,
|
||||
window_options: {
|
||||
cover_page: true,
|
||||
has_head: false,
|
||||
},
|
||||
page: true,
|
||||
});
|
||||
}
|
||||
if (!reload_on_success && window.is_auth()) {
|
||||
@@ -2717,13 +2708,10 @@ window.initgui = async function (options) {
|
||||
// hide Turnstile challenge
|
||||
$('.captcha-modal').hide();
|
||||
|
||||
await UIWindowSignup({
|
||||
await UIAuthSignup({
|
||||
reload_on_success: !window.embedded_in_popup,
|
||||
send_confirmation_code: true,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
},
|
||||
page: true,
|
||||
});
|
||||
|
||||
// Popup-only: it posts to `window.opener` and closes the
|
||||
@@ -3088,14 +3076,6 @@ $(window).on('resize', function () {
|
||||
left: (window.desktop_width - width) / 2,
|
||||
top: top / ratio,
|
||||
});
|
||||
|
||||
// Re-center the create account window
|
||||
const top2 = $('.window-signup').position()?.top;
|
||||
const width2 = $('.window-signup').width();
|
||||
$('.window-signup').css({
|
||||
left: (window.desktop_width - width2) / 2,
|
||||
top: top2 / ratio,
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('contextmenu', '.disable-context-menu', function (e) {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -29,15 +29,14 @@ const resolveBundle = (): string => {
|
||||
);
|
||||
};
|
||||
|
||||
// In coverage mode every vm context is retained so its `__coverage__`
|
||||
// counters can be merged after the run (each test loads a fresh SDK).
|
||||
const coverageContexts: Array<Record<string, unknown>> = [];
|
||||
// In coverage mode every vm context is handed the first context's
|
||||
// `__coverage__`: istanbul reuses a file entry whose hash matches, so all
|
||||
// contexts count into one object instead of each holding a ~6 MB copy.
|
||||
let nodeCoverage: IstanbulCoverage | undefined;
|
||||
|
||||
/** Coverage counters from every SDK context created so far. */
|
||||
export const collectNodeCoverage = (): Array<IstanbulCoverage | undefined> =>
|
||||
coverageContexts.map(
|
||||
(context) => context.__coverage__ as IstanbulCoverage | undefined,
|
||||
);
|
||||
export const collectNodeCoverage = (): IstanbulCoverage | undefined =>
|
||||
nodeCoverage;
|
||||
|
||||
// Compile the ~MB bundle once; isolation comes from a fresh context per
|
||||
// call, not a fresh parse. The filename ties stack traces and V8 coverage
|
||||
@@ -76,9 +75,12 @@ export const loadNodePuter = (env: EnvManifest, token: string): PuterSDK => {
|
||||
// The SDK's nodejs branch only shims localStorage when it's absent;
|
||||
// clear any inherited one so instances never share auth state.
|
||||
delete context.localStorage;
|
||||
if (nodeCoverage) context.__coverage__ = nodeCoverage;
|
||||
|
||||
sdkScript().runInContext(vm.createContext(context));
|
||||
if (coverageEnabled()) coverageContexts.push(context);
|
||||
if (coverageEnabled()) {
|
||||
nodeCoverage ??= context.__coverage__ as IstanbulCoverage | undefined;
|
||||
}
|
||||
|
||||
const puter = context.puter as PuterSDK;
|
||||
puter.setAuthToken(token);
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('puter.js API suites (node)', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
if (coverageEnabled()) {
|
||||
await writeCoverageShard('node', collectNodeCoverage());
|
||||
await writeCoverageShard('node', [collectNodeCoverage()]);
|
||||
}
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCoverageMap } from 'istanbul-lib-coverage';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
@@ -6,11 +7,7 @@ import {
|
||||
import type { IConfig } from '@heyputer/backend/types.ts';
|
||||
import { bundleHarnessEntry } from '../harness/bundleHarnessEntry.ts';
|
||||
import { loadPuterJsTestOptions } from '../harness/capabilities.ts';
|
||||
import {
|
||||
coverageEnabled,
|
||||
writeCoverageShard,
|
||||
type IstanbulCoverage,
|
||||
} from '../harness/coverage.ts';
|
||||
import { coverageEnabled, writeCoverageShard } from '../harness/coverage.ts';
|
||||
import { listTests, skipReason, type RunTestArgs } from '../harness/executor.ts';
|
||||
import { loadNodePuter } from '../harness/nodeSdkLoader.ts';
|
||||
import type { EnvManifest, RunTestResult } from '../harness/types.ts';
|
||||
@@ -20,8 +17,9 @@ const WORKER_NAME = 'puterjs-suites';
|
||||
const options = loadPuterJsTestOptions();
|
||||
|
||||
// Counters ride along on every /run response (the workerd isolate can
|
||||
// recycle mid-run); merged into one shard after the run.
|
||||
const workerdCoverage: IstanbulCoverage[] = [];
|
||||
// recycle mid-run). Merged as they arrive, since keeping each ~6 MB
|
||||
// snapshot until the end runs the fork out of heap.
|
||||
const workerdCoverage = createCoverageMap({});
|
||||
|
||||
// The shared puter.js suites running inside local workerd. The suite
|
||||
// bundle is deployed through the real workers pipeline (SDK
|
||||
@@ -61,7 +59,7 @@ describe('puter.js API suites (workerd)', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
if (coverageEnabled()) {
|
||||
await writeCoverageShard('workerd', workerdCoverage);
|
||||
await writeCoverageShard('workerd', [workerdCoverage.toJSON()]);
|
||||
}
|
||||
await env?.shutdown();
|
||||
});
|
||||
@@ -89,7 +87,7 @@ describe('puter.js API suites (workerd)', () => {
|
||||
}
|
||||
const result = JSON.parse(text) as RunTestResult;
|
||||
if (result.coverage) {
|
||||
workerdCoverage.push(result.coverage);
|
||||
workerdCoverage.merge(result.coverage);
|
||||
}
|
||||
expect(result.error ?? '').toBe('');
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
@@ -30,6 +30,7 @@ const COMPONENT_TAGS = [
|
||||
'puter-menubar',
|
||||
'puter-color-picker',
|
||||
'puter-font-picker',
|
||||
'puter-user-menu',
|
||||
];
|
||||
|
||||
const raf = (): Promise<void> =>
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
);
|
||||
|
||||
// The user is asked, rather than the popup deciding for them.
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
@@ -106,7 +106,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
'&opener_origin=https%3A%2F%2Fconsole.puter.com',
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForTimeout(5000);
|
||||
@@ -129,7 +129,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
`&signin_session=${session}&msg_id=90`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
@@ -149,7 +149,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
`&signin_session=${session}&msg_id=92`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
@@ -170,7 +170,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
`&signin_session=${session}&msg_id=91`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await popup.close();
|
||||
|
||||
@@ -320,7 +320,7 @@ export const transformToV2 = (source) => {
|
||||
if ( svc['onlyoffice-app'] ) out.onlyoffice = svc['onlyoffice-app'];
|
||||
|
||||
// Cloudflare Turnstile: v2 GUI renders the challenge widget when
|
||||
// `gui_params.turnstileSiteKey` is set (see initgui.js / UIWindowSignup).
|
||||
// `gui_params.turnstileSiteKey` is set (see initgui.js / UI/Auth/UIAuthSignup.js).
|
||||
// Preserve the full block at `turnstile` for whenever the backend verifier
|
||||
// is ported, and surface the site key into gui_params so the widget works.
|
||||
if ( svc['cloudflare-turnstile'] ) {
|
||||
|
||||
Reference in new issue
Block a user