improvements
This commit is contained in:
+40
-4
@@ -126,6 +126,20 @@ 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 {
|
||||
@@ -140,7 +154,10 @@ async function loadResources() {
|
||||
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="${escapeHtml(safeAppUrl(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 class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><button class="action-button action-button--secondary compact-button" type="button" data-password>Change password</button><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
|
||||
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>`;
|
||||
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(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 class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button">Password…<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>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 = ""; };
|
||||
@@ -267,16 +284,17 @@ async function loadResources() {
|
||||
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
||||
});
|
||||
|
||||
row.querySelector("[data-password]")?.addEventListener("click", () => {
|
||||
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>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>`;
|
||||
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 && password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
|
||||
if (password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
|
||||
const submit = form.querySelector('[type="submit"]');
|
||||
submit.disabled = true;
|
||||
setInlineMessage("");
|
||||
@@ -291,6 +309,24 @@ async function loadResources() {
|
||||
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>`;
|
||||
|
||||
@@ -38,7 +38,10 @@ export function createPadAdapter() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind: "pad", slug, password }),
|
||||
}),
|
||||
setPassword: password => api(`${base}/password`, { method: "POST", body: JSON.stringify({ password }) }),
|
||||
setPassword: (password, clientId) => api(`${base}/password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, client_id: clientId || null }),
|
||||
}),
|
||||
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
|
||||
@@ -81,9 +84,9 @@ export function createWorkspaceNoteAdapter() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
|
||||
}),
|
||||
setPassword: password => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
|
||||
setPassword: (password, clientId) => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({ password, client_id: clientId || null }),
|
||||
}),
|
||||
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1332,6 +1332,24 @@ export function startNoteEditor(adapter) {
|
||||
onLatency: updateLatency,
|
||||
onDiagnostics: renderConnectionDiagnostics,
|
||||
onChat: appendChatMessage,
|
||||
onPasswordRequired: async () => {
|
||||
resourceUnlocked = false;
|
||||
info = { ...info, protected: true, access_level: "none", can_set_password: false };
|
||||
updatePageControls();
|
||||
clearTimeout(saveTimer);
|
||||
saveState.textContent = collaboration.hasPending()
|
||||
? "Password required — pending changes kept"
|
||||
: "Password required";
|
||||
setDocumentReadOnly(true, "Password required");
|
||||
document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue.";
|
||||
if (!passwordDialog.open) passwordDialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
try {
|
||||
await loadNoteInfo();
|
||||
adapter.configureView?.(info);
|
||||
updatePageControls();
|
||||
} catch { }
|
||||
},
|
||||
onError: message => {
|
||||
hideConnectionNotice();
|
||||
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
|
||||
@@ -1932,7 +1950,7 @@ export function startNoteEditor(adapter) {
|
||||
const submit = setPagePasswordForm.querySelector('button[type="submit"]');
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await adapter.setPassword(passwordValue);
|
||||
await adapter.setPassword(passwordValue, collaborationClientId);
|
||||
const access = await adapter.requestAccess(passwordValue);
|
||||
setAccessToken(adapter.access.kind, adapter.access.key, access.granted);
|
||||
accessToken = getAccessToken(adapter.access.kind, adapter.access.key) || shareToken;
|
||||
|
||||
@@ -109,6 +109,17 @@ class RoomSocket {
|
||||
try { message = JSON.parse(event.data); } catch { return; }
|
||||
this.lastMessageAt = Date.now();
|
||||
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
|
||||
if (message.type === "password_required") {
|
||||
this.intentionalClose = true;
|
||||
this.onPasswordRequired?.();
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
if (message.type === "password_changed") {
|
||||
this.intentionalClose = true;
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
this.intentionalClose = true;
|
||||
this.onError?.(message.message);
|
||||
|
||||
+103
-5
@@ -25,6 +25,12 @@ const shareToken = new URLSearchParams(location.search).get("share");
|
||||
let accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
let nickname = getNickname();
|
||||
getGuestId();
|
||||
const workspaceWatchClientId = `workspace_watch_${crypto.randomUUID()}`;
|
||||
let workspaceWatchSocket;
|
||||
let workspaceWatchPingTimer;
|
||||
let workspaceWatchReconnectTimer;
|
||||
let workspaceWatchIntentionalClose = false;
|
||||
let workspaceLockedForPassword = false;
|
||||
const dialog = document.querySelector("#password-dialog");
|
||||
const identityDialog = document.querySelector("#identity-dialog");
|
||||
const workspaceContent = document.querySelector("#workspace-content");
|
||||
@@ -45,6 +51,86 @@ function updateWorkspacePasswordControl() {
|
||||
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
|
||||
}
|
||||
|
||||
function stopWorkspaceWatch() {
|
||||
clearInterval(workspaceWatchPingTimer);
|
||||
clearTimeout(workspaceWatchReconnectTimer);
|
||||
workspaceWatchPingTimer = undefined;
|
||||
workspaceWatchReconnectTimer = undefined;
|
||||
if (workspaceWatchSocket) {
|
||||
workspaceWatchIntentionalClose = true;
|
||||
workspaceWatchSocket.close();
|
||||
workspaceWatchSocket = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function lockWorkspaceForPassword(message = "A password was set for this workspace. Enter it to continue.") {
|
||||
workspaceLockedForPassword = true;
|
||||
info = { ...info, protected: true, access_level: "none", can_set_password: false };
|
||||
stopWorkspaceWatch();
|
||||
workspaceContent.hidden = true;
|
||||
document.querySelector("#password-error").textContent = message;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
}
|
||||
|
||||
function scheduleWorkspaceWatchReconnect() {
|
||||
clearTimeout(workspaceWatchReconnectTimer);
|
||||
if (workspaceLockedForPassword || !nickname) return;
|
||||
workspaceWatchReconnectTimer = window.setTimeout(connectWorkspaceWatch, 1500);
|
||||
}
|
||||
|
||||
function connectWorkspaceWatch() {
|
||||
if (workspaceLockedForPassword || !nickname) return;
|
||||
if (workspaceWatchSocket?.readyState === WebSocket.OPEN || workspaceWatchSocket?.readyState === WebSocket.CONNECTING) return;
|
||||
clearTimeout(workspaceWatchReconnectTimer);
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${location.host}/ws/watch/workspace/${encodeURIComponent(slug)}`);
|
||||
workspaceWatchSocket = socket;
|
||||
workspaceWatchIntentionalClose = false;
|
||||
socket.addEventListener("open", () => {
|
||||
if (socket !== workspaceWatchSocket) return;
|
||||
socket.send(JSON.stringify({
|
||||
type: "authenticate",
|
||||
access_token: accessToken || null,
|
||||
client_id: workspaceWatchClientId,
|
||||
}));
|
||||
clearInterval(workspaceWatchPingTimer);
|
||||
workspaceWatchPingTimer = window.setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping", nonce: Date.now() }));
|
||||
}, 10000);
|
||||
});
|
||||
socket.addEventListener("message", event => {
|
||||
if (socket !== workspaceWatchSocket) return;
|
||||
let message;
|
||||
try { message = JSON.parse(event.data); } catch { return; }
|
||||
if (message.type === "password_required") {
|
||||
workspaceWatchIntentionalClose = true;
|
||||
lockWorkspaceForPassword();
|
||||
return;
|
||||
}
|
||||
if (message.type === "password_changed") {
|
||||
workspaceWatchIntentionalClose = true;
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
workspaceWatchIntentionalClose = true;
|
||||
socket.close();
|
||||
if (/password/i.test(message.message || "")) lockWorkspaceForPassword(message.message);
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (socket !== workspaceWatchSocket) return;
|
||||
clearInterval(workspaceWatchPingTimer);
|
||||
workspaceWatchPingTimer = undefined;
|
||||
workspaceWatchSocket = undefined;
|
||||
if (!workspaceWatchIntentionalClose) scheduleWorkspaceWatchReconnect();
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) socket.close();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
|
||||
function formatBytes(value) {
|
||||
const bytes = Math.max(0, Number(value) || 0);
|
||||
@@ -143,11 +229,13 @@ async function openWorkspace() {
|
||||
renderNotes();
|
||||
renderNotesPagination(data.pagination);
|
||||
updateWorkspacePasswordControl();
|
||||
workspaceLockedForPassword = false;
|
||||
workspaceContent.hidden = false;
|
||||
if (dialog.open) dialog.close();
|
||||
connectWorkspaceWatch();
|
||||
} catch (e) {
|
||||
if (info?.protected || e.message.toLowerCase().includes("password")) {
|
||||
document.querySelector("#password-error").textContent = e.message;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
lockWorkspaceForPassword(e.message);
|
||||
} else if (e.status === 403 || e.status === 404) {
|
||||
await showSystemNotFound();
|
||||
} else document.querySelector("#workspace-error").textContent = e.message;
|
||||
@@ -160,7 +248,8 @@ async function init() {
|
||||
document.querySelector("#workspace-title").textContent = info.title;
|
||||
document.querySelector("#workspace-url").textContent = location.pathname;
|
||||
updateWorkspacePasswordControl();
|
||||
if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace();
|
||||
if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue.");
|
||||
else await openWorkspace();
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) await showSystemNotFound();
|
||||
else document.querySelector("#workspace-error").textContent = e.message;
|
||||
@@ -175,7 +264,7 @@ document.querySelector("#password-form").addEventListener("submit", async e => {
|
||||
accessToken = getAccessToken("workspace", slug);
|
||||
document.querySelector("#open-password").value = "";
|
||||
document.querySelector("#password-error").textContent = "";
|
||||
openWorkspace();
|
||||
await openWorkspace();
|
||||
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
|
||||
});
|
||||
workspacePasswordForm.addEventListener("submit", async event => {
|
||||
@@ -191,7 +280,7 @@ workspacePasswordForm.addEventListener("submit", async event => {
|
||||
try {
|
||||
await api(`/api/workspaces/${encodeURIComponent(slug)}/password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({ password, client_id: workspaceWatchClientId }),
|
||||
});
|
||||
const result = await api("/api/access-token", {
|
||||
method: "POST",
|
||||
@@ -278,5 +367,14 @@ identityDialog.addEventListener("close", () => {
|
||||
});
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", event => {
|
||||
if (workspaceLockedForPassword) {
|
||||
event.preventDefault();
|
||||
document.querySelector("#open-password")?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", stopWorkspaceWatch);
|
||||
|
||||
setNotesView(notesView);
|
||||
startAuthorizedWorkspace();
|
||||
|
||||
Reference in New Issue
Block a user