Files

477 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { bindIdentityDialog, handleAccountActionToken, handleAccountConfirmationToken, handleResetToken, handleShareInvitationToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui";
import { getAuthToken, getGuestId, setAccessToken } from "@rustpad/session";
import { applyTheme, getTheme } from "@rustpad/theme";
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
}
function bindPreview(inputId, previewId, countId, prefix, fallback) {
const input = document.querySelector(inputId);
const preview = document.querySelector(previewId);
const count = document.querySelector(countId);
const update = () => {
count.textContent = `${input.value.length}/80`;
preview.textContent = `${prefix}${slugify(input.value, fallback)}`;
};
input.addEventListener("input", update);
update();
}
function setBusy(button, busy, idleText, busyText) {
button.disabled = busy;
button.textContent = busy ? busyText : idleText;
}
getGuestId();
bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "note");
bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace");
document.querySelectorAll(".password-toggle").forEach((button) => {
button.addEventListener("click", () => {
const input = document.getElementById(button.dataset.target);
const show = input.type === "password";
input.type = show ? "text" : "password";
button.textContent = show ? "Hide" : "Show";
});
});
document.querySelector("#pad-form").addEventListener("submit", async (event) => {
event.preventDefault();
const name = document.querySelector("#pad-name");
const password = document.querySelector("#pad-password");
const button = document.querySelector("#pad-button");
const error = document.querySelector("#pad-error");
error.textContent = "";
setBusy(button, true, "Create note", "Creating…");
try {
const payload = { name: name.value.trim() };
if (password.value) payload.password = password.value;
const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.granted); }
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
} finally {
setBusy(button, false, "Create note", "Creating…");
}
});
document.querySelector("#workspace-form").addEventListener("submit", async (event) => {
event.preventDefault();
const name = document.querySelector("#workspace-name");
const password = document.querySelector("#workspace-password");
const button = document.querySelector("#workspace-button");
const error = document.querySelector("#workspace-error");
error.textContent = "";
setBusy(button, true, "Create workspace", "Creating…");
try {
const payload = { name: name.value.trim() };
if (password.value) payload.password = password.value;
const result = await api("/api/workspaces", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: result.slug, password: password.value }) }); setAccessToken("workspace", result.slug, grant.granted); }
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
} finally {
setBusy(button, false, "Create workspace", "Creating…");
}
});
await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken() || await handleShareInvitationToken();
const identityDialog = document.querySelector("#identity-dialog");
const guestAccount = document.querySelector("#footer-account-guest");
const userAccount = document.querySelector("#footer-account-user");
const userLabel = document.querySelector("#footer-user-label");
const userLabelPrimary = userLabel?.querySelector("[data-account-primary]");
const userLabelSecondary = userLabel?.querySelector("[data-account-secondary]");
const registerLink = document.querySelector("#footer-register");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
const resourcesDialog = document.querySelector("#resources-dialog");
const resourcesList = document.querySelector("#resources-list");
const resourcesError = document.querySelector("#resources-error");
const resourcesSearch = document.querySelector("#resources-search");
const resourcesPerPage = document.querySelector("#resources-per-page");
const resourcesPagination = document.querySelector("#resources-pagination");
let resourcesPage = 1;
let resourcesSearchTimer;
const profileDialog = document.querySelector("#profile-dialog");
const profileForm = document.querySelector("#profile-form");
let currentSession = null;
function authHeaders() { return {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
function resourceActionLabel(full, short = full) {
return `<span class="resource-action-label resource-action-label--full">${escapeHtml(full)}</span><span class="resource-action-label resource-action-label--short" aria-hidden="true">${escapeHtml(short)}</span>`;
}
function setResourcePrivacyLabel(button, isPrivate) {
if (!button) return;
const full = isPrivate ? "Make public" : "Make private";
const short = isPrivate ? "Public" : "Private";
button.setAttribute("aria-label", full);
button.title = full;
const fullLabel = button.querySelector(".resource-action-label--full");
const shortLabel = button.querySelector(".resource-action-label--short");
if (fullLabel) fullLabel.textContent = full;
if (shortLabel) shortLabel.textContent = short;
}
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
function renderResourcesPagination(meta) {
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
}
function closeResourcePasswordMenus(except = null) {
document.querySelectorAll(".resource-password-menu[open]").forEach(menu => {
if (menu !== except) menu.removeAttribute("open");
});
}
document.addEventListener("click", event => {
const menu = event.target.closest?.(".resource-password-menu");
closeResourcePasswordMenus(menu);
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") closeResourcePasswordMenus();
});
async function loadResources() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
try {
const params = new URLSearchParams({ q: resourcesSearch.value.trim(), page: String(resourcesPage), per_page: resourcesPerPage.value });
const data = await api(`/api/auth/resources?${params}`, { headers: authHeaders() });
resourcesPage = data.pagination.page;
const items = data.items.map(item => ({ ...item, url: item.kind === "workspace" ? `/w/${item.slug}` : `/p/${item.slug}` }));
resourcesList.innerHTML = items.length ? "" : "<p>No assigned items yet.</p>";
for (const item of items) {
const row = document.createElement("article");
row.className = "resource-row";
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
const privacyAction = item.private ? "Make public" : "Make private";
const privacyShort = item.private ? "Public" : "Private";
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy aria-label="${privacyAction}" title="${privacyAction}">${resourceActionLabel(privacyAction, privacyShort)}</button><button class="action-button action-button--primary compact-button" type="button" data-share aria-label="Share" title="Share">${resourceActionLabel("Share")}</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button" aria-label="Password settings" title="Password settings">${resourceActionLabel("Password…", "Pass…")}<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete aria-label="Delete" title="Delete">${resourceActionLabel("Delete")}</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
const setInlineMessage = (text, type = "") => {
const message = inline.querySelector("[data-inline-message]");
if (!message) return;
message.className = `form-message resource-inline-message ${type}`.trim();
message.textContent = text;
};
row.querySelector("[data-privacy]")?.addEventListener("click", async event => {
const button = event.currentTarget;
const nextPrivate = !Boolean(item.private);
button.disabled = true;
try {
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
item.private = nextPrivate;
setResourcePrivacyLabel(button, nextPrivate);
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
} catch (e) {
resourcesError.textContent = e.message;
} finally {
button.disabled = false;
}
});
row.querySelector("[data-share]")?.addEventListener("click", async () => {
const initialShareLinksEnabled = Boolean(item.private || item.protected);
const dialog = document.createElement("dialog");
dialog.className = "app-dialog share-dialog";
dialog.innerHTML = `<div class="share-panel">
<div class="share-panel-head"><div><h3>Share access</h3><p>Manage registered users and direct access links for <strong>${escapeHtml(item.title)}</strong>.</p></div><button class="dialog-close" type="button" data-cancel aria-label="Close share dialog">×</button></div>
<div class="share-dialog-body">
<section class="share-section"><div class="share-section-head"><div><h4>People with access</h4><p>Invite existing RustPad users by e-mail or account/directory username.</p></div></div><form data-user-share-form class="share-form-grid">
<label class="share-emails"><span>E-mail or username</span><input name="recipients" type="text" placeholder="alice@example.com, jsmith" autocomplete="off"></label>
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<button class="primary-button" type="submit">Grant access</button>
</form><div data-user-list class="share-list"></div></section>
<section class="share-section" data-direct-links-section><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><div data-direct-links-enabled ${initialShareLinksEnabled ? "" : "hidden"}><form data-link-form class="share-form-grid share-link-form">
<label class="share-link-label"><span>Label</span><input name="label" type="text" maxlength="120" placeholder="Client ABC" autocomplete="off"></label>
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<label><span>Valid for</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" inputmode="numeric"><span>hours</span></div></label>
<label class="share-forever"><input name="forever" type="checkbox"><span>Never expires</span></label>
<button class="primary-button" type="submit">Create link</button>
</form><div class="share-link-list-wrap"><h5>Individual links</h5><div data-link-list class="share-list share-link-list"></div></div></div><div class="share-empty" data-direct-links-disabled ${initialShareLinksEnabled ? "hidden" : ""}>Direct links are disabled while this item is public and has no password. Existing links are preserved and become active again after you make it private or add a password.</div></section>
</div>
<div class="share-dialog-footer"><p class="form-message resource-inline-message" data-inline-message role="status"></p><button class="secondary-button" type="button" data-done>Done</button></div>
</div>`;
document.body.append(dialog);
const closeDialog = () => dialog.close();
dialog.addEventListener("close", () => dialog.remove(), { once: true });
dialog.addEventListener("click", event => { if (event.target === dialog) closeDialog(); });
dialog.querySelector("[data-cancel]").addEventListener("click", closeDialog);
dialog.querySelector("[data-done]").addEventListener("click", closeDialog);
const setDialogMessage = (text, type = "") => {
const message = dialog.querySelector("[data-inline-message]");
message.className = `form-message resource-inline-message ${type}`.trim();
message.textContent = text;
};
const userForm = dialog.querySelector("[data-user-share-form]");
const linkForm = dialog.querySelector("[data-link-form]");
const userList = dialog.querySelector("[data-user-list]");
const linkList = dialog.querySelector("[data-link-list]");
const directLinksEnabled = dialog.querySelector("[data-direct-links-enabled]");
const directLinksDisabled = dialog.querySelector("[data-direct-links-disabled]");
const createdLinkValues = new Map();
const syncForever = () => { linkForm.hours.disabled = linkForm.forever.checked; };
linkForm.forever.addEventListener("change", syncForever); syncForever();
const refresh = async () => {
const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() });
const shareLinksEnabled = d.share_links_enabled !== false;
directLinksEnabled.hidden = !shareLinksEnabled;
directLinksDisabled.hidden = shareLinksEnabled;
userList.innerHTML = d.users.length ? d.users.map(u => `<div class="share-list-row"><div class="share-list-identity"><span class="share-avatar">${escapeHtml((u.nickname || u.email || "?").slice(0, 1).toUpperCase())}</span><div><strong>${escapeHtml(u.nickname)}</strong><small>${escapeHtml(u.email)}</small></div></div><span class="share-role">${u.permission === "rw" ? "Read and write" : "Read only"}</span><button class="secondary-button compact-button" type="button" data-remove-user="${escapeHtml(u.email)}">Remove</button></div>`).join("") : '<p class="share-empty">No users have access.</p>';
linkList.innerHTML = shareLinksEnabled && d.links.length ? d.links.map(link => {
const identifier = shareLinkId(link.token_hash);
const visibleLink = createdLinkValues.get(link.token_hash);
const linkPreview = visibleLink
? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(visibleLink)}" aria-label="New share link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div><small class="share-link-once">The full address remains visible until this dialog is closed.</small>`
: `<small class="share-link-legacy">The full address is not stored. Use the label or link ID #${escapeHtml(identifier)} to identify it.</small>`;
return `<form class="share-list-row share-link-row" data-link-token-hash="${escapeHtml(link.token_hash)}"><div class="share-link-info"><label class="share-link-label-edit"><span>Label</span><input name="label" type="text" maxlength="120" value="${escapeHtml(link.label || "")}" placeholder="Unlabeled link" autocomplete="off"></label><small>Link ID #${escapeHtml(identifier)} · ${escapeHtml(formatShareCreated(link.created_at))} · ${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
}).join("") : '<p class="share-empty">No active links.</p>';
userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } }));
linkList.querySelectorAll("[data-link-token-hash]").forEach(linkRow => {
const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync();
linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async event => {
const button = event.currentTarget;
const input = linkRow.querySelector(".share-link-inline input");
try {
button.disabled = true;
await copyText(input.value);
setDialogMessage("Link copied.", "success");
} catch (err) {
setDialogMessage(err.message, "error");
} finally {
button.disabled = false;
}
});
linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash, label: linkRow.elements.label.value, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash }) }); createdLinkValues.delete(linkRow.dataset.linkTokenHash); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
});
};
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkForm.addEventListener("submit", async event => {
event.preventDefault();
try {
const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked);
const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, label: linkForm.label.value, permission: linkForm.permission.value, expires_at }) });
const absolute = new URL(result.url, location.origin).href;
createdLinkValues.set(result.token_hash, absolute);
linkForm.label.value = "";
await refresh();
try {
await copyText(absolute);
setDialogMessage("Link created, displayed below and copied.", "success");
} catch {
setDialogMessage("Link created and displayed below. Automatic copying failed; use the Copy button.", "warning");
}
} catch (err) {
setDialogMessage(err.message, "error");
}
});
dialog.showModal();
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
});
row.querySelector("[data-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>${item.protected ? "New password" : "Password"}<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters" required></label><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
const form = inline.querySelector("form");
const input = form.querySelector("input");
form.querySelector("[data-cancel]").addEventListener("click", closeInline);
form.addEventListener("submit", async event => {
event.preventDefault();
const password = input.value;
if (password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
const submit = form.querySelector('[type="submit"]');
submit.disabled = true;
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password }) });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
submit.disabled = false;
}
});
input.focus();
});
row.querySelector("[data-remove-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Remove password protection from “${escapeHtml(item.title)}”?</p><p class="resource-inline-help">Anyone with the public link will be able to open it without a password.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-remove-password>Remove password</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.querySelector("[data-cancel]").addEventListener("click", closeInline);
inline.querySelector("[data-confirm-remove-password]").addEventListener("click", async event => {
event.currentTarget.disabled = true;
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password: "" }) });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
event.currentTarget.disabled = false;
}
});
});
row.querySelector("[data-delete]")?.addEventListener("click", () => {
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Delete “${escapeHtml(item.title)}” permanently?</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-delete>Delete</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.querySelector("[data-cancel]").addEventListener("click", closeInline);
inline.querySelector("[data-confirm-delete]").addEventListener("click", async event => {
event.currentTarget.disabled = true;
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug }) });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
event.currentTarget.disabled = false;
}
});
});
resourcesList.append(row);
}
renderResourcesPagination(data.pagination);
} catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; }
}
resourcesSearch?.addEventListener("input", () => { clearTimeout(resourcesSearchTimer); resourcesSearchTimer = setTimeout(() => { resourcesPage = 1; loadResources(); }, 250); });
resourcesPerPage?.addEventListener("change", () => { resourcesPage = 1; loadResources(); });
resourcesPagination?.addEventListener("click", event => { const button = event.target.closest("[data-page]"); if (!button || button.disabled) return; resourcesPage = Number(button.dataset.page) || 1; loadResources(); });
function renderAccount(session) {
currentSession = session;
guestAccount.hidden = Boolean(session);
userAccount.hidden = !session;
if (session) {
const nickname = String(session.nickname || "").trim();
let primaryIdentity = String(session.email || nickname).trim();
let secondaryIdentity = nickname;
if (session.directory_managed) {
const organization = String(session.directory_organization || "").trim();
const displayName = String(session.directory_display_name || session.email || nickname).trim();
primaryIdentity = organization ? `${organization}\\${displayName}` : displayName;
const suggestedNickname = String(session.suggested_nickname || "").trim();
secondaryIdentity = nickname && suggestedNickname
&& nickname.toLowerCase() !== suggestedNickname.toLowerCase()
? nickname
: "";
}
userLabelPrimary.textContent = `Signed as ${primaryIdentity}`;
userLabelPrimary.title = `Signed as ${primaryIdentity}`;
userLabelSecondary.textContent = secondaryIdentity;
userLabelSecondary.title = secondaryIdentity;
userLabelSecondary.hidden = !secondaryIdentity;
} else if (userLabelPrimary && userLabelSecondary) {
userLabelPrimary.textContent = "";
userLabelPrimary.removeAttribute("title");
userLabelSecondary.textContent = "";
userLabelSecondary.removeAttribute("title");
userLabelSecondary.hidden = true;
}
registerLink.hidden = !registrationEnabled;
}
if (identityDialog) {
const authDialog = bindIdentityDialog({
dialog: identityDialog,
onIdentity: async (_nickname, session) => { renderAccount(session); if (session) toast(`Logged in as ${session.nickname}.`); },
});
document.querySelector("#footer-login")?.addEventListener("click", () => {
authDialog.setMode("login");
identityDialog.showModal();
});
registerLink?.addEventListener("click", () => {
authDialog.setMode("register");
identityDialog.showModal();
});
document.querySelector("#footer-profile")?.addEventListener("click", () => {
document.querySelector("#profile-nickname").value = currentSession?.nickname || "";
const profileColor = document.querySelector("#profile-color");
profileColor.value = currentSession?.editor_color || "#7c6cff";
const selectedTheme = currentSession?.theme || getTheme();
profileForm.querySelectorAll(`input[name="profile-theme"]`).forEach(input => { input.checked = input.value === selectedTheme; });
document.querySelector("#profile-current-email").value = currentSession?.email || "";
document.querySelector("#profile-email").value = "";
document.querySelector("#profile-new-password").value = "";
document.querySelector("#profile-password").value = "";
const profileMessage = document.querySelector("#profile-message");
profileMessage.textContent = "";
profileMessage.classList.remove("success", "error");
const directoryManaged = Boolean(currentSession?.directory_managed);
document.querySelector("#profile-copy").textContent = directoryManaged
? "Directory account details are read-only. You can change the nickname, editor color, and interface theme."
: "Manage your local RustPad account.";
document.querySelectorAll("[data-local-profile-field]").forEach(element => { element.hidden = directoryManaged; });
document.querySelectorAll("[data-directory-profile-field]").forEach(element => { element.hidden = !directoryManaged; });
document.querySelector("#profile-directory-name").value = currentSession?.directory_display_name || "";
document.querySelector("#profile-directory-organization").value = currentSession?.directory_organization || "";
const suggestion = currentSession?.suggested_nickname;
const suggestionElement = document.querySelector("#profile-nickname-suggestion");
suggestionElement.textContent = suggestion ? `Suggested nickname: ${suggestion}` : "No automatic nickname suggestion is available.";
document.querySelector("#profile-password").required = false;
profileDialog.showModal();
});
document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close());
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
profileForm?.addEventListener("submit", async event => {
event.preventDefault(); const message = document.querySelector("#profile-message"); message.textContent = ""; message.classList.remove("success", "error");
try { const selectedColor = document.querySelector("#profile-color").value; const selectedTheme = profileForm.querySelector(`input[name="profile-theme"]:checked`)?.value || "dark"; const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null); const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null); const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result = await api("/api/auth/profile", { method: "POST", headers: authHeaders(), body: JSON.stringify({ nickname: document.querySelector("#profile-nickname").value.trim(), editor_color: selectedColor, theme: selectedTheme, new_email: newEmail, new_password: newPassword, password }) }); message.textContent = result.message; message.classList.add("success"); currentSession.nickname = result.nickname; currentSession.editor_color = result.editor_color; currentSession.theme = result.theme; applyTheme(result.theme); renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
});
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
const message = document.querySelector("#profile-message"); const password = document.querySelector("#profile-password").value;
message.classList.remove("success", "error");
if (!password) { message.textContent = "Enter the current password first."; message.classList.add("error"); return; }
if (!confirm("Send an e-mail link to permanently delete this account?")) return;
try { const result = await api("/api/auth/account/delete", { method: "POST", headers: authHeaders(), body: JSON.stringify({ password }) }); message.textContent = result.message; message.classList.add("success"); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
});
document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); });
document.querySelector("#close-resources")?.addEventListener("click", () => resourcesDialog.close());
resourcesDialog?.addEventListener("click", (event) => { if (event.target === resourcesDialog) resourcesDialog.close(); });
document.querySelector("#footer-logout")?.addEventListener("click", async () => {
await logoutCurrentSession();
renderAccount(null);
toast("Logged out.");
});
window.addEventListener("rustpad:session-expired", () => renderAccount(null));
renderAccount(null);
validateCurrentSession().then(renderAccount);
}