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 parent
43d5a6e2f9
commit
99aa81d682
10 files changed
+299
-92
No files matched your search
+1
-1
@@ -13,7 +13,7 @@ serde_json = "1"
|
|||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
futures = "0.3"
|
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"
|
js-sys = "0.3"
|
||||||
gloo-timers = "0.3"
|
gloo-timers = "0.3"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|||||||
+32
-25
@@ -1,13 +1,32 @@
|
|||||||
//! REST client for the tavolo backend. Same-origin requests carry the
|
//! REST client for the tavolo backend. Same-origin requests carry the
|
||||||
//! session cookie automatically.
|
//! session cookie automatically.
|
||||||
|
use crate::auth;
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::{Request, Response};
|
||||||
|
|
||||||
fn server_error(status: u16) -> String {
|
fn server_error(status: u16) -> String {
|
||||||
format!("server returned {status}")
|
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).
|
/// 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> {
|
pub async fn me() -> Result<Option<User>, String> {
|
||||||
let resp = Request::get("/api/me")
|
let resp = Request::get("/api/me")
|
||||||
.send()
|
.send()
|
||||||
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.providers)
|
Ok(page.providers)
|
||||||
}
|
}
|
||||||
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.results)
|
Ok(page.results)
|
||||||
}
|
}
|
||||||
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if resp.status() == 401 {
|
||||||
|
auth::redirect_to_login();
|
||||||
|
return Err("login required".to_string());
|
||||||
|
}
|
||||||
if !resp.ok() {
|
if !resp.ok() {
|
||||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
let message = body
|
let message = body
|
||||||
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
|||||||
if resp.status() == 404 {
|
if resp.status() == 404 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
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()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.results)
|
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(),
|
None => "/api/me/matches?limit=10".to_string(),
|
||||||
};
|
};
|
||||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
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 briscola;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod copy;
|
pub mod copy;
|
||||||
|
pub mod login;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
+33
-1
@@ -1,22 +1,27 @@
|
|||||||
//! Application entry point.
|
//! Application entry point.
|
||||||
mod api;
|
mod api;
|
||||||
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
mod model;
|
mod model;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
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::game::GamePage;
|
||||||
use pages::history::HistoryPage;
|
use pages::history::HistoryPage;
|
||||||
use pages::leaderboard::LeaderboardPage;
|
use pages::leaderboard::LeaderboardPage;
|
||||||
use pages::lobby::LobbyPage;
|
use pages::lobby::LobbyPage;
|
||||||
|
use pages::login::LoginPage;
|
||||||
|
|
||||||
#[derive(Route, Clone)]
|
#[derive(Route, Clone)]
|
||||||
enum AppRoutes {
|
enum AppRoutes {
|
||||||
#[to("/")]
|
#[to("/")]
|
||||||
Lobby,
|
Lobby,
|
||||||
|
#[to("/login")]
|
||||||
|
Login,
|
||||||
#[to("/game/<id>")]
|
#[to("/game/<id>")]
|
||||||
Game { id: String },
|
Game { id: String },
|
||||||
#[to("/history")]
|
#[to("/history")]
|
||||||
@@ -27,6 +32,31 @@ enum AppRoutes {
|
|||||||
NotFound,
|
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() {
|
fn main() {
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
sycamore::render(|| {
|
sycamore::render(|| {
|
||||||
@@ -38,6 +68,7 @@ fn main() {
|
|||||||
div(class="app") {
|
div(class="app") {
|
||||||
(match route.get_clone() {
|
(match route.get_clone() {
|
||||||
AppRoutes::Lobby => view! { LobbyPage() },
|
AppRoutes::Lobby => view! { LobbyPage() },
|
||||||
|
AppRoutes::Login => view! { LoginPage() },
|
||||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||||
AppRoutes::History => view! { HistoryPage() },
|
AppRoutes::History => view! { HistoryPage() },
|
||||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
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 {
|
spawn_local(async move {
|
||||||
match api::my_matches(next.as_deref()).await {
|
match api::my_matches(next.as_deref()).await {
|
||||||
Ok(p) => {
|
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());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||||
page.set(Some(p));
|
page.set(Some(p));
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e)),
|
Err(e) => {
|
||||||
|
if error.is_alive() {
|
||||||
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(types) = api::game_types().await {
|
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 {
|
spawn_local(async move {
|
||||||
match api::my_matches(None).await {
|
match api::my_matches(None).await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
|
if !page.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
cursor.set(p.next_cursor.clone());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.set(p.results.clone());
|
rows.set(p.results.clone());
|
||||||
page.set(Some(p));
|
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::api;
|
||||||
use crate::components::card::card_back;
|
use crate::components::card::card_back;
|
||||||
|
use crate::components::login::login_card;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||||
MyGame, OptionField, OptionKind, PlayerRating, User,
|
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 {
|
fn json_to_string(value: &serde_json::Value) -> String {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
@@ -386,18 +378,30 @@ pub fn LobbyPage() -> View {
|
|||||||
if me.is_some() {
|
if me.is_some() {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(p) = api::my_ratings().await {
|
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 {
|
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) => {
|
Err(e) => {
|
||||||
error.set(Some(e));
|
if error.is_alive() {
|
||||||
user.set(Some(None));
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
if user.is_alive() {
|
||||||
|
user.set(Some(None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -405,6 +409,9 @@ pub fn LobbyPage() -> View {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::game_types().await {
|
match api::game_types().await {
|
||||||
Ok(types) if !types.is_empty() => {
|
Ok(types) if !types.is_empty() => {
|
||||||
|
if !game_types.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
selected_game.set(types[0].id.clone());
|
selected_game.set(types[0].id.clone());
|
||||||
game_types.set(types);
|
game_types.set(types);
|
||||||
}
|
}
|
||||||
@@ -414,7 +421,9 @@ pub fn LobbyPage() -> View {
|
|||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(list) = api::auth_providers().await {
|
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! {
|
Some(None) => view! { (login_card(auth_providers)) },
|
||||||
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(Some(_me)) => {
|
Some(Some(_me)) => {
|
||||||
view! {
|
view! {
|
||||||
(crate::components::nav::app_nav("/", move || {
|
(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 history;
|
||||||
pub mod leaderboard;
|
pub mod leaderboard;
|
||||||
pub mod lobby;
|
pub mod lobby;
|
||||||
|
pub mod login;
|
||||||
Reference in new issue
Block a user