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:
woggioni committed 2026-10-08 21:52:51 +00:00
1 parent 43d5a6e2f9
commit 99aa81d682
10 files changed
+299 -92

No files matched your search

+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;