Compare commits

...
Author SHA1 Message Date
Nariman Jelveh efc71a5537 Announce publish domain checks in a status region that stays on screen
Each check redrew the domain section, status line included, so its result
landed in a brand-new live region that screen readers don't announce, while
the redrawn records sat in the done screen's status region. The summary is
now redrawn on its own, outside the announcements.
2026-11-06 19:48:49 -07:00
Nariman Jelveh 97ea3ca26f Drop "After publishing" from the domain hint once the site is live
When adding the domain needed another try, the hint still promised DNS
records "after publishing" for a site already published; it now keeps
only the price.
2026-11-06 19:48:09 -07:00
Nariman Jelveh 066b37a3f9 Keep the upgrade prompt's text beside its icon on phones
Wrapping put the text on its own line under the icon, leaving the icon
alone on the first row; now only the button wraps.
2026-11-06 19:47:49 -07:00
Nariman Jelveh 0ec3d45407 Listen for plan changes only once the publish dialog has offered the upgrade
Every website publish dialog added a window listener that held its closed
dialog until the next plan change, which may never come. The dialog is
modal, so the plan can only change after its Upgrade button opened the flow;
the listener is added then.
2026-11-06 19:47:27 -07:00
Nariman Jelveh f2263ff5cf Keep the publish done screen up through a stray backdrop click when it lists DNS records
The records to set at the DNS provider appear only on this screen; a click
outside the card while switching to the provider's tab threw them away.
Done, Escape and the close button still close it.
2026-11-06 19:47:01 -07:00
Nariman Jelveh 55354594b2 Keep the ownership record and the Verify step in step while typing
Any edit hid the claim's TXT record for good, but the button went back to
Verify and Add as soon as the claimed hostname was typed again, asking to
verify a record no longer on screen. Both now follow the typed hostname.
2026-11-06 19:46:46 -07:00
Nariman Jelveh bfdc10ac4e Finish with the live site when the plan can't add its domain
Once the site was live, a 402 from adding the domain left the button
reading Add Domain, and pressing it said to turn the switch off "to publish
without one". The button now reads Done and finishes with the site; an
upgrade brings the add back.
2026-11-06 19:46:27 -07:00
Nariman Jelveh f44ca5daa2 Keep focus on the custom domain switch when it shows the upgrade prompt
Turning the switch on moved focus to Upgrade, so a second Space meant to
turn it back off opened the upgrade flow instead. The prompt follows the
switch in tab order.
2026-11-06 19:45:54 -07:00
Nariman Jelveh 817977cff0 Run one domain action at a time on the publish done screen
Check Status and the switch to www each disabled only themselves, so both
could be in flight and a slow status check could redraw the root domain
over the www one it had just become.
2026-11-06 19:45:36 -07:00
Nariman Jelveh e48e5c70c8 Fit DNS record labels of any length in the publish dialog
The label column was a fixed 44px, so a longer translation ("Pangalan")
ran under the field beside it. Each record card is now one grid whose label
column grows to its longest label, keeping the name and value aligned.
2026-11-06 19:45:14 -07:00
Nariman Jelveh c091c2e9b1 Name the served and forwarding domains the right way round in the redirect note
The note reads "served on {first}, forwarded from {second}", but both the
publish dialog and the attach dialog passed the root domain first.
2026-11-06 19:44:56 -07:00
Nariman Jelveh 5f03613716 Offer a custom domain when publishing a folder from the dashboard
The Files tab's Publish Website dialog has a "Use a custom domain" switch.
It appears only when the server has custom domains. Turned on, it asks for
the domain, with the monthly price and suggestions from the domains already
on the account. Picking one of those moves it to the new site instead of
adding it again.

Publishing creates the site, then adds the domain. The done screen shows the
live address and the domain's status with the DNS records to set, each with
a Copy button, and a Check Status button. A failure after the site is live
keeps the site: the address locks, and the button retries only the domain.
A domain held by another account shows its ownership record to verify.

On a plan without custom domains, the switch shows an upgrade prompt in
place of the field and opens the upgrade flow where the deployment has one.
Once the plan changes, the field unlocks in place.

The /custom-domains request, error wording, price format and status-check
merge move to helpers/customDomains.js, shared with the desktop's attach
dialog.
2026-11-06 18:54:57 -07:00
9 changed files with 1480 additions and 104 deletions

No files matched your search

