From d38b5b0b51117cb1929da15db95d276eacf6d0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 28 Jul 2026 10:13:41 +0200 Subject: [PATCH] paths in mail --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/mod.rs | 3 +++ src/auth/mod.rs | 6 ++--- static/js/auth-ui.js | 63 +++++++++++++++++++++++++++++++++++--------- static/js/home.js | 7 ++--- 6 files changed, 60 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa8bf76..ccbe251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.12" +version = "0.1.13" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 77aace5..48a55c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.12" +version = "0.1.13" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/app/mod.rs b/src/app/mod.rs index 19295c5..5839c99 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -43,6 +43,9 @@ pub fn router( Router::new() .route("/", get(home)) + .route("/auth/confirm/{token}", get(home)) + .route("/auth/reset-password/{token}", get(home)) + .route("/auth/account-action/{token}", get(home)) .route("/p/{slug}", get(pad)) .route("/s/{token}", get(public_page)) .route("/w/{workspace_slug}", get(workspace)) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 29490be..11787be 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -883,7 +883,7 @@ async fn send_account_action( token: &str, ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); - let url = format!("{site}/?account_action_token={token}"); + let url = format!("{site}/auth/account-action/{token}"); let sender = smtp .from .parse::() @@ -1954,7 +1954,7 @@ async fn send_registration_email( let message = match token { Some(token) => { - let confirmation_url = format!("{site}/?confirm_token={token}"); + let confirmation_url = format!("{site}/auth/confirm/{token}"); let subject = "Confirm your RustPad account"; let text_body = format!( "Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours by opening this link:\n{}\n", @@ -2142,7 +2142,7 @@ async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Resul async fn send_reset(smtp: &SmtpConfig, user: &User, token: &str) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); - let reset_url = format!("{site}/?reset_token={token}"); + let reset_url = format!("{site}/auth/reset-password/{token}"); let sender = smtp .from .parse::() diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index 7a3f43b..2c7aa67 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -1,6 +1,6 @@ import { api } from "@rustpad/api"; import * as sessionStore from "@rustpad/session"; -import { askInput, showMessage } from "@rustpad/modal"; +import { askConfirm, askInput, showMessage } from "@rustpad/modal"; const { getAuthToken, setAuthSession, setNickname } = sessionStore; const clearAuthSession = sessionStore.clearAuthSession || (() => { @@ -319,28 +319,44 @@ export async function logoutCurrentSession() { } -export async function handleAccountConfirmationToken() { +function mailActionToken(pathPrefix, legacyQueryName) { + const url = new URL(location.href); + const prefix = `${pathPrefix}/`; + let token = null; + + if (url.pathname.startsWith(prefix)) { + const encodedToken = url.pathname.slice(prefix.length).split("/", 1)[0]; + try { token = decodeURIComponent(encodedToken); } catch { token = null; } + } + if (!token) token = url.searchParams.get(legacyQueryName); + return token?.trim() || null; +} + +function clearMailActionUrl() { const url = new URL(location.href); - const token = url.searchParams.get("confirm_token"); - if (!token) return; url.searchParams.delete("confirm_token"); - history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + url.searchParams.delete("reset_token"); + url.searchParams.delete("account_action_token"); + history.replaceState({}, "", `/${url.search}${url.hash}`); +} + +export async function handleAccountConfirmationToken() { + const token = mailActionToken("/auth/confirm", "confirm_token"); + if (!token) return false; + clearMailActionUrl(); try { const result = await api("/api/auth/confirm-account", { method: "POST", body: JSON.stringify({ token }) }); await showMessage(result.message, { title: "Account confirmed" }); } catch (error) { await showMessage(error.message, { title: "Account confirmation failed" }); } + return true; } export async function handleResetToken() { - const url = new URL(location.href); - const token = url.searchParams.get("reset_token"); - if (!token) return; - - // Remove the token immediately. Refreshing or navigating back must not reopen the reset dialog. - url.searchParams.delete("reset_token"); - history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + const token = mailActionToken("/auth/reset-password", "reset_token"); + if (!token) return false; + clearMailActionUrl(); const password = await askInput({ title: "Set a new password", @@ -352,11 +368,32 @@ export async function handleResetToken() { confirmText: "Change password", bitwardenIgnore: true, }); - if (!password) return; + if (!password) return true; try { await api("/api/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }); await showMessage("Password changed. The reset link has been used and cannot be opened again.", { title: "Password changed" }); } catch (error) { await showMessage(error.message, { title: "Password reset failed" }); } + return true; } + +export async function handleAccountActionToken() { + const token = mailActionToken("/auth/account-action", "account_action_token"); + if (!token) return false; + clearMailActionUrl(); + + const confirmed = await askConfirm( + "Confirm the requested account action. If you did not request it, cancel and ignore the e-mail.", + { title: "Confirm account action", confirmText: "Confirm action", danger: true }, + ); + if (!confirmed) return true; + try { + const result = await api("/api/auth/account-action/confirm", { method: "POST", body: JSON.stringify({ token }) }); + await showMessage(result.message, { title: "Account action confirmed" }); + } catch (error) { + await showMessage(error.message, { title: "Account action failed" }); + } + return true; +} + diff --git a/static/js/home.js b/static/js/home.js index 2a62650..018b050 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -1,7 +1,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger"; installGlobalDiagnostics(); -import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui"; +import { bindIdentityDialog, handleAccountActionToken, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui"; import { getAuthToken, setAccessToken } from "@rustpad/session"; import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; @@ -83,10 +83,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even } }); -handleAccountConfirmationToken(); -handleResetToken(); - -{ const params=new URLSearchParams(location.search); const token=params.get("account_action_token"); if(token){ api("/api/auth/account-action/confirm",{method:"POST",body:JSON.stringify({token})}).then(r=>toast(r.message)).catch(e=>toast(e.message)).finally(()=>{params.delete("account_action_token");history.replaceState({},"",`${location.pathname}${params.size?`?${params}`:""}${location.hash}`);}); } } +await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken(); const identityDialog = document.querySelector("#identity-dialog"); const guestAccount = document.querySelector("#footer-account-guest");