0.2.94
This commit is contained in:
1 parent
f508b7c882
commit
25d04daaee
12 files changed
+323
-90
No files matched your search
Generated
+1
-1
@@ -2674,7 +2674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.93"
|
||||
version = "0.2.94"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.93"
|
||||
version = "0.2.94"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
RustPad is a collaborative Markdown editor with standalone notes and workspaces.
|
||||
|
||||
## Release 0.2.94 — authentication session handling
|
||||
|
||||
- `GET /api/auth/session` checks the visitor's login state: `200 {"authenticated":false,"session":null}` for guests, missing/expired cookies, or logged-out users; `200 {"authenticated":true,"session":{...}}` for a valid account session. The response is never cached. Expired cookies are cleared; valid session cookies are refreshed as before.
|
||||
- `GET /api/auth/me` remains a protected endpoint and returns `401` when unauthenticated. Other authenticated endpoints continue to use `401` for missing sessions.
|
||||
- Frontend pages now distinguish a logged-out visitor from a server/network failure while checking the session. Session expiry clears client state once and raises the existing expiry notification; unexpected errors no longer force logout.
|
||||
|
||||
Run the frontend session regression tests from the project root with Node.js 22+:
|
||||
|
||||
```bash
|
||||
node --experimental-vm-modules scripts/test_auth_session.mjs
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -107,6 +107,7 @@ pub fn router(
|
||||
post(auth::resend_confirmation),
|
||||
)
|
||||
.route("/api/auth/me", get(auth::me))
|
||||
.route("/api/auth/session", get(auth::session))
|
||||
.route("/api/auth/profile", post(auth::update_profile))
|
||||
.route(
|
||||
"/api/auth/account/delete",
|
||||
|
||||
+79
-18
@@ -374,6 +374,11 @@ pub struct SessionResponse {
|
||||
language: String,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct SessionStatusResponse {
|
||||
authenticated: bool,
|
||||
session: Option<SessionResponse>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct IdentityResponse {
|
||||
nickname: String,
|
||||
registered: bool,
|
||||
@@ -808,6 +813,30 @@ fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Opti
|
||||
.and_then(|value| validate_nickname(&value).ok())
|
||||
}
|
||||
|
||||
/// Check whether a visitor has an active account session without treating guests as errors.
|
||||
pub async fn session(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AuthError> {
|
||||
let Some(token) = crate::security::session_token(&headers) else {
|
||||
return Ok(session_status_response(None, state.user_session_ttl_days));
|
||||
};
|
||||
let Some(user) = user_from_token(&state, token).await? else {
|
||||
let mut response = session_status_response(None, state.user_session_ttl_days);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
|
||||
return Ok(response);
|
||||
};
|
||||
debug!(user_id = user.id, "session validation successful");
|
||||
let details = session_details(&state, token, user).await?;
|
||||
Ok(session_status_response(
|
||||
Some(details),
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
}
|
||||
|
||||
/// Preserve the existing protected `/api/auth/me` API contract (401 for guests).
|
||||
pub async fn me(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
@@ -822,6 +851,19 @@ pub async fn me(
|
||||
return Ok(response);
|
||||
};
|
||||
debug!(user_id = user.id, "session validation successful");
|
||||
let details = session_details(&state, token, user).await?;
|
||||
Ok(session_json_response(
|
||||
StatusCode::OK,
|
||||
details,
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
}
|
||||
|
||||
async fn session_details(
|
||||
state: &SharedState,
|
||||
token: &str,
|
||||
user: User,
|
||||
) -> Result<SessionResponse, AuthError> {
|
||||
let expires_at: String = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_SESSION_EXPIRES_AT,
|
||||
@@ -831,7 +873,7 @@ pub async fn me(
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
|
||||
directory_profile_metadata(&state, &user).await?;
|
||||
directory_profile_metadata(state, &user).await?;
|
||||
let editor_color: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_EDITOR_COLOR_BY_USER,
|
||||
@@ -840,23 +882,19 @@ pub async fn me(
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(session_json_response(
|
||||
StatusCode::OK,
|
||||
SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
theme: user.theme,
|
||||
language: user.language,
|
||||
},
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
Ok(SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
theme: user.theme,
|
||||
language: user.language,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
@@ -3135,6 +3173,29 @@ fn session_json_response(status: StatusCode, session: SessionResponse, ttl_days:
|
||||
let cookie = crate::security::session_cookie(&session.token, ttl_days);
|
||||
let mut response = (status, Json(session)).into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn session_status_response(session: Option<SessionResponse>, ttl_days: i64) -> Response {
|
||||
let cookie = session
|
||||
.as_ref()
|
||||
.map(|details| crate::security::session_cookie(&details.token, ttl_days));
|
||||
let mut response = Json(SessionStatusResponse {
|
||||
authenticated: session.is_some(),
|
||||
session,
|
||||
})
|
||||
.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
if let Some(cookie) = cookie {
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ fn classifies_other_routes_as_application() {
|
||||
for path in [
|
||||
"/",
|
||||
"/api/auth/me",
|
||||
"/api/auth/session",
|
||||
"/static/missing.css",
|
||||
"/files/legacy/image.png",
|
||||
"/unknown",
|
||||
@@ -163,6 +164,7 @@ async fn file_listener_never_exposes_application_routes() {
|
||||
for path in [
|
||||
"/",
|
||||
"/api/auth/me",
|
||||
"/api/auth/session",
|
||||
"/assets/css/styles.css",
|
||||
"/f",
|
||||
"/robots.txt",
|
||||
|
||||
+170
-32
@@ -8,11 +8,7 @@
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
database::Database,
|
||||
state::AppState,
|
||||
storage::Storage,
|
||||
};
|
||||
use crate::{database::Database, state::AppState, storage::Storage};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn logout_test_state() -> SharedState {
|
||||
@@ -85,12 +81,8 @@ async fn logout_only_revokes_current_browser_session_and_password_access() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let access_cookie = crate::security::resource_cookie(
|
||||
"pad",
|
||||
"current-pad",
|
||||
¤t_access_token,
|
||||
7,
|
||||
);
|
||||
let access_cookie =
|
||||
crate::security::resource_cookie("pad", "current-pad", ¤t_access_token, 7);
|
||||
let access_cookie_name = access_cookie
|
||||
.to_str()
|
||||
.unwrap()
|
||||
@@ -129,20 +121,18 @@ async fn logout_only_revokes_current_browser_session_and_password_access() {
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let current_access_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
|
||||
)
|
||||
.bind(hash_token(¤t_access_token))
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let other_access_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
|
||||
)
|
||||
.bind(hash_token(&other_access_token))
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let current_access_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?")
|
||||
.bind(hash_token(¤t_access_token))
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let other_access_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?")
|
||||
.bind(hash_token(&other_access_token))
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 1")
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
@@ -160,11 +150,159 @@ async fn logout_only_revokes_current_browser_session_and_password_access() {
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(set_cookies.iter().any(|value| {
|
||||
value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE))
|
||||
}));
|
||||
assert!(set_cookies.iter().any(|value| {
|
||||
value.starts_with(&format!("{access_cookie_name}=;"))
|
||||
}));
|
||||
assert!(!set_cookies.iter().any(|value| value.starts_with(share_cookie_name)));
|
||||
assert!(
|
||||
set_cookies
|
||||
.iter()
|
||||
.any(|value| { value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE)) })
|
||||
);
|
||||
assert!(
|
||||
set_cookies
|
||||
.iter()
|
||||
.any(|value| { value.starts_with(&format!("{access_cookie_name}=;")) })
|
||||
);
|
||||
assert!(
|
||||
!set_cookies
|
||||
.iter()
|
||||
.any(|value| value.starts_with(share_cookie_name))
|
||||
);
|
||||
}
|
||||
|
||||
fn account_cookie_headers(token: &str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
axum::http::HeaderValue::from_str(&format!("{}={token}", crate::security::SESSION_COOKIE))
|
||||
.unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
async fn auth_response_body(response: Response) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anonymous_session_status_is_ok_without_creating_a_cookie() {
|
||||
let state = logout_test_state().await;
|
||||
let response = session(State(state.clone()), HeaderMap::new())
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("session failed: {}", error.message));
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert!(!response.headers().contains_key(header::SET_COOKIE));
|
||||
assert_eq!(
|
||||
auth_response_body(response).await,
|
||||
serde_json::json!({"authenticated": false, "session": null})
|
||||
);
|
||||
|
||||
// The older `/api/auth/me` contract remains protected.
|
||||
let error = me(State(state), HeaderMap::new()).await.err().unwrap();
|
||||
assert_eq!(error.status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_session_status_returns_profile_and_refreshes_cookie() {
|
||||
let state = logout_test_state().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("Session Test")
|
||||
.bind("session test")
|
||||
.bind("session@example.test")
|
||||
.bind("session@example.test")
|
||||
.bind("password-hash")
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)")
|
||||
.bind("valid-session")
|
||||
.bind((Utc::now() + Duration::days(7)).to_rfc3339())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let headers = account_cookie_headers("valid-session");
|
||||
let response = session(State(state.clone()), headers.clone())
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("session failed: {}", error.message));
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert!(
|
||||
response.headers()[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("__Host-rustpad_session=valid-session;")
|
||||
);
|
||||
let body = auth_response_body(response).await;
|
||||
assert_eq!(body["authenticated"], true);
|
||||
assert_eq!(body["session"]["nickname"], "Session Test");
|
||||
assert_eq!(body["session"]["email"], "session@example.test");
|
||||
assert!(body["session"].get("token").is_none());
|
||||
|
||||
let me_response = me(State(state), headers)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("me failed: {}", error.message));
|
||||
assert_eq!(me_response.status(), StatusCode::OK);
|
||||
let body = auth_response_body(me_response).await;
|
||||
assert_eq!(body["nickname"], "Session Test");
|
||||
assert!(body.get("authenticated").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_or_expired_session_returns_guest_and_clears_stale_cookie() {
|
||||
let state = logout_test_state().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("Expired Test")
|
||||
.bind("expired test")
|
||||
.bind("expired@example.test")
|
||||
.bind("expired@example.test")
|
||||
.bind("password-hash")
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)")
|
||||
.bind("expired-session")
|
||||
.bind((Utc::now() - Duration::days(1)).to_rfc3339())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for token in ["expired-session", "unknown-session"] {
|
||||
let response = session(State(state.clone()), account_cookie_headers(token))
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("session failed: {}", error.message));
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert!(
|
||||
response.headers()[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("__Host-rustpad_session=;")
|
||||
);
|
||||
assert_eq!(
|
||||
auth_response_body(response).await,
|
||||
serde_json::json!({"authenticated": false, "session": null})
|
||||
);
|
||||
}
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions")
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_check_propagates_database_errors_instead_of_reporting_guest() {
|
||||
let state = logout_test_state().await;
|
||||
state.db.pool().close().await;
|
||||
let error = session(State(state), account_cookie_headers("valid-session"))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
+20
-23
@@ -8,8 +8,8 @@
|
||||
*/
|
||||
|
||||
import { logDebug, logError, logWarn } from "@rustpad/logger";
|
||||
import { formatNumber, restoreGuestLanguage, translateApiMessage } from "@rustpad/i18n";
|
||||
import { restoreGuestTheme } from "@rustpad/theme";
|
||||
import { formatNumber, translateApiMessage } from "@rustpad/i18n";
|
||||
import { expireAuthSession, getAuthToken } from "@rustpad/session";
|
||||
|
||||
const DEFAULT_ERRORS = {
|
||||
400: "Invalid request.",
|
||||
@@ -65,29 +65,26 @@ function formatBytes(bytes) {
|
||||
return `${formatNumber(bytes)} B`;
|
||||
}
|
||||
|
||||
function clearExpiredSession() {
|
||||
localStorage.removeItem("rustpad:auth-state");
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
sessionStorage.removeItem("rustpad:auth-token");
|
||||
localStorage.removeItem("rustpad:nickname");
|
||||
sessionStorage.removeItem("rustpad:nickname");
|
||||
document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0";
|
||||
restoreGuestTheme();
|
||||
void restoreGuestLanguage();
|
||||
window.dispatchEvent(new CustomEvent("rustpad:session-expired"));
|
||||
window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session: null } }));
|
||||
}
|
||||
|
||||
// Multiple simultaneous 401s must not start multiple session checks.
|
||||
let sessionCheckPromise = null;
|
||||
async function clearSessionIfInvalid() {
|
||||
if (!localStorage.getItem("rustpad:auth-state")) return;
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (response.status === 401) clearExpiredSession();
|
||||
} catch {
|
||||
// A network failure does not prove that the session is invalid.
|
||||
if (!getAuthToken()) return;
|
||||
if (!sessionCheckPromise) {
|
||||
sessionCheckPromise = (async () => {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
// A failed check is not proof that the user is logged out.
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.authenticated === false && data.session === null) await expireAuthSession();
|
||||
})().catch(() => {
|
||||
// Network and parsing errors must not erase the saved session.
|
||||
}).finally(() => { sessionCheckPromise = null; });
|
||||
}
|
||||
await sessionCheckPromise;
|
||||
}
|
||||
|
||||
function validateUploadSize(body, configuredMaxBytes) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as sessionStore from "@rustpad/session";
|
||||
import { askConfirm, askInput, showMessage } from "@rustpad/modal";
|
||||
import { toast } from "@rustpad/toast";
|
||||
|
||||
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
|
||||
const { getAuthToken, setAuthSession, setNickname, expireAuthSession } = sessionStore;
|
||||
const clearAuthSession = sessionStore.clearAuthSession || (() => {
|
||||
localStorage.removeItem("rustpad:auth-state");
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
@@ -343,15 +343,16 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
||||
}
|
||||
|
||||
export async function validateCurrentSession() {
|
||||
const expectedSession = Boolean(getAuthToken());
|
||||
try {
|
||||
const session = await api("/api/auth/me");
|
||||
await setAuthSession(session);
|
||||
return session;
|
||||
} catch {
|
||||
if (expectedSession) await clearAuthSession();
|
||||
const result = await api("/api/auth/session", { cache: "no-store" });
|
||||
if (result.authenticated === false && result.session === null) {
|
||||
await expireAuthSession();
|
||||
return null;
|
||||
}
|
||||
if (result.authenticated !== true || !result.session || typeof result.session.nickname !== "string") {
|
||||
throw new Error("Invalid authentication session response.");
|
||||
}
|
||||
await setAuthSession(result.session);
|
||||
return result.session;
|
||||
}
|
||||
|
||||
export async function logoutCurrentSession() {
|
||||
|
||||
+7
-1
@@ -618,6 +618,12 @@ if (identityDialog) {
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn("Could not validate the saved session", error);
|
||||
if (accountStateRevision === lookupRevision) renderAccount(null);
|
||||
if (accountStateRevision !== lookupRevision) return;
|
||||
if (accountSlot) {
|
||||
accountSlot.dataset.state = "error";
|
||||
accountSlot.setAttribute("aria-busy", "false");
|
||||
}
|
||||
const retry = document.querySelector("#footer-account-retry");
|
||||
if (retry) retry.hidden = false;
|
||||
});
|
||||
}
|
||||
+10
-1
@@ -93,6 +93,11 @@ export async function setAuthSession(session) {
|
||||
await setLanguage(session?.language || "en", { scope: "account" });
|
||||
window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session } }));
|
||||
}
|
||||
export async function expireAuthSession() {
|
||||
if (!getAuthToken()) return;
|
||||
await clearAuthSession();
|
||||
window.dispatchEvent(new CustomEvent("rustpad:session-expired"));
|
||||
}
|
||||
export async function clearAuthSession() {
|
||||
localStorage.removeItem(AUTH_STATE_KEY);
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
@@ -101,6 +106,10 @@ export async function clearAuthSession() {
|
||||
sessionStorage.removeItem(NICKNAME_KEY);
|
||||
setNicknameCookie("");
|
||||
restoreGuestTheme();
|
||||
await restoreGuestLanguage();
|
||||
try {
|
||||
await restoreGuestLanguage();
|
||||
} catch {
|
||||
// A language bundle failure must not block logout or session-expiry events.
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("rustpad:session-change", { detail: { session: null } }));
|
||||
}
|
||||
+11
-5
@@ -266,11 +266,17 @@ async function toggleNoteFavorite(button) {
|
||||
{ title: t("favorites.title", {}, "Favorites") },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
const session = await validateCurrentSession();
|
||||
if (!session) {
|
||||
accountSession = null;
|
||||
renderNotes();
|
||||
// The API client already rechecks the session after a 401.
|
||||
// Only a 403 needs an additional check for an unrelated access restriction.
|
||||
if (error.status === 403) {
|
||||
try {
|
||||
const session = await validateCurrentSession();
|
||||
if (!session) {
|
||||
accountSession = null;
|
||||
renderNotes();
|
||||
}
|
||||
} catch {
|
||||
// A failed recheck is not evidence that the account session expired.
|
||||
}
|
||||
}
|
||||
toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") });
|
||||
|
||||
Reference in new issue
Block a user