Files
selfpost/internal/web/static/panel.js
T
mix 9745edd132 feat(panel): detect import encryption from file extension, not a checkbox
The import form used to ask users to tick "the file is encrypted" before
showing the password field, even though the server already decides purely
from the envelope magic bytes. Reveal the password field automatically for
a .spde file (hide it for .json) so the checkbox is no longer needed.
2026-08-07 04:30:24 +03:00

221 lines
8.8 KiB
JavaScript

// Panel progressive enhancement. Everything here is optional convenience: the
// pages are fully usable with JavaScript disabled or blocked, and nothing is
// sent to the server from this file.
(function () {
"use strict";
// --- Copy buttons on .code values ------------------------------------
// Values that get carried into another interface (a DNS panel, a mail
// client) sit in a .code-row wrapper next to a Copy button. The text is read
// from the .code element itself, so it can never drift from what is shown.
// navigator.clipboard needs a secure context (HTTPS or localhost); over plain
// HTTP in development it is simply absent, in which case the value stays
// selectable by hand.
document.addEventListener("click", function (ev) {
var button = ev.target.closest("button.copy");
if (!button) {
return;
}
var row = button.closest(".code-row");
var code = row && row.querySelector(".code");
if (!code || !navigator.clipboard) {
return;
}
navigator.clipboard.writeText(code.textContent).then(function () {
var original = button.textContent;
button.textContent = "Copied";
setTimeout(function () {
button.textContent = original;
}, 1500);
}, function () {
/* Clipboard refused (permissions, insecure context): leave the page be. */
});
});
// --- Confirmation on destructive forms --------------------------------
// Forms that delete something or invalidate a working credential carry a
// data-confirm message. The prompt lives here rather than in an inline
// onsubmit attribute because the panel's Content-Security-Policy allows no
// inline script. The listener is delegated from the document,
// so it also covers markup swapped in by HTMX. With JavaScript disabled the
// form submits without asking — exactly as the inline handler behaved.
document.addEventListener("submit", function (ev) {
var form = ev.target.closest("form[data-confirm]");
if (form && !window.confirm(form.dataset.confirm)) {
ev.preventDefault();
}
});
// --- Address list shown only in list mode -----------------------------
// The "Addresses" field applies to list mode only; in wildcard mode the
// server ignores it, so hiding it removes a field that does nothing. The
// toggle runs on load too, because the edit form of an existing application
// may already be set to list mode.
function syncAddressField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-addresses]");
if (!field) {
return;
}
// The mode values come from the server (store.AddressModeList), so the
// select carries the one that means "list" rather than this script
// hard-coding it.
field.hidden = select.value !== select.dataset.listMode;
}
function initAddressFields(root) {
root.querySelectorAll("select[data-list-mode]").forEach(function (select) {
syncAddressField(select);
select.addEventListener("change", function () {
syncAddressField(select);
});
});
}
// --- Encryption password fields shown only when asked for --------------
// The backup, export and import forms carry an optional password block. It
// is hidden until the checkbox next to it is ticked, and cleared when it is
// unticked, so a password typed and then abandoned is never submitted. With
// JavaScript blocked the block stays visible and the forms behave exactly as
// the server reads them: the checkbox alone decides whether encryption
// happens.
function syncEncryptFields(box) {
var form = box.closest("form");
var fields = form && form.querySelector("[data-encrypt-fields]");
if (!fields) {
return;
}
fields.hidden = !box.checked;
if (!box.checked) {
fields.querySelectorAll("input").forEach(function (input) {
input.value = "";
});
}
}
function initEncryptFields(root) {
root.querySelectorAll("input[data-encrypt-toggle]").forEach(function (box) {
syncEncryptFields(box);
box.addEventListener("change", function () {
syncEncryptFields(box);
});
});
}
// --- Import password field shown based on the chosen file's extension ---
// The domain-import file decides for itself whether it is encrypted (the
// server checks the envelope magic, not a checkbox), so the panel offers
// the password field the same way: reveal it for a .spde file, hide and
// clear it for a plain .json one. An unrecognised name leaves the field
// visible rather than guessing wrong and hiding a password the file needs.
function syncImportPasswordField(input) {
var form = input.closest("form");
var fields = form && form.querySelector("[data-import-password-fields]");
if (!fields) {
return;
}
var name = (input.files && input.files[0] && input.files[0].name || "").toLowerCase();
var hide = name !== "" && /\.json$/.test(name);
fields.hidden = hide;
if (hide) {
fields.querySelectorAll("input").forEach(function (pw) {
pw.value = "";
});
}
}
function initImportPasswordField(root) {
root.querySelectorAll("input[data-import-file]").forEach(function (input) {
syncImportPasswordField(input);
input.addEventListener("change", function () {
syncImportPasswordField(input);
});
});
}
// --- Section index follows the page -----------------------------------
// The long pages list their own sections in the navigation column (the
// "sections" template). Marking the one currently in view turns that list
// from an index into a position, which is the whole point of it on a page
// nine cards tall. The links work without any of this; only the highlight
// depends on it.
//
// Each pass looks its targets up by id rather than holding on to elements
// found once: the status page replaces its cards wholesale every five
// seconds (hx-swap on #status-body), and anything remembered here would be
// measuring boxes that had left the document.
var sectionLinks = [];
function markCurrentSection() {
var current = null;
sectionLinks.forEach(function (link) {
var target = document.getElementById(link.hash.slice(1));
// The section in view is the last one whose top has passed the reading
// line; the links are in document order, so the last match wins.
if (target && target.getBoundingClientRect().top <= 100) {
current = link;
}
});
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
// At the foot of the page there is no scroll left to bring the last
// cards up to the reading line, so without this they could never be
// marked however far down you are — and the last card of the domain
// page is the one that deletes it.
current = sectionLinks[sectionLinks.length - 1];
} else if (!current) {
// Above the first heading nothing has been passed yet, and the page is
// still on its first section.
current = sectionLinks[0];
}
sectionLinks.forEach(function (link) {
link.classList.toggle("current", link === current);
});
}
function initSectionIndex() {
sectionLinks = Array.prototype.slice.call(
document.querySelectorAll(".sections a[href^='#']")
);
if (!sectionLinks.length) {
return;
}
var pending = false;
// Scroll fires far more often than the highlight can change, so the work
// is collapsed onto the next frame.
window.addEventListener("scroll", function () {
if (pending) {
return;
}
pending = true;
window.requestAnimationFrame(function () {
pending = false;
markCurrentSection();
});
}, { passive: true });
markCurrentSection();
}
document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document);
initEncryptFields(document);
initImportPasswordField(document);
initSectionIndex();
});
// --- Skip polling while the tab is hidden ------------------------------
// The monitoring pages (status, mail queue, system log, deliveries) poll
// every 5s via hx-trigger="every 5s". htmx has a built-in way to make that
// conditional (an event filter, hx-trigger="every 5s [expr]"), but it
// evaluates the filter with `new Function`, which the panel's CSP
// (default-src 'self', no 'unsafe-eval') would silently break. Skipping the
// request here instead needs nothing beyond what the CSP already allows: a
// request due while the tab is hidden is simply not sent, and the next
// request after it becomes visible again picks up on schedule as usual.
document.body.addEventListener("htmx:beforeRequest", function (ev) {
var trigger = ev.target.getAttribute && ev.target.getAttribute("hx-trigger");
if (document.hidden && trigger && trigger.indexOf("every") !== -1) {
ev.preventDefault();
}
});
})();