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:
+1
-1
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 || {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod game;
|
||||
pub mod history;
|
||||
pub mod leaderboard;
|
||||
pub mod lobby;
|
||||
pub mod login;
|
||||
|
||||
Reference in New Issue
Block a user