mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8b2bd314e | ||
|
|
de6781719c | ||
|
|
1a46d81fad | ||
|
|
2dbfd7d2f1 | ||
|
|
13e2867b1c | ||
|
|
0625492676 | ||
|
|
24b0a8ab61 | ||
|
|
64329c74ef | ||
|
|
07fc195c77 | ||
|
|
92282ecb7d | ||
|
|
a5ac373bfd |
No files matched your search
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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 <1></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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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%',
|
||||
|
||||
|
||||
Reference in new issue
Block a user