session and auth fix

This commit is contained in:
Mateusz Gruszczyński
2026-07-26 15:59:23 +02:00
parent 9a95119b06
commit f145c3c121
6 changed files with 106 additions and 12 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.0.34" version = "0.0.39"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.0.38" version = "0.0.39"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+3 -1
View File
@@ -278,7 +278,9 @@ export async function validateCurrentSession() {
const token = getAuthToken(); const token = getAuthToken();
if (!token) return null; if (!token) return null;
try { 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 { } catch {
clearAuthSession(); clearAuthSession();
return null; return null;
+27 -4
View File
@@ -7,7 +7,7 @@ import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker"; import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown"; import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; 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 { bindNoteFiles } from "@rustpad/note-files";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
@@ -18,7 +18,7 @@ export function startNoteEditor(adapter) {
let unreadChat = 0; 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 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); 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"; 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"; previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off"; compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
@@ -177,9 +177,32 @@ export function startNoteEditor(adapter) {
endpoints: adapter.fileEndpoints, 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(); } 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(); }); }); 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 = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } } 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 = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;
}
}
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(); }); 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 }); 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 });
+41 -3
View File
@@ -4,15 +4,19 @@ installGlobalDiagnostics();
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session"; import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { askConfirm } from "@rustpad/modal"; import { askConfirm } from "@rustpad/modal";
const parts = location.pathname.split("/").filter(Boolean); const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1]; const slug = parts[1];
let info; let info;
const shareToken = new URLSearchParams(location.search).get("share"); 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); if (shareToken) setAccessToken("workspace", slug, shareToken);
const dialog = document.querySelector("#password-dialog"); 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 notesList = document.querySelector("#notes-list");
const notesViewKey = `rustpad:workspace:${slug}:notes-view`; const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
@@ -127,7 +131,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
try { try {
const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, {
method: "POST", 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`); location.assign(`${note.url}?view=split&mode=markdown`);
} catch (err) { error.textContent = err.message; } } 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"); } try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); }
catch (e) { toast(e.message); } 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); setNotesView(notesView);
init(); startAuthorizedWorkspace();
+33 -2
View File
@@ -11,7 +11,7 @@
__APP_ENTRYPOINT__ __APP_ENTRYPOINT__
</head> </head>
<body> <body data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header"> <header class="app-header">
<div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span> <div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span>
<div class="document-heading"> <div class="document-heading">
@@ -21,7 +21,7 @@
</div> </div>
<div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div> <div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
</header> </header>
<main class="workspace-page"> <main id="workspace-content" class="workspace-page" hidden>
<section class="workspace-top"> <section class="workspace-top">
<div> <div>
<h2>Notes</h2> <h2>Notes</h2>
@@ -36,6 +36,37 @@
<p id="workspace-error" class="form-message error"></p> <p id="workspace-error" class="form-message error"></p>
<section id="notes-list" class="notes-grid" aria-live="polite"></section> <section id="notes-list" class="notes-grid" aria-live="polite"></section>
</main> </main>
<dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
<h2>Identify yourself</h2>
<p class="dialog-copy">Log in, or choose a free nickname to continue as a guest.</p>
<input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required
placeholder="Name or nickname">
<div class="identity-actions">
<button id="guest-continue" class="primary-button" type="submit">Continue as guest</button>
<button id="show-register" class="text-button" type="button">Register</button>
<button id="show-login" class="text-button" type="button">Log in</button>
</div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3>
<label id="auth-email-field">E-mail / organization login
<input id="auth-email" name="username" type="email" maxlength="320"
autocomplete="username" placeholder="you@example.com">
</label>
<label>Password
<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password">
</label>
<button id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
<div class="identity-links">
<button id="show-reset" class="text-button" type="button">Forgot password?</button>
<button id="auth-back" class="text-button" type="button">Back to nickname</button>
<button id="logout-account" class="text-button" type="button">Log out saved account</button>
</div>
</section>
<p id="identity-error" class="form-message error" role="alert"></p>
</form>
</dialog>
<dialog id="password-dialog"> <dialog id="password-dialog">
<form id="password-form" class="dialog-panel"> <form id="password-form" class="dialog-panel">
<h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" <h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password"