diff --git a/Cargo.lock b/Cargo.lock index ea1665c..a203259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.39" +version = "0.2.40" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 53f89a2..5c66a56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.39" +version = "0.2.40" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/static/js/note-editor.js b/static/js/note-editor.js index e225b0e..72187e1 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -27,6 +27,7 @@ import { bindNoteFiles } from "@rustpad/note-files"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { toast } from "@rustpad/toast"; import { getTheme } from "@rustpad/theme"; +import { isResourceAccessError } from "@rustpad/security"; export function startNoteEditor(adapter) { const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); @@ -1353,7 +1354,6 @@ export function startNoteEditor(adapter) { 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; - document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(message)) { toast(friendly); accessLevel.textContent = "Access: read only"; @@ -1368,9 +1368,17 @@ export function startNoteEditor(adapter) { if (saveState.textContent === "Saving…") saveState.textContent = "Save failed"; if (/nickname|session|account/i.test(message)) { if (!identityDialog.open) identityDialog.showModal(); - } else if (info?.protected && !passwordDialog.open) { - passwordDialog.showModal(); + return; } + if (info?.protected && isResourceAccessError(message)) { + resourceUnlocked = false; + setDocumentReadOnly(true, "Password required"); + document.querySelector("#password-error").textContent = friendly; + if (!passwordDialog.open) passwordDialog.showModal(); + document.querySelector("#open-password")?.focus(); + return; + } + toast(friendly); }, }); socket.connect(); diff --git a/static/js/security.js b/static/js/security.js index 2ac0d89..5941d41 100644 --- a/static/js/security.js +++ b/static/js/security.js @@ -7,6 +7,14 @@ * See LICENSE file in repository root for details. */ +const RESOURCE_ACCESS_ERROR_PATTERN = /(?:invalid password|password required|authentication required|access expired or revoked)/i; + +export function isResourceAccessError(error) { + const status = Number(error?.status); + const message = typeof error === "string" ? error : error?.message; + return status === 401 || RESOURCE_ACCESS_ERROR_PATTERN.test(String(message || "")); +} + export function safeAppUrl(value, fallback = "/") { try { const url = new URL(String(value || ""), location.origin); diff --git a/static/js/workspace.js b/static/js/workspace.js index 2e977cb..3cd78ee 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -15,7 +15,7 @@ import { copyText } from "@rustpad/clipboard"; import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { askConfirm } from "@rustpad/modal"; -import { safeAppUrl } from "@rustpad/security"; +import { isResourceAccessError, safeAppUrl } from "@rustpad/security"; import { toast } from "@rustpad/toast"; const parts = location.pathname.split("/").filter(Boolean); @@ -116,7 +116,8 @@ function connectWorkspaceWatch() { if (message.type === "error") { workspaceWatchIntentionalClose = true; socket.close(); - if (/password/i.test(message.message || "")) lockWorkspaceForPassword(message.message); + if (isResourceAccessError(message.message)) lockWorkspaceForPassword(message.message); + else document.querySelector("#workspace-error").textContent = message.message || "Workspace connection error"; } }); socket.addEventListener("close", () => { @@ -234,7 +235,7 @@ async function openWorkspace() { if (dialog.open) dialog.close(); connectWorkspaceWatch(); } catch (e) { - if (info?.protected || e.message.toLowerCase().includes("password")) { + if (info?.protected && isResourceAccessError(e)) { lockWorkspaceForPassword(e.message); } else if (e.status === 403 || e.status === 404) { await showSystemNotFound();