Compare commits

...
Author SHA1 Message Date
Nariman Jelveh a8b2bd314e fix(dashboard): let a publish address finish composing before shaping it
The address was reshaped on every input, composing included: a dead
key's accent turned into a hyphen before its letter arrived, and IME
text was rewritten under the composition. Composed text is now shaped
once it's in.
2026-10-25 20:38:51 -07:00
Nariman Jelveh de6781719c fix(dashboard): cap a website address at a hostname label's 63 characters
The server takes a 64-character subdomain, but DNS caps a label at 63,
so the site it publishes can't be reached. The field now stops at 63,
as the worker one does.
2026-10-25 20:38:28 -07:00
Nariman Jelveh 1a46d81fad fix(dashboard): keep the letter when an accented one is typed into a publish address
Café became caf: an accented letter isn't a DNS character, so it was
dropped whole. The accent comes off and the letter stays.
2026-10-25 20:38:04 -07:00
Nariman Jelveh 2dbfd7d2f1 fix(dashboard): read an underscore in a website address as a word break
A website name can't hold an underscore, so typing My_Cool Site gave
mycool-site. Underscores now become a hyphen, as spaces do; a worker
name keeps them.
2026-10-25 20:37:50 -07:00
Nariman Jelveh 13e2867b1c chore(dashboard): drop a phone width rule the icon buttons never used
The form dialog's phone flex rule sizes these buttons, so the width never
applied; touch screens get 44px from the coarse-pointer min-width.
2026-10-25 20:26:44 -07:00
Nariman Jelveh 0625492676 fix(dashboard): type into a suffixed field when its suffix is pressed
The domain after the name is drawn inside the field's box but was a plain
span, so pressing it focused nothing. A press there now focuses the field
with the caret at the end, and the suffix shows a text cursor.
2026-10-25 20:26:12 -07:00
Nariman Jelveh 24b0a8ab61 fix(dashboard): point a taken or reserved publish name at the address field
The server's reason showed in the status line, but the field stayed
unmarked and focus stayed on Publish, so changing the name took an extra
click. Mark the field invalid and focus it for the rejections that mean
"pick another name".
2026-10-25 20:24:26 -07:00
Nariman Jelveh 64329c74ef fix(dashboard): dim a suffixed field as one box while the form submits
Only the input inside it dimmed, leaving the domain after the name at full
contrast while the field was locked.
2026-10-25 20:23:19 -07:00
Nariman Jelveh 07fc195c77 fix(dashboard): link a published site to the address the server reports
The success screen built https://<name>.<hosting domain>, which is wrong on
a self-hosted instance serving sites over http. Use the address the create
response carries, the one the rest of the GUI shows, and fall back to the
built one only when it's missing.
2026-10-25 20:22:09 -07:00
Nariman Jelveh 92282ecb7d fix(gui): ask a just-saved account to confirm its email before publishing
Saving a temporary account can leave its email unconfirmed when signup
doesn't require confirmation. The publish gate returned right after the
save, skipping the confirm-email step the old inline checks still ran.
2026-10-25 20:20:40 -07:00
Nariman Jelveh a5ac373bfd feat(dashboard): publish websites and workers from Files in dashboard dialogs
The Files tab's Publish as website and Publish as Worker actions open
dashboard form dialogs instead of desktop windows: the address field shows
the domain inline and shapes the typed name into a valid one, workers get a
Sandboxed switch, and success shows the live address with copy and open.
Published rows get their badges right away.

The form dialog gains suffix and switch fields and a shared copy row, which
the API token dialog now uses too. The save-account / confirm-email gate
before publishing moves to one helper shared by both flows.
2026-10-25 20:14:53 -07:00
15 changed files with 980 additions and 103 deletions

No files matched your search

