Compare commits

...
Author SHA1 Message Date
Nariman Jelveh cd48235e11 fix(gui): the switch-to-www link shows when it's working
The link that switches a root domain to www had no disabled or loading
style, so while its request ran it still looked clickable and showed
nothing, while every other control dimmed. It now dims like them when
waiting and shows a spinner when it's the one working.
2026-11-04 16:39:27 -07:00
Nariman Jelveh 882af9df6c fix(gui): readable Remove and Confirm buttons in dark mode
The red fill under their white labels used the danger text color, which
dark mode lightens to #f87171: 2.8:1 against white. Fills now use their
own token that stays #dc2626 (4.8:1); light mode is unchanged.
2026-11-04 16:38:52 -07:00
Nariman Jelveh 7254987fa8 fix(gui): hostnames in the custom domain dialog wrap whole
Every bold hostname broke at any letter, so one that would fit on the
next line was split mid-word at the end of this one ("puter.lo / calhost").
It now moves down whole and breaks only when it's wider than the line.
2026-11-04 16:37:59 -07:00
Nariman Jelveh 3f9fea7142 fix(gui): custom domain rows keep the icon beside the name on phones
The name was sized to the row less 46px for the icon and gap, but the icon
is 36px wide with its border, so the row overflowed by 2px and every name
dropped below its icon. Only the actions wrap now; the name takes the rest
of the first line.
2026-11-04 16:37:36 -07:00
Nariman Jelveh e05c239ca4 fix(gui): DNS record copy buttons work without the clipboard API
On an origin with no navigator.clipboard (a Puter served over plain http)
or when the browser refuses the write, the copy buttons did nothing. They
now select the value and try the legacy copy, like the dashboard's form
dialog, and leave it selected to copy by hand if that fails too.
2026-11-04 16:37:13 -07:00
Nariman Jelveh 66fcd410d6 fix(gui): a removed custom domain row takes no input as it fades
Its buttons came back on once the delete finished, so a second click in
the fade sent the delete again, and Cancel brought back Use for a domain
that no longer exists. The row is inert until it's gone.
2026-11-04 16:36:29 -07:00
Nariman Jelveh ad088378cb fix(gui): keyboard focus stays in the custom domain dialog
Focus on a control that got hidden or disabled fell to the page behind the
dialog, so Tab started over from there. Switching steps now focuses the
dialog, backing out of a row's confirmation returns to the button that
asked, a request gives focus back to the button that started it, and
removing a row moves focus to the next one.
2026-11-04 16:35:57 -07:00
Nariman Jelveh 5186fa8067 fix(gui): each custom domain step opens scrolled to the top
Switching steps reset the scroll of the dialog's body, which only scrolls
when the dialog fills a popup. The floating modal scrolls itself, so a
step opened from far down the list started scrolled past its status card.
2026-11-04 16:34:03 -07:00
Nariman Jelveh 5ea26f30f6 fix(gui): Escape backs out of removing the chosen custom domain
Escape already put back a list row's pending confirmation. On the records
step, it closed the dialog while Remove was asking to confirm; it now puts
the button back first, like the rows.
2026-11-04 16:33:22 -07:00
Nariman Jelveh 7f2675450b feat(gui): redesign the custom domain dialog, full page in its popup
- Domains list as cards with a status badge, trash icon, and inline
  Cancel/Confirm for remove and move
- DNS records as cards with copy buttons that confirm with a check
- Status card on the records step; a green check once the domain serves
  the site, after which the dialog closes
- Spinners on the button that's working, skeleton while loading, empty
  and unavailable states
- Pasted URLs are trimmed to their hostname before adding
- In the attach-custom-domain popup the dialog fills the page, with the
  actions pinned to the bottom
2026-11-04 15:37:14 -07:00
7 changed files with 1379 additions and 203 deletions

No files matched your search

