new functions and js v=

This commit is contained in:
Mateusz Gruszczyński
2026-07-25 17:15:54 +02:00
parent d1d8bb0259
commit 0a02a0880e
31 changed files with 1035 additions and 610 deletions
+42 -36
View File
@@ -1,9 +1,10 @@
import { installGlobalDiagnostics, logInfo } from "./logger.js";
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui";
import { getAuthToken, setAccessToken } from "@rustpad/session";
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
@@ -94,14 +95,14 @@ const resourcesList = document.querySelector("#resources-list");
const resourcesError = document.querySelector("#resources-error");
function authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
function escapeHtml(value) { const node=document.createElement("div"); node.textContent=String(value ?? ""); return node.innerHTML; }
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 escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
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()}`; }
async function loadResources() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
try {
const data = await api("/api/auth/resources", { headers: authHeaders() });
const items = [...data.workspaces.map(item => ({...item, kind:"workspace", url:`/w/${item.slug}`})), ...data.pads.map(item => ({...item, kind:"pad", url:`/p/${item.slug}`}))];
const items = [...data.workspaces.map(item => ({ ...item, kind: "workspace", url: `/w/${item.slug}` })), ...data.pads.map(item => ({ ...item, kind: "pad", url: `/p/${item.slug}` }))];
resourcesList.innerHTML = items.length ? "" : "<p>No assigned items yet.</p>";
for (const item of items) {
const row = document.createElement("article");
@@ -121,8 +122,8 @@ async function loadResources() {
};
row.querySelector("[data-privacy]")?.addEventListener("click", async () => {
try { await api("/api/auth/resources/privacy", {method:"POST",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,private:!Boolean(item.private)})}); await loadResources(); }
catch(e){ resourcesError.textContent=e.message; }
try { await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: !Boolean(item.private) }) }); await loadResources(); }
catch (e) { resourcesError.textContent = e.message; }
});
row.querySelector("[data-share]")?.addEventListener("click", async () => {
const dialog = document.createElement("dialog");
@@ -145,37 +146,42 @@ async function loadResources() {
<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 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 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()});
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=d.links.length?d.links.map(link=>`<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token)}"><div class="share-link-info"><strong>Link ${escapeHtml(link.token.slice(0,8))}…</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small></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]").forEach(linkRow=>{
const forever=linkRow.elements.forever, hours=linkRow.elements.hours; const sync=()=>{hours.disabled=forever.checked;}; forever.addEventListener("change",sync); sync();
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:linkRow.dataset.linkToken,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:linkRow.dataset.linkToken})});setDialogMessage("Link revoked.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");}});
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 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() });
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 = d.links.length ? d.links.map(link => {
const directUrl = link.token ? new URL(`${item.url}?share=${encodeURIComponent(link.token)}`, location.origin).href : "";
const linkPreview = link.token ? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(directUrl)}" aria-label="Direct access link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div>` : '<small class="share-link-legacy">Link value unavailable. Recreate this legacy link to display it.</small>';
return `<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token_hash)}"><div class="share-link-info"><strong>Individual link</strong><small>${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]").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 () => { try { await copyText(linkRow.querySelector(".share-link-inline input").value); setDialogMessage("Link copied.", "success"); } catch (err) { setDialogMessage(err.message, "error"); } });
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: linkRow.dataset.linkToken, 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: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
});
};
userForm.addEventListener("submit",async event=>{event.preventDefault();try{await api("/api/auth/resources/sharing",{method:"POST",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,emails:userForm.emails.value,permission:userForm.permission.value})});userForm.emails.value="";setDialogMessage("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,permission:linkForm.permission.value,expires_at})});const absolute=new URL(result.url,location.origin).href;await navigator.clipboard.writeText(absolute);setDialogMessage("Link created and copied.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");}});
userForm.addEventListener("submit", async event => { event.preventDefault(); try { await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, emails: userForm.emails.value, permission: userForm.permission.value }) }); userForm.emails.value = ""; setDialogMessage("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, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
dialog.showModal();
try { await refresh(); } catch(err) { setDialogMessage(err.message,"error"); }
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
});
row.querySelector("[data-password]")?.addEventListener("click", () => {
@@ -192,9 +198,9 @@ async function loadResources() {
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 api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password }) });
await loadResources();
} catch(e) {
} catch (e) {
setInlineMessage(e.message, "error");
submit.disabled = false;
}
@@ -210,9 +216,9 @@ async function loadResources() {
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 api("/api/auth/resources", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug }) });
await loadResources();
} catch(e) {
} catch (e) {
setInlineMessage(e.message, "error");
event.currentTarget.disabled = false;
}
@@ -220,7 +226,7 @@ async function loadResources() {
});
resourcesList.append(row);
}
} catch(e) { resourcesList.innerHTML=""; resourcesError.textContent=e.message; }
} catch (e) { resourcesList.innerHTML = ""; resourcesError.textContent = e.message; }
}