+50 -22
View File
@@ -17,13 +17,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// The Files tab's publish flows, a folder as a website and a .js file as a
// worker: dashboard form dialogs rather than desktop windows.
// The Files tab's publish flows, a folder as a website (optionally on a
// custom domain) and a .js file as a worker: dashboard form dialogs rather
// than desktop windows.
import ensure_can_publish from '../../helpers/ensureCanPublish.js';
import forget_parent_listing from '../../helpers/forgetParentListing.js';
import { SVG } from './UIDashboardDialog.js';
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
import { openLinkHtml, publishDomainFields, setupPublishDomain } from './publishDomain.js';
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
/**
@@ -36,8 +37,6 @@ import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from '.
const t = (key) => i18n(key, [], false);
const OPEN_ICON = SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>');
/** Rows showing `path` or anything under it, on the dashboard or the desktop. */
const each_item_at = (path, fn) => $('.item').each(function () {
const item_path = this.dataset.path ?? '';
@@ -54,6 +53,8 @@ const KINDS = {
nameErrors: new Set(['conflict', 'subdomain_reserved']),
suffix: () => `.${window.hosting_domain}`,
requiresVerification: () => !! window.require_email_verification_to_publish_website,
extraFields: publishDomainFields,
setup: setupPublishDomain,
async publish (name, path) {
const site = await puter.hosting.create(name, path);
// The server's address carries the deployment's real scheme (a
@@ -136,6 +137,11 @@ const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
}];
fields.push(...(kind.extraFields?.() ?? []));
/** Set once the site is live; a retry for a step after it skips publishing. */
let published = null;
/** @type {import('./publishDomain.js').PublishDomain | undefined} */
let extra;
const dialog = UIDashboardFormDialog({
...opts,
title: t(kind.title),
@@ -144,30 +150,52 @@ const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
fields,
async onSubmit () {
const values = dialog.values();
const subdomain = finalizePublishName(values.name, kind.rule);
dialog.field('name').val(subdomain);
if ( ! subdomain ) {
dialog.setStatus(t('publish_name_required'), { field: 'name' });
return;
const hostname = extra?.prepare(!! published) ?? null;
if ( hostname === false ) return;
if ( ! published ) {
const subdomain = finalizePublishName(values.name, kind.rule);
dialog.field('name').val(subdomain);
if ( ! subdomain ) {
dialog.setStatus(t('publish_name_required'), { field: 'name' });
return;
}
let url;
try {
url = await kind.publish(subdomain, path, values);
} catch ( err ) {
const code = err?.error?.code ?? err?.code;
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
return;
}
kind.markPublished(path, url);
published = { name: subdomain, url };
// The address is taken now; what's left to retry is the domain.
dialog.field('name').prop('readOnly', true).attr('aria-readonly', 'true');
update_submit_label();
}
let url;
try {
url = await kind.publish(subdomain, path, values);
} catch ( err ) {
const code = err?.error?.code ?? err?.code;
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
return;
}
kind.markPublished(path, url);
const open_link = `<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${OPEN_ICON}</a>`;
const domain = hostname ? await extra.attach(hostname, published) : null;
if ( hostname && ! domain ) return;
dialog.finish({
message: t(kind.published),
html: copyRowHtml({ value: url, label: t('publish_address'), compact: true, after: open_link }),
html: copyRowHtml({ value: published.url, label: t('publish_address'), compact: true, after: openLinkHtml(published.url) })
+ (domain ? extra.doneHtml(domain) : ''),
// The DNS records are shown only here.
keepOnBackdrop: !! domain,
});
},
});
// Before the site is live the button publishes; after, it adds the domain.
const update_submit_label = () => {
let key = 'publish';
if ( published ) {
if ( extra?.claiming() ) key = 'custom_domain_verify_and_add';
else key = extra?.addable() ? 'custom_domain_add' : 'done';
}
dialog.$dialog.find('.dashboard-form-submit-label').text(t(key));
};
extra = kind.setup?.(dialog, { onChange: update_submit_label });
// Shape the name as it's typed, keeping the caret where it was.
const el_name = dialog.field('name').get(0);
if ( document.activeElement === el_name ) el_name.select();
@@ -35,13 +35,59 @@ const { openPublishWebsiteDialog, openPublishWorkerDialog } = await import('./UI
const { default: ensure_can_publish } = await import('../../helpers/ensureCanPublish.js');
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
const flush = async () => {
for ( let i = 0; i < 5; i++ ) await settle();
};
const nameField = () => $('.dashboard-form-input[name="name"]');
const status = () => $('.dashboard-form-status').text();
const submitWith = async (name) => {
if ( name !== undefined ) nameField().val(name);
$('.dashboard-form-dialog').trigger('submit');
for ( let i = 0; i < 5; i++ ) await settle();
await flush();
};
const json = (code, data) => new Response(JSON.stringify(data), {
status: code,
headers: { 'Content-Type': 'application/json' },
});
/**
* Stands in for the `/custom-domains` API. The list call answers with
* `list`; anything else with its entry in `routes` (keyed `METHOD /path`),
* or a 404.
*/
const serveDomains = ({ list = { items: [], canAdd: true, pricing: { amount: 0.5, currency: 'USD' } }, routes = {} } = {}) => {
globalThis.fetch = vi.fn(async (url, opts = {}) => {
const key = `${opts.method ?? 'GET'} ${String(url).split('/custom-domains')[1]}`;
if ( key === 'GET ' ) return json(200, typeof list === 'function' ? list() : list);
return routes[key]?.(opts.body ? JSON.parse(opts.body) : undefined) ?? json(404, {});
});
};
const requests = (method) => fetch.mock.calls
.filter(([, opts = {}]) => (opts.method ?? 'GET') === method)
.map(([url, opts]) => ({ path: String(url).split('/custom-domains')[1], body: opts.body ? JSON.parse(opts.body) : undefined }));
const pendingDomain = (overrides = {}) => ({
uuid: 'd1',
hostname: 'www.example.com',
site: 'my-site',
status: 'pending',
isApex: false,
records: [{ type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing', required: true }],
...overrides,
});
const domainSwitch = () => $('.dashboard-form-input[name="useDomain"]');
const domainField = () => $('.dashboard-form-input[name="domain"]');
const shown = (name) => $(`.dashboard-form-field[data-field="${name}"]`).prop('hidden') === false;
const submitLabel = () => $('.dashboard-form-submit-label').text();
const turnOnDomain = () => domainSwitch().prop('checked', true).trigger('change');
/** Opens the website dialog and waits for the custom domain list. */
const openWebsite = async () => {
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await flush();
};
const addRow = (path) => {
@@ -59,6 +105,10 @@ beforeEach(() => {
window.generate_identifier = () => 'clever-idea-42';
window.update_sites_cache = vi.fn();
window.require_email_verification_to_publish_website = false;
window.api_origin = 'https://api.puter.test';
delete window.UIUpgradeAccount;
// A Puter without custom domains, unless a test serves them.
globalThis.fetch = vi.fn(async () => json(404, {}));
globalThis.puter = {
fsCacheKey: (kind, path) => `${kind}:${path}`,
_cache: { del: vi.fn() },
@@ -126,7 +176,7 @@ describe('publish website', () => {
expect(nameField().val()).toBe('cafe');
});
it('asks for an address when nothing usable was typed', async () => {
it('asks for an address when nothing usable was typed', async () => {
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
await submitWith('---');
expect(status()).toBe('publish_name_required');
@@ -164,6 +214,358 @@ describe('publish website', () => {
});
});
describe('publish website on a custom domain', () => {
it('leaves the option out where the server has no custom domains', async () => {
await openWebsite();
expect(shown('useDomain')).toBe(false);
expect(shown('domain')).toBe(false);
await submitWith('my-site');
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
expect(requests('POST')).toEqual([]);
});
it('asks for the domain once the switch is on, with what it costs', async () => {
serveDomains();
await openWebsite();
expect(shown('useDomain')).toBe(true);
expect(shown('domain')).toBe(false);
turnOnDomain();
expect(shown('domain')).toBe(true);
expect(document.activeElement).toBe(domainField().get(0));
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text())
.toBe('publish_custom_domain_dns_hint custom_domain_price_note');
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(true);
});
it('publishes the site, adds the domain, and shows the records to set', async () => {
serveDomains({ routes: { 'POST ': () => json(201, pendingDomain()) } });
await openWebsite();
turnOnDomain();
domainField().val('https://WWW.Example.com/about');
await submitWith('my-site');
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
expect(requests('POST')).toEqual([{ path: '', body: { site: 'my-site', hostname: 'www.example.com' } }]);
expect($('.dashboard-form-done p').text()).toBe('website_published');
expect($('.dashboard-copy-value').first().val()).toBe('https://my-site.puter.site');
expect($('.dashboard-publish-domain-host').text()).toBe('www.example.com');
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('pending');
expect($('.dashboard-dns-record-type').text()).toBe('CNAME');
expect($('.dashboard-dns-record .dashboard-copy-value').map((_, el) => el.value).get())
.toEqual(['www', 'my-site.puter.site']);
});
it('keeps the records up through a stray click outside the card', async () => {
serveDomains({ routes: { 'POST ': () => json(201, pendingDomain()) } });
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
await submitWith('my-site');
const $backdrop = $('.dashboard-dialog-overlay');
$backdrop.trigger('mousedown').trigger('click');
await flush();
expect($backdrop.hasClass('dashboard-dialog-show')).toBe(true);
expect($('.dashboard-dns-record')).toHaveLength(1);
});
it('checks the domain again and shows its address once connected', async () => {
serveDomains({
routes: {
'POST ': () => json(201, pendingDomain()),
'GET /d1': () => json(200, pendingDomain({ status: 'active', records: [] })),
},
});
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
await submitWith('my-site');
$('.dashboard-publish-domain-check').trigger('click');
await flush();
expect(requests('GET').map((r) => r.path)).toContain('/d1');
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('active');
expect($('.dashboard-publish-domain .dashboard-copy-value').val()).toBe('https://www.example.com');
expect($('.dashboard-publish-domain-status').text()).toBe('custom_domain_success_note');
expect($('.dashboard-publish-domain-check')).toHaveLength(0);
});
it('announces a check in the status region already on screen', async () => {
serveDomains({
routes: {
'POST ': () => json(201, pendingDomain()),
'GET /d1': () => json(200, pendingDomain()),
},
});
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
await submitWith('my-site');
const el_status = $('.dashboard-publish-domain-status').get(0);
$('.dashboard-publish-domain-check').trigger('click');
await flush();
expect($('.dashboard-publish-domain-status').get(0)).toBe(el_status);
expect(el_status.textContent).toBe('custom_domain_status_pending');
// The redrawn records are not read out again.
expect($('.dashboard-dns-record').closest('[aria-live]').attr('aria-live')).toBe('off');
});
it('runs one domain action at a time on the done screen', async () => {
let answer_check;
serveDomains({
routes: {
'POST ': () => json(201, pendingDomain({ hostname: 'example.com', isApex: true })),
'GET /d1': () => new Promise((resolve) => {
answer_check = () => resolve(json(200, pendingDomain({ hostname: 'example.com', isApex: true })));
}),
'POST /d1/use-www': () => json(200, pendingDomain({ redirectFrom: 'example.com' })),
},
});
await openWebsite();
turnOnDomain();
domainField().val('example.com');
await submitWith('my-site');
$('.dashboard-publish-domain-check').trigger('click');
await flush();
expect($('.dashboard-publish-domain-www').prop('disabled')).toBe(true);
$('.dashboard-publish-domain-www').trigger('click');
await flush();
expect(requests('POST').map((r) => r.path)).not.toContain('/d1/use-www');
answer_check();
await flush();
expect($('.dashboard-publish-domain-www').prop('disabled')).toBe(false);
});
it('stops before publishing when the domain is not one', async () => {
serveDomains();
await openWebsite();
turnOnDomain();
domainField().val('localhost');
await submitWith('my-site');
expect(status()).toBe('custom_domain_error_invalid');
expect(domainField().attr('aria-invalid')).toBe('true');
expect(puter.hosting.create).not.toHaveBeenCalled();
});
it('keeps the live site and retries only the domain when adding it fails', async () => {
const add = vi.fn()
.mockReturnValueOnce(json(409, { code: 'custom_domain_taken', message: 'That hostname is already in use' }))
.mockReturnValueOnce(json(201, pendingDomain({ hostname: 'www.example.org' })));
serveDomains({ routes: { 'POST ': add } });
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
await submitWith('my-site');
expect(status()).toBe('publish_domain_failed\ncustom_domain_error_taken');
expect(domainField().attr('aria-invalid')).toBe('true');
expect(nameField().prop('readOnly')).toBe(true);
expect(submitLabel()).toBe('custom_domain_add');
// Published now, so the hint no longer speaks of "after publishing".
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text()).toBe('custom_domain_price_note');
domainField().val('www.example.org');
await submitWith();
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenLastCalledWith({ site: 'my-site', hostname: 'www.example.org' });
expect($('.dashboard-publish-domain-host').text()).toBe('www.example.org');
});
it('finishes with just the site when the domain is turned off after a failure', async () => {
serveDomains({ routes: { 'POST ': () => json(409, { code: 'custom_domain_taken' }) } });
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
await submitWith('my-site');
domainSwitch().prop('checked', false).trigger('change');
expect(submitLabel()).toBe('done');
await submitWith();
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
expect($('.dashboard-form-done p').text()).toBe('website_published');
expect($('.dashboard-publish-domain')).toHaveLength(0);
});
it('moves a domain already on the account instead of adding it again', async () => {
serveDomains({
list: { items: [pendingDomain({ site: 'old-site', status: 'active' })], canAdd: true },
routes: { 'PATCH /d1': () => json(200, pendingDomain({ status: 'active' })) },
});
await openWebsite();
turnOnDomain();
expect($(`#${domainField().attr('list')} option`).map((_, el) => el.value).get()).toEqual(['www.example.com']);
domainField().val('www.example.com').trigger('input');
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text()).toBe('publish_custom_domain_moves');
await submitWith('my-site');
expect(requests('POST')).toEqual([]);
expect(requests('PATCH')).toEqual([{ path: '/d1', body: { site: 'my-site' } }]);
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('active');
});
it('says the www hostname serves the site and the root domain forwards to it', async () => {
serveDomains({
routes: {
'POST ': () => json(201, pendingDomain({
redirectFrom: 'example.com',
records: [
{ type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing', required: true },
{ type: 'A', name: '@', value: '192.0.2.1', purpose: 'redirect', required: true },
],
})),
},
});
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
const i18n_stub = globalThis.i18n;
globalThis.i18n = (key, replacements) => (Array.isArray(replacements) && replacements.length ? `${key}:${replacements.join('|')}` : key);
try {
await submitWith('my-site');
} finally {
globalThis.i18n = i18n_stub;
}
expect($('.dashboard-publish-domain .dashboard-form-hint').last().text())
.toBe('custom_domain_redirect_note:www.example.com|example.com');
});
it('shows the ownership record for a domain held by another account', async () => {
const add = vi.fn(() => json(409, {
code: 'custom_domain_claim_required',
verification: { name: '_puter.www.example.com', value: 'token-1' },
}));
serveDomains({ routes: { 'POST ': add } });
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
await submitWith('my-site');
expect($('.dashboard-publish-claim').prop('hidden')).toBe(false);
expect($('.dashboard-publish-claim .dashboard-dns-record-type').text()).toBe('TXT');
expect($('.dashboard-publish-claim .dashboard-copy-value').map((_, el) => el.value).get())
.toEqual(['_puter.www.example.com', 'token-1']);
expect(submitLabel()).toBe('custom_domain_verify_and_add');
expect(status()).toBe('publish_site_live');
await submitWith();
expect(status()).toBe('publish_domain_failed\ncustom_domain_claim_not_found');
// A claim is for one hostname; editing it starts over.
domainField().val('www.example.net').trigger('input');
expect($('.dashboard-publish-claim').prop('hidden')).toBe(true);
expect(submitLabel()).toBe('custom_domain_add');
// Back to the claimed hostname, its record and the verify step return together.
domainField().val('www.example.com').trigger('input');
expect($('.dashboard-publish-claim').prop('hidden')).toBe(false);
expect(submitLabel()).toBe('custom_domain_verify_and_add');
});
describe('on a plan without custom domains', () => {
beforeEach(() => {
window.UIUpgradeAccount = vi.fn(function () {
this.open_as_window = vi.fn();
});
});
it('offers the upgrade in place of the field, leaving focus on the switch', async () => {
serveDomains({ list: { items: [], canAdd: false } });
await openWebsite();
domainSwitch().get(0).focus();
turnOnDomain();
expect(shown('domain')).toBe(false);
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(false);
expect(document.activeElement).toBe(domainSwitch().get(0));
$('.dashboard-publish-upgrade').trigger('click');
expect(window.UIUpgradeAccount).toHaveBeenCalled();
});
it('does not publish while the switch asks for a domain it cannot add', async () => {
serveDomains({ list: { items: [], canAdd: false } });
await openWebsite();
turnOnDomain();
await submitWith('my-site');
expect(status()).toBe('publish_custom_domain_needs_plan');
expect(puter.hosting.create).not.toHaveBeenCalled();
});
it('unlocks the field once the upgrade goes through', async () => {
let can_add = false;
serveDomains({ list: () => ({ items: [], canAdd: can_add }) });
await openWebsite();
turnOnDomain();
$('.dashboard-publish-upgrade').trigger('click');
can_add = true;
window.dispatchEvent(new CustomEvent('puter:subscription:changed'));
await flush();
expect(shown('domain')).toBe(true);
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(true);
expect(document.activeElement).toBe(domainField().get(0));
});
it('only listens for a plan change once it has offered the upgrade', async () => {
serveDomains({ list: { items: [], canAdd: false } });
const listen = vi.spyOn(window, 'addEventListener');
try {
await openWebsite();
turnOnDomain();
expect(listen.mock.calls.map(([type]) => type)).not.toContain('puter:subscription:changed');
$('.dashboard-publish-upgrade').trigger('click');
expect(listen.mock.calls.map(([type]) => type)).toContain('puter:subscription:changed');
} finally {
listen.mockRestore();
}
});
it('explains the plan without a button where there is no upgrade flow', async () => {
delete window.UIUpgradeAccount;
serveDomains({ list: { items: [], canAdd: false } });
await openWebsite();
turnOnDomain();
expect($('.dashboard-publish-upgrade')).toHaveLength(0);
await submitWith('my-site');
expect(status()).toBe('custom_domain_error_plan');
});
it('finishes with the live site when adding the domain turns out to need a plan', async () => {
serveDomains({ routes: { 'POST ': () => json(402, { code: 'subscription_required' }) } });
await openWebsite();
turnOnDomain();
domainField().val('www.example.com');
await submitWith('my-site');
expect(status()).toBe('publish_domain_failed\ncustom_domain_error_plan');
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(false);
expect(submitLabel()).toBe('done');
await submitWith();
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
expect(requests('POST')).toHaveLength(1);
expect($('.dashboard-form-done p').text()).toBe('website_published');
expect($('.dashboard-publish-domain')).toHaveLength(0);
});
});
});
describe('publish worker', () => {
it('always needs a verified account', async () => {
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
+425
View File
@@ -0,0 +1,425 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// The custom domain part of the Publish Website dialog: a switch that asks
// for a domain, an upgrade prompt where the plan doesn't include one, and,
// once the site is live, the domain's DNS records with a status check.
import { isPlausibleHostname, normalizeHostnameInput } from '../../helpers/customDomainHostname.js';
import {
CUSTOM_DOMAIN_HOSTNAME_ERRORS,
customDomainErrorText,
customDomainsRequest,
customDomainStatus,
formatCustomDomainPrice,
mergeCustomDomainRefresh,
} from '../../helpers/customDomains.js';
import { has_upgrade_flow, open_upgrade_flow } from '../../helpers/planGate.js';
import { SVG } from './UIDashboardDialog.js';
import { copyRowHtml } from './UIDashboardFormDialog.js';
/** @typedef {import('../../helpers/customDomains.js').CustomDomain} CustomDomain */
/** @typedef {import('../../helpers/customDomains.js').CustomDomainRecord} CustomDomainRecord */
/** @typedef {import('./UIDashboardFormDialog.js').FormDialog} FormDialog */
/** @typedef {import('./UIDashboardFormDialog.js').FormDialogField} FormDialogField */
const t = (key, args = []) => i18n(key, args, false);
const ICONS = {
open: SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>'),
globe: SVG('<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/>'),
sparkle: SVG('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"/>'),
};
const BADGE_KEYS = {
active: 'custom_domain_badge_active',
pending: 'custom_domain_badge_pending',
failed: 'custom_domain_badge_failed',
};
/**
* An icon link opening `url` in a new tab, beside a compact copy row.
*
* @param {string} url
* @returns {string}
*/
export const openLinkHtml = (url) =>
`<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${ICONS.open}</a>`;
/**
* DNS records as cards: the type, then the name and value, each with a Copy
* button the form dialog wires.
*
* @param {CustomDomainRecord[]} records
* @param {{ isApex?: boolean }} [domain] A root domain's routing record can
* be any of the record types that alias a root.
* @returns {string}
*/
export const dnsRecordsHtml = (records, { isApex = false } = {}) => records.map((record) => {
const type = isApex && record.purpose === 'routing' ? t('custom_domain_record_type_apex') : record.type;
const field = (labelKey, value) => '<div class="dashboard-dns-record-field">'
+ `<span class="dashboard-dns-record-label">${i18n(labelKey)}</span>`
+ copyRowHtml({ value, label: `${type} ${t(labelKey)}`, compact: true })
+ '</div>';
return '<div class="dashboard-dns-record">'
+ '<div class="dashboard-dns-record-head">'
+ `<span class="dashboard-dns-record-type">${html_encode(type)}</span>`
+ (record.required === false ? `<span class="dashboard-dns-record-optional">${i18n('custom_domain_optional')}</span>` : '')
+ '</div>'
+ field('name', record.name)
+ field('custom_domain_record_value', record.value)
+ '</div>';
}).join('');
/**
* The done screen's account of a domain: its status, and until it's
* connected, the records still to set.
*
* @param {CustomDomain} domain
* @returns {string}
*/
export const domainSummaryHtml = (domain) => {
const status = customDomainStatus(domain);
let h = '<div class="dashboard-publish-domain-head">'
+ `<span class="dashboard-publish-domain-icon">${ICONS.globe}</span>`
+ `<span class="dashboard-publish-domain-host">${html_encode(domain.hostname)}</span>`
+ `<span class="dashboard-publish-domain-badge" data-status="${status}">${i18n(BADGE_KEYS[status])}</span>`
+ '</div>';
if ( status === 'active' ) {
const url = `https://${domain.hostname}`;
h += copyRowHtml({ value: url, label: domain.hostname, compact: true, after: openLinkHtml(url) });
} else {
if ( status === 'failed' && domain.statusDetail ) {
h += `<p class="dashboard-publish-domain-detail">${html_encode(domain.statusDetail)}</p>`;
}
h += `<p class="dashboard-form-hint">${i18n('custom_domain_records_c2a')}</p>`;
h += `<div class="dashboard-dns-records">${dnsRecordsHtml(domain.records ?? [], domain)}</div>`;
if ( domain.isApex ) {
h += `<p class="dashboard-form-hint">${i18n('custom_domain_apex_hint')}</p>`;
h += `<button type="button" class="dashboard-publish-link-btn dashboard-publish-domain-www">${i18n('custom_domain_no_root_alias')}</button>`;
}
}
if ( domain.redirectFrom ) {
h += `<p class="dashboard-form-hint">${i18n('custom_domain_redirect_note', [domain.hostname, domain.redirectFrom])}</p>`;
}
if ( status !== 'active' ) {
h += '<div class="dashboard-publish-domain-actions">'
+ '<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-publish-domain-check">'
+ '<span class="dashboard-form-spinner" aria-hidden="true"></span>'
+ `<span>${i18n('custom_domain_check_status')}</span>`
+ '</button>'
+ '</div>';
}
return h;
};
/**
* The switch and the domain field. Both start hidden: the switch shows once
* the server answers that it has custom domains.
*
* @returns {FormDialogField[]}
*/
export const publishDomainFields = () => [
{
name: 'useDomain',
type: 'switch',
label: i18n('publish_custom_domain'),
hint: i18n('publish_custom_domain_hint'),
hidden: true,
},
{
name: 'domain',
label: i18n('custom_domain_hostname_label'),
placeholder: 'www.example.com',
hint: i18n('publish_custom_domain_dns_hint'),
hidden: true,
attrs: {
inputmode: 'url',
enterkeyhint: 'go',
autocomplete: 'off',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: 'false',
'data-gramm_editor': 'false',
},
},
];
/**
* @typedef {object} PublishDomain
* @property {() => boolean} addable Whether the switch is on and the plan
* lets a domain be added.
* @property {(live: boolean) => string | null | false} prepare Before each
* submit: null when no domain is to be added (including when the site is
* `live` and the plan can't add one), false when the form can't go ahead
* (the dialog says why), else the hostname to add.
* @property {(hostname: string, site: { name: string, url: string }) => Promise<CustomDomain | null>} attach
* Adds the hostname to the live site, or moves it there when it's already
* on the account. Null when it didn't; the dialog says why.
* @property {(domain: CustomDomain) => string} doneHtml The done screen's domain part.
* @property {() => boolean} claiming Whether an ownership record for the
* typed hostname is on screen, waiting to be verified.
*/
/**
* Wires the custom domain fields of a publish form dialog.
*
* @param {FormDialog} dialog
* @param {object} opts
* @param {() => void} opts.onChange Runs when what the submit button does may
* have changed: the switch, the plan, or a claim to verify.
* @returns {PublishDomain}
*/
export const setupPublishDomain = (dialog, { onChange }) => {
const { $dialog } = dialog;
const $switch = dialog.field('useDomain');
const $input = dialog.field('domain');
const $field = $input.closest('.dashboard-form-field');
const $hint = $field.find('.dashboard-form-hint');
const id = $input.attr('id');
const upgradable = has_upgrade_flow();
$field.after(
`<div class="dashboard-publish-upsell" hidden>`
+ `<span class="dashboard-publish-upsell-icon">${ICONS.sparkle}</span>`
+ '<div class="dashboard-publish-upsell-text">'
+ `<p class="dashboard-publish-upsell-title">${i18n('publish_custom_domain_upsell_title')}</p>`
+ (upgradable ? `<p class="dashboard-form-hint">${i18n('publish_custom_domain_upsell_text')}</p>` : '')
+ '</div>'
+ (upgradable ? `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-publish-upgrade">${i18n('billing.upgrade')}</button>` : '')
+ '</div>'
+ '<div class="dashboard-publish-claim" hidden>'
+ `<p class="dashboard-publish-claim-text">${i18n('custom_domain_claim_c2a')}</p>`
+ '<div class="dashboard-dns-records"></div>'
+ '</div>'
+ `<datalist id="${id}-owned"></datalist>`,
);
const $upsell = $dialog.find('.dashboard-publish-upsell');
const $claim = $dialog.find('.dashboard-publish-claim');
const $owned = $dialog.find(`#${id}-owned`);
$input.attr('list', `${id}-owned`);
let available = false;
let can_add = true;
let price = null;
/** @type {CustomDomain[]} */
let owned = [];
/** The hostname whose ownership record is on screen. */
let claim_for = null;
/** @type {CustomDomain | null} */
let shown = null;
/** Whether the site is published, so the domain is all that's left. */
let live = false;
const typed = () => normalizeHostnameInput($input.val());
const owned_named = (hostname) => owned.find((domain) => domain.hostname === hostname);
const wanted = () => available && $switch.prop('checked');
const addable = () => wanted() && can_add;
const update_hint = () => {
const existing = owned_named(typed());
$hint.text(existing
? t('publish_custom_domain_moves', [`${existing.site}.${window.hosting_domain}`])
: [! live && t('publish_custom_domain_dns_hint'), price && t('custom_domain_price_note', [price])].filter(Boolean).join(' '));
};
const sync = () => {
const on = wanted();
dialog.showField('domain', on && can_add);
$upsell.prop('hidden', ! on || can_add);
$claim.prop('hidden', ! on || ! can_add || claim_for !== typed());
update_hint();
onChange();
};
const load = async () => {
let res;
try {
res = await customDomainsRequest('GET');
} catch {
return;
}
// No custom domain API on this Puter, or none for this account:
// the option stays out of the way.
if ( ! res.ok || ! Array.isArray(res.data?.items) ) return;
owned = res.data.items;
can_add = res.data.canAdd !== false;
price = formatCustomDomainPrice(res.data.pricing);
$owned.html(owned.map((domain) => `<option value="${html_encode(domain.hostname)}"></option>`).join(''));
if ( ! available ) {
available = true;
dialog.showField('useDomain', true);
}
sync();
};
load();
// The field takes focus as the switch reveals it. The upgrade prompt
// doesn't: a second Space meant for the switch would open the upgrade.
$switch.on('change', () => {
sync();
if ( addable() ) $input.get(0)?.focus({ preventScroll: true });
});
// A claim's record shows only while its hostname is the one typed.
$input.on('input', sync);
// Once an upgrade lands, the field unlocks where the prompt was. The
// dialog is modal, so only its own prompt can change the plan meanwhile.
const on_plan_change = async () => {
if ( ! document.contains($dialog.get(0)) ) {
window.removeEventListener('puter:subscription:changed', on_plan_change);
return;
}
const had = can_add;
await load();
if ( ! had && addable() ) $input.get(0)?.focus({ preventScroll: true });
};
$dialog.on('click', '.dashboard-publish-upgrade', () => {
if ( ! open_upgrade_flow() ) return;
window.addEventListener('puter:subscription:changed', on_plan_change);
});
// -- Done screen --
/** Encoded HTML into the done screen's status line; empty clears it. */
const set_done_status = (html, error = false) => {
$dialog.find('.dashboard-publish-domain-status').html(html)
.toggleClass('dashboard-publish-domain-status-error', error);
};
const render_done = (focus_selector) => {
const $section = $dialog.find('.dashboard-publish-domain-summary');
$section.html(domainSummaryHtml(shown));
const el = $section.find(focus_selector).get(0)
?? $section.find('.dashboard-copy-value').get(0)
?? $dialog.find('.dashboard-form-done-btn').get(0);
el?.focus({ preventScroll: true });
};
const DONE_ACTIONS = '.dashboard-publish-domain-check, .dashboard-publish-domain-www';
let done_busy = false;
/**
* Runs a done-screen request with `$btn` busy and the other actions held,
* since each answer redraws the domain; resolves with the response, or null.
*/
const done_request = async ($btn, method, path) => {
done_busy = true;
$dialog.find(DONE_ACTIONS).prop('disabled', true);
$btn.addClass('dashboard-form-busy').attr('aria-busy', 'true');
set_done_status('');
try {
const res = await customDomainsRequest(method, path);
if ( res.ok ) return res;
set_done_status(html_encode(customDomainErrorText(res)), true);
} catch {
set_done_status(i18n('custom_domain_error_generic'), true);
} finally {
done_busy = false;
$dialog.find(DONE_ACTIONS).prop('disabled', false);
$btn.removeClass('dashboard-form-busy').removeAttr('aria-busy');
}
return null;
};
$dialog.on('click', '.dashboard-publish-domain-check', async function () {
if ( ! shown || done_busy ) return;
const res = await done_request($(this), 'GET', `/${encodeURIComponent(shown.uuid)}`);
if ( ! res ) return;
shown = mergeCustomDomainRefresh(shown, res.data);
render_done('.dashboard-publish-domain-check');
const status = customDomainStatus(shown);
set_done_status(status === 'active'
? i18n('custom_domain_success_note', [shown.hostname, `${shown.site}.${window.hosting_domain}`])
: i18n(status === 'failed' ? 'custom_domain_status_failed' : 'custom_domain_status_pending', [shown.hostname]));
});
// Same domain, so the month already paid covers the www hostname.
$dialog.on('click', '.dashboard-publish-domain-www', async function () {
if ( ! shown || done_busy ) return;
const res = await done_request($(this), 'POST', `/${encodeURIComponent(shown.uuid)}/use-www`);
if ( ! res ) return;
shown = res.data;
render_done('.dashboard-publish-domain-check');
});
return {
addable,
prepare (live) {
if ( ! wanted() || (live && ! can_add) ) return null;
if ( ! can_add ) {
dialog.setStatus(t(upgradable ? 'publish_custom_domain_needs_plan' : 'custom_domain_error_plan'));
return false;
}
const hostname = typed();
$input.val(hostname);
if ( ! isPlausibleHostname(hostname) ) {
dialog.setStatus(t('custom_domain_error_invalid'), { field: 'domain' });
return false;
}
return hostname;
},
async attach (hostname, site) {
if ( ! live ) {
live = true;
update_hint();
}
const existing = owned_named(hostname);
const failed = (reason, field) => {
dialog.setStatus(`${t('publish_domain_failed', [site.url])}\n${reason}`, field ? { field } : undefined);
return null;
};
let res;
try {
res = existing
? await customDomainsRequest('PATCH', `/${encodeURIComponent(existing.uuid)}`, { site: site.name })
: await customDomainsRequest('POST', '', { site: site.name, hostname });
} catch {
return failed(t('custom_domain_error_generic'));
}
if ( res.ok ) {
claim_for = null;
return res.data;
}
// Held by another account: shown its ownership record the first
// time; a retry that still can't see the record says so.
const verification = res.data?.verification;
if ( res.data?.code === 'custom_domain_claim_required' && verification ) {
const retry = claim_for === hostname;
claim_for = hostname;
$claim.find('.dashboard-dns-records').html(dnsRecordsHtml([{ type: 'TXT', name: verification.name, value: verification.value }]));
sync();
if ( retry ) return failed(t('custom_domain_claim_not_found'));
dialog.setStatus(t('publish_site_live', [site.url]), { kind: 'info' });
return null;
}
if ( res.status === 402 && res.data?.code === 'subscription_required' ) {
can_add = false;
sync();
}
return failed(customDomainErrorText(res), CUSTOM_DOMAIN_HOSTNAME_ERRORS.has(res.data?.code) ? 'domain' : undefined);
},
doneHtml (domain) {
shown = domain;
// The summary is redrawn after each check; the status line stays,
// so screen readers announce what changed and not the records again.
return '<section class="dashboard-publish-domain">'
+ `<div class="dashboard-publish-domain-summary" aria-live="off">${domainSummaryHtml(domain)}</div>`
+ '<p class="dashboard-publish-domain-status" role="status" aria-live="polite"></p>'
+ '</section>';
},
claiming: () => addable() && claim_for !== null && claim_for === typed(),
};
};
+17 -76
View File
@@ -18,31 +18,25 @@
*/
import { isPlausibleHostname, normalizeHostnameInput } from '../helpers/customDomainHostname.js';
import {
customDomainErrorText,
customDomainsRequest,
customDomainStatus as statusOf,
formatCustomDomainPrice,
mergeCustomDomainRefresh,
} from '../helpers/customDomains.js';
let attach_modal_seq = 0;
const SUCCESS_AUTOCLOSE_MS = 1800;
const COPIED_RESET_MS = 1400;
/** API error code → message key; anything unlisted gets the generic error. */
const ERROR_KEYS = {
invalid_hostname: 'custom_domain_error_invalid',
custom_domain_taken: 'custom_domain_error_taken',
custom_domain_limit_reached: 'custom_domain_error_limit',
custom_domain_rejected: 'custom_domain_error_rejected',
site_not_found: 'custom_domain_error_site',
custom_domain_not_found: 'custom_domain_error_not_found',
custom_domains_not_enabled: 'custom_domain_not_enabled',
custom_domains_permission_required: 'custom_domain_error_generic',
};
/** Domain status → the badge shown for it; anything else reads as pending. */
/** Domain status → the badge shown for it. */
const STATUS_BADGE_KEYS = {
active: 'custom_domain_badge_active',
pending: 'custom_domain_badge_pending',
failed: 'custom_domain_badge_failed',
};
const statusOf = (domain) => (domain?.status in STATUS_BADGE_KEYS ? domain.status : 'pending');
const svg = (body, strokeWidth = 2) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
@@ -55,19 +49,6 @@ const ICON_ALERT = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><pat
const ICON_PLUS = svg('<path d="M12 5v14M5 12h14"/>', 2.2);
const ICON_X = svg('<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>', 2.2);
const format_price = (pricing) => {
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: pricing.currency || 'USD',
minimumFractionDigits: 2,
}).format(pricing.amount);
} catch {
return null;
}
};
/**
* "Connect a custom domain to this site" modal, behind
* `puter.hosting.attach()`. The user picks one of their domains (moving it
@@ -254,35 +235,7 @@ async function UIWindowAttachCustomDomain (options) {
focusedBeforeBusy = null;
};
const api = async (method, path = '', body) => {
const res = await fetch(`${window.api_origin}/custom-domains${path}`, {
method,
headers: {
'Authorization': `Bearer ${authToken}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let data = null;
try {
data = await res.json();
} catch {
// Non-JSON body; callers go by the status.
}
return { status: res.status, ok: res.ok, data };
};
// A known code gets our wording; anything else shows what the server
// said, which beats a generic message when something is misconfigured.
const errorFor = (res) => {
if ( res.status === 402 ) return text('custom_domain_error_plan');
if ( res.status === 429 ) return text('custom_domain_error_rate_limited');
const key = ERROR_KEYS[res.data?.code];
if ( key ) return text(key);
if ( typeof res.data?.message === 'string' && res.data.message ) {
return res.data.message;
}
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
};
const api = (method, path = '', body) => customDomainsRequest(method, path, body, authToken);
// Escape backs out of a pending confirmation first. Escape, the
// backdrop and the close buttons all dismiss, except while a request
@@ -503,7 +456,7 @@ async function UIWindowAttachCustomDomain (options) {
try {
const res = await api('PATCH', `/${encodeURIComponent(item.uuid)}`, { site: subdomain });
if ( ! res.ok ) {
showError('list', errorFor(res));
showError('list', customDomainErrorText(res));
return;
}
settleOn(res.data);
@@ -524,7 +477,7 @@ async function UIWindowAttachCustomDomain (options) {
try {
const res = await api('DELETE', `/${encodeURIComponent(item.uuid)}`);
if ( ! res.ok && res.status !== 404 ) {
showError(pane, errorFor(res));
showError(pane, customDomainErrorText(res));
return false;
}
// A removed domain is no answer to hand the caller.
@@ -611,7 +564,7 @@ async function UIWindowAttachCustomDomain (options) {
return;
}
if ( ! res.ok ) {
showError('add', errorFor(res));
showError('add', customDomainErrorText(res));
return;
}
hideClaim();
@@ -650,7 +603,7 @@ async function UIWindowAttachCustomDomain (options) {
.toggle(!!domain.statusDetail && status !== 'active');
const $redirect = $overlay.find('.custom-domain-redirect').toggle(!!domain.redirectFrom);
if ( domain.redirectFrom ) {
$redirect.html(i18n('custom_domain_redirect_note', [domain.redirectFrom, domain.hostname]));
$redirect.html(i18n('custom_domain_redirect_note', [domain.hostname, domain.redirectFrom]));
}
$overlay.find('.custom-domain-apex').toggle(domain.isApex === true);
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && status !== 'active');
@@ -670,22 +623,10 @@ async function UIWindowAttachCustomDomain (options) {
try {
const res = await api('GET', `/${encodeURIComponent(chosen.uuid)}`);
if ( ! res.ok ) {
showError('records', errorFor(res));
showError('records', customDomainErrorText(res));
return;
}
// A domain that is no longer pending comes back with only its
// routing record, and a GET never repeats the provider or the
// redirect; keep what was shown when it was added.
const known = chosen.records ?? [];
const redirect = known.filter((r) => r.purpose === 'redirect');
const fresh = (res.data.records?.length > 1 ? res.data.records : known)
.filter((r) => r.purpose !== 'redirect');
const next = {
...res.data,
dnsProvider: chosen.dnsProvider,
redirectFrom: chosen.redirectFrom,
records: [...fresh.slice(0, 1), ...redirect, ...fresh.slice(1)],
};
const next = mergeCustomDomainRefresh(chosen, res.data);
if ( statusOf(next) === 'active' ) {
showSuccess(next);
return;
@@ -706,7 +647,7 @@ async function UIWindowAttachCustomDomain (options) {
try {
const res = await api('POST', `/${encodeURIComponent(chosen.uuid)}/use-www`);
if ( ! res.ok ) {
showError('records', errorFor(res));
showError('records', customDomainErrorText(res));
return;
}
chosen = res.data;
@@ -758,7 +699,7 @@ async function UIWindowAttachCustomDomain (options) {
return;
}
renderList(res.data?.items ?? []);
const price = format_price(res.data?.pricing);
const price = formatCustomDomainPrice(res.data?.pricing);
if ( price ) {
$overlay.find('.custom-domain-price').text(text('custom_domain_price_note', [price])).show();
}
+281
View File
@@ -10681,6 +10681,287 @@ body.dashboard-mode .notifications-close-all {
}
}
/* ======================================
Publish dialog: custom domain
====================================== */
/* The address, once the site is live under it, reads as settled. */
.dashboard-form-affix:has([readonly]) {
background: var(--dashboard-sidebar-background);
}
.dashboard-form-affix:has([readonly]):focus-within {
border-color: var(--dashboard-border);
box-shadow: none;
}
.dashboard-publish-upsell {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--dashboard-border);
border-radius: 12px;
background: var(--select-tint-weak);
}
.dashboard-publish-upsell[hidden],
.dashboard-publish-claim[hidden] {
display: none;
}
.dashboard-publish-upsell-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--select-tint);
color: var(--select-color);
}
.dashboard-publish-upsell-icon svg {
width: 16px;
height: 16px;
}
.dashboard-publish-upsell-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dashboard-publish-upsell-title {
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: var(--dashboard-text-primary);
}
.dashboard-publish-upsell .dashboard-dialog-btn {
flex-shrink: 0;
min-width: 0;
}
@media (max-width: 600px) {
.dashboard-publish-upsell {
flex-wrap: wrap;
}
/* The text keeps to the icon's row; only the button takes its own. */
.dashboard-publish-upsell-text {
flex-basis: 0;
}
.dashboard-publish-upsell .dashboard-dialog-btn {
flex-basis: 100%;
}
}
.dashboard-publish-claim {
display: flex;
flex-direction: column;
gap: 10px;
}
.dashboard-publish-claim-text {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--dashboard-warning-border);
border-radius: 8px;
background: var(--dashboard-warning-background);
color: var(--dashboard-warning-text);
font-size: 13px;
line-height: 1.45;
}
/* DNS records: the type, then a copyable name and value. */
.dashboard-dns-records {
display: flex;
flex-direction: column;
gap: 10px;
}
/* One grid per card, so the label column fits the longest label and the
name and value line up. */
.dashboard-dns-record {
display: grid;
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid var(--dashboard-border);
border-radius: 10px;
background: var(--dashboard-sidebar-background);
}
.dashboard-dns-record-head {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
}
.dashboard-dns-record-type {
padding: 2px 7px;
border-radius: 6px;
background: var(--select-tint);
color: var(--select-color);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 600;
}
.dashboard-dns-record-optional {
font-size: 11px;
color: var(--dashboard-text-muted);
}
.dashboard-dns-record-field {
display: contents;
}
.dashboard-dns-record-label {
font-size: 12px;
font-weight: 600;
color: var(--dashboard-text-secondary);
}
/* The done screen's domain, under the live address. */
.dashboard-publish-domain {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 14px;
border-top: 1px solid var(--dashboard-border);
}
.dashboard-publish-domain-summary {
display: flex;
flex-direction: column;
gap: 10px;
}
.dashboard-publish-domain-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.dashboard-publish-domain-icon {
flex-shrink: 0;
display: flex;
color: var(--dashboard-text-muted);
}
.dashboard-publish-domain-icon svg {
width: 16px;
height: 16px;
}
.dashboard-publish-domain-host {
min-width: 0;
font-size: 14px;
font-weight: 600;
color: var(--dashboard-text-primary);
overflow-wrap: anywhere;
}
.dashboard-publish-domain-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
background: var(--dashboard-warning-background);
color: var(--dashboard-warning-text);
font-size: 11px;
font-weight: 600;
}
.dashboard-publish-domain-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.dashboard-publish-domain-badge[data-status='active'] {
background: var(--dashboard-success-background);
color: var(--dashboard-success-text);
}
.dashboard-publish-domain-badge[data-status='failed'] {
background: var(--dashboard-danger-background);
color: var(--dashboard-danger-text);
}
.dashboard-publish-domain-detail {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--dashboard-danger-text);
overflow-wrap: anywhere;
}
.dashboard-publish-domain-actions {
display: flex;
}
.dashboard-publish-domain-status {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--dashboard-text-secondary);
}
/* Empty, it stays in the a11y tree and gives back the gap it would claim. */
.dashboard-publish-domain-status:empty {
margin-top: -10px;
}
.dashboard-publish-domain-status-error {
color: var(--dashboard-danger-text);
}
.dashboard-publish-link-btn {
align-self: flex-start;
padding: 0;
border: none;
background: none;
color: var(--dashboard-link);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
@media (hover: hover) {
.dashboard-publish-link-btn:hover:not(:disabled) {
text-decoration: underline;
}
}
.dashboard-publish-link-btn:disabled {
opacity: 0.6;
cursor: default;
}
.dashboard-publish-link-btn:focus-visible {
outline: none;
border-radius: 4px;
box-shadow: 0 0 0 3px var(--select-ring);
}
/* ======================================
Auth dialog (login, signup, account picker, password recovery, 2FA)
====================================== */
+168
View File
@@ -0,0 +1,168 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// The `/custom-domains` API as the GUI talks to it, with its own session:
// the desktop's attach dialog and the dashboard's publish dialog share it.
/**
* @typedef {object} CustomDomainRecord
* @property {string} type
* @property {string} name
* @property {string} value
* @property {'routing'|'redirect'|string} [purpose]
* @property {boolean} [required]
*/
/**
* @typedef {object} CustomDomain
* @property {string} uuid
* @property {string} hostname
* @property {string} site Subdomain of the website it serves.
* @property {'active'|'pending'|'failed'|string} status
* @property {string} [statusDetail]
* @property {boolean} [isApex]
* @property {string} [redirectFrom] The root domain forwarding to `hostname`.
* @property {string} [dnsProvider]
* @property {CustomDomainRecord[]} [records]
*/
/**
* @typedef {object} CustomDomainResponse
* @property {number} status
* @property {boolean} ok
* @property {any} data The parsed JSON body, or null.
*/
/** API error code → message key; anything unlisted gets the server's message. */
const ERROR_KEYS = {
invalid_hostname: 'custom_domain_error_invalid',
custom_domain_taken: 'custom_domain_error_taken',
custom_domain_limit_reached: 'custom_domain_error_limit',
custom_domain_rejected: 'custom_domain_error_rejected',
site_not_found: 'custom_domain_error_site',
custom_domain_not_found: 'custom_domain_error_not_found',
custom_domains_not_enabled: 'custom_domain_not_enabled',
custom_domains_permission_required: 'custom_domain_error_generic',
};
/** Error codes that mean "try another hostname". */
export const CUSTOM_DOMAIN_HOSTNAME_ERRORS = new Set([
'invalid_hostname',
'custom_domain_taken',
'custom_domain_rejected',
]);
const STATUSES = new Set(['active', 'pending', 'failed']);
/**
* A domain's status as the GUI draws it; anything unknown reads as pending.
*
* @param {Partial<CustomDomain> | null | undefined} domain
* @returns {'active'|'pending'|'failed'}
*/
export const customDomainStatus = (domain) => (STATUSES.has(domain?.status) ? domain.status : 'pending');
/**
* The monthly price the list call reports, in the user's locale.
*
* @param {{ amount?: number, currency?: string } | null | undefined} pricing
* @returns {string | null}
*/
export const formatCustomDomainPrice = (pricing) => {
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: pricing.currency || 'USD',
minimumFractionDigits: 2,
}).format(pricing.amount);
} catch {
return null;
}
};
/**
* Calls `/custom-domains{path}`. Resolves with the status and parsed body,
* even for an error status; rejects only when the request itself fails.
*
* @param {'GET'|'POST'|'PATCH'|'DELETE'} method
* @param {string} [path] Starting with `/`, after `/custom-domains`.
* @param {object} [body]
* @param {string} [authToken] Defaults to the GUI's session.
* @returns {Promise<CustomDomainResponse>}
*/
export const customDomainsRequest = async (method, path = '', body, authToken = puter.authToken ?? window.auth_token) => {
const res = await fetch(`${window.api_origin}/custom-domains${path}`, {
method,
headers: {
'Authorization': `Bearer ${authToken}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let data = null;
try {
data = await res.json();
} catch {
// Non-JSON body; callers go by the status.
}
return { status: res.status, ok: res.ok, data };
};
/**
* Plain text for a failed response. A known code gets our wording; anything
* else shows what the server said, which beats a generic message when
* something is misconfigured.
*
* @param {CustomDomainResponse} res
* @returns {string}
*/
export const customDomainErrorText = (res) => {
const text = (key) => i18n(key, [], false);
if ( res.status === 402 ) return text('custom_domain_error_plan');
if ( res.status === 429 ) return text('custom_domain_error_rate_limited');
const key = ERROR_KEYS[res.data?.code];
if ( key ) return text(key);
if ( typeof res.data?.message === 'string' && res.data.message ) {
return res.data.message;
}
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
};
/**
* What to show after a status check. A domain that is no longer pending
* comes back with only its routing record, and a GET never repeats the DNS
* provider or the redirect, so those are kept from what was shown before.
*
* @param {CustomDomain} known What the add (or the last check) returned.
* @param {CustomDomain} fresh What the GET returned.
* @returns {CustomDomain}
*/
export const mergeCustomDomainRefresh = (known, fresh) => {
const knownRecords = known.records ?? [];
const redirect = knownRecords.filter((r) => r.purpose === 'redirect');
const records = (fresh.records?.length > 1 ? fresh.records : knownRecords)
.filter((r) => r.purpose !== 'redirect');
return {
...fresh,
dnsProvider: known.dnsProvider,
redirectFrom: known.redirectFrom,
records: [...records.slice(0, 1), ...redirect, ...records.slice(1)],
};
};
+115
View File
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
customDomainErrorText,
customDomainsRequest,
customDomainStatus,
formatCustomDomainPrice,
mergeCustomDomainRefresh,
} from './customDomains.js';
beforeEach(() => {
globalThis.i18n = (key) => key;
globalThis.puter = { authToken: 'session-token' };
window.api_origin = 'https://api.puter.test';
});
describe('customDomainStatus', () => {
it('keeps a known status and reads anything else as pending', () => {
expect(customDomainStatus({ status: 'active' })).toBe('active');
expect(customDomainStatus({ status: 'failed' })).toBe('failed');
expect(customDomainStatus({ status: 'verifying' })).toBe('pending');
expect(customDomainStatus(null)).toBe('pending');
});
});
describe('formatCustomDomainPrice', () => {
it('formats the amount in its currency', () => {
expect(formatCustomDomainPrice({ amount: 0.5, currency: 'USD' })).toMatch(/0[.,]50/);
});
it('gives null without an amount or for a currency it cannot format', () => {
expect(formatCustomDomainPrice(null)).toBeNull();
expect(formatCustomDomainPrice({ currency: 'USD' })).toBeNull();
expect(formatCustomDomainPrice({ amount: 1, currency: 'not-a-currency' })).toBeNull();
});
});
describe('customDomainsRequest', () => {
it('sends the session token and a JSON body, and parses the reply', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ uuid: 'd1' }), { status: 201 }));
const res = await customDomainsRequest('POST', '', { site: 'my-site', hostname: 'www.example.com' });
expect(fetch).toHaveBeenCalledWith('https://api.puter.test/custom-domains', {
method: 'POST',
headers: { 'Authorization': 'Bearer session-token', 'Content-Type': 'application/json' },
body: '{"site":"my-site","hostname":"www.example.com"}',
});
expect(res).toEqual({ status: 201, ok: true, data: { uuid: 'd1' } });
});
it('resolves an error status with a null body when it is not JSON', async () => {
globalThis.fetch = vi.fn(async () => new Response('Not Found', { status: 404 }));
expect(await customDomainsRequest('GET', '/d1')).toEqual({ status: 404, ok: false, data: null });
expect(fetch.mock.calls[0][0]).toBe('https://api.puter.test/custom-domains/d1');
expect(fetch.mock.calls[0][1].headers).toEqual({ 'Authorization': 'Bearer session-token' });
});
});
describe('customDomainErrorText', () => {
it('words plan, rate limit and known codes itself', () => {
expect(customDomainErrorText({ status: 402, data: { code: 'subscription_required' } })).toBe('custom_domain_error_plan');
expect(customDomainErrorText({ status: 429, data: null })).toBe('custom_domain_error_rate_limited');
expect(customDomainErrorText({ status: 409, data: { code: 'custom_domain_taken', message: 'x' } })).toBe('custom_domain_error_taken');
});
it("falls back to the server's message, then to the status", () => {
expect(customDomainErrorText({ status: 502, data: { message: 'Provider down' } })).toBe('Provider down');
expect(customDomainErrorText({ status: 500, data: null })).toBe('custom_domain_error_generic (HTTP 500)');
});
});
describe('mergeCustomDomainRefresh', () => {
const routing = { type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing' };
const redirect = { type: 'A', name: '@', value: '192.0.2.1', purpose: 'redirect' };
const ownership = { type: 'TXT', name: '_cf', value: 'abc', purpose: 'ownership' };
const known = {
uuid: 'd1',
status: 'pending',
dnsProvider: 'example-dns',
redirectFrom: 'example.com',
records: [routing, redirect, ownership],
};
it('keeps the records shown before when the check returns only routing', () => {
const merged = mergeCustomDomainRefresh(known, { uuid: 'd1', status: 'active', records: [routing] });
expect(merged.status).toBe('active');
expect(merged.records).toEqual([routing, redirect, ownership]);
expect(merged.dnsProvider).toBe('example-dns');
expect(merged.redirectFrom).toBe('example.com');
});
it('takes fresh records, with the redirect after the routing one', () => {
const fresh_ownership = { ...ownership, value: 'def' };
const merged = mergeCustomDomainRefresh(known, { uuid: 'd1', status: 'pending', records: [routing, fresh_ownership] });
expect(merged.records).toEqual([routing, redirect, fresh_ownership]);
});
});
+11 -4
View File
@@ -28,14 +28,21 @@ export const is_plan_gate_error = (error) =>
Boolean(error && typeof error === 'object' && error.code === 'subscription_required');
/**
* Open the upgrade flow, where the deployment has one. Hosted puter.com
* attaches `UIUpgradeAccount`; a self-hosted install has no plan to offer,
* and the caller explains the refusal in its own words instead.
* Whether the deployment has an upgrade flow. Hosted puter.com attaches
* `UIUpgradeAccount`; a self-hosted install has no plan to offer.
*
* @returns {boolean}
*/
export const has_upgrade_flow = () => typeof window.UIUpgradeAccount === 'function';
/**
* Open the upgrade flow, where the deployment has one. Without one, the
* caller explains the refusal in its own words instead.
*
* @returns {boolean} Whether the upgrade flow was opened.
*/
export const open_upgrade_flow = () => {
if ( typeof window.UIUpgradeAccount !== 'function' ) return false;
if ( ! has_upgrade_flow() ) return false;
(new window.UIUpgradeAccount()).open_as_window();
return true;
};
+9
View File
@@ -472,7 +472,16 @@ const en = {
publish_as_website: 'Publish as website',
publish_as_serverless_worker: 'Publish as Worker',
publish_address: 'Address',
publish_custom_domain: 'Use a custom domain',
publish_custom_domain_dns_hint: 'After publishing, you\'ll get the DNS records to add at your domain provider.',
publish_custom_domain_hint: 'Serve this website from a domain you own.',
publish_custom_domain_moves: 'This domain is on your account. Publishing moves it here from %%, at no extra cost.',
publish_custom_domain_needs_plan: 'Upgrade to use a custom domain, or turn it off to publish without one.',
publish_custom_domain_upsell_text: 'Upgrade to serve this website from a domain you own.',
publish_custom_domain_upsell_title: 'Custom domains come with a paid plan',
publish_domain_failed: 'Your website is live at %%, but the domain wasn\'t added.',
publish_name_required: 'Enter an address.',
publish_site_live: 'Your website is live at %%.',
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
push_app_turn_off_failed: "Couldn't turn that off. Please try again.",