share funxtions

This commit is contained in:
Mateusz Gruszczyński
2026-07-23 15:07:59 +02:00
parent dc6227896b
commit 8c3842157d
18 changed files with 409 additions and 29 deletions
+1
View File
@@ -16,6 +16,7 @@ export async function api(path, options = {}) {
if (!response.ok) {
const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The uploaded data is too large.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
const requestError = new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
requestError.status = response.status;
logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: requestError.message });
throw requestError;
}
+67 -3
View File
@@ -94,6 +94,9 @@ 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()}`; }
async function loadResources() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
try {
@@ -103,7 +106,10 @@ async function loadResources() {
for (const item of items) {
const row = document.createElement("article");
row.className = "resource-row";
row.innerHTML = `<div class="resource-main"><div><a href="${item.url}">${item.title}</a><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.protected ? " · password protected" : ""}</small></div><div class="resource-actions"><button type="button" data-password>Change password</button><button type="button" data-delete>Delete</button></div></div><div class="resource-inline" data-inline hidden></div>`;
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));
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${item.url}">${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 type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button type="button" data-share>Share</button><button type="button" data-password>Change password</button><button type="button" data-delete>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 = ""; };
@@ -114,7 +120,65 @@ async function loadResources() {
message.textContent = text;
};
row.querySelector("[data-password]").addEventListener("click", () => {
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; }
});
row.querySelector("[data-share]")?.addEventListener("click", async () => {
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.</p></div></div><form data-user-share-form class="share-form-grid">
<label class="share-emails"><span>E-mail addresses</span><input name="emails" type="text" placeholder="alice@example.com, bob@example.com" 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"><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><form data-link-form class="share-form-grid share-link-form">
<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 data-link-list class="share-list"></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 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");}});
});
};
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");}});
dialog.showModal();
try { await refresh(); } catch(err) { setDialogMessage(err.message,"error"); }
});
row.querySelector("[data-password]")?.addEventListener("click", () => {
inline.hidden = false;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>New password<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters"></label><p class="resource-inline-help">Leave empty to remove password protection.</p><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");
@@ -138,7 +202,7 @@ async function loadResources() {
input.focus();
});
row.querySelector("[data-delete]").addEventListener("click", () => {
row.querySelector("[data-delete]")?.addEventListener("click", () => {
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Delete “${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);
+2 -1
View File
@@ -18,7 +18,8 @@ const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document
const roomDetails=document.querySelector("#room-details"), roomUsers=document.querySelector("#room-users"), roomCount=document.querySelector("#room-count"), socketLatency=document.querySelector("#socket-latency"), chatMessages=document.querySelector("#chat-messages"), chatForm=document.querySelector("#chat-form"), chatInput=document.querySelector("#chat-input"), chatUnread=document.querySelector("#chat-unread");
let unreadChat=0;
const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker");
let accessToken=getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("workspace",workspaceSlug,shareToken);
let accessToken=shareToken||getAuthToken()||getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off";
fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono";
+2 -1
View File
@@ -17,7 +17,8 @@ const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document
const roomDetails=document.querySelector("#room-details"), roomUsers=document.querySelector("#room-users"), roomCount=document.querySelector("#room-count"), socketLatency=document.querySelector("#socket-latency"), chatMessages=document.querySelector("#chat-messages"), chatForm=document.querySelector("#chat-form"), chatInput=document.querySelector("#chat-input"), chatUnread=document.querySelector("#chat-unread");
let unreadChat=0;
const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker");
let accessToken=getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("pad",slug,shareToken);
let accessToken=shareToken||getAuthToken()||getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off";
fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono";
+10 -3
View File
@@ -3,13 +3,15 @@ installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { getNickname, getAccessToken, setAccessToken } from "@rustpad/session";
import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session";
import { askConfirm } from "./modal.js";
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
let info;
let accessToken = getAccessToken("workspace", slug);
const shareToken=new URLSearchParams(location.search).get("share");
let accessToken=shareToken||getAuthToken()||getAccessToken("workspace",slug);
if(shareToken)setAccessToken("workspace",slug,shareToken);
const dialog = document.querySelector("#password-dialog");
const notesList = document.querySelector("#notes-list");
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
@@ -89,6 +91,8 @@ async function openWorkspace() {
if (info?.protected || e.message.toLowerCase().includes("password")) {
document.querySelector("#password-error").textContent = e.message;
if (!dialog.open) dialog.showModal();
} else if (e.status === 403) {
location.replace("/errors/private-workspace");
} else document.querySelector("#workspace-error").textContent = e.message;
}
}
@@ -98,7 +102,10 @@ async function init() {
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();
} catch (e) { document.querySelector("#workspace-error").textContent = e.message; }
} catch (e) {
if (e.status === 403) location.replace("/errors/private-workspace");
else document.querySelector("#workspace-error").textContent = e.message;
}
}
document.querySelector("#password-form").addEventListener("submit", async e => {
e.preventDefault();