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
This commit is contained in:
2026-10-09 05:53:21 +08:00
parent 353f012906
commit 0674e06565
10 changed files with 299 additions and 92 deletions
+1 -1
View File
@@ -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"
+32 -25
View File
@@ -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<Option<User>, String> {
let resp = Request::get("/api/me")
.send()
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, 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<Vec<GameTypeInfo>, 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<GameView, String> {
.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<Option<JoinPreview>, 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<Vec<MyGame>, 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<MatchesPage, String> {
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<RatingsPage, String> {
.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<LeaderboardPage, String> {
.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())
}
+74
View File
@@ -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::Storage> {
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<String> {
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);
}
}
+70
View File
@@ -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::<String>() + 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<Vec<AuthProvider>>) -> 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::<Vec<_>>())
}
}
}
})
}
}
}
}
}
+1
View File
@@ -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;
+33 -1
View File
@@ -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/<id>")]
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();
}
+21 -3
View File
@@ -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));
}
}
}
});
+25 -62
View File
@@ -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::<String>() + 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::<Vec<_>>())
}
}
}
})
}
}
}
},
Some(None) => view! { (login_card(auth_providers)) },
Some(Some(_me)) => {
view! {
(crate::components::nav::app_nav("/", move || {
+41
View File
@@ -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::<AuthProvider>::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))
}
}
+1
View File
@@ -4,3 +4,4 @@ pub mod game;
pub mod history;
pub mod leaderboard;
pub mod lobby;
pub mod login;