From 0674e06565035250bf10f1b4f13cfa517294a376 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Thu, 8 Oct 2026 21:52:51 +0000 Subject: [PATCH] Redirect to login on 401 and return afterwards The SPA now sends the user to a new /login page whenever an API call answers 401, remembering the page they were on. The OIDC callback always lands back on the configured post-login redirect, so the saved page is restored client-side once a session exists. - auth: save/restore the return path in sessionStorage; defer the navigation so the failing request can finish first - api: centralize 401 handling (me() still treats 401 as logged out) - login page + shared login card, extracted from the lobby - guard async signal writes so an in-flight request cannot touch a page unmounted by the redirect --- web/Cargo.toml | 2 +- web/src/api.rs | 57 +++++++++++++----------- web/src/auth.rs | 74 +++++++++++++++++++++++++++++++ web/src/components/login.rs | 70 +++++++++++++++++++++++++++++ web/src/components/mod.rs | 1 + web/src/main.rs | 34 ++++++++++++++- web/src/pages/history.rs | 24 ++++++++-- web/src/pages/lobby.rs | 87 +++++++++++-------------------------- web/src/pages/login.rs | 41 +++++++++++++++++ web/src/pages/mod.rs | 1 + 10 files changed, 299 insertions(+), 92 deletions(-) create mode 100644 web/src/auth.rs create mode 100644 web/src/components/login.rs create mode 100644 web/src/pages/login.rs diff --git a/web/Cargo.toml b/web/Cargo.toml index d8cb479..fdaa36f 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -13,7 +13,7 @@ serde_json = "1" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" futures = "0.3" -web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] } +web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] } js-sys = "0.3" gloo-timers = "0.3" console_error_panic_hook = "0.1" diff --git a/web/src/api.rs b/web/src/api.rs index 40e37de..34b7438 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -1,13 +1,32 @@ //! REST client for the tavolo backend. Same-origin requests carry the //! session cookie automatically. +use crate::auth; use crate::model::*; -use gloo_net::http::Request; +use gloo_net::http::{Request, Response}; fn server_error(status: u16) -> String { format!("server returned {status}") } +/// Map a failed response to an error. A `401` additionally starts the +/// login flow: the session is gone (or the caller was never logged in), +/// and the user is returned to this page after logging in. +fn check(resp: &Response) -> Result<(), String> { + if resp.status() == 401 { + auth::redirect_to_login(); + return Err("login required".to_string()); + } + if !resp.ok() { + return Err(server_error(resp.status())); + } + Ok(()) +} + /// Fetch the current user; `None` when unauthenticated (401). +/// +/// Deliberately bypasses [`check`]: an unauthenticated answer is how the +/// app renders its logged-out state, so it must not trigger the login +/// redirect. pub async fn me() -> Result, String> { let resp = Request::get("/api/me") .send() @@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result, String> { .send() .await .map_err(|e| e.to_string())?; - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?; Ok(page.providers) } @@ -41,9 +58,7 @@ pub async fn game_types() -> Result, String> { .send() .await .map_err(|e| e.to_string())?; - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?; Ok(page.results) } @@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result< .send() .await .map_err(|e| e.to_string())?; - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } @@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result { .send() .await .map_err(|e| e.to_string())?; + if resp.status() == 401 { + auth::redirect_to_login(); + return Err("login required".to_string()); + } if !resp.ok() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); let message = body @@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result, String> { if resp.status() == 404 { return Ok(None); } - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; resp.json().await.map(Some).map_err(|e| e.to_string()) } @@ -135,9 +150,7 @@ pub async fn my_games() -> Result, String> { .send() .await .map_err(|e| e.to_string())?; - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?; Ok(page.results) } @@ -148,9 +161,7 @@ pub async fn my_matches(cursor: Option<&str>) -> Result { None => "/api/me/matches?limit=10".to_string(), }; let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?; - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } @@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result { .send() .await .map_err(|e| e.to_string())?; - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } @@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result { .send() .await .map_err(|e| e.to_string())?; - if !resp.ok() { - return Err(server_error(resp.status())); - } + check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } diff --git a/web/src/auth.rs b/web/src/auth.rs new file mode 100644 index 0000000..839fd72 --- /dev/null +++ b/web/src/auth.rs @@ -0,0 +1,74 @@ +//! Client-side login redirect handling. +//! +//! The OIDC callback always returns the browser to the configured +//! post-login redirect (usually `/`), and the backend's login flow has no +//! per-request `next` support. So when the API answers `401`, the page the +//! user is on is saved in `sessionStorage` and restored once a session +//! exists again. +use sycamore_router::navigate_replace; + +/// `sessionStorage` key holding the page to return to after login. +const RETURN_TO_KEY: &str = "tavolo.login.return_to"; + +fn session_storage() -> Option { + web_sys::window()?.session_storage().ok().flatten() +} + +/// The current page (path plus query), the URL to come back to after +/// login. +pub fn current_path() -> String { + let Some(location) = web_sys::window().map(|w| w.location()) else { + return String::new(); + }; + format!( + "{}{}", + location.pathname().unwrap_or_default(), + location.search().unwrap_or_default() + ) +} + +/// Whether a path is a page worth returning to: not the login flow +/// itself, and not an absolute/protocol-relative URL. +fn is_restorable(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.starts_with("/auth/") + && !path.starts_with("/api/") + && path != "/login" +} + +/// Handle an expired session: remember the current page and go to the +/// login page. +/// +/// The navigation is deferred to a timer so the caller can finish handling +/// the failed response (e.g. set an error) before its page is unmounted. +pub fn redirect_to_login() { + let path = current_path(); + if is_restorable(&path) { + if let Some(storage) = session_storage() { + let _ = storage.set_item(RETURN_TO_KEY, &path); + } + } + gloo_timers::callback::Timeout::new(0, move || { + // Several in-flight requests may fail at once; only the first + // takes us to the login page. + if current_path() != "/login" { + navigate_replace("/login"); + } + }) + .forget(); +} + +/// The page saved by [`redirect_to_login`], if any. +pub fn saved_return_to() -> Option { + let storage = session_storage()?; + let path = storage.get_item(RETURN_TO_KEY).ok().flatten()?; + is_restorable(&path).then_some(path) +} + +/// Drop the saved page, e.g. after it has been restored. +pub fn clear_return_to() { + if let Some(storage) = session_storage() { + let _ = storage.remove_item(RETURN_TO_KEY); + } +} diff --git a/web/src/components/login.rs b/web/src/components/login.rs new file mode 100644 index 0000000..d98134a --- /dev/null +++ b/web/src/components/login.rs @@ -0,0 +1,70 @@ +//! Login card, shared by the lobby's logged-out state and the `/login` +//! page. +use sycamore::prelude::*; + +use crate::model::AuthProvider; + +/// Human-readable label for a provider name ("google" -> "Google"). +fn provider_label(name: &str) -> String { + let mut chars = name.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// Card offering one login button per configured provider. +/// +/// `providers` is filled by the caller from `GET /api/auth/providers`; +/// while it is empty (or the fetch failed) the card falls back to the +/// generic `/auth/login` route, which redirects to the first configured +/// provider. +pub fn login_card(providers: Signal>) -> View { + view! { + div(class="status-card") { + div(class="card") { + div(class="card-body p-4") { + h1(class="h3 mb-3") { + span(class="text-warning") { "♠" } + " tavolo" + } + p(class="text-secondary") { "Log in with your account to play." } + // rel="external" opts out of the SPA router's click + // interception: these must be full page navigations + // to the backend's OIDC login endpoints. + (move || { + let providers = providers.get_clone(); + if providers.is_empty() { + // Providers could not be fetched: the generic + // route redirects to the first configured + // provider. + view! { + a( + class="btn btn-warning btn-lg w-100", + href="/auth/login", + rel="external", + ) { + "Log in" + } + } + } else { + view! { + div(class="d-grid gap-2") { + (providers.iter().cloned().map(|p| view! { + a( + class="btn btn-warning btn-lg w-100", + href=p.login_url, + rel="external", + ) { + "Log in with " (provider_label(&p.name)) + } + }).collect::>()) + } + } + } + }) + } + } + } + } +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index 094fca5..3f0f0af 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,6 +1,7 @@ pub mod briscola; pub mod card; pub mod copy; +pub mod login; pub mod nav; pub mod summary; pub mod toast; diff --git a/web/src/main.rs b/web/src/main.rs index b99c80f..ed0c83a 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -1,22 +1,27 @@ //! Application entry point. mod api; +mod auth; mod components; mod model; mod pages; mod ws; use sycamore::prelude::*; -use sycamore_router::{HistoryIntegration, Route, Router}; +use sycamore_router::{navigate, HistoryIntegration, Route, Router}; +use wasm_bindgen_futures::spawn_local; use pages::game::GamePage; use pages::history::HistoryPage; use pages::leaderboard::LeaderboardPage; use pages::lobby::LobbyPage; +use pages::login::LoginPage; #[derive(Route, Clone)] enum AppRoutes { #[to("/")] Lobby, + #[to("/login")] + Login, #[to("/game/")] Game { id: String }, #[to("/history")] @@ -27,6 +32,31 @@ enum AppRoutes { NotFound, } +/// After the OIDC callback lands the SPA back on the configured +/// post-login redirect, forward the user to the page they were on before +/// being sent to the login page (see [`auth`]) — but only once a session +/// exists, so a cancelled login never bounces into a stale page. +fn restore_page_after_login() { + let Some(path) = auth::saved_return_to() else { + return; + }; + spawn_local(async move { + match api::me().await { + Ok(Some(_)) => { + auth::clear_return_to(); + if auth::current_path() != path { + navigate(&path); + } + } + // Not logged in: keep the target for the next successful + // login (a reload of the login page must not lose it). + Ok(None) => {} + // The probe failed (offline?): keep the target, too. + Err(_) => {} + } + }); +} + fn main() { console_error_panic_hook::set_once(); sycamore::render(|| { @@ -38,6 +68,7 @@ fn main() { div(class="app") { (match route.get_clone() { AppRoutes::Lobby => view! { LobbyPage() }, + AppRoutes::Login => view! { LoginPage() }, AppRoutes::Game { id } => view! { GamePage(id=id) }, AppRoutes::History => view! { HistoryPage() }, AppRoutes::Leaderboard => view! { LeaderboardPage() }, @@ -60,4 +91,5 @@ fn main() { ) } }); + restore_page_after_login(); } diff --git a/web/src/pages/history.rs b/web/src/pages/history.rs index 15ea5e4..d72f755 100644 --- a/web/src/pages/history.rs +++ b/web/src/pages/history.rs @@ -105,18 +105,29 @@ pub fn HistoryPage() -> View { spawn_local(async move { match api::my_matches(next.as_deref()).await { Ok(p) => { + // The page can unmount (e.g. the login redirect) while + // the request is in flight. + if !page.is_alive() { + return; + } cursor.set(p.next_cursor.clone()); rows.update(|acc| acc.extend(p.results.iter().cloned())); page.set(Some(p)); } - Err(e) => error.set(Some(e)), + Err(e) => { + if error.is_alive() { + error.set(Some(e)); + } + } } }); }; spawn_local(async move { if let Ok(types) = api::game_types().await { - game_types.set(types); + if game_types.is_alive() { + game_types.set(types); + } } }); @@ -124,11 +135,18 @@ pub fn HistoryPage() -> View { spawn_local(async move { match api::my_matches(None).await { Ok(p) => { + if !page.is_alive() { + return; + } cursor.set(p.next_cursor.clone()); rows.set(p.results.clone()); page.set(Some(p)); } - Err(e) => error.set(Some(e)), + Err(e) => { + if error.is_alive() { + error.set(Some(e)); + } + } } }); diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index a4eb333..0f1f675 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -13,21 +13,13 @@ use sycamore_router::navigate; use crate::api; use crate::components::card::card_back; +use crate::components::login::login_card; use crate::components::toast::toast; use crate::model::{ fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview, MyGame, OptionField, OptionKind, PlayerRating, User, }; -/// Human-readable label for a provider name ("google" -> "Google"). -fn provider_label(name: &str) -> String { - let mut chars = name.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - } -} - fn json_to_string(value: &serde_json::Value) -> String { match value { serde_json::Value::String(s) => s.clone(), @@ -386,18 +378,30 @@ pub fn LobbyPage() -> View { if me.is_some() { spawn_local(async move { if let Ok(p) = api::my_ratings().await { - ratings.set(p.results); + if ratings.is_alive() { + ratings.set(p.results); + } } if let Ok(games) = api::my_games().await { - my_games.set(games); + if my_games.is_alive() { + my_games.set(games); + } } }); } - user.set(Some(me)); + // The page may have been unmounted (e.g. by the login + // redirect triggered while this request was in flight). + if user.is_alive() { + user.set(Some(me)); + } } Err(e) => { - error.set(Some(e)); - user.set(Some(None)); + if error.is_alive() { + error.set(Some(e)); + } + if user.is_alive() { + user.set(Some(None)); + } } } }); @@ -405,6 +409,9 @@ pub fn LobbyPage() -> View { spawn_local(async move { match api::game_types().await { Ok(types) if !types.is_empty() => { + if !game_types.is_alive() { + return; + } selected_game.set(types[0].id.clone()); game_types.set(types); } @@ -414,7 +421,9 @@ pub fn LobbyPage() -> View { spawn_local(async move { if let Ok(list) = api::auth_providers().await { - auth_providers.set(list); + if auth_providers.is_alive() { + auth_providers.set(list); + } } }); @@ -470,53 +479,7 @@ pub fn LobbyPage() -> View { } } }, - Some(None) => view! { - div(class="status-card") { - div(class="card") { - div(class="card-body p-4") { - h1(class="h3 mb-3") { - span(class="text-warning") { "♠" } - " tavolo" - } - p(class="text-secondary") { "Log in with your account to play." } - // rel="external" opts out of the SPA router's click - // interception: these must be full page navigations - // to the backend's OIDC login endpoints. - (move || { - let providers = auth_providers.get_clone(); - if providers.is_empty() { - // Providers could not be fetched: the - // generic route redirects to the first - // configured provider. - view! { - a( - class="btn btn-warning btn-lg w-100", - href="/auth/login", - rel="external", - ) { - "Log in" - } - } - } else { - view! { - div(class="d-grid gap-2") { - (providers.iter().cloned().map(|p| view! { - a( - class="btn btn-warning btn-lg w-100", - href=p.login_url, - rel="external", - ) { - "Log in with " (provider_label(&p.name)) - } - }).collect::>()) - } - } - } - }) - } - } - } - }, + Some(None) => view! { (login_card(auth_providers)) }, Some(Some(_me)) => { view! { (crate::components::nav::app_nav("/", move || { diff --git a/web/src/pages/login.rs b/web/src/pages/login.rs new file mode 100644 index 0000000..81b3c8c --- /dev/null +++ b/web/src/pages/login.rs @@ -0,0 +1,41 @@ +//! Login page, shown after the API answered `401` (see [`crate::auth`]): +//! the session expired, or the visitor tried to open a page that needs an +//! account. +//! +//! The backend's OIDC callback lands the SPA back on the configured +//! post-login redirect; [`crate::auth`] then returns the user to the page +//! they were on before this one. +use sycamore::prelude::*; +use sycamore_router::navigate_replace; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::auth; +use crate::components::login::login_card; +use crate::model::AuthProvider; + +#[component] +pub fn LoginPage() -> View { + let providers = create_signal(Vec::::new()); + + spawn_local(async move { + // Already signed in: no business here — return to where the user + // was headed, or the lobby. + if matches!(api::me().await, Ok(Some(_))) { + let target = auth::saved_return_to().unwrap_or_else(|| "/".to_string()); + auth::clear_return_to(); + navigate_replace(&target); + return; + } + if let Ok(list) = api::auth_providers().await { + // The page can unmount while the fetch is in flight. + if providers.is_alive() { + providers.set(list); + } + } + }); + + view! { + (login_card(providers)) + } +} diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 71fbf40..558a10e 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -4,3 +4,4 @@ pub mod game; pub mod history; pub mod leaderboard; pub mod lobby; +pub mod login;