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-09 05:53:21 +08:00
1 parent 353f012906
commit 0674e06565
10 files changed
+299 -92

No files matched your search

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