+338 -145
View File
@@ -17,8 +17,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { isPlausibleHostname, normalizeHostnameInput } from '../helpers/customDomainHostname.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',
@@ -31,6 +36,25 @@ const ERROR_KEYS = {
custom_domains_permission_required: 'custom_domain_error_generic',
};
/** Domain status → the badge shown for it; anything else reads as pending. */
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>`;
const ICON_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"/>', 1.8);
const ICON_TRASH = svg('<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"/><path d="M9 7V4h6v3"/>', 1.8);
const ICON_COPY = svg('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h8"/>', 1.8);
const ICON_CHECK = svg('<path d="M20 6 9 17l-5-5"/>', 2.4);
const ICON_CLOCK = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>');
const ICON_ALERT = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/>', 2.2);
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 {
@@ -50,13 +74,14 @@ const format_price = (pricing) => {
* from another site if need be) or adds one; the caller learns only the
* domain that ends up chosen.
*
* Built on the app feedback modal's shell (`.app-feedback-*`) so it renders
* the same on the desktop and in the standalone popup. Talks to the
* Built on the app feedback modal's shell (`.app-feedback-*`). Talks to the
* `/custom-domains` API with the GUI's own session: the requesting app never
* holds a token that can manage domains.
*
* @param {{ subdomain: string, requester?: string }} options `requester` is
* the app title or opener origin shown to the user, as the GUI resolved it.
* @param {{ subdomain: string, requester?: string, fullpage?: boolean }} options
* `requester` is the app title or opener origin shown to the user, as the
* GUI resolved it. `fullpage` fills the viewport instead of floating over
* it, for a popup window that exists only to host this dialog.
* @returns {Promise<object|null>} The chosen domain as the API returns it, or
* null when the user closes the dialog first. Never rejects.
*/
@@ -75,22 +100,28 @@ async function UIWindowAttachCustomDomain (options) {
let chosen = null;
const titleId = `${modal_id}-title`;
const closeLabel = html_encode(i18n('close'));
const h = `
<div class="app-feedback-overlay custom-domain-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' custom-domain-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-modal custom-domain-modal" tabindex="-1">
<div class="app-feedback-head">
<h2 class="app-feedback-title" id="${titleId}">${i18n('custom_domain_title')}</h2>
<button type="button" class="app-feedback-x" aria-label="${html_encode(i18n('close'))}" title="${html_encode(i18n('close'))}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
<div class="app-feedback-head custom-domain-head">
<div class="custom-domain-head-icon">${ICON_GLOBE}</div>
<div class="custom-domain-head-text">
<h2 class="app-feedback-title" id="${titleId}">${i18n('custom_domain_title')}</h2>
<p class="custom-domain-intro">${i18n('custom_domain_intro', [site_host])}</p>
${options.requester ? `<p class="custom-domain-requester">${i18n('custom_domain_requested_by', [options.requester])}</p>` : ''}
</div>
<button type="button" class="app-feedback-x" aria-label="${closeLabel}" title="${closeLabel}">${ICON_X}</button>
</div>
<div class="app-feedback-body">
<p class="custom-domain-intro">${i18n('custom_domain_intro', [site_host])}</p>
${options.requester ? `<p class="custom-domain-requester">${i18n('custom_domain_requested_by', [options.requester])}</p>` : ''}
<div class="app-feedback-body custom-domain-body">
<div class="custom-domain-pane" data-pane="loading" aria-busy="true">
<span class="custom-domain-sr">${i18n('loading')}…</span>
<div class="custom-domain-skeleton"><span></span><span></span></div>
<div class="custom-domain-skeleton"><span></span><span></span></div>
</div>
<div class="app-feedback-loading custom-domain-pane" data-pane="loading">${i18n('loading')}…</div>
<div class="custom-domain-pane" data-pane="unavailable" style="display:none;">
<div class="custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
<div class="custom-domain-state-icon">${ICON_ALERT}</div>
<p class="app-feedback-unavailable-message"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-close">${i18n('close')}</button>
@@ -99,28 +130,29 @@ async function UIWindowAttachCustomDomain (options) {
<div class="custom-domain-pane" data-pane="list" style="display:none;">
<ul class="custom-domain-list"></ul>
<p class="custom-domain-empty" style="display:none;">${i18n('custom_domain_none_yet')}</p>
<div class="custom-domain-state custom-domain-empty" style="display:none;">
<div class="custom-domain-state-icon">${ICON_GLOBE}</div>
<p class="custom-domain-state-title">${i18n('custom_domain_none_yet')}</p>
<p class="custom-domain-state-text">${i18n('custom_domain_empty_hint')}</p>
</div>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn custom-domain-close">${i18n('cancel')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add">${i18n('custom_domain_add_new')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add">${ICON_PLUS}<span>${i18n('custom_domain_add_new')}</span></button>
</div>
</div>
<form class="custom-domain-pane" data-pane="add" style="display:none;">
<form class="custom-domain-pane" data-pane="add" style="display:none;" novalidate>
<label class="custom-domain-label" for="${modal_id}-hostname">${i18n('custom_domain_hostname_label')}</label>
<input type="text" class="custom-domain-input" id="${modal_id}-hostname" placeholder="www.example.com" autocomplete="off" autocapitalize="off" spellcheck="false">
<p class="app-feedback-note custom-domain-price" style="display:none;"></p>
<div class="custom-domain-field">
${ICON_GLOBE}
<input type="text" class="custom-domain-input" id="${modal_id}-hostname" placeholder="www.example.com" autocomplete="off" autocapitalize="off" spellcheck="false" inputmode="url" enterkeyhint="go">
</div>
<p class="app-feedback-note custom-domain-hint">${i18n('custom_domain_hostname_hint')}</p>
<p class="custom-domain-callout custom-domain-price" style="display:none;"></p>
<div class="custom-domain-claim" style="display:none;">
<p class="app-feedback-note">${i18n('custom_domain_claim_c2a')}</p>
<table class="custom-domain-records">
<thead><tr><th>${i18n('custom_domain_record_type')}</th><th>${i18n('name')}</th><th>${i18n('custom_domain_record_value')}</th></tr></thead>
<tbody><tr>
<td>TXT</td>
<td><code class="custom-domain-copy custom-domain-claim-name"></code></td>
<td><code class="custom-domain-copy custom-domain-claim-value"></code></td>
</tr></tbody>
</table>
<p class="custom-domain-callout custom-domain-callout-warn">${i18n('custom_domain_claim_c2a')}</p>
<div class="custom-domain-records"></div>
</div>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<div class="app-feedback-actions">
@@ -130,23 +162,32 @@ async function UIWindowAttachCustomDomain (options) {
</form>
<div class="custom-domain-pane" data-pane="records" style="display:none;">
<p class="custom-domain-status"></p>
<p class="app-feedback-note custom-domain-records-c2a">${i18n('custom_domain_records_c2a')}</p>
<table class="custom-domain-records">
<thead><tr><th>${i18n('custom_domain_record_type')}</th><th>${i18n('name')}</th><th>${i18n('custom_domain_record_value')}</th></tr></thead>
<tbody></tbody>
</table>
<div class="custom-domain-status-card">
<div class="custom-domain-status-icon"></div>
<div class="custom-domain-status-main">
<p class="custom-domain-status"></p>
<p class="custom-domain-status-detail" style="display:none;"></p>
</div>
</div>
<p class="custom-domain-records-c2a">${i18n('custom_domain_records_c2a')}</p>
<div class="custom-domain-records"></div>
<p class="app-feedback-note custom-domain-redirect" style="display:none;"></p>
<p class="app-feedback-note custom-domain-apex" style="display:none;">${i18n('custom_domain_apex_hint')}</p>
<button type="button" class="app-feedback-btn custom-domain-use-www" style="display:none;">${i18n('custom_domain_no_root_alias')}</button>
<button type="button" class="custom-domain-link-btn custom-domain-use-www" style="display:none;">${i18n('custom_domain_no_root_alias')}</button>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<p class="app-feedback-note custom-domain-remove-note" style="display:none;">${i18n('custom_domain_remove_confirm')}</p>
<p class="custom-domain-callout custom-domain-callout-danger custom-domain-remove-note" style="display:none;">${i18n('custom_domain_remove_confirm')}</p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn custom-domain-remove-chosen">${i18n('custom_domain_remove')}</button>
<button type="button" class="app-feedback-btn custom-domain-btn-danger-text custom-domain-remove-chosen">${i18n('custom_domain_remove')}</button>
<button type="button" class="app-feedback-btn custom-domain-check">${i18n('custom_domain_check_status')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-done">${i18n('done')}</button>
</div>
</div>
<div class="custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
<div class="app-feedback-success-check">${ICON_CHECK}</div>
<p class="app-feedback-success-text custom-domain-success-text"></p>
<p class="app-feedback-note custom-domain-success-note"></p>
</div>
</div>
</div>
</div>
@@ -155,6 +196,9 @@ async function UIWindowAttachCustomDomain (options) {
const $overlay = $(h);
$('body').append($overlay);
requestAnimationFrame(() => $overlay.addClass('app-feedback-open'));
const modalEl = $overlay.find('.custom-domain-modal').get(0);
const focusOn = (el) => el?.focus({ preventScroll: true });
focusOn(modalEl);
const close = () => {
if ( closed ) return;
@@ -164,19 +208,25 @@ async function UIWindowAttachCustomDomain (options) {
setTimeout(() => $overlay.remove(), 160);
resolve(chosen);
};
const finish = (domain) => {
chosen = domain;
close();
};
let currentPane = 'loading';
const paneOf = (el) => $(el).closest('.custom-domain-pane').attr('data-pane');
// A focused control that gets hidden or disabled hands focus to the
// page behind the dialog; keep it in the dialog instead.
const focusLost = () => ! document.activeElement || document.activeElement === document.body;
const showPane = (pane) => {
const from = paneOf(document.activeElement);
$overlay.find('.custom-domain-pane').hide();
$overlay.find('.app-feedback-error').hide();
$overlay.find(`.custom-domain-pane[data-pane="${pane}"]`).show();
currentPane = pane;
// The modal scrolls when it floats, the body when it fills the page.
$overlay.find('.custom-domain-modal, .custom-domain-body').scrollTop(0);
if ( focusLost() || (from && from !== pane) ) focusOn(modalEl);
};
// Plain strings into `.text()`: i18n's HTML encoding would show up
// literally.
const text = (key) => i18n(key, [], false);
const text = (key, args = []) => i18n(key, args, false);
const showUnavailable = (key) => {
$overlay.find('.app-feedback-unavailable-message').text(text(key));
showPane('unavailable');
@@ -185,10 +235,22 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find(`.custom-domain-pane[data-pane="${pane}"] .app-feedback-error`)
.text(message).show();
};
const setBusy = (value) => {
/** `$trigger` shows the spinner; every other control just waits. */
let focusedBeforeBusy = null;
const setBusy = (value, $trigger) => {
busy = value;
if ( value ) focusedBeforeBusy = document.activeElement;
$overlay.find('button').prop('disabled', value);
if ( ! value ) updateAddButton();
$overlay.find('.custom-domain-input').prop('readonly', value);
$overlay.find('.is-loading').removeClass('is-loading');
if ( value && $trigger ) $trigger.addClass('is-loading');
if ( value ) return;
updateAddButton();
if ( focusLost() && focusedBeforeBusy?.isConnected && paneOf(focusedBeforeBusy) === currentPane ) {
focusOn(focusedBeforeBusy);
}
if ( focusLost() ) focusOn(modalEl);
focusedBeforeBusy = null;
};
const api = async (method, path = '', body) => {
@@ -221,11 +283,21 @@ async function UIWindowAttachCustomDomain (options) {
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
};
// Escape, the backdrop and the close buttons all dismiss, except
// while a request is in flight: closing then would report no domain
// for one that may still land server-side.
// Escape backs out of a pending confirmation first. Escape, the
// backdrop and the close buttons all dismiss, except while a request
// is in flight: closing then would report no domain for one that may
// still land server-side.
$(document).on(`keydown.${modal_id}`, (e) => {
if ( e.key === 'Escape' && ! busy ) close();
if ( e.key !== 'Escape' || busy ) return;
if ( $overlay.find('.custom-domain-row[data-confirming]').length ) {
resetConfirming();
return;
}
if ( $overlay.find('.custom-domain-remove-chosen.custom-domain-confirming').length ) {
resetRemoveChosen();
return;
}
close();
});
$overlay.on('mousedown', (e) => {
// Same reason as the feedback modal: keep the press from focusing
@@ -237,77 +309,200 @@ async function UIWindowAttachCustomDomain (options) {
if ( ! busy ) close();
});
/**
* True once `el`'s text is on the clipboard. Without clipboard access
* (an insecure origin has no `navigator.clipboard` at all), the text
* is selected for the legacy copy and stays selected to copy by hand.
*/
const copyText = async (el) => {
try {
await navigator.clipboard.writeText(el.textContent);
return true;
} catch {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
return document.execCommand('copy');
} catch {
return false;
}
}
};
// Copy buttons flip to a check for a moment once the value is copied.
$overlay.on('click', '.custom-domain-copy', async function () {
const $btn = $(this);
if ( ! await copyText($btn.siblings('code').get(0)) ) return;
clearTimeout($btn.data('reset'));
$btn.addClass('is-copied').attr('aria-label', text('copied')).attr('title', text('copied'));
$btn.data('reset', setTimeout(() => {
$btn.removeClass('is-copied').attr('aria-label', text('copy')).attr('title', text('copy'));
}, COPIED_RESET_MS));
});
/**
* One DNS record as a card: its type, then the name and value, each
* with a copy button.
*/
const recordCard = (record, typeLabel) => {
const $card = $(`
<div class="custom-domain-record">
<div class="custom-domain-record-head">
<span class="custom-domain-record-type"></span>
<span class="custom-domain-record-optional" style="display:none;">${i18n('custom_domain_optional')}</span>
</div>
<div class="custom-domain-record-field">
<span class="custom-domain-record-label">${i18n('name')}</span>
<code></code>
<button type="button" class="custom-domain-copy">${ICON_COPY}${ICON_CHECK}</button>
</div>
<div class="custom-domain-record-field">
<span class="custom-domain-record-label">${i18n('custom_domain_record_value')}</span>
<code></code>
<button type="button" class="custom-domain-copy">${ICON_COPY}${ICON_CHECK}</button>
</div>
</div>
`);
$card.find('.custom-domain-record-type').text(typeLabel ?? record.type);
$card.find('.custom-domain-record-optional').toggle(record.required === false);
const [$name, $value] = $card.find('code').toArray().map((el) => $(el));
$name.text(record.name);
$value.text(record.value);
$card.find('.custom-domain-copy').attr({ 'aria-label': text('copy'), title: text('copy') });
return $card;
};
// -- Success --------------------------------------------------------
// The check is the last thing the user sees: the domain serves this
// site, so there's nothing left to do here.
const showSuccess = (domain) => {
chosen = domain;
$overlay.find('.custom-domain-success-text').html(i18n('custom_domain_status_active', [domain.hostname]));
$overlay.find('.custom-domain-success-note').html(i18n('custom_domain_success_note', [domain.hostname, site_host]));
showPane('success');
setTimeout(close, SUCCESS_AUTOCLOSE_MS);
};
/** Where to go once `domain` serves this site. */
const settleOn = (domain) => {
if ( statusOf(domain) === 'active' ) {
showSuccess(domain);
return;
}
chosen = domain;
renderRecords(domain);
showPane('records');
};
// -- List ---------------------------------------------------------
let listed = [];
const metaFor = (item) => item.site !== subdomain
? i18n('custom_domain_serves', [`${item.site}.${window.hosting_domain}`], false)
: i18n('custom_domain_serves_this', [], false);
? text('custom_domain_serves', [`${item.site}.${window.hosting_domain}`])
: text('custom_domain_serves_this');
const toggleEmpty = () => {
$overlay.find('.custom-domain-empty').toggle(listed.length === 0);
};
// One row asks for confirmation at a time; asking on another puts the
// first back.
const resetConfirming = () => {
$overlay.find('.custom-domain-row.custom-domain-confirming').each(function () {
$overlay.find('.custom-domain-row[data-confirming]').each(function () {
const $row = $(this);
$row.removeClass('custom-domain-confirming').removeAttr('data-confirming');
const kind = $row.attr('data-confirming');
const hadFocus = $.contains($row.find('.custom-domain-row-confirm').get(0), document.activeElement);
$row.removeAttr('data-confirming');
$row.find('.custom-domain-row-meta').text(metaFor($row.data('item')));
$row.find('.custom-domain-use').text(text('custom_domain_use'));
$row.find('.custom-domain-remove').text(text('custom_domain_remove'));
// Back to the button that asked, rather than one now hidden.
if ( hadFocus ) {
focusOn($row.find(kind === 'move' ? '.custom-domain-use' : '.custom-domain-remove').get(0));
}
});
};
const confirmOn = ($row, kind) => {
const confirmOn = ($row, kind, message) => {
resetConfirming();
$row.addClass('custom-domain-confirming').attr('data-confirming', kind);
$row.attr('data-confirming', kind);
$row.find('.custom-domain-row-meta').text(message);
$row.find('.custom-domain-confirm-go')
.text(text(kind === 'move' ? 'custom_domain_move' : 'custom_domain_remove'))
.get(0)?.focus({ preventScroll: true });
};
const renderList = (items) => {
listed = items;
const $list = $overlay.find('.custom-domain-list').empty();
$overlay.find('.custom-domain-empty').toggle(items.length === 0);
toggleEmpty();
for ( const item of items ) {
const status = statusOf(item);
const $row = $(`
<li class="custom-domain-row">
<span class="custom-domain-row-icon">${ICON_GLOBE}</span>
<div class="custom-domain-row-main">
<div class="custom-domain-row-host"></div>
<div class="custom-domain-row-title">
<span class="custom-domain-row-host"></span>
<span class="custom-domain-badge" data-status="${status}">${i18n(STATUS_BADGE_KEYS[status])}</span>
</div>
<div class="custom-domain-row-meta"></div>
</div>
<button type="button" class="app-feedback-btn custom-domain-remove">${i18n('custom_domain_remove')}</button>
<button type="button" class="app-feedback-btn custom-domain-use">${i18n('custom_domain_use')}</button>
<div class="custom-domain-row-actions">
<button type="button" class="custom-domain-icon-btn custom-domain-remove">${ICON_TRASH}</button>
<button type="button" class="app-feedback-btn custom-domain-use">${i18n('custom_domain_use')}</button>
</div>
<div class="custom-domain-row-confirm">
<button type="button" class="app-feedback-btn custom-domain-confirm-cancel">${i18n('cancel')}</button>
<button type="button" class="app-feedback-btn custom-domain-confirm-go"></button>
</div>
</li>
`);
$row.data('item', item);
$row.toggleClass('is-current', item.site === subdomain);
$row.find('.custom-domain-row-host').text(item.hostname);
$row.find('.custom-domain-row-meta').text(metaFor(item));
$row.find('.custom-domain-remove').attr({
'aria-label': text('custom_domain_remove_named', [item.hostname]),
title: text('custom_domain_remove'),
});
$row.find('.custom-domain-use').on('click', () => use(item, $row));
$row.find('.custom-domain-remove').on('click', () => remove(item, $row));
$row.find('.custom-domain-remove').on('click', () => {
if ( ! busy ) confirmOn($row, 'remove', text('custom_domain_remove_confirm'));
});
$row.find('.custom-domain-confirm-cancel').on('click', () => {
if ( ! busy ) resetConfirming();
});
$row.find('.custom-domain-confirm-go').on('click', (e) => {
const $btn = $(e.currentTarget);
if ( $row.attr('data-confirming') === 'move' ) move(item, $btn);
else remove(item, $row, $btn);
});
$list.append($row);
}
};
const use = async (item, $row) => {
const use = (item, $row) => {
if ( busy ) return;
if ( item.site === subdomain ) {
finish(item);
settleOn(item);
return;
}
// Moving takes the domain off another site, so say so first.
if ( $row.attr('data-confirming') !== 'move' ) {
confirmOn($row, 'move');
$row.find('.custom-domain-row-meta').text(i18n('custom_domain_move_confirm', [
`${item.site}.${window.hosting_domain}`,
site_host,
], false));
$row.find('.custom-domain-use').text(text('custom_domain_move'));
return;
}
setBusy(true);
confirmOn($row, 'move', text('custom_domain_move_confirm', [
`${item.site}.${window.hosting_domain}`,
site_host,
]));
};
const move = async (item, $btn) => {
if ( busy ) return;
setBusy(true, $btn);
try {
const res = await api('PATCH', `/${encodeURIComponent(item.uuid)}`, { site: subdomain });
if ( ! res.ok ) {
showError('list', errorFor(res));
return;
}
finish(res.data);
settleOn(res.data);
} catch ( e ) {
console.error('custom-domain: move failed', e);
showError('list', text('custom_domain_error_generic'));
@@ -320,8 +515,8 @@ async function UIWindowAttachCustomDomain (options) {
* Removes the domain from the account, which also stops its rent.
* True once it's gone, including when it already was.
*/
const deleteDomain = async (item, pane) => {
setBusy(true);
const deleteDomain = async (item, pane, $btn) => {
setBusy(true, $btn);
try {
const res = await api('DELETE', `/${encodeURIComponent(item.uuid)}`);
if ( ! res.ok && res.status !== 404 ) {
@@ -340,26 +535,32 @@ async function UIWindowAttachCustomDomain (options) {
}
};
// Removing takes the domain offline at once, so say so first.
const remove = async (item, $row) => {
const remove = async (item, $row, $btn) => {
if ( busy ) return;
if ( $row.attr('data-confirming') !== 'remove' ) {
confirmOn($row, 'remove');
$row.find('.custom-domain-row-meta').text(text('custom_domain_remove_confirm'));
$row.find('.custom-domain-remove').text(text('confirm'));
return;
}
if ( ! await deleteDomain(item, 'list') ) return;
$row.remove();
if ( ! await deleteDomain(item, 'list', $btn) ) return;
listed = listed.filter((i) => i.uuid !== item.uuid);
$overlay.find('.custom-domain-empty').toggle(listed.length === 0);
// Focus moves to the next row, else the previous one, else to
// adding a domain.
if ( $.contains($row.get(0), document.activeElement) ) {
const $next = $row.nextAll('.custom-domain-row:not(.is-leaving)').first();
const $other = $next.length ? $next : $row.prevAll('.custom-domain-row:not(.is-leaving)').first();
focusOn($other.find('.custom-domain-remove').get(0)
?? $overlay.find('.custom-domain-to-add').get(0));
}
// Gone server-side: its buttons can't act on it again as it fades.
$row.addClass('is-leaving').attr('inert', '');
setTimeout(() => {
$row.remove();
toggleEmpty();
}, 180);
};
// -- Add ----------------------------------------------------------
const $input = $overlay.find('.custom-domain-input');
const typedHostname = () => normalizeHostnameInput($input.val());
const updateAddButton = () => {
$overlay.find('.custom-domain-add').prop('disabled', busy || String($input.val() || '').trim() === '');
$overlay.find('.custom-domain-add').prop('disabled', busy || ! isPlausibleHostname(typedHostname()));
};
// A claim is for one hostname; editing it starts over.
const hideClaim = () => {
@@ -367,22 +568,20 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find('.custom-domain-add').text(text('custom_domain_add'));
};
const showClaim = (record) => {
$overlay.find('.custom-domain-claim-name').text(record.name);
$overlay.find('.custom-domain-claim-value').text(record.value);
$overlay.find('.custom-domain-claim .custom-domain-records')
.empty()
.append(recordCard({ type: 'TXT', name: record.name, value: record.value, required: true }));
$overlay.find('.custom-domain-claim').show();
$overlay.find('.custom-domain-add').text(text('custom_domain_verify_and_add'));
};
$input.on('input', () => {
hideClaim();
$overlay.find('[data-pane="add"] .app-feedback-error').hide();
updateAddButton();
});
$overlay.find('.custom-domain-claim .custom-domain-copy')
.attr('title', text('copy'))
.on('click', function () {
navigator.clipboard?.writeText($(this).text()).catch(() => {});
});
$overlay.find('.custom-domain-to-add').on('click', () => {
resetConfirming();
showPane('add');
$input.get(0)?.focus({ preventScroll: true });
});
@@ -392,9 +591,10 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find('form[data-pane="add"]').on('submit', async (e) => {
e.preventDefault();
const hostname = String($input.val() || '').trim();
if ( ! hostname || busy ) return;
setBusy(true);
const hostname = typedHostname();
if ( ! isPlausibleHostname(hostname) || busy ) return;
$input.val(hostname);
setBusy(true, $overlay.find('.custom-domain-add'));
try {
const res = await api('POST', '', { site: subdomain, hostname });
if ( res.data?.code === 'custom_domain_claim_required' && res.data.verification ) {
@@ -410,9 +610,7 @@ async function UIWindowAttachCustomDomain (options) {
return;
}
hideClaim();
chosen = res.data;
renderRecords(res.data);
showPane('records');
settleOn(res.data);
} catch ( err ) {
console.error('custom-domain: add failed', err);
showError('add', text('custom_domain_error_generic'));
@@ -423,6 +621,13 @@ async function UIWindowAttachCustomDomain (options) {
// -- Records --------------------------------------------------------
const STATUS_ICONS = { active: ICON_CHECK, pending: ICON_CLOCK, failed: ICON_ALERT };
const STATUS_TEXT_KEYS = {
active: 'custom_domain_status_active',
pending: 'custom_domain_status_pending',
failed: 'custom_domain_status_failed',
};
const resetRemoveChosen = () => {
$overlay.find('.custom-domain-remove-chosen')
.removeClass('custom-domain-confirming').text(text('custom_domain_remove'));
@@ -431,51 +636,32 @@ async function UIWindowAttachCustomDomain (options) {
const renderRecords = (domain) => {
resetRemoveChosen();
const key = domain.status === 'active'
? 'custom_domain_status_active'
: domain.status === 'failed'
? 'custom_domain_status_failed'
: 'custom_domain_status_pending';
const $status = $overlay.find('.custom-domain-status')
.html(i18n(key, [domain.hostname]));
if ( domain.statusDetail && domain.status !== 'active' ) {
$status.append($('<span class="custom-domain-status-detail"></span>').text(domain.statusDetail));
}
const status = statusOf(domain);
$overlay.find('.custom-domain-status-card').attr('data-status', status);
$overlay.find('.custom-domain-status-icon').html(STATUS_ICONS[status]);
$overlay.find('.custom-domain-status').html(i18n(STATUS_TEXT_KEYS[status], [domain.hostname]));
$overlay.find('.custom-domain-status-detail')
.text(domain.statusDetail ?? '')
.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]));
}
$overlay.find('.custom-domain-apex').toggle(domain.isApex === true);
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && domain.status !== 'active');
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && status !== 'active');
const $tbody = $overlay.find('.custom-domain-records tbody').empty();
const $records = $overlay.find('[data-pane="records"] .custom-domain-records').empty();
for ( const record of domain.records ?? [] ) {
const $tr = $(`
<tr>
<td class="custom-domain-record-type"></td>
<td><code class="custom-domain-copy"></code></td>
<td><code class="custom-domain-copy"></code></td>
</tr>
`);
const type = domain.isApex && record.purpose === 'routing'
? text('custom_domain_record_type_apex')
: record.type;
$tr.find('.custom-domain-record-type').text(record.required
? type
: `${type} ${text('custom_domain_optional')}`);
const [$name, $value] = $tr.find('code').toArray().map((el) => $(el));
$name.text(record.name);
$value.text(record.value);
$tbody.append($tr);
$records.append(recordCard(record, type));
}
$tbody.find('.custom-domain-copy').attr('title', i18n('copy', [], false)).on('click', function () {
navigator.clipboard?.writeText($(this).text()).catch(() => {});
});
};
$overlay.find('.custom-domain-check').on('click', async () => {
$overlay.find('.custom-domain-check').on('click', async (e) => {
if ( busy || ! chosen ) return;
setBusy(true);
setBusy(true, $(e.currentTarget));
try {
const res = await api('GET', `/${encodeURIComponent(chosen.uuid)}`);
if ( ! res.ok ) {
@@ -485,27 +671,33 @@ async function UIWindowAttachCustomDomain (options) {
// 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 redirect = chosen.records.filter((r) => r.purpose === 'redirect');
const fresh = (res.data.records?.length > 1 ? res.data.records : chosen.records)
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');
chosen = {
const next = {
...res.data,
dnsProvider: chosen.dnsProvider,
redirectFrom: chosen.redirectFrom,
records: [...fresh.slice(0, 1), ...redirect, ...fresh.slice(1)],
};
if ( statusOf(next) === 'active' ) {
showSuccess(next);
return;
}
chosen = next;
renderRecords(chosen);
} catch ( e ) {
console.error('custom-domain: status check failed', e);
} catch ( err ) {
console.error('custom-domain: status check failed', err);
showError('records', text('custom_domain_error_generic'));
} finally {
setBusy(false);
}
});
// Same row, so the month already paid covers the www hostname.
$overlay.find('.custom-domain-use-www').on('click', async () => {
$overlay.find('.custom-domain-use-www').on('click', async (e) => {
if ( busy || ! chosen ) return;
setBusy(true);
setBusy(true, $(e.currentTarget));
try {
const res = await api('POST', `/${encodeURIComponent(chosen.uuid)}/use-www`);
if ( ! res.ok ) {
@@ -514,8 +706,8 @@ async function UIWindowAttachCustomDomain (options) {
}
chosen = res.data;
renderRecords(chosen);
} catch ( e ) {
console.error('custom-domain: switch to www failed', e);
} catch ( err ) {
console.error('custom-domain: switch to www failed', err);
showError('records', text('custom_domain_error_generic'));
} finally {
setBusy(false);
@@ -531,8 +723,9 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find('.custom-domain-remove-note').show();
return;
}
if ( ! await deleteDomain(chosen, 'records') ) return;
if ( ! await deleteDomain(chosen, 'records', $btn) ) return;
resetRemoveChosen();
showPane('loading');
await loadList();
});
$overlay.find('.custom-domain-done').on('click', () => {
@@ -562,7 +755,7 @@ async function UIWindowAttachCustomDomain (options) {
renderList(res.data?.items ?? []);
const price = format_price(res.data?.pricing);
if ( price ) {
$overlay.find('.custom-domain-price').text(i18n('custom_domain_price_note', [price], false)).show();
$overlay.find('.custom-domain-price').text(text('custom_domain_price_note', [price])).show();
}
showPane('list');
} catch ( e ) {
@@ -0,0 +1,222 @@
/*
* 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/>.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
const { default: UIWindowAttachCustomDomain } = await import('./UIWindowAttachCustomDomain.js');
const pendingDomain = (overrides = {}) => ({
uuid: 'd1',
hostname: 'www.example.com',
site: 'mysite',
status: 'pending',
isApex: false,
records: [{ type: 'CNAME', name: 'www.example.com', value: 'mysite.puter.site', purpose: 'routing', required: true }],
...overrides,
});
const json = (status, data) => new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** Answers the list call with `items`; anything else 200s with `{}`. */
const serve = (items) => vi.fn(async (url, opts = {}) => {
const path = String(url).split('/custom-domains')[1];
if ( (opts.method ?? 'GET') === 'GET' && ! path ) return json(200, { items });
return json(200, {});
});
const flush = () => new Promise((r) => setTimeout(r, 0));
const $ = (sel) => jQuery(sel);
const visiblePane = () => $('.custom-domain-pane').filter((_, el) => el.style.display !== 'none').attr('data-pane');
const pressEscape = () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
/** Opens the dialog and waits for the list to load. */
const open = async (items = [pendingDomain()]) => {
globalThis.fetch = serve(items);
let result = 'open';
UIWindowAttachCustomDomain({ subdomain: 'mysite' }).then((r) => {
result = r;
});
await flush();
await flush();
return { result: () => result };
};
beforeEach(() => {
document.body.innerHTML = '';
globalThis.$ = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
globalThis.puter = { authToken: 'token' };
window.api_origin = 'https://api.puter.test';
window.hosting_domain = 'puter.site';
});
afterEach(() => {
$(document).off('keydown');
vi.useRealTimers();
});
describe('UIWindowAttachCustomDomain', () => {
it('backs out of the records pane remove confirmation on Escape', async () => {
const dialog = await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
expect(visiblePane()).toBe('records');
$('.custom-domain-remove-chosen').trigger('click');
expect($('.custom-domain-remove-chosen').hasClass('custom-domain-confirming')).toBe(true);
pressEscape();
expect($('.custom-domain-remove-chosen').hasClass('custom-domain-confirming')).toBe(false);
expect($('.custom-domain-remove-note').css('display')).toBe('none');
expect(dialog.result()).toBe('open');
pressEscape();
await flush();
expect(dialog.result()).toMatchObject({ uuid: 'd1' });
});
it('starts each step scrolled to the top of the floating modal', async () => {
await open();
// jsdom has no layout, so stand in for a modal scrolled down the list.
const modal = document.querySelector('.custom-domain-modal');
let scrollTop = 240;
Object.defineProperty(modal, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value;
},
});
$('.custom-domain-row .custom-domain-use').trigger('click');
expect(visiblePane()).toBe('records');
expect(scrollTop).toBe(0);
});
describe('keyboard focus', () => {
const otherSite = (uuid, hostname) => pendingDomain({ uuid, hostname, site: 'other' });
it('moves into the dialog when the focused control\'s step is hidden', async () => {
await open();
const use = document.querySelector('.custom-domain-use');
use.focus();
$(use).trigger('click');
expect(visiblePane()).toBe('records');
expect(document.activeElement).toBe(document.querySelector('.custom-domain-modal'));
});
it('returns to the button that asked when a row confirmation is backed out', async () => {
await open([otherSite('a', 'a.example.com')]);
const $row = $('.custom-domain-row');
$row.find('.custom-domain-use').trigger('click');
expect(document.activeElement).toBe($row.find('.custom-domain-confirm-go').get(0));
pressEscape();
expect(document.activeElement).toBe($row.find('.custom-domain-use').get(0));
$row.find('.custom-domain-remove').trigger('click');
$row.find('.custom-domain-confirm-cancel').trigger('click');
expect(document.activeElement).toBe($row.find('.custom-domain-remove').get(0));
});
it('comes back to the clicked button once a request finishes', async () => {
await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
const check = document.querySelector('.custom-domain-check');
check.focus();
// A browser drops focus to the page once the focused control is
// disabled; jsdom doesn't, so drop it by hand.
globalThis.fetch = vi.fn(async () => {
const elsewhere = document.createElement('input');
document.body.append(elsewhere);
elsewhere.focus();
elsewhere.remove();
return json(200, pendingDomain());
});
$(check).trigger('click');
await flush();
await flush();
expect(check.disabled).toBe(false);
expect(document.activeElement).toBe(check);
});
it('moves to the next row when a row is removed', async () => {
await open([otherSite('a', 'a.example.com'), otherSite('b', 'b.example.com')]);
const [rowA, rowB] = $('.custom-domain-row').toArray();
$(rowA).find('.custom-domain-remove').trigger('click');
$(rowA).find('.custom-domain-confirm-go').trigger('click');
await flush();
await flush();
expect(document.activeElement).toBe($(rowB).find('.custom-domain-remove').get(0));
});
});
describe('copy buttons', () => {
afterEach(() => {
delete navigator.clipboard;
delete document.execCommand;
});
const copyCnameValue = async () => {
await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
const $btn = $('[data-pane="records"] .custom-domain-copy').eq(1);
$btn.trigger('click');
await flush();
return $btn;
};
it('copy with the clipboard API', async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const $btn = await copyCnameValue();
expect(writeText).toHaveBeenCalledWith('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(true);
});
it('fall back to selecting and copying when there is no clipboard API', async () => {
document.execCommand = vi.fn(() => true);
const $btn = await copyCnameValue();
expect(document.execCommand).toHaveBeenCalledWith('copy');
expect(window.getSelection().toString()).toBe('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(true);
});
it('leave the value selected when nothing can copy it', async () => {
const $btn = await copyCnameValue();
expect(window.getSelection().toString()).toBe('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(false);
});
});
it('takes no more input on a removed row while it fades out', async () => {
await open([pendingDomain({ site: 'other' })]);
const row = document.querySelector('.custom-domain-row');
$(row).find('.custom-domain-remove').trigger('click');
$(row).find('.custom-domain-confirm-go').trigger('click');
await flush();
await flush();
expect(row.isConnected).toBe(true);
expect(row.hasAttribute('inert')).toBe(true);
});
});
+706 -56
View File
@@ -2007,28 +2007,171 @@ input.myapps-group-name:focus {
}
/* Custom domain modal (puter.hosting.attach), on the app feedback shell. */
.custom-domain-overlay {
--cd-accent: var(--dashboard-button-primary, #4a67f5);
--cd-subtle: rgba(15, 23, 42, 0.035);
--cd-success: #16a34a;
--cd-success-bg: rgba(34, 197, 94, 0.14);
--cd-warn: #b45309;
--cd-warn-bg: rgba(245, 158, 11, 0.14);
--cd-danger: #dc2626;
/* Under white text, so it stays this dark in both themes (4.8:1). */
--cd-danger-fill: #dc2626;
--cd-danger-bg: rgba(220, 38, 38, 0.08);
--cd-skeleton: rgba(15, 23, 42, 0.07);
}
@media (prefers-color-scheme: dark) {
.custom-domain-overlay {
--cd-subtle: rgba(255, 255, 255, 0.04);
--cd-success: #4ade80;
--cd-success-bg: rgba(74, 222, 128, 0.14);
--cd-warn: #fbbf24;
--cd-warn-bg: rgba(251, 191, 36, 0.14);
--cd-danger: #f87171;
--cd-danger-bg: rgba(248, 113, 113, 0.12);
--cd-skeleton: rgba(255, 255, 255, 0.08);
}
}
.custom-domain-modal {
width: min(520px, 100%);
display: flex;
flex-direction: column;
}
.custom-domain-modal svg {
flex: none;
}
/* A hostname moves to the next line whole, and breaks only when it's wider
than the line. */
.custom-domain-modal strong {
font-weight: 600;
overflow-wrap: anywhere;
}
.custom-domain-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* -- Header -- */
.custom-domain-head {
align-items: flex-start;
gap: 12px;
padding: 20px 16px 4px 20px;
}
.custom-domain-head-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 11px;
background: color-mix(in srgb, var(--cd-accent) 12%, transparent);
color: var(--cd-accent);
}
.custom-domain-head-icon svg {
width: 22px;
height: 22px;
}
.custom-domain-head-text {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.custom-domain-intro,
.custom-domain-requester {
margin: 0 0 12px;
font-size: 14px;
margin: 3px 0 0;
font-size: 13px;
line-height: 1.45;
color: var(--afb-muted);
word-break: break-word;
}
.custom-domain-intro strong {
color: var(--afb-fg);
}
.custom-domain-requester {
margin-top: -6px;
margin-top: 2px;
font-size: 12px;
}
.custom-domain-body {
padding-top: 16px;
}
/* -- Loading -- */
.custom-domain-skeleton {
padding: 14px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
}
.custom-domain-skeleton + .custom-domain-skeleton {
margin-top: 8px;
}
.custom-domain-skeleton span {
display: block;
height: 10px;
width: 46%;
border-radius: 5px;
background: var(--cd-skeleton);
animation: custom-domain-pulse 1.2s ease-in-out infinite;
}
.custom-domain-skeleton span + span {
width: 30%;
margin-top: 8px;
height: 8px;
}
@keyframes custom-domain-pulse {
50% { opacity: 0.45; }
}
/* -- Empty, unavailable -- */
.custom-domain-state {
text-align: center;
padding: 18px 12px 6px;
}
.custom-domain-state-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
margin: 0 auto 12px;
border-radius: 50%;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-state-icon svg {
width: 24px;
height: 24px;
}
.custom-domain-state-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.custom-domain-state-text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.45;
color: var(--afb-muted);
}
.custom-domain-empty {
border: 1px dashed var(--afb-field-border);
border-radius: 12px;
padding: 24px 16px;
}
/* -- Domain list -- */
.custom-domain-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--afb-border);
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
}
.custom-domain-list:empty {
display: none;
@@ -2037,117 +2180,624 @@ input.myapps-group-name:focus {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
padding: 10px 10px 10px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
transition: border-color 0.12s ease, background-color 0.12s ease, opacity 0.18s ease, transform 0.18s ease;
}
.custom-domain-row + .custom-domain-row {
border-top: 1px solid var(--afb-border);
@media (hover: hover) {
.custom-domain-row:hover {
border-color: var(--afb-field-border);
background: var(--cd-subtle);
}
}
.custom-domain-row.is-current {
border-color: color-mix(in srgb, var(--cd-accent) 45%, transparent);
}
.custom-domain-row.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.custom-domain-row-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex: none;
border-radius: 9px;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-row-icon svg {
width: 18px;
height: 18px;
}
.custom-domain-row-main {
flex: 1;
min-width: 0;
}
.custom-domain-row-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
}
.custom-domain-row-host {
min-width: 0;
font-size: 14px;
font-weight: 600;
word-break: break-all;
}
.custom-domain-row-meta {
font-size: 12px;
line-height: 1.4;
color: var(--afb-muted);
margin-top: 2px;
word-break: break-word;
}
.custom-domain-confirming .custom-domain-row-meta {
.custom-domain-row-actions,
.custom-domain-row-confirm {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.custom-domain-row-confirm,
.custom-domain-row[data-confirming] .custom-domain-row-actions {
display: none;
}
.custom-domain-row[data-confirming] .custom-domain-row-confirm {
display: flex;
}
.custom-domain-row[data-confirming] .custom-domain-row-meta {
color: var(--afb-fg);
}
.custom-domain-row[data-confirming="remove"] {
border-color: color-mix(in srgb, var(--cd-danger) 40%, transparent);
background: var(--cd-danger-bg);
}
.custom-domain-row[data-confirming="move"] {
border-color: color-mix(in srgb, var(--cd-accent) 45%, transparent);
background: color-mix(in srgb, var(--cd-accent) 6%, transparent);
}
.custom-domain-row .app-feedback-btn {
flex: none;
padding: 6px 14px;
font-size: 13px;
}
.custom-domain-row[data-confirming="remove"] .custom-domain-remove,
.custom-domain-remove-chosen.custom-domain-confirming {
color: var(--afb-error);
.custom-domain-row[data-confirming="move"] .custom-domain-confirm-go {
border-color: var(--cd-accent);
background: var(--cd-accent);
color: #fff;
}
.custom-domain-empty {
margin: 4px 0 0;
font-size: 14px;
.custom-domain-row[data-confirming="remove"] .custom-domain-confirm-go {
border-color: var(--cd-danger-fill);
background: var(--cd-danger-fill);
color: #fff;
}
@media (hover: hover) {
.custom-domain-row[data-confirming] .custom-domain-confirm-go:hover:not(:disabled) {
filter: brightness(0.92);
}
}
/* Status pill next to a hostname. */
.custom-domain-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 1px 8px 1px 7px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
border-radius: 999px;
white-space: nowrap;
color: var(--cd-warn);
background: var(--cd-warn-bg);
}
.custom-domain-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.custom-domain-badge[data-status="active"] {
color: var(--cd-success);
background: var(--cd-success-bg);
}
.custom-domain-badge[data-status="failed"] {
color: var(--cd-danger);
background: var(--cd-danger-bg);
}
/* Icon-only button: trash on a row. */
.custom-domain-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
color: var(--afb-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
.custom-domain-icon-btn svg {
width: 16px;
height: 16px;
}
@media (hover: hover) {
.custom-domain-icon-btn:hover:not(:disabled) {
background: var(--cd-danger-bg);
color: var(--cd-danger);
}
}
.custom-domain-icon-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* -- Buttons -- */
.custom-domain-modal .app-feedback-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.custom-domain-modal .app-feedback-btn svg {
width: 15px;
height: 15px;
}
.custom-domain-modal .app-feedback-btn:focus-visible,
.custom-domain-icon-btn:focus-visible,
.custom-domain-copy:focus-visible,
.custom-domain-link-btn:focus-visible {
outline: 2px solid var(--cd-accent);
outline-offset: 2px;
}
/* The clicked button holds its size and shows a spinner while it works. */
.custom-domain-modal .app-feedback-btn.is-loading {
color: transparent !important;
opacity: 1;
}
.custom-domain-modal .app-feedback-btn.is-loading::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 14px;
height: 14px;
margin: -9px 0 0 -9px;
border: 2px solid var(--afb-muted);
border-right-color: transparent;
border-radius: 50%;
animation: share-modal-spin 0.7s linear infinite;
}
.custom-domain-modal .app-feedback-btn-primary.is-loading::after,
.custom-domain-row[data-confirming] .custom-domain-confirm-go.is-loading::after {
border-color: #fff;
border-right-color: transparent;
}
.custom-domain-modal .app-feedback-btn.is-loading svg {
visibility: hidden;
}
.custom-domain-btn-danger-text {
margin-right: auto;
border-color: transparent;
background: none;
color: var(--cd-danger);
padding-left: 10px;
padding-right: 10px;
}
@media (hover: hover) {
.custom-domain-modal .app-feedback-btn.custom-domain-btn-danger-text:hover:not(:disabled) {
background: var(--cd-danger-bg);
}
}
.custom-domain-btn-danger-text.custom-domain-confirming {
border-color: var(--cd-danger-fill);
background: var(--cd-danger-fill);
color: #fff;
}
@media (hover: hover) {
.custom-domain-modal .app-feedback-btn.custom-domain-btn-danger-text.custom-domain-confirming:hover:not(:disabled) {
background: var(--cd-danger-fill);
filter: brightness(0.92);
}
}
.custom-domain-link-btn {
display: block;
margin: 10px 0 0;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 12px;
text-align: left;
color: var(--cd-accent);
cursor: pointer;
}
@media (hover: hover) {
.custom-domain-link-btn:hover:not(:disabled) {
text-decoration: underline;
}
}
.custom-domain-link-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* Working: full strength, with a spinner after the label. */
.custom-domain-link-btn.is-loading {
opacity: 1;
}
.custom-domain-link-btn.is-loading::after {
content: '';
display: inline-block;
width: 8px;
height: 8px;
margin-left: 6px;
vertical-align: -1px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: share-modal-spin 0.7s linear infinite;
}
/* -- Add form -- */
.custom-domain-label {
display: block;
font-size: 13px;
font-weight: 500;
font-weight: 600;
margin-bottom: 6px;
}
.custom-domain-input {
.custom-domain-field {
position: relative;
display: flex;
align-items: center;
}
.custom-domain-field svg {
position: absolute;
left: 12px;
width: 16px;
height: 16px;
color: var(--afb-muted);
pointer-events: none;
}
.custom-domain-field .custom-domain-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 9px 12px;
height: 42px;
padding: 0 12px 0 36px;
font: inherit;
font-size: 14px;
font-size: 15px;
color: var(--afb-fg);
background: var(--afb-field-bg);
border: 1px solid var(--afb-field-border);
border-radius: 8px;
border-radius: 10px;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.custom-domain-input:focus {
.custom-domain-field .custom-domain-input::placeholder {
color: var(--afb-muted);
opacity: 0.7;
}
.custom-domain-field .custom-domain-input:focus {
outline: none;
border-color: #088ef0;
box-shadow: 0 0 0 3px rgba(8, 142, 240, 0.18);
padding: 0 12px 0 36px;
border: 1px solid var(--cd-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cd-accent) 20%, transparent);
}
.custom-domain-hint {
margin-top: 6px;
}
.custom-domain-callout {
margin: 12px 0 0;
padding: 10px 12px;
font-size: 12.5px;
line-height: 1.45;
border-radius: 10px;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-callout-warn {
color: var(--afb-fg);
background: var(--cd-warn-bg);
border-color: color-mix(in srgb, var(--cd-warn) 30%, transparent);
}
.custom-domain-callout-danger {
color: var(--afb-fg);
background: var(--cd-danger-bg);
border-color: color-mix(in srgb, var(--cd-danger) 30%, transparent);
}
/* -- Status and DNS records -- */
.custom-domain-status-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: var(--cd-warn-bg);
border: 1px solid color-mix(in srgb, var(--cd-warn) 25%, transparent);
}
.custom-domain-status-card[data-status="active"] {
background: var(--cd-success-bg);
border-color: color-mix(in srgb, var(--cd-success) 25%, transparent);
}
.custom-domain-status-card[data-status="failed"] {
background: var(--cd-danger-bg);
border-color: color-mix(in srgb, var(--cd-danger) 25%, transparent);
}
.custom-domain-status-icon {
display: flex;
color: var(--cd-warn);
padding-top: 1px;
}
.custom-domain-status-card[data-status="active"] .custom-domain-status-icon {
color: var(--cd-success);
}
.custom-domain-status-card[data-status="failed"] .custom-domain-status-icon {
color: var(--cd-danger);
}
.custom-domain-status-icon svg {
width: 20px;
height: 20px;
}
.custom-domain-status-main {
flex: 1;
min-width: 0;
}
.custom-domain-status {
margin: 0 0 8px;
margin: 0;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
}
.custom-domain-status-detail {
display: block;
margin-top: 4px;
margin: 3px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--afb-muted);
word-break: break-word;
}
.custom-domain-records-c2a {
margin: 16px 0 0;
font-size: 13px;
line-height: 1.45;
}
.custom-domain-records {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.custom-domain-records th {
text-align: left;
.custom-domain-records:empty {
display: none;
}
.custom-domain-record {
padding: 10px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
background: var(--cd-subtle);
}
.custom-domain-record-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.custom-domain-record-type {
padding: 1px 7px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 600;
line-height: 18px;
border-radius: 6px;
color: var(--cd-accent);
background: color-mix(in srgb, var(--cd-accent) 12%, transparent);
}
.custom-domain-record-optional {
font-size: 11px;
color: var(--afb-muted);
}
.custom-domain-record-field {
display: grid;
grid-template-columns: 48px 1fr auto;
align-items: center;
gap: 8px;
min-height: 30px;
}
.custom-domain-record-label {
font-size: 11px;
font-weight: 500;
color: var(--afb-muted);
padding: 0 6px 6px 0;
}
.custom-domain-records th:first-child {
width: 86px;
}
.custom-domain-records td {
padding: 6px 6px 6px 0;
border-top: 1px solid var(--afb-border);
vertical-align: top;
}
.custom-domain-redirect strong {
color: var(--afb-fg);
.custom-domain-record-field code {
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
word-break: break-all;
}
.custom-domain-use-www {
display: block;
width: 100%;
margin-top: 10px;
font-size: 13px;
user-select: all;
}
.custom-domain-copy {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
background: none;
color: var(--afb-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.custom-domain-copy svg {
width: 15px;
height: 15px;
}
.custom-domain-copy svg + svg,
.custom-domain-copy.is-copied svg:first-child {
display: none;
}
.custom-domain-copy.is-copied svg + svg {
display: block;
word-break: break-all;
cursor: copy;
border-radius: 4px;
animation: custom-domain-pop 0.2s ease-out;
}
.custom-domain-copy.is-copied {
color: var(--cd-success);
}
@media (hover: hover) {
.custom-domain-copy:hover {
background: var(--afb-hover);
.custom-domain-copy:hover:not(:disabled) {
border-color: var(--afb-border);
background: var(--afb-bg);
color: var(--afb-fg);
}
.custom-domain-copy.is-copied:hover:not(:disabled) {
color: var(--cd-success);
}
}
.custom-domain-claim .custom-domain-records {
margin-top: 8px;
}
/* -- Success -- */
.custom-domain-success {
padding: 28px 10px 16px;
}
.custom-domain-success .app-feedback-success-check {
width: 60px;
height: 60px;
background: var(--cd-success-bg);
color: var(--cd-success);
animation: custom-domain-pop 0.32s cubic-bezier(0.2, 0.9, 0.3, 1.3);
}
.custom-domain-success .app-feedback-success-check svg {
width: 30px;
height: 30px;
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: custom-domain-draw 0.32s 0.14s ease-out forwards;
}
.custom-domain-success-note {
margin-top: 6px;
font-size: 13px;
word-break: break-word;
}
@keyframes custom-domain-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes custom-domain-draw {
to { stroke-dashoffset: 0; }
}
/* -- Panes -- */
.custom-domain-pane {
animation: custom-domain-fade 0.16s ease-out;
}
@keyframes custom-domain-fade {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.custom-domain-pane,
.custom-domain-success .app-feedback-success-check,
.custom-domain-copy.is-copied svg + svg {
animation: none;
}
.custom-domain-success .app-feedback-success-check svg {
animation: none;
stroke-dashoffset: 0;
}
.custom-domain-skeleton span {
animation: none;
}
}
@media (max-width: 500px) {
/* The full-width actions wrap below; the name fills the line beside
the icon. */
.custom-domain-row {
flex-wrap: wrap;
}
.custom-domain-row-actions,
.custom-domain-row-confirm {
width: 100%;
justify-content: flex-end;
}
}
/* In a popup that exists only for this dialog, the dialog is the page: a
centered column, with the actions pinned to the bottom edge. */
.custom-domain-fullpage {
--cd-column: min(552px, 100% - 48px);
padding: 0;
align-items: stretch;
background: var(--afb-bg);
}
.custom-domain-fullpage .custom-domain-modal {
width: 100%;
height: 100dvh;
max-height: none;
border: none;
border-radius: 0;
box-shadow: none;
transform: none;
padding-bottom: 0;
overflow: hidden;
}
.custom-domain-fullpage .app-feedback-x {
display: none;
}
.custom-domain-fullpage .custom-domain-head {
width: var(--cd-column);
margin: 0 auto;
padding: 28px 0 4px;
}
.custom-domain-fullpage .custom-domain-body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px 0 0;
}
.custom-domain-fullpage .custom-domain-pane {
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
.custom-domain-fullpage .custom-domain-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
box-sizing: border-box;
width: var(--cd-column);
margin-left: auto;
margin-right: auto;
}
/* Keeps the actions off the content when it fills the page. */
.custom-domain-fullpage .custom-domain-pane::after {
content: '';
flex: 1 0 20px;
order: 1;
}
.custom-domain-fullpage .app-feedback-actions {
order: 2;
position: sticky;
bottom: 0;
margin: 0;
padding: 14px max(24px, calc((100% - 552px) / 2)) calc(14px + env(safe-area-inset-bottom, 0px));
background: var(--afb-bg);
border-top: 1px solid var(--afb-border);
}
.custom-domain-fullpage .custom-domain-state .app-feedback-actions {
justify-content: center;
}
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
@@ -0,0 +1,45 @@
/*
* 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/>.
*/
/**
* Turns what a user typed or pasted into a bare hostname: a URL like
* `https://www.example.com/about` becomes `www.example.com`. The server
* still validates the result.
*
* @param {unknown} raw
* @returns {string} The hostname, or '' when nothing is left.
*/
export const normalizeHostnameInput = (raw) => String(raw ?? '')
.trim()
.toLowerCase()
.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
.replace(/^[^@/?#]*@/, '')
.replace(/[/?#].*$/, '')
.replace(/:\d*$/, '')
.replace(/\.+$/, '');
/**
* Whether a normalized hostname is worth sending: something with a dot and
* no whitespace. Anything subtler is the server's call.
*
* @param {string} hostname
* @returns {boolean}
*/
export const isPlausibleHostname = (hostname) =>
/^[^\s.]+(\.[^\s.]+)+$/.test(hostname);
@@ -0,0 +1,59 @@
/*
* 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, it, expect } from 'vitest';
import { isPlausibleHostname, normalizeHostnameInput } from './customDomainHostname.js';
describe('normalizeHostnameInput', () => {
it('keeps a bare hostname, lowercased and trimmed', () => {
expect(normalizeHostnameInput(' WWW.Example.com ')).toBe('www.example.com');
});
it('strips a pasted URL down to its hostname', () => {
expect(normalizeHostnameInput('https://www.example.com/about?x=1#top')).toBe('www.example.com');
expect(normalizeHostnameInput('http://example.com:8080')).toBe('example.com');
expect(normalizeHostnameInput('https://user:[email protected]/')).toBe('example.com');
});
it('drops a trailing slash or dot', () => {
expect(normalizeHostnameInput('example.com/')).toBe('example.com');
expect(normalizeHostnameInput('example.com.')).toBe('example.com');
});
it('returns an empty string for nothing', () => {
expect(normalizeHostnameInput(undefined)).toBe('');
expect(normalizeHostnameInput(' ')).toBe('');
expect(normalizeHostnameInput('https://')).toBe('');
});
});
describe('isPlausibleHostname', () => {
it('accepts names with at least one dot', () => {
expect(isPlausibleHostname('example.com')).toBe(true);
expect(isPlausibleHostname('www.example.co.uk')).toBe(true);
});
it('rejects names without a dot, with spaces or with empty labels', () => {
expect(isPlausibleHostname('')).toBe(false);
expect(isPlausibleHostname('localhost')).toBe(false);
expect(isPlausibleHostname('www.exa mple.com')).toBe(false);
expect(isPlausibleHostname('www..example.com')).toBe(false);
expect(isPlausibleHostname('.example.com')).toBe(false);
});
});
+8 -2
View File
@@ -161,9 +161,13 @@ const en = {
custom_domain_add: 'Add Domain',
custom_domain_add_new: 'Add a New Domain',
custom_domain_apex_hint: 'This is a root domain, so your DNS provider needs to support a CNAME, ALIAS or ANAME record at the root (sometimes called CNAME flattening).',
custom_domain_badge_active: 'Connected',
custom_domain_badge_failed: 'Failed',
custom_domain_badge_pending: 'Pending',
custom_domain_claim_c2a: 'This domain is on another Puter account. If it\'s yours, add this record at your DNS provider, then verify. The domain moves to your account.',
custom_domain_claim_not_found: 'The record isn\'t visible yet. DNS changes can take a few minutes; try again shortly.',
custom_domain_check_status: 'Check Status',
custom_domain_empty_hint: 'Add a domain you own and point it at this website.',
custom_domain_error_generic: 'Something went wrong. Please try again.',
custom_domain_error_invalid: 'Enter a domain name like www.example.com.',
custom_domain_error_limit: 'Your account has reached the maximum number of custom domains.',
@@ -174,27 +178,29 @@ const en = {
custom_domain_error_site: 'This website isn\'t on your account.',
custom_domain_error_taken: 'That domain is already connected to a Puter account.',
custom_domain_hostname_label: 'Domain',
custom_domain_hostname_hint: 'Use a domain you own, like www.example.com or example.com.',
custom_domain_intro: 'Choose a domain for %strong%.',
custom_domain_move: 'Move',
custom_domain_move_confirm: 'Moves it from %% to %%.',
custom_domain_not_enabled: 'Custom domains aren\'t available on your account yet.',
custom_domain_none_yet: 'You don\'t have any custom domains yet.',
custom_domain_optional: '(optional)',
custom_domain_optional: 'Optional',
custom_domain_price_note: 'Each domain costs %% per month. example.com and www.example.com count as two domains.',
custom_domain_no_root_alias: 'My DNS provider doesn\'t support CNAME, ALIAS or ANAME at the root',
custom_domain_record_type: 'Type',
custom_domain_record_type_apex: 'CNAME / ALIAS / ANAME',
custom_domain_redirect_note: 'Your website is served on %strong%. The A records forward visitors from %strong% to it.',
custom_domain_record_value: 'Value',
custom_domain_remove: 'Remove',
custom_domain_remove_confirm: 'The domain stops working right away and is removed from your account. This month isn\'t refunded.',
custom_domain_records_c2a: 'Add these records at your DNS provider. The domain starts working a few minutes after they\'re in place.',
custom_domain_remove_named: 'Remove %%',
custom_domain_requested_by: 'Requested by %strong%',
custom_domain_serves: 'Serving %%',
custom_domain_serves_this: 'Serving this website',
custom_domain_status_active: '%strong% is connected.',
custom_domain_status_failed: '%strong% couldn\'t be verified.',
custom_domain_status_pending: '%strong% is waiting for its DNS records.',
custom_domain_success_note: 'Visitors to %strong% now see %strong%.',
custom_domain_title: 'Connect a Custom Domain',
custom_domain_unavailable: 'Custom domains aren\'t available on this Puter.',
custom_domain_use: 'Use',
+1
View File
@@ -1001,6 +1001,7 @@ const postAuthActions = async (action) => {
domain = await UIWindowAttachCustomDomain({
subdomain,
requester: target_origin,
fullpage: window.embedded_in_popup === true,
});
} catch (e) {
console.error('attach-custom-domain action failed', e);