cmap functions, email, architecture documentation.

This commit is contained in:
2026-08-18 03:33:10 +02:00
parent 12f1ed2764
commit 63b7ca0853
33 changed files with 3974 additions and 80 deletions
+398 -16
View File
@@ -43,6 +43,7 @@
let cmapStatusTimer = null;
let cmapAutosaveTimer = null;
let cmapSavePromise = null;
let cmapEmbedHydrationTimer = null;
const CMAP_AUTOSAVE_DELAY = 1500;
@@ -58,7 +59,7 @@
const wikiCmapLinkCombobox = new window.RacketWikiComboBox($("wiki-cmap-link-combobox"));
function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "admin-view", "alias-admin-view", "user-admin-view", "orphaned-uploads-view"]) {
for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view"]) {
$(id).classList.toggle("hidden", id !== viewId);
}
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
@@ -492,13 +493,123 @@
* post : The returned HTML is sanitized with DOMPurify.
* result : Safe HTML for preview, reader view or history view.
*/
function extractCmapEmbeds(markdown) {
const embeds = [];
let fence = null;
const lines = String(markdown || "").split("\n").map((line) => {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
if (fence === null) fence = marker;
else if (fence === marker) fence = null;
return line;
}
if (fence !== null) return line;
const match = line.match(/^\s*\{\{cmap:([^{}\n]+)\}\}\s*$/i);
if (!match) return line;
const token = `RACKETWIKICMAPEMBED${embeds.length}TOKEN`;
embeds.push({ token, reference: match[1].trim() });
return token;
});
return { markdown: lines.join("\n"), embeds };
}
function restoreCmapEmbeds(html, embeds) {
let result = html;
for (const embed of embeds) {
const placeholder = `<section class="rw-cmap-embed" data-cmap-reference="${escapeHtml(embed.reference)}"><div class="rw-cmap-embed-loading">${escapeHtml(tr("loading", "Loading…"))}</div></section>`;
result = result.replace(`<p>${embed.token}</p>`, placeholder).replace(embed.token, placeholder);
}
return result;
}
function queueCmapEmbedHydration() {
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
cmapEmbedHydrationTimer = window.setTimeout(() => {
cmapEmbedHydrationTimer = null;
hydrateCmapEmbeds(document).catch((error) => console.error(error));
}, 0);
}
function renderMarkdown(markdown, pageSlug = null) {
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(markdown || "");
const extracted = extractCmapEmbeds(markdown || "");
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(extracted.markdown);
const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug);
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug);
const html = easyMDE.markdown(withTodos);
const withImages = applyImageWidthMarkup(html);
return DOMPurify.sanitize(withImages);
const withEmbeds = restoreCmapEmbeds(html, extracted.embeds);
const withImages = applyImageWidthMarkup(withEmbeds);
const safeHtml = DOMPurify.sanitize(withImages);
if (extracted.embeds.length) queueCmapEmbedHydration();
return safeHtml;
}
async function hydrateCmapEmbeds(root) {
const embeds = Array.from(root.querySelectorAll(".rw-cmap-embed:not([data-cmap-hydrated])"));
for (const embed of embeds) {
embed.dataset.cmapHydrated = "loading";
const conceptMap = cmapMentionTarget(embed.dataset.cmapReference || "");
if (!conceptMap) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = tr("concept-map-not-found", "CMap not found");
embed.append(message);
continue;
}
try {
const stored = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`);
const documentValue = decodeStoredConceptMapDocument(stored);
embed.replaceChildren();
embed.dataset.cmapSlug = conceptMap.slug;
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
const header = document.createElement("header");
const title = document.createElement("strong");
title.textContent = conceptMap.title;
const hint = document.createElement("span");
hint.textContent = tr("embedded-concept-map-help", "Double-click to open this CMap.");
header.append(title, hint);
const viewport = document.createElement("div");
viewport.className = "rw-cmap-embed-viewport";
const canvas = document.createElement("div");
canvas.className = "cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
viewport.append(canvas);
embed.append(header, viewport);
const editor = window.RacketWikiCmap.createEditor(canvas, {
Cmap: window.Cmap,
renderItem: (record) => cmapNodeHtml(record)
});
editor.loadDocument(documentValue);
const visible = editor.items.filter((item) => editor.isEffectiveItemVisible(item));
if (visible.length) {
const left = Math.min(...visible.map((item) => Number(item.node.attr("x")))) - 24;
const top = Math.min(...visible.map((item) => Number(item.node.attr("y")))) - 24;
const right = Math.max(...visible.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
const bottom = Math.max(...visible.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
const availableWidth = Math.max(320, embed.clientWidth - 2);
const scale = Math.min(1, availableWidth / Math.max(1, right - left), 520 / Math.max(1, bottom - top));
editor.zoomFactor = scale;
editor.map.zoom(scale);
viewport.style.height = `${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
window.requestAnimationFrame(() => {
viewport.scrollLeft = Math.max(0, left * scale);
viewport.scrollTop = Math.max(0, top * scale);
});
}
embed.dataset.cmapHydrated = "ready";
embed.addEventListener("dblclick", () => {
navigateToHash(cmapRoute(conceptMap.slug)).catch((error) => console.error(error));
});
} catch (error) {
embed.dataset.cmapHydrated = "error";
embed.replaceChildren();
const message = document.createElement("p");
message.className = "error";
message.textContent = error.message;
embed.append(message);
}
}
}
function slugTitle(slug) {
@@ -1463,6 +1574,7 @@
wikiCmapLinkCombobox.setOptions(
state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)), "");
$("wiki-cmap-link-submit").disabled = state.conceptMaps.length === 0;
$("wiki-cmap-embed-submit").disabled = state.conceptMaps.length === 0;
$("wiki-cmap-link").placeholder = state.conceptMaps.length ?
tr("filter-concept-maps", "Filter concept maps") :
tr("no-concept-maps", "No saved CMaps");
@@ -1486,6 +1598,21 @@
return true;
}
function insertSelectedWikiCmapEmbed() {
const slug = wikiCmapLinkCombobox.value();
if (slug === null || !slug) {
const input = $("wiki-cmap-link");
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
input.reportValidity();
return false;
}
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
if (!conceptMap) return false;
insertTextAtCursor(`\n\n{{cmap:${conceptMap.slug}}}\n\n`);
$("wiki-cmap-link-dialog").close();
return true;
}
function isInlineImage(file) {
return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.type);
}
@@ -2049,6 +2176,10 @@
username.textContent = user.username;
const display = document.createElement("input");
display.value = user.displayName;
const email = document.createElement("input");
email.type = "email";
email.value = user.email || "";
email.placeholder = tr("email-address", "Email address");
const role = document.createElement("select");
for (const roleName of ["reader", "editor", "admin"]) {
const option = document.createElement("option");
@@ -2072,6 +2203,7 @@
method: "PUT",
body: JSON.stringify({
displayName: display.value,
email: email.value,
role: role.value,
enabled: enabled.checked,
password: password.value || undefined
@@ -2088,11 +2220,75 @@
await loadUsersAdmin();
});
actions.append(save, remove);
row.append(username, display, role, enabled, password, actions);
row.append(username, display, email, role, enabled, password, actions);
list.append(row);
}
}
function showProfile() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("profile", "Profile") }
]);
show("profile-view");
const user = state.session.user;
$("profile-username").value = user.username;
$("profile-display-name").value = user.displayName;
$("profile-email").value = user.email || "";
$("profile-current-password").value = "";
$("profile-new-password").value = "";
$("profile-repeat-password").value = "";
$("profile-status").textContent = "";
}
async function loadMailSettingsAdmin() {
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("admin", "Admin"), href: "#admin" },
{ label: tr("email-and-password-reset", "Email and password reset") }
]);
show("mail-admin-view");
const settings = await api("/api/admin/mail-settings");
$("mail-public-url").value = settings.publicUrl || window.location.origin;
$("mail-smtp-host").value = settings.smtpHost || "";
$("mail-smtp-port").value = settings.smtpPort || "587";
$("mail-smtp-from").value = settings.smtpFrom || "";
$("mail-smtp-user").value = settings.smtpUser || "";
$("mail-smtp-password").value = "";
$("mail-smtp-tls").checked = settings.smtpTls !== false;
$("mail-smtp-accept-untrusted-certificates").checked = settings.smtpAcceptUntrustedCertificates === true;
$("mail-smtp-accept-untrusted-certificates").disabled = !$("mail-smtp-tls").checked;
$("mail-reset-limit").value = settings.resetLimit || "2";
$("mail-test-recipient").value = state.session.user.email || "";
$("mail-password-help").textContent = settings.hasPassword ? tr("smtp-password-kept", "A password is stored; leave empty to keep it.") : "";
$("mail-settings-status").textContent = "";
}
function mailSettingsFormData() {
return {
publicUrl: $("mail-public-url").value,
smtpHost: $("mail-smtp-host").value,
smtpPort: $("mail-smtp-port").value,
smtpFrom: $("mail-smtp-from").value,
smtpUser: $("mail-smtp-user").value,
smtpPassword: $("mail-smtp-password").value,
smtpTls: $("mail-smtp-tls").checked,
smtpAcceptUntrustedCertificates: $("mail-smtp-accept-untrusted-certificates").checked,
resetLimit: $("mail-reset-limit").value
};
}
function smtpErrorMessage(error) {
const message = error && error.message ? error.message : String(error);
if (message.includes("certificate verify failed") || message.includes("TLS certificate verification failed")) {
return tr("smtp-certificate-verification-failed", "The SMTP server certificate could not be verified. Install a valid certificate, or select the local-server exception if this is a trusted local SMTP server.");
}
if (message.includes("no protocols available")) {
return tr("smtp-no-modern-tls", "The SMTP connection attempted an obsolete TLS protocol. Install the current Racket Wiki version, which negotiates modern TLS automatically.");
}
return message;
}
function showNotFound(slug) {
state.currentPage = null;
state.editingNew = false;
@@ -2131,6 +2327,10 @@
* post : Exactly one application view is made active.
*/
async function route() {
if (location.hash === "#profile") {
showProfile();
return;
}
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
if (todoMatch) {
await showTodos(decodeURIComponent(todoMatch[1]), Number(todoMatch[2]));
@@ -2186,6 +2386,11 @@
return;
}
if (location.hash === "#admin/mail" && can("admin")) {
await loadMailSettingsAdmin();
return;
}
if (location.hash === "#admin/aliases" && can("admin")) {
await loadAliasesAdmin();
return;
@@ -2899,7 +3104,7 @@
canvas.replaceChildren();
state.cmapPrototype = null;
console.info("[racket-wiki:cmap-host 0.2.84] resetCmapPrototype", {
console.info("[racket-wiki:cmap-host 0.2.94] resetCmapPrototype", {
cmapAvailable: typeof window.Cmap === "function",
interactionLayerAvailable: Boolean(window.RacketWikiCmap),
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
@@ -2931,7 +3136,7 @@
}
},
onOpenSubMap: (record) => {
console.info("[racket-wiki:cmap-host 0.2.84] submap state changed", {
console.info("[racket-wiki:cmap-host 0.2.94] submap state changed", {
id: record.id,
expanded: record.expanded,
childMap: record.childMap
@@ -2969,7 +3174,7 @@
relationLabel: tr("relation", "Relation")
});
restoreCmapZoom();
console.info("[racket-wiki:cmap-host 0.2.84] editor stored", {
console.info("[racket-wiki:cmap-host 0.2.94] editor stored", {
editorAvailable: Boolean(prototype.editor),
canvasChildCount: canvas.children.length
});
@@ -3036,6 +3241,8 @@
const hasStoredMap = Boolean(state.currentConceptMap);
$("cmap-rename-map").disabled = !hasStoredMap;
$("cmap-delete-map").disabled = !hasStoredMap;
$("cmap-create-snapshot").disabled = !hasStoredMap;
$("cmap-history").disabled = !hasStoredMap;
}
async function loadConceptMaps() {
@@ -3052,7 +3259,7 @@
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
console.error("[racket-wiki:cmap-host 0.2.84] invalid stored CMap document", {
console.error("[racket-wiki:cmap-host 0.2.94] invalid stored CMap document", {
slug: conceptMap.slug,
valueType: Array.isArray(documentValue) ? "array" : typeof documentValue,
value: documentValue
@@ -3152,7 +3359,7 @@
const conceptMap = await api(`/api/cmaps/${encodeURIComponent(slug)}`);
if (loadSequence !== state.cmapLoadSequence) return;
conceptMap.document = decodeStoredConceptMapDocument(conceptMap);
console.info("[racket-wiki:cmap-host 0.2.84] stored CMap received", {
console.info("[racket-wiki:cmap-host 0.2.94] stored CMap received", {
slug: conceptMap.slug,
version: conceptMap.currentVersion,
itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0,
@@ -3163,7 +3370,7 @@
resetCmapPrototype(conceptMap.document, false);
markCurrentCmapSaved();
const loadedEditor = cmapPrototypeState().editor;
console.info("[racket-wiki:cmap-host 0.2.84] stored CMap loaded", {
console.info("[racket-wiki:cmap-host 0.2.94] stored CMap loaded", {
slug: conceptMap.slug,
editorAvailable: Boolean(loadedEditor),
itemCount: loadedEditor ? loadedEditor.items.length : 0,
@@ -3172,6 +3379,68 @@
showCmapStatus(tr("concept-map-loaded", "CMap loaded"), true);
}
async function loadHistoricalConceptMapVersion(version) {
const conceptMap = state.currentConceptMap;
if (!conceptMap) return;
const historical = await api(
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version)}`);
historical.document = decodeStoredConceptMapDocument(historical);
const currentSnapshot = JSON.stringify(conceptMap.document);
resetCmapPrototype(historical.document, false);
state.cmapSavedSnapshot = currentSnapshot;
showCmapStatus(
tr("concept-map-version-loaded", "Version {version} loaded; save to make it current.")
.replace("{version}", String(historical.version)),
true);
}
async function showConceptMapHistory() {
const conceptMap = state.currentConceptMap;
if (!conceptMap) return;
const result = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/history`);
const list = $("cmap-history-list");
list.replaceChildren();
for (const version of result.versions || []) {
const row = document.createElement("div");
row.className = "cmap-history-row";
const label = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = `${tr("version", "Version")} ${version.version}${version.title}`;
const meta = document.createElement("div");
meta.className = "muted";
const knownSummaries = {
create: tr("concept-map-created-version", "CMap created"),
rename: tr("concept-map-renamed-version", "CMap renamed")
};
let summary = knownSummaries[version.action] || version.summary;
if (version.action === "snapshot") {
const snapshotLabel = tr("snapshot", "Snapshot");
if (version.summary === "Current state when CMap history was enabled") {
summary = tr("concept-map-initial-version", "Initial available version");
} else {
summary = version.summary === snapshotLabel ? snapshotLabel : `${snapshotLabel}${version.summary}`;
}
}
if (version.summary === "Automatic save") summary = tr("automatic-save", "Automatic save");
if (version.summary === "Manual save") summary = tr("manual-save", "Manual save");
meta.textContent = `${pageDisplayDate(version.createdAt)} · ${version.author} · ${summary}`;
label.append(heading, document.createElement("br"), meta);
const load = document.createElement("button");
load.type = "button";
load.textContent = version.version === conceptMap.currentVersion ?
tr("current-version", "Current") : tr("load-version", "Load version");
load.disabled = version.version === conceptMap.currentVersion;
load.addEventListener("click", () => {
$("cmap-history-dialog").close();
requestCmapTransition(() => loadHistoricalConceptMapVersion(version.version))
.catch((error) => showCmapStatus(error.message));
});
row.append(label, load);
list.append(row);
}
$("cmap-history-dialog").showModal();
}
async function createStoredConceptMap() {
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) return;
@@ -3239,7 +3508,8 @@
}
}
async function saveStoredConceptMap({ automatic = false } = {}) {
async function saveStoredConceptMap({ automatic = false, force = false,
summary = null, snapshotVersion = false } = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return false;
cancelCmapAutosave();
@@ -3247,13 +3517,15 @@
if (cmapSavePromise) {
const firstSaveSucceeded = await cmapSavePromise;
if (!firstSaveSucceeded) return false;
if (cmapHasUnsavedChanges()) return saveStoredConceptMap({ automatic });
if (force || cmapHasUnsavedChanges()) {
return saveStoredConceptMap({ automatic, force, summary, snapshotVersion });
}
return true;
}
const snapshot = currentCmapSnapshot();
if (snapshot === null) return false;
if (snapshot === state.cmapSavedSnapshot) {
if (!force && snapshot === state.cmapSavedSnapshot) {
if (!automatic) showCmapStatus(tr("concept-map-saved", "CMap saved"), true);
return true;
}
@@ -3261,7 +3533,8 @@
const document = JSON.parse(snapshot);
const conceptMapAtStart = state.currentConceptMap;
let saveSucceeded = false;
showCmapStatus(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…"));
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…")));
cmapSavePromise = (async () => {
try {
if (!conceptMapAtStart) {
@@ -3286,6 +3559,8 @@
body: JSON.stringify({
title: conceptMapAtStart.title,
baseVersion: conceptMapAtStart.currentVersion,
summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")),
snapshot: snapshotVersion,
document
})
});
@@ -3296,7 +3571,8 @@
}
markCurrentCmapSaved(snapshot);
showCmapStatus(
automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved"),
snapshotVersion ? tr("snapshot-created", "Snapshot created") :
(automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")),
true);
saveSucceeded = true;
return true;
@@ -3311,6 +3587,17 @@
return cmapSavePromise;
}
async function createConceptMapSnapshot() {
if (!state.currentConceptMap) return false;
const description = window.prompt(tr("snapshot-description", "Snapshot description"), "");
if (description === null) return false;
return saveStoredConceptMap({
force: true,
summary: description.trim() || tr("snapshot", "Snapshot"),
snapshotVersion: true
});
}
async function showCmapPrototype(requestedSlug = null) {
state.previousView = state.currentPage ? "page-view" : "cmap-view";
renderBreadcrumbs([
@@ -3366,6 +3653,10 @@
const pageSlug = wikiSlugFromHref(href);
if (pageSlug) pages.add(pageSlug);
}
for (const embed of container.querySelectorAll(".rw-cmap-embed[data-cmap-reference]")) {
const target = cmapMentionTarget(embed.dataset.cmapReference || "");
if (target) conceptMaps.add(target.slug);
}
return { pages, conceptMaps };
}
@@ -3902,6 +4193,12 @@
$("cmap-save-map").addEventListener("click", () => saveStoredConceptMap());
$("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap());
$("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap());
$("cmap-create-snapshot").addEventListener("click", () => {
createConceptMapSnapshot().catch((error) => showCmapStatus(error.message));
});
$("cmap-history").addEventListener("click", () => {
showConceptMapHistory().catch((error) => showCmapStatus(error.message));
});
$("cmap-add-concept").addEventListener("click", () => {
openNewCmapConceptDialog(cmapContextCreateContext || {});
});
@@ -4115,6 +4412,7 @@
event.preventDefault();
insertSelectedWikiCmapLink();
});
$("wiki-cmap-embed-submit").addEventListener("click", insertSelectedWikiCmapEmbed);
$("wiki-cmap-link-dialog").addEventListener("close", () => {
pendingWikiCmapLinkLabel = "";
wikiCmapLinkCombobox.clear();
@@ -4128,6 +4426,7 @@
event.preventDefault();
cancelCmapTransition();
});
$("cmap-history-close").addEventListener("click", () => $("cmap-history-dialog").close());
$("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraphOverlay().catch((error) => console.error(error)); });
$("context-overlay-close").addEventListener("click", (event) => { event.preventDefault(); closeContextOverlay(); });
$("context-overlay").addEventListener("click", (event) => { if (event.target === $("context-overlay")) closeContextOverlay(); });
@@ -4157,6 +4456,37 @@
await api("/api/logout", { method: "POST", body: "{}" });
window.location.replace("/login");
});
$("profile-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#profile").catch(console.error);
});
$("profile-form").addEventListener("submit", async (event) => {
event.preventDefault();
const newPassword = $("profile-new-password").value;
if (newPassword !== $("profile-repeat-password").value) {
$("profile-status").textContent = tr("passwords-do-not-match", "Passwords do not match.");
return;
}
try {
const result = await api("/api/profile", {
method: "PUT",
body: JSON.stringify({
displayName: $("profile-display-name").value,
email: $("profile-email").value,
currentPassword: $("profile-current-password").value,
newPassword
})
});
state.session = result.session;
$("account-name").textContent = state.session.user.displayName;
$("profile-current-password").value = "";
$("profile-new-password").value = "";
$("profile-repeat-password").value = "";
$("profile-status").textContent = tr("profile-saved", "Profile saved.");
} catch (error) {
$("profile-status").textContent = error.message;
}
});
$("admin-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
@@ -4165,6 +4495,10 @@
event.preventDefault();
navigateToHash("#admin/users").catch(console.error);
});
$("admin-mail-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/mail").catch(console.error);
});
$("admin-aliases-link").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin/aliases").catch(console.error);
@@ -4195,6 +4529,10 @@
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-mail-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
});
$("close-alias-admin").addEventListener("click", (event) => {
event.preventDefault();
navigateToHash("#admin").catch(console.error);
@@ -4247,6 +4585,7 @@
body: JSON.stringify({
username: $("new-username").value,
displayName: $("new-display-name").value || $("new-username").value,
email: $("new-email").value,
password: $("new-password").value,
role: $("new-role").value,
enabled: true
@@ -4256,6 +4595,49 @@
await loadUsersAdmin();
});
$("mail-settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/api/admin/mail-settings", {
method: "PUT",
body: JSON.stringify(mailSettingsFormData())
});
$("mail-smtp-password").value = "";
$("mail-password-help").textContent = tr("smtp-password-kept", "A password is stored; leave empty to keep it.");
$("mail-settings-status").textContent = tr("saved", "Saved");
} catch (error) {
$("mail-settings-status").textContent = error.message;
}
});
$("mail-test-button").addEventListener("click", async () => {
const button = $("mail-test-button");
const recipient = $("mail-test-recipient").value.trim();
button.disabled = true;
$("mail-settings-status").textContent = tr("sending-test-mail", "Sending test email…");
try {
await api("/api/admin/mail-settings/test", {
method: "POST",
body: JSON.stringify({
...mailSettingsFormData(),
recipient
})
});
$("mail-settings-status").textContent = tr("test-mail-sent", "Test email accepted by SMTP server");
} catch (error) {
console.error("SMTP test failed", error);
$("mail-settings-status").textContent = smtpErrorMessage(error);
} finally {
button.disabled = false;
}
});
$("mail-smtp-tls").addEventListener("change", () => {
const acceptUntrustedCertificates = $("mail-smtp-accept-untrusted-certificates");
acceptUntrustedCertificates.disabled = !$("mail-smtp-tls").checked;
if (acceptUntrustedCertificates.disabled) acceptUntrustedCertificates.checked = false;
});
window.addEventListener("hashchange", () => {
if (cmapHasUnsavedChanges()) {
const requestedHash = location.hash;