diff --git a/Cargo.lock b/Cargo.lock index 0435dfa..dd01d8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.34" +version = "0.0.39" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 03146c9..6151ae7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.38" +version = "0.0.39" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index 9c4efdc..bde6a14 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -278,7 +278,9 @@ export async function validateCurrentSession() { const token = getAuthToken(); if (!token) return null; try { - return await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } }); + const session = await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } }); + setAuthSession(session); + return session; } catch { clearAuthSession(); return null; diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 6bcbcf9..16d10f5 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -7,7 +7,7 @@ import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { bindEmojiPicker } from "@rustpad/emoji-picker"; import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown"; import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; -import { bindIdentityDialog } from "@rustpad/auth-ui"; +import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { bindNoteFiles } from "@rustpad/note-files"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; @@ -18,7 +18,7 @@ export function startNoteEditor(adapter) { 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"); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); - let accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = ""; + let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = ""; const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off"; previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on"; compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off"; @@ -177,9 +177,32 @@ export function startNoteEditor(adapter) { endpoints: adapter.fileEndpoints, }); function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } - bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); + bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); - async function initialize() { try { await loadNoteInfo(); document.title = adapter.title(info); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); adapter.configureView?.(info); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `

Note not found

${escapeHtml(e.message)}

`; } } + async function initialize() { + try { + if (getAuthToken()) { + const session = await validateCurrentSession(); + nickname = session?.nickname || getNickname(); + } + if (!nickname) { + if (!identityDialog.open) identityDialog.showModal(); + return; + } + + accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); + await loadNoteInfo(); + document.title = adapter.title(info); + publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); + adapter.configureView?.(info); + applyUi({ write: true, replace: true }); + updateCurrentUser(); + if (info.protected && !accessToken) passwordDialog.showModal(); + else { loadFiles(); connect(); } + } catch (e) { + document.body.innerHTML = `

Note not found

${escapeHtml(e.message)}

`; + } + } document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); }); window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true }); diff --git a/static/js/workspace.js b/static/js/workspace.js index f607c1b..8c0033e 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -4,15 +4,19 @@ installGlobalDiagnostics(); import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session"; +import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { askConfirm } from "@rustpad/modal"; const parts = location.pathname.split("/").filter(Boolean); const slug = parts[1]; let info; const shareToken = new URLSearchParams(location.search).get("share"); -let accessToken = shareToken || getAuthToken() || getAccessToken("workspace", slug); +let accessToken = shareToken || getAccessToken("workspace", slug); +let nickname = getNickname(); if (shareToken) setAccessToken("workspace", slug, shareToken); const dialog = document.querySelector("#password-dialog"); +const identityDialog = document.querySelector("#identity-dialog"); +const workspaceContent = document.querySelector("#workspace-content"); const notesList = document.querySelector("#notes-list"); const notesViewKey = `rustpad:workspace:${slug}:notes-view`; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; @@ -127,7 +131,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => { try { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { method: "POST", - body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: getNickname() || null }) + body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null }) }); location.assign(`${note.url}?view=split&mode=markdown`); } catch (err) { error.textContent = err.message; } @@ -155,5 +159,39 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async ( try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); } catch (e) { toast(e.message); } }); +async function startAuthorizedWorkspace() { + const hadAccountToken = Boolean(getAuthToken()); + if (hadAccountToken) { + const session = await validateCurrentSession(); + nickname = session?.nickname || getNickname(); + } + + if (!nickname) { + workspaceContent.hidden = true; + if (!identityDialog.open) identityDialog.showModal(); + return; + } + + accessToken = shareToken || getAuthToken() || getAccessToken("workspace", slug); + workspaceContent.hidden = false; + await init(); +} + +bindIdentityDialog({ + dialog: identityDialog, + onIdentity: async value => { + nickname = value; + accessToken = shareToken || getAuthToken() || getAccessToken("workspace", slug); + workspaceContent.hidden = false; + await init(); + }, +}); +identityDialog.addEventListener("close", () => { + if (!nickname) queueMicrotask(() => { + workspaceContent.hidden = true; + if (!identityDialog.open) identityDialog.showModal(); + }); +}); + setNotesView(notesView); -init(); +startAuthorizedWorkspace(); diff --git a/static/workspace.html b/static/workspace.html index 876c7ef..a2862cb 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -11,7 +11,7 @@ __APP_ENTRYPOINT__ - +
RustPad
@@ -21,7 +21,7 @@
-
+

Notes

@@ -36,6 +36,37 @@

+ +
+

Identify yourself

+

Log in, or choose a free nickname to continue as a guest.

+ +
+ + + +
+ + +
+

Protected workspace