+9 -12
View File
@@ -32,10 +32,10 @@ import mime from '../../lib/mime.js';
import { formatRelativeTime } from '../../i18n/formatTime.js';
import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js';
import new_context_menu_item from '../../helpers/newContextMenuItem.js';
import publish_as_website from '../../helpers/publishAsWebsite.js';
import ContextMenuModal, { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import UIItemPropertiesModal from './UIItemPropertiesModal.js';
import UIShareModal from './UIShareModal.js';
import { openPublishWebsiteDialog, openPublishWorkerDialog } from './UIPublishDialogs.js';
import { dedupedName } from '../../helpers/dedupedName.js';
import { compareEntries, compareNames } from './sortEntries.js';
import { middleTruncate } from './middleTruncate.js';
@@ -5040,6 +5040,12 @@ const TabFiles = {
$container: _this.$el_window,
});
},
onPublishWebsite: ({ name, path: item_path }) => {
openPublishWebsiteDialog({ name, path: item_path, $container: _this.$el_window });
},
onPublishWorker: ({ name, path: item_path }) => {
openPublishWorkerDialog({ name, path: item_path, $container: _this.$el_window });
},
onShare: ({ name, path: item_path }) => {
// Dashboard uses a responsive modal instead of the desktop UIWindow.
UIShareModal({
@@ -5371,17 +5377,8 @@ const TabFiles = {
// The right sharing takes, as on the folder's own menu: the
// folder is yours, or shared with you to manage.
disabled: ! is_owned_by_me(targetPath) && shared_root_for(targetPath)?.mode !== 'manage',
onClick: async function () {
// Publishing works off the path, but the uid is what
// refreshes the folder's website badge if its row is on
// screen — the dashboard tracks paths, so look it up.
let uid;
try {
uid = (await puter.fs.stat({ path: targetPath, consistency: 'eventual' }))?.uid;
} catch ( err ) {
// Badge refresh is best-effort; publish still works.
}
await publish_as_website({ uid, name: targetName, path: targetPath });
onClick: function () {
openPublishWebsiteDialog({ name: targetName, path: targetPath, $container: _this.$el_window });
},
});
}
+2 -33
View File
@@ -26,13 +26,12 @@ import create_access_token from '../../helpers/createAccessToken.js';
import postUserProtected from '../../helpers/postUserProtected.js';
import update_username_in_gui from '../../helpers/updateUsernameInGui.js';
import UIWindowManageSessions from '../UIWindowManageSessions.js';
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
// Keep in sync with ProfileService on the backend.
const DISPLAY_NAME_MAX_LENGTH = 64;
const BIO_MAX_LENGTH = 280;
const TOKEN_LABEL_MAX_LENGTH = 64;
const TOKEN_COPIED_RESET_MS = 2000;
/**
* @typedef {object} AccountDialogOptions
@@ -343,43 +342,13 @@ export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
const token = await create_access_token({ label: label.trim(), expiresIn: expiresIn || null });
let h = `<div class="dashboard-token-warning">${i18n('token_shown_once_warning')}</div>`;
h += '<div class="dashboard-token-row">';
h += `<input type="text" class="dashboard-dialog-input dashboard-token-value" readonly value="${html_encode(token)}" aria-label="${i18n('api_token')}" spellcheck="false">`;
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-token-copy">${i18n('copy')}</button>`;
h += '</div>';
h += copyRowHtml({ value: token, label: t('api_token'), copiedLabel: t('token_copied') });
h += `<p class="dashboard-form-hint">${i18n('token_manage_hint')} <button type="button" class="dashboard-token-sessions">${i18n('ui_manage_sessions')}</button>.</p>`;
// The token is shown only once; only Done, ✕ or Escape put it away.
dialog.finish({ html: h, describe: '.dashboard-token-warning', keepOnBackdrop: true });
},
});
let copied_timer = null;
dialog.$dialog.on('click', '.dashboard-token-copy', function () {
const $btn = $(this);
const $value = dialog.$dialog.find('.dashboard-token-value');
const copied = () => {
$btn.text(t('token_copied'));
clearTimeout(copied_timer);
copied_timer = setTimeout(() => $btn.text(t('copy')), TOKEN_COPIED_RESET_MS);
};
// No clipboard access (an insecure origin has no navigator.clipboard
// at all): select it, try the legacy copy, and leave it selected so
// the user can copy it by hand.
const by_hand = () => {
$value.get(0)?.select();
try {
if ( document.execCommand('copy') ) copied();
} catch { /* stays selected */ }
};
if ( navigator.clipboard?.writeText ) {
navigator.clipboard.writeText(String($value.val())).then(copied, by_hand);
} else {
by_hand();
}
});
dialog.$dialog.on('focus', '.dashboard-token-value', function () {
this.select();
});
dialog.$dialog.on('click', '.dashboard-token-sessions', () => {
UIWindowManageSessions({
window_options: {
@@ -275,12 +275,12 @@ describe('create API token', () => {
expect(String(fetch.mock.calls[0][0])).toBe('https://api.puter.test/auth/create-access-token');
expect(bodyOf(0)).toMatchObject({ label: 'CLI', expiresIn: '30d' });
expect($('.dashboard-token-value').val()).toBe('tok-abc');
expect($('.dashboard-copy-value').val()).toBe('tok-abc');
$('.dashboard-token-copy').trigger('click');
$('.dashboard-copy-btn').trigger('click');
await settle();
expect(writeText).toHaveBeenCalledWith('tok-abc');
expect($('.dashboard-token-copy').text()).toBe('token_copied');
expect($('.dashboard-copy-btn').text()).toBe('token_copied');
});
it('shows why minting failed', async () => {
+108 -10
View File
@@ -28,6 +28,8 @@ import { ICONS, SVG } from './UIDashboardDialog.js';
const FORM_ICONS = {
done: SVG('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/>'),
copy: SVG('<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
copied: SVG('<path d="M20 6L9 17l-5-5"/>'),
show: SVG('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
hide: SVG('<path d="M17.9 17.9A10.4 10.4 0 0 1 12 19c-6.4 0-10-7-10-7a18.5 18.5 0 0 1 5.1-5.9"/><path d="M9.9 5.2A9.1 9.1 0 0 1 12 5c6.4 0 10 7 10 7a18.6 18.6 0 0 1-2.2 3.2"/><path d="M14.1 14.1a3 3 0 1 1-4.2-4.2"/><path d="M2 2l20 20"/>'),
};
@@ -36,8 +38,11 @@ const FORM_ICONS = {
* @typedef {object} FormDialogField
* @property {string} name Key in `values()`.
* @property {string} label HTML the caller has already encoded.
* @property {'text'|'email'|'password'|'textarea'|'select'} [type] Defaults to text.
* @property {'text'|'email'|'password'|'textarea'|'select'|'switch'} [type] Defaults to text.
* @property {string} [value]
* @property {boolean} [checked] A switch's initial state.
* @property {string} [suffix] Plain text shown at the end of a text field, as
* in a domain after the name typed into it.
* @property {string} [placeholder] Already encoded.
* @property {string} [hint] HTML the caller has already encoded, shown under the field.
* @property {boolean} [hidden] Rendered but not shown; see `showField`.
@@ -51,18 +56,21 @@ const FORM_ICONS = {
* @typedef {object} FormDialog
* @property {jQuery} $dialog
* @property {() => void} close
* @property {() => Record<string, string>} values Every field's current value.
* @property {() => Record<string, string|boolean>} values Every field's
* current value; a switch's is a boolean.
* @property {(name: string) => jQuery} field A field's control.
* @property {(name: string, show: boolean) => void} showField
* @property {(text: string, opts?: { kind?: 'error'|'info', field?: string }) => void} setStatus
* Plain text; an empty string clears it. Naming a field marks it invalid and focuses it.
* @property {(opts: { message?: string, html?: string, describe?: string, keepOnBackdrop?: boolean }) => void} finish
* Swaps the form for a done state (a plain-text `message`, or the caller's
* encoded `html`) with a single Done button. `describe` selects the part of
* Swaps the form for a done state (a plain-text `message`, then the
* caller's encoded `html`, either or both) with a single Done button. `describe` selects the part of
* `html` the focused Done button reads out. `keepOnBackdrop` stops a stray
* click outside the card from closing it, for content shown only once.
*/
const COPIED_RESET_MS = 2000;
const attrs_html = (attrs) => Object.entries(attrs ?? {})
.filter(([, value]) => value !== undefined && value !== null && value !== false)
.map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${html_encode(String(value))}"`))
@@ -71,11 +79,28 @@ const attrs_html = (attrs) => Object.entries(attrs ?? {})
const field_html = (field, id) => {
const control_id = `${id}-${field.name}`;
const hint_id = `${control_id}-hint`;
const suffix_id = `${control_id}-suffix`;
const type = field.type ?? 'text';
const common = ` id="${control_id}" name="${html_encode(field.name)}" class="dashboard-dialog-input dashboard-form-input"`
+ (field.hint ? ` aria-describedby="${hint_id}"` : '')
// A switch is drawn by its slider, not the text-box style.
const control_class = type === 'switch' ? 'dashboard-form-input' : 'dashboard-dialog-input dashboard-form-input';
const described_by = [field.suffix && suffix_id, field.hint && hint_id].filter(Boolean).join(' ');
const common = ` id="${control_id}" name="${html_encode(field.name)}" class="${control_class}"`
+ (described_by ? ` aria-describedby="${described_by}"` : '')
+ attrs_html(field.attrs);
if ( type === 'switch' ) {
return `<div class="dashboard-form-field dashboard-form-switch-field" data-field="${html_encode(field.name)}"${field.hidden ? ' hidden' : ''}>`
+ '<div class="dashboard-form-switch-text">'
+ `<label class="dashboard-form-label" for="${control_id}">${field.label}</label>`
+ (field.hint ? `<p class="dashboard-form-hint" id="${hint_id}">${field.hint}</p>` : '')
+ '</div>'
+ '<label class="dashboard-switch">'
+ `<input type="checkbox" role="switch"${common}${field.checked ? ' checked' : ''}>`
+ '<span class="dashboard-switch-slider"></span>'
+ '</label>'
+ '</div>';
}
let control;
if ( type === 'textarea' ) {
control = `<textarea${common} placeholder="${field.placeholder ?? ''}">${html_encode(field.value ?? '')}</textarea>`;
@@ -85,7 +110,9 @@ const field_html = (field, id) => {
} else {
control = `<input type="${type}"${common} placeholder="${field.placeholder ?? ''}" value="${html_encode(field.value ?? '')}">`;
}
if ( type === 'password' ) {
if ( field.suffix ) {
control = `<div class="dashboard-form-affix">${control}<span class="dashboard-form-suffix" id="${suffix_id}">${html_encode(field.suffix)}</span></div>`;
} else if ( type === 'password' ) {
control = '<div class="dashboard-form-secret">'
+ control
+ `<button type="button" class="dashboard-form-reveal" aria-controls="${control_id}"`
@@ -100,6 +127,29 @@ const field_html = (field, id) => {
+ '</div>';
};
/**
* A read-only value with a Copy button, for a `finish` screen. The dialog
* wires the button.
*
* @param {object} opts
* @param {string} opts.value Plain text.
* @param {string} opts.label Plain text naming the value for screen readers.
* @param {string} [opts.copiedLabel] Plain text the button shows once copied.
* @param {boolean} [opts.compact] Icon buttons, leaving the value the width;
* `after` should hold `dashboard-icon-btn`s too.
* @param {string} [opts.after] HTML the caller has already encoded, after the button.
* @returns {string}
*/
export const copyRowHtml = ({ value, label, copiedLabel, compact = false, after = '' }) =>
`<div class="dashboard-copy-row${compact ? ' dashboard-copy-row-compact' : ''}">`
+ `<input type="text" class="dashboard-dialog-input dashboard-copy-value" readonly value="${html_encode(value)}" aria-label="${html_encode(label)}" spellcheck="false">`
+ `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-copy-btn${compact ? ' dashboard-icon-btn' : ''}"`
+ ` data-copied="${html_encode(copiedLabel ?? i18n('copied', [], false))}"`
+ (compact ? ` aria-label="${i18n('copy')}" title="${i18n('copy')}">${FORM_ICONS.copy}` : `>${i18n('copy')}`)
+ '</button>'
+ after
+ '</div>';
/**
* @param {object} opts
* @param {string} opts.title Plain text.
@@ -164,7 +214,7 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
values () {
const out = {};
$controls().each(function () {
out[this.name] = String($(this).val() ?? '');
out[this.name] = this.type === 'checkbox' ? this.checked : String($(this).val() ?? '');
});
return out;
},
@@ -196,10 +246,12 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
},
finish ({ message, html, describe, keepOnBackdrop = false }) {
keep_on_backdrop = keepOnBackdrop;
const content = html ?? `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message ?? '')}</p></div>`;
const content = (message === undefined ? '' : `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message)}</p></div>`)
+ (html ?? '');
$dialog.attr('aria-describedby', null);
$dialog.find('.dashboard-dialog-body').html(`<div class="dashboard-dialog-content" role="status">${content}</div>`);
const described = ! html || (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
const described = message !== undefined
|| (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
// A status region that arrives with its text isn't announced;
// the focused Done button reads the message out instead.
$dialog.find('.dashboard-dialog-footer').html(
@@ -219,6 +271,52 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
$dialog.on('click', '.dashboard-form-cancel, .dashboard-form-done-btn', () => close());
let copied_timer = null;
$dialog.on('click', '.dashboard-copy-btn', function () {
const $btn = $(this);
const $value = $btn.siblings('.dashboard-copy-value');
const show = (done) => {
const text = done ? $btn.attr('data-copied') : i18n('copy', [], false);
if ( $btn.hasClass('dashboard-icon-btn') ) {
$btn.attr({ 'aria-label': text, title: text }).html(done ? FORM_ICONS.copied : FORM_ICONS.copy);
} else {
$btn.text(text);
}
};
const copied = () => {
show(true);
clearTimeout(copied_timer);
copied_timer = setTimeout(() => show(false), COPIED_RESET_MS);
};
// No clipboard access (an insecure origin has no navigator.clipboard
// at all): select it, try the legacy copy, and leave it selected so
// the user can copy it by hand.
const by_hand = () => {
$value.get(0)?.select();
try {
if ( document.execCommand('copy') ) copied();
} catch { /* stays selected */ }
};
if ( navigator.clipboard?.writeText ) {
navigator.clipboard.writeText(String($value.val())).then(copied, by_hand);
} else {
by_hand();
}
});
$dialog.on('focus', '.dashboard-copy-value', function () {
this.select();
});
// The suffix is drawn inside the field's box, so a press on it types into
// the field, at the end of the name.
$dialog.on('mousedown', '.dashboard-form-suffix', function (e) {
const input = $(this).siblings('.dashboard-form-input').get(0);
if ( ! input || input.disabled ) return;
e.preventDefault();
input.focus({ preventScroll: true });
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());
@@ -29,7 +29,7 @@ window.html_encode = globalThis.html_encode;
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
window.matchMedia = () => ({ matches: false });
const { default: UIDashboardFormDialog } = await import('./UIDashboardFormDialog.js');
const { default: UIDashboardFormDialog, copyRowHtml } = await import('./UIDashboardFormDialog.js');
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
const submit = () => $('.dashboard-form-dialog').trigger('submit');
@@ -207,6 +207,52 @@ describe('UIDashboardFormDialog', () => {
expect($('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show')).toBe(false);
});
it('shows a message and the caller\'s html together, with a working copy row', async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
open({ onSubmit: (dialog) => dialog.finish({
message: 'Live',
html: copyRowHtml({ value: 'https://a.example', label: 'Address' }),
}) });
submit();
await tick();
expect($('.dashboard-form-done p').text()).toBe('Live');
expect($(`#${$('.dashboard-form-done-btn').attr('aria-describedby')}`).text()).toBe('Live');
expect($('.dashboard-copy-value').val()).toBe('https://a.example');
$('.dashboard-copy-btn').trigger('click');
await tick();
expect(writeText).toHaveBeenCalledWith('https://a.example');
expect($('.dashboard-copy-btn').text()).toBe('copied');
});
it('draws a suffix inside the field and has it read with the field', () => {
open({ fields: [{ name: 'name', label: 'Address', suffix: '.puter.site <b>' }] });
const $input = $('.dashboard-form-input[name="name"]');
expect($input.closest('.dashboard-form-affix')).toHaveLength(1);
expect($(`#${$input.attr('aria-describedby')}`).text()).toBe('.puter.site <b>');
});
it('types into a suffixed field when its suffix is pressed', () => {
open({ fields: [{ name: 'name', label: 'Address', value: 'my-site', suffix: '.puter.site' }] });
const $input = $('.dashboard-form-input[name="name"]');
$input.get(0).blur();
const down = $.Event('mousedown');
$('.dashboard-form-suffix').trigger(down);
expect(down.isDefaultPrevented()).toBe(true);
expect(document.activeElement).toBe($input.get(0));
expect($input.get(0).selectionStart).toBe('my-site'.length);
});
it('reads a switch back as a boolean', () => {
const dialog = open({ fields: [
{ name: 'on', type: 'switch', label: 'On', checked: true },
{ name: 'off', type: 'switch', label: 'Off' },
] });
expect($('.dashboard-form-input[name="on"]').attr('role')).toBe('switch');
expect(dialog.values()).toEqual({ on: true, off: false });
});
it('toggles a password between hidden and shown', () => {
open();
const $input = $('.dashboard-form-input[name="secret"]');
@@ -0,0 +1,195 @@
/*
* 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 Files tab's publish flows, a folder as a website and a .js file as a
// worker: dashboard form dialogs rather than desktop windows.
import ensure_can_publish from '../../helpers/ensureCanPublish.js';
import { SVG } from './UIDashboardDialog.js';
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
/**
* @typedef {object} PublishTarget
* @property {string} name Display name of the folder or file.
* @property {string} path
* @property {jQuery} [$container] Where the overlay mounts.
* @property {HTMLElement} [returnFocusTo] Where focus goes on close.
*/
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 ?? '';
if ( item_path === path || item_path.startsWith(`${path}/`) ) fn($(this), item_path);
});
const KINDS = {
website: {
title: 'window_title_publish_website',
intro: 'publish_website_intro',
published: 'website_published',
rule: PUBLISH_NAME_RULES.website,
// Rejections that mean "pick another name" (SubdomainDriver).
nameErrors: new Set(['conflict', 'subdomain_reserved']),
suffix: () => `.${window.hosting_domain}`,
requiresVerification: () => !! window.require_email_verification_to_publish_website,
async publish (name, path) {
const site = await puter.hosting.create(name, path);
// The server's address carries the deployment's real scheme (a
// self-hosted instance may serve sites over http).
return site?.root_dir?.subdomains?.find((entry) => entry.subdomain === name)?.address
?? `https://${name}.${window.hosting_domain}`;
},
markPublished (path, url) {
each_item_at(path, ($item, item_path) => {
if ( item_path === path ) {
$item.attr('data-has_website', '1').find('.item-has-website-badge').show();
const fsentry = $item.data('fsentry');
if ( fsentry ) fsentry.has_website = true;
} else {
$item.attr('data-website_url', url + item_path.slice(path.length))
.find('.item-has-website-url-badge').show();
}
});
window.update_sites_cache?.();
},
},
worker: {
title: 'window_title_publish_worker',
intro: 'publish_worker_intro',
published: 'worker_published',
rule: PUBLISH_NAME_RULES.worker,
nameErrors: new Set(['conflict']),
suffix: () => '.puter.work',
requiresVerification: () => true,
extraFields: () => [{
name: 'sandboxed',
type: 'switch',
label: i18n('worker_sandboxed'),
hint: i18n('worker_sandboxed_hint'),
checked: true,
}],
async publish (name, path, { sandboxed }) {
const result = await puter.workers.create(name, path, { sandbox: sandboxed });
return result?.url ?? `https://${name}.puter.work`;
},
markPublished (path, url) {
each_item_at(path, ($item, item_path) => {
if ( item_path !== path ) return;
$item.attr({ 'data-is_worker': '1', 'data-worker_url': url })
.find('.item-is-worker').show();
});
},
},
};
/** puter.js rejects with an Error, or with the API's `{ error: { message } }` / `{ message }`. */
const error_message = (err) => err?.error?.message ?? err?.message ?? t('error_unknown_cause');
/**
* @param {'website'|'worker'} kind_name
* @param {PublishTarget} target
*/
const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
const kind = KINDS[kind_name];
if ( ! await ensure_can_publish(kind.requiresVerification()) ) return;
const fields = [{
name: 'name',
label: i18n('publish_address'),
value: normalizePublishName(window.generate_identifier(), kind.rule),
suffix: kind.suffix(),
attrs: {
maxlength: kind.rule.maxLength,
autocomplete: 'off',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: 'false',
'data-gramm_editor': 'false',
},
}];
fields.push(...(kind.extraFields?.() ?? []));
const dialog = UIDashboardFormDialog({
...opts,
title: t(kind.title),
intro: `<p>${i18n(kind.intro, `<strong>${html_encode(name)}</strong>`, false)}</p>`,
submitLabel: t('publish'),
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;
}
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>`;
dialog.finish({
message: t(kind.published),
html: copyRowHtml({ value: url, label: t('publish_address'), compact: true, after: open_link }),
});
},
});
// 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();
const shape = () => {
const next = normalizePublishName(el_name.value, kind.rule);
if ( next === el_name.value ) return;
const caret = normalizePublishName(el_name.value.slice(0, el_name.selectionStart ?? el_name.value.length), kind.rule).length;
el_name.value = next;
el_name.setSelectionRange(caret, caret);
};
// Rewriting the text mid-composition (an IME, or a dead key's accent)
// breaks it; the composed text is shaped once it's in.
$(el_name).on('input', (e) => {
if ( ! e.originalEvent?.isComposing ) shape();
}).on('compositionend', shape);
return dialog;
};
/**
* Publishes a folder as a website, once the account may.
*
* @param {PublishTarget} target
*/
export const openPublishWebsiteDialog = (target) => open_publish_dialog('website', target);
/**
* Deploys a .js file as a worker, once the account may.
*
* @param {PublishTarget} target
*/
export const openPublishWorkerDialog = (target) => open_publish_dialog('worker', target);
@@ -0,0 +1,204 @@
// @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';
vi.mock('../../helpers/ensureCanPublish.js', () => ({ default: vi.fn(async () => true) }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = (key, replacements) => (Array.isArray(replacements) || replacements === undefined ? key : `${key}:${replacements}`);
globalThis.html_encode = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
window.html_encode = globalThis.html_encode;
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
window.matchMedia = () => ({ matches: false });
const { openPublishWebsiteDialog, openPublishWorkerDialog } = await import('./UIPublishDialogs.js');
const { default: ensure_can_publish } = await import('../../helpers/ensureCanPublish.js');
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
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();
};
const addRow = (path) => {
$('body').append(`<div class="item" data-path="${path}" data-has_website="0" data-is_worker="0">`
+ '<img class="item-has-website-badge" style="display:none"><img class="item-has-website-url-badge" style="display:none">'
+ '<img class="item-is-worker" style="display:none"></div>');
return $('.item').last();
};
beforeEach(() => {
document.body.innerHTML = '';
ensure_can_publish.mockClear();
ensure_can_publish.mockResolvedValue(true);
window.hosting_domain = 'puter.site';
window.generate_identifier = () => 'clever-idea-42';
window.update_sites_cache = vi.fn();
window.require_email_verification_to_publish_website = false;
globalThis.puter = {
hosting: { create: vi.fn(async () => ({})) },
workers: { create: vi.fn(async (name) => ({ success: true, url: `https://${name}.puter.work` })) },
};
});
describe('publish website', () => {
it('names the folder and suggests an address on the hosting domain', async () => {
await openPublishWebsiteDialog({ name: 'Site <1>', path: '/alice/Site' });
expect($('.dashboard-dialog-message').html()).toContain('<strong>Site &lt;1&gt;</strong>');
expect(nameField().val()).toBe('clever-idea-42');
expect($('.dashboard-form-suffix').text()).toBe('.puter.site');
});
it('publishes the folder, shows the live address, and badges its rows', async () => {
const $folder = addRow('/alice/Site');
const $child = addRow('/alice/Site/index.html');
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith(' -My Site- ');
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
expect($('.dashboard-form-done p').text()).toBe('website_published');
expect($('.dashboard-copy-value').val()).toBe('https://my-site.puter.site');
expect($('.dashboard-publish-open').attr('href')).toBe('https://my-site.puter.site');
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
$('.dashboard-copy-btn').trigger('click');
await settle();
expect(writeText).toHaveBeenCalledWith('https://my-site.puter.site');
expect($('.dashboard-copy-btn').attr('aria-label')).toBe('copied');
expect($folder.attr('data-has_website')).toBe('1');
expect($folder.find('.item-has-website-badge').css('display')).not.toBe('none');
expect($child.attr('data-website_url')).toBe('https://my-site.puter.site/index.html');
expect(window.update_sites_cache).toHaveBeenCalled();
});
it("links to the server's address for the site when it sends one", async () => {
puter.hosting.create.mockResolvedValueOnce({
subdomain: 'docs',
root_dir: { subdomains: [{ subdomain: 'docs', address: 'http://docs.site.example.test' }] },
});
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('docs');
expect($('.dashboard-copy-value').val()).toBe('http://docs.site.example.test');
expect($('.dashboard-publish-open').attr('href')).toBe('http://docs.site.example.test');
});
it('shapes the address as it is typed', async () => {
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
nameField().val('My_Site 2!').trigger('input');
expect(nameField().val()).toBe('my-site-2');
});
it('leaves text being composed alone and shapes it once composed', async () => {
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
nameField().val('caf\u00B4');
nameField().get(0).dispatchEvent(new InputEvent('input', { bubbles: true, isComposing: true }));
expect(nameField().val()).toBe('caf\u00B4');
nameField().val('café').trigger('compositionend');
expect(nameField().val()).toBe('cafe');
});
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');
expect(nameField().attr('aria-invalid')).toBe('true');
expect(puter.hosting.create).not.toHaveBeenCalled();
});
it('shows why publishing failed and lets the user try again', async () => {
puter.hosting.create.mockRejectedValueOnce({ error: { code: 'internal_error', message: 'Something broke' } });
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('fine');
expect(status()).toBe('Something broke');
expect(nameField().prop('disabled')).toBe(false);
expect(nameField().attr('aria-invalid')).toBeUndefined();
});
it.each([
['conflict', 'A site with this subdomain already exists'],
['subdomain_reserved', "Subdomain 'www' is reserved"],
])('points a %s rejection at the address field', async (code, message) => {
puter.hosting.create.mockRejectedValueOnce({ error: { code, message }, code, message });
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('www');
expect(status()).toBe(message);
expect(nameField().attr('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(nameField().get(0));
});
it('asks for verification only when the deployment needs it', async () => {
window.require_email_verification_to_publish_website = true;
ensure_can_publish.mockResolvedValueOnce(false);
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
expect(ensure_can_publish).toHaveBeenCalledWith(true);
expect($('.dashboard-form-dialog')).toHaveLength(0);
});
});
describe('publish worker', () => {
it('always needs a verified account', async () => {
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
expect(ensure_can_publish).toHaveBeenCalledWith(true);
});
it('deploys sandboxed by default and badges the file', async () => {
const $file = addRow('/alice/api.js');
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
expect($('.dashboard-form-suffix').text()).toBe('.puter.work');
await submitWith('my_api');
expect(puter.workers.create).toHaveBeenCalledWith('my_api', '/alice/api.js', { sandbox: true });
expect($('.dashboard-copy-value').val()).toBe('https://my_api.puter.work');
expect($file.attr('data-is_worker')).toBe('1');
expect($file.attr('data-worker_url')).toBe('https://my_api.puter.work');
});
it('deploys unsandboxed when the switch is off', async () => {
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
$('.dashboard-form-input[name="sandboxed"]').prop('checked', false);
await submitWith('api');
expect(puter.workers.create).toHaveBeenCalledWith('api', '/alice/api.js', { sandbox: false });
});
it('shows a failed build in the status line', async () => {
puter.workers.create.mockRejectedValueOnce(new Error('SyntaxError: Unexpected token'));
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
await submitWith('api');
expect(status()).toBe('SyntaxError: Unexpected token');
expect(nameField().attr('aria-invalid')).toBeUndefined();
});
it('points a taken worker name at the address field', async () => {
puter.workers.create.mockRejectedValueOnce({ error: { code: 'conflict', message: 'Worker name is already in use' } });
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
await submitWith('api');
expect(status()).toBe('Worker name is already in use');
expect(nameField().attr('aria-invalid')).toBe('true');
});
});
+68
View File
@@ -0,0 +1,68 @@
/*
* 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 names a website or worker can be published under, as the backend
// checks them: a website's is a DNS label (SubdomainDriver), and a worker's
// also allows underscores (WorkerDriver).
/**
* @typedef {object} PublishNameRule
* @property {RegExp} separators Runs that become one hyphen, as between words.
* @property {RegExp} invalid Characters the name can't hold.
* @property {number} maxLength
*/
// Either name becomes a hostname label, which DNS caps at 63; the backend
// takes a 64-character website name, but nothing can reach it.
const LABEL_MAX_LENGTH = 63;
/** @type {{ website: PublishNameRule, worker: PublishNameRule }} */
export const PUBLISH_NAME_RULES = {
// A website name can't hold an underscore, so it separates words too.
website: { separators: /[\s_]+/g, invalid: /[^a-z0-9-]/g, maxLength: LABEL_MAX_LENGTH },
worker: { separators: /\s+/g, invalid: /[^a-z0-9_-]/g, maxLength: LABEL_MAX_LENGTH },
};
/**
* What a typed name becomes as the user types: lowercase, accents taken off
* their letters, separator runs turned into a hyphen, and anything else the
* rule refuses dropped.
*
* @param {string} value
* @param {PublishNameRule} rule
* @returns {string}
*/
export const normalizePublishName = (value, rule) => String(value ?? '')
.toLowerCase()
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.replace(rule.separators, '-')
.replace(rule.invalid, '');
/**
* The name to submit: normalized, without the leading or trailing hyphens a
* hostname can't start or end with. Empty when nothing usable is left.
*
* @param {string} value
* @param {PublishNameRule} rule
* @returns {string}
*/
export const finalizePublishName = (value, rule) => normalizePublishName(value, rule)
.slice(0, rule.maxLength)
.replace(/^-+|-+$/g, '');
@@ -0,0 +1,64 @@
/*
* 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 { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
const { website, worker } = PUBLISH_NAME_RULES;
describe('normalizePublishName', () => {
it('lowercases and turns whitespace runs into one hyphen', () => {
expect(normalizePublishName('My Cool Site', website)).toBe('my-cool-site');
});
it('drops characters a website name cannot hold', () => {
expect(normalizePublishName('my site!.v2', website)).toBe('my-sitev2');
});
it('keeps the letter under an accent', () => {
expect(normalizePublishName('Café Ünïcode', website)).toBe('cafe-unicode');
expect(normalizePublishName('Ñandú_API', worker)).toBe('nandu_api');
});
it('turns underscores into hyphens in a website name', () => {
expect(normalizePublishName('My_Cool _Site', website)).toBe('my-cool-site');
});
it('keeps underscores in a worker name', () => {
expect(normalizePublishName('My_API', worker)).toBe('my_api');
});
it('keeps hyphens at the ends while the user is still typing', () => {
expect(normalizePublishName('my-', website)).toBe('my-');
});
});
describe('finalizePublishName', () => {
it('trims hyphens from both ends', () => {
expect(finalizePublishName(' -my site- ', website)).toBe('my-site');
});
it('is empty when nothing usable is left', () => {
expect(finalizePublishName('!!! ', website)).toBe('');
});
it('caps the length at a hostname label\'s 63', () => {
expect(finalizePublishName('a'.repeat(80), website)).toHaveLength(63);
expect(finalizePublishName('a'.repeat(80), worker)).toHaveLength(63);
});
});
+118 -5
View File
@@ -9284,6 +9284,95 @@ body.dashboard-mode .notifications-close-all {
}
}
/* A text field with fixed text after it (a domain), drawn as one box. */
.dashboard-form-affix {
display: flex;
align-items: center;
height: 38px;
border: 1px solid var(--dashboard-border);
border-radius: 10px;
background: var(--dashboard-input-background);
box-sizing: border-box;
overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.dashboard-form-affix:focus-within {
border-color: var(--select-color);
background: var(--dashboard-card-background);
box-shadow: 0 0 0 3px var(--select-ring);
}
.dashboard-form-affix:has([aria-invalid='true']) {
border-color: var(--dashboard-danger-text);
}
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input,
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input:focus {
flex: 1 1 auto;
min-width: 0;
height: 100%;
padding: 0 2px 0 12px;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
text-align: right;
}
/* Locked while the form submits: dim the whole box, suffix included, as
one field rather than the input alone. */
.dashboard-form-affix:has(:disabled) {
opacity: 0.6;
}
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input:disabled {
opacity: 1;
}
.dashboard-form-suffix {
flex-shrink: 0;
max-width: 60%;
padding-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: var(--dashboard-text-muted);
cursor: text;
}
@media (pointer: coarse) {
.dashboard-form-affix {
height: 44px;
}
.dashboard-form-suffix {
font-size: 16px;
}
}
/* A switch sits beside its label and hint, as on the settings cards. */
.dashboard-form-switch-field {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.dashboard-form-switch-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.dashboard-form-switch-field .dashboard-form-label {
font-size: 13px;
color: var(--dashboard-text-primary);
cursor: pointer;
}
/* Password fields carry a show/hide toggle inside the box. */
.dashboard-form-secret {
position: relative;
@@ -9343,6 +9432,7 @@ body.dashboard-mode .notifications-close-all {
the content column's gap it would otherwise still claim. */
.dashboard-form-status {
padding: 9px 12px;
white-space: pre-line;
border: 1px solid transparent;
border-radius: 8px;
font-size: 13px;
@@ -9430,12 +9520,13 @@ body.dashboard-mode .notifications-close-all {
line-height: 1.45;
}
.dashboard-token-row {
/* A value to copy: the API token, a published address. */
.dashboard-copy-row {
display: flex;
gap: 8px;
}
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
.dashboard-dialog .dashboard-dialog-input.dashboard-copy-value {
flex: 1 1 auto;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -9444,15 +9535,37 @@ body.dashboard-mode .notifications-close-all {
/* 16px, like the other dialog inputs on touch, so iOS doesn't zoom on focus. */
@media (pointer: coarse) {
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
.dashboard-dialog .dashboard-dialog-input.dashboard-copy-value {
font-size: 16px;
}
}
.dashboard-token-copy {
.dashboard-copy-row .dashboard-dialog-btn {
flex-shrink: 0;
}
/* A square button holding only an icon, as in a compact copy row. */
.dashboard-dialog-btn.dashboard-icon-btn {
min-width: 36px;
width: 36px;
padding: 0;
color: var(--dashboard-text-secondary);
text-decoration: none;
}
.dashboard-icon-btn svg {
width: 16px;
height: 16px;
}
@media (pointer: coarse) {
.dashboard-dialog-btn.dashboard-icon-btn {
min-width: 44px;
width: 44px;
padding: 0;
}
}
.dashboard-token-sessions {
display: inline;
padding: 0;
@@ -9476,7 +9589,7 @@ body.dashboard-mode .notifications-close-all {
}
@media (max-width: 600px) {
.dashboard-token-row {
.dashboard-copy-row:not(.dashboard-copy-row-compact) {
flex-direction: column;
}
}
+51
View File
@@ -0,0 +1,51 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
/**
* Walks a temporary account through saving itself, and an unconfirmed one
* through confirming its email, before it publishes.
*
* @param {boolean} required - Whether this deployment needs a verified email
* for this kind of publish; when false, resolves true straight away.
* @returns {Promise<boolean>} False if the user backed out of either step.
*/
const ensure_can_publish = async function (required) {
if ( ! required ) return true;
if ( window.user.is_temp && ! await UIWindowSaveAccount({
send_confirmation_code: true,
message: i18n('save_account_to_publish'),
window_options: {
backdrop: true,
close_on_backdrop_click: false,
},
}) ) {
return false;
}
// Saving refreshes window.user; a deployment that doesn't confirm at
// signup leaves the new account unconfirmed, and it still has to confirm.
if ( ! window.user.email_confirmed ) {
return !! await UIWindowEmailConfirmationRequired();
}
return true;
};
export default ensure_can_publish;
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
vi.mock('../UI/UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
globalThis.i18n = (key) => key;
const { default: ensure_can_publish } = await import('./ensureCanPublish.js');
const { default: UIWindowSaveAccount } = await import('../UI/UIWindowSaveAccount.js');
const { default: UIWindowEmailConfirmationRequired } = await import('../UI/UIWindowEmailConfirmationRequired.js');
beforeEach(() => {
UIWindowSaveAccount.mockReset();
UIWindowEmailConfirmationRequired.mockReset();
});
describe('ensure_can_publish', () => {
it('lets anyone through when verification is not required', async () => {
window.user = { is_temp: true };
expect(await ensure_can_publish(false)).toBe(true);
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
});
it('has a temporary account saved first', async () => {
window.user = { is_temp: true };
UIWindowSaveAccount.mockResolvedValueOnce(false);
expect(await ensure_can_publish(true)).toBe(false);
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
// Saving replaces window.user with the saved, confirmed account.
UIWindowSaveAccount.mockImplementationOnce(async () => {
window.user = { is_temp: false, email_confirmed: true };
return true;
});
expect(await ensure_can_publish(true)).toBe(true);
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
});
it('still has a just-saved account confirm its email when saving left it unconfirmed', async () => {
window.user = { is_temp: true };
UIWindowSaveAccount.mockImplementationOnce(async () => {
window.user = { is_temp: false, email_confirmed: false };
return true;
});
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false);
expect(await ensure_can_publish(true)).toBe(false);
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
});
it('has an unconfirmed email confirmed first', async () => {
window.user = { is_temp: false, email_confirmed: false };
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false);
expect(await ensure_can_publish(true)).toBe(false);
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
});
it('lets a confirmed account through', async () => {
window.user = { is_temp: false, email_confirmed: true };
expect(await ensure_can_publish(true)).toBe(true);
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
});
});
+19 -18
View File
@@ -20,11 +20,10 @@
import UIAlert from '../UI/UIAlert.js';
import UIWindowItemProperties from '../UI/UIWindowItemProperties.js';
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
import UIWindowPublishWorker from '../UI/UIWindowPublishWorker.js';
import UIWindowShare from '../UI/UIWindowShare.js';
import publish_as_website from './publishAsWebsite.js';
import ensure_can_publish from './ensureCanPublish.js';
import open_item from './openItem.js';
import openWithAI from './openWithAI.js';
import launch_app from './launchApp.js';
@@ -47,6 +46,8 @@ import { is_default_app } from './defaultApp.js';
* @param {Function} options.onOpen - Optional custom open handler (used by Dashboard)
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
* @returns {Promise<Array>} Array of context menu items
@@ -142,11 +143,16 @@ const generate_file_context_menu = async function (options) {
// enforces that). `write` on someone else's folder is not enough.
disabled: !fsentry.is_dir || fsentry.has_website || !may_share,
onClick: async function () {
await publish_as_website({
const target = {
uid: fsentry.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
});
};
if ( options.onPublishWebsite ) {
options.onPublishWebsite(target);
} else {
await publish_as_website(target);
}
},
});
}
@@ -160,22 +166,17 @@ const generate_file_context_menu = async function (options) {
html: i18n('publish_as_serverless_worker'),
disabled: is_worker,
onClick: async function () {
if ( window.user.is_temp &&
!await UIWindowSaveAccount({
send_confirmation_code: true,
message: i18n('save_account_to_publish'),
window_options: {
backdrop: true,
close_on_backdrop_click: false,
},
}) ) {
const target = {
uid: fsentry.uid,
name: $(el_item).attr('data-name'),
path: $(el_item).attr('data-path'),
};
if ( options.onPublishWorker ) {
options.onPublishWorker(target);
return;
}
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) {
return;
}
UIWindowPublishWorker(fsentry.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path'));
if ( ! await ensure_can_publish(true) ) return;
UIWindowPublishWorker(target.uid, target.name, target.path);
},
});
}
+2 -21
View File
@@ -18,8 +18,7 @@
*/
import UIWindowPublishWebsite from '../UI/UIWindowPublishWebsite.js';
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
import ensure_can_publish from './ensureCanPublish.js';
/**
* Opens the "Publish as website" flow for a directory, first walking the user
@@ -34,25 +33,7 @@ import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRe
* @returns {Promise<void>}
*/
const publish_as_website = async function ({ uid, name, path }) {
if ( window.require_email_verification_to_publish_website ) {
if ( window.user.is_temp &&
!await UIWindowSaveAccount({
send_confirmation_code: true,
message: i18n('save_account_to_publish'),
window_options: {
backdrop: true,
close_on_backdrop_click: false,
},
}) )
{
return;
}
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() )
{
return;
}
}
if ( ! await ensure_can_publish(window.require_email_verification_to_publish_website) ) return;
await UIWindowPublishWebsite(uid, name, path);
};
+9
View File
@@ -140,6 +140,7 @@ const en = {
contact_us_verification_required: 'You must have a verified email address to use this.',
contain: 'Contain',
continue: 'Continue',
copied: 'Copied',
copy: 'Copy',
copy_link: 'Copy Link',
copying: 'Copying',
@@ -416,6 +417,10 @@ const en = {
publish: 'Publish',
publish_as_website: 'Publish as website',
publish_as_serverless_worker: 'Publish as Worker',
publish_address: 'Address',
publish_name_required: 'Enter an address.',
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
puter_description: 'Puter is a privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time.',
ram: 'RAM',
reading: 'Reading %strong%',
@@ -751,6 +756,10 @@ const en = {
download_as_tar: 'Download as Tar',
sequencing: 'Sequencing %strong%',
worker: 'Worker',
worker_published: 'Your worker is live',
worker_sandboxed: 'Sandboxed',
worker_sandboxed_hint: 'Keeps the worker\'s data separate from the rest of your account.',
website_published: 'Your website is live',
zipping: 'Zipping %strong%',
tarring: 'Tarring %strong%',