Files
tavolo/web/src/api.rs
T
woggioni 0674e06565 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
2026-10-09 05:53:21 +08:00

188 lines
6.4 KiB
Rust

//! 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, 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()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 401 {
return Ok(None);
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map(Some).map_err(|e| e.to_string())
}
/// Fetch the configured OIDC login providers (public endpoint).
pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
let resp = Request::get("/api/auth/providers")
.send()
.await
.map_err(|e| e.to_string())?;
check(&resp)?;
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.providers)
}
/// Fetch the card games the platform can host (for the creation dropdown).
pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
let resp = Request::get("/api/game-types")
.send()
.await
.map_err(|e| e.to_string())?;
check(&resp)?;
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
/// Create a lobby game. `options` is the game-specific creation object
/// described by the engine's `options_schema` (see
/// [`crate::model::GameTypeInfo::option_fields`]).
pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<GameView, String> {
let resp = Request::post("/api/games")
.json(&serde_json::json!({ "game_type": game_type, "options": options }))
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
pub async fn join_game(code: &str) -> Result<GameView, String> {
let resp = Request::post("/api/games/join")
.json(&serde_json::json!({ "code": code }))
.map_err(|e| e.to_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
.get("error")
.and_then(|e| e.as_str())
.map(str::to_string)
.unwrap_or_else(|| server_error(resp.status()));
return Err(message);
}
resp.json().await.map_err(|e| e.to_string())
}
/// Look up a game by join code without joining, for the pre-join summary.
/// `Ok(None)` means the code names no live game (404); other failures are
/// `Err`.
pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
let resp = Request::get(&format!("/api/games/preview?code={code}"))
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 404 {
return Ok(None);
}
check(&resp)?;
resp.json().await.map(Some).map_err(|e| e.to_string())
}
/// Why a game connection failed before the first snapshot arrived.
///
/// A refused WebSocket handshake is invisible to the browser: the close
/// event reports an abnormal closure (1006) with no HTTP status, so the
/// game page asks the REST API to tell a deliberate rejection from a
/// transient network problem. Anonymous users and non-players are
/// welcome as spectators, so the only deliberate refusal is an unknown
/// game.
pub enum GameEntry {
/// Unknown game (`404`): the id is wrong or the game's TTL expired.
Unavailable,
/// The API could not be asked (or answered unexpectedly): treat the
/// connection loss as transient and retry.
Transient,
}
/// Classify a failed game connection via `GET /api/games/{game_id}`,
/// mirroring the status used to refuse the websocket upgrade.
pub async fn probe_game(game_id: &str) -> GameEntry {
match Request::get(&format!("/api/games/{game_id}")).send().await {
Ok(resp) => match resp.status() {
404 => GameEntry::Unavailable,
_ => GameEntry::Transient,
},
Err(_) => GameEntry::Transient,
}
}
/// Fetch the live games the caller is seated in, for the lobby's
/// "Continue" list.
pub async fn my_games() -> Result<Vec<MyGame>, String> {
let resp = Request::get("/api/me/games")
.send()
.await
.map_err(|e| e.to_string())?;
check(&resp)?;
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
let url = match cursor {
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
None => "/api/me/matches?limit=10".to_string(),
};
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
/// Fetch the caller's Elo ratings (one row per game type played).
pub async fn my_ratings() -> Result<RatingsPage, String> {
let resp = Request::get("/api/me/ratings")
.send()
.await
.map_err(|e| e.to_string())?;
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
/// are all scoped to that game.
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
.send()
.await
.map_err(|e| e.to_string())?;
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}