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
188 lines
6.4 KiB
Rust
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())
|
|
}
|