diff --git a/Cargo.toml b/Cargo.toml index 796ec1c..3ce9d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,12 +16,12 @@ serde_json = "1" env_logger = "0.10.0" log = "0.4.20" once_cell = "1.18.0" -web-sys = "0.3.64" +web-sys = { version = "0.3.64", features = ["Window", "Storage"] } cfg-if = "1.0.0" chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] } password-auth = "1.0.0" lazy_static = "1.5" -jsonwebtoken = "9" + [target.'cfg(engine)'.dev-dependencies] fantoccini = "0.19" @@ -37,6 +37,7 @@ sea-orm = { version = "1.0", features = [ "macros", "with-chrono", ] } +jsonwebtoken = "9.3.0" [target.'cfg(client)'.dependencies] wasm-bindgen = "0.2.93" diff --git a/src/capsules/login_form.rs b/src/capsules/login_form.rs index fd4d121..58c6d72 100644 --- a/src/capsules/login_form.rs +++ b/src/capsules/login_form.rs @@ -1,4 +1,4 @@ -use std::num::NonZeroU16; +use std::arch::global_asm; use lazy_static::lazy_static; use perseus::prelude::*; @@ -6,16 +6,15 @@ use serde::{Deserialize, Serialize}; use sycamore::prelude::*; use web_sys::Event; -use crate::{ - endpoints::LOGIN, - state_enums::OpenState, - templates::{get_api_path, global_state::AppStateRx}, -}; - cfg_if::cfg_if! { if #[cfg(client)] { use crate::{ models::auth::{LoginInfo, LoginResponse}, + endpoints::LOGIN, + state_enums::{LoginState, OpenState}, + templates::{get_api_path}, + global_state::{self, AppStateRx}, + models::auth::WebAuthInfo, }; use reqwest::StatusCode; } @@ -61,10 +60,12 @@ fn login_form_capsule( #[cfg(client)] { spawn_local_scoped(cx, async move { + let remember_me = state.remember_me.get().as_ref().clone(); + let username = state.username.get().as_ref().clone(); let login_info = LoginInfo { - username: state.username.get().as_ref().clone(), + username: username.clone(), password: state.password.get().as_ref().clone(), - remember_me: state.remember_me.get().as_ref().clone(), + remember_me, }; // // @todo clean up error handling @@ -79,12 +80,23 @@ fn login_form_capsule( let global_state = Reactor::::from_cx(cx).get_global_state::(cx); if response.status() != StatusCode::OK { + // todo update to some type of alert state.username.set(response.status().to_string()); return; } let response = response.json::().await.unwrap(); - state.username.set(response.token.clone()); + + // Save token to session/local storage and update state + global_state.auth.handle_log_in(WebAuthInfo { + token: response.token, + expires: response.expires, + username, + remember_me, + }); + + // Close modal + global_state.modals_open.login.set(OpenState::Closed); }); } }; diff --git a/src/components/header.rs b/src/components/header.rs index 7fcf713..149840b 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -7,9 +7,9 @@ use web_sys::Event; use crate::{ capsules::login_form::{LoginFormProps, LOGIN_FORM}, endpoints::LOGIN, + global_state::AppStateRx, models::auth::LoginInfo, state_enums::{GameState, LoginState, OpenState}, - templates::global_state::AppStateRx, }; #[derive(Prop)] @@ -33,6 +33,16 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro } }; + let handle_log_out = move |_event: Event| { + #[cfg(client)] + { + spawn_local_scoped(cx, async move { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + global_state.auth.handle_log_out(); + }); + } + }; + view! { cx, header { div (class = "flex items-center justify-between w-full md:text-center h-20") { @@ -52,14 +62,18 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro "Register" } button(on:click = handle_log_in,class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { - "Login" + "Log in" } } } LoginState::Authenticated => { view! { cx, div { - "Hello {username}!" + "Hello " + (global_state.auth.username.get().as_ref().clone().unwrap_or("".to_owned())) + } + button(on:click = handle_log_out, class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") { + "Log out" } } } diff --git a/src/components/layout.rs b/src/components/layout.rs index 6c5604e..e9bcc36 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,8 +1,8 @@ use crate::{ capsules::login_form::{LoginFormProps, LOGIN_FORM}, components::header::{Header, HeaderProps}, + global_state::AppStateRx, state_enums::{GameState, LoginState}, - templates::global_state::AppStateRx, }; use perseus::prelude::*; use sycamore::prelude::*; diff --git a/src/global_state.rs b/src/global_state.rs new file mode 100644 index 0000000..2fe2a33 --- /dev/null +++ b/src/global_state.rs @@ -0,0 +1,115 @@ +// Not a page, global state that is shared between all pages + +use perseus::{prelude::*, state::GlobalStateCreator}; +use serde::{Deserialize, Serialize}; + +use crate::{ + models::auth::{Claims, WebAuthInfo}, + state_enums::{LoginState, OpenState}, +}; + +cfg_if::cfg_if! { + if #[cfg(engine)] { + + } +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AppStateRx")] +pub struct AppState { + #[rx(nested)] + pub auth: AuthData, + #[rx(nested)] + pub modals_open: ModalOpenData, +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "AuthDataRx")] +pub struct AuthData { + pub state: LoginState, + pub username: Option, + pub remember_me: Option, + pub auth_info: Option, +} + +impl AuthDataRx { + pub fn handle_log_in(&self, auth_info: WebAuthInfo) { + // Save new token to persistent storage + if auth_info.remember_me { + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let value = serde_json::to_string(&auth_info).unwrap(); + storage.set_item("auth", &value).unwrap(); + } + + // Save token to session storage + self.username.set(Some(auth_info.username.clone())); + self.remember_me.set(Some(auth_info.remember_me.clone())); + self.auth_info.set(Some(auth_info)); + self.state.set(LoginState::Authenticated); + } + + pub fn handle_log_out(&self) { + // Delete persistent storage + // TODO -> handle error if local storage is not readable in browser + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + storage.remove_item("auth").unwrap(); + // Update state + self.auth_info.set(None); + self.username.set(None); + self.remember_me.set(None); + self.state.set(LoginState::NotAuthenticated); + } +} + +#[derive(Serialize, Deserialize, ReactiveState, Clone)] +#[rx(alias = "ModalOpenDataRx")] +pub struct ModalOpenData { + pub login: OpenState, +} + +pub fn get_global_state_creator() -> GlobalStateCreator { + GlobalStateCreator::new().build_state_fn(get_build_state) +} + +#[engine_only_fn] +pub async fn get_build_state() -> AppState { + AppState { + auth: AuthData { + state: LoginState::Unknown, + username: None, + remember_me: None, + auth_info: None, + }, + modals_open: ModalOpenData { + login: OpenState::Closed, + }, + } +} + +// Client only code to check if they're authenticated +#[cfg(client)] +impl AuthDataRx { + pub fn detect_state(&self) { + // If the user is in a known state, return + if let LoginState::Authenticated | LoginState::NotAuthenticated = *self.state.get() { + return; + } + // TODO handle error case better + // Save new token to persistent storage + let storage: web_sys::Storage = + web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let saved_auth = storage.get("auth").unwrap(); + match saved_auth { + Some(auth_info) => { + // TODO check if session is expiring + let auth_info = serde_json::from_str(&auth_info).unwrap(); + self.handle_log_in(auth_info); + } + None => { + self.state.set(LoginState::NotAuthenticated); + } + } + } +} diff --git a/src/main.rs b/src/main.rs index e762c56..6b96339 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod endpoints; #[allow(unused_imports)] mod entity; mod error_views; +mod global_state; mod models; #[cfg(engine)] mod server; @@ -59,7 +60,7 @@ pub fn main() -> PerseusApp { env_logger::init(); PerseusApp::new() - .global_state_creator(crate::templates::global_state::get_global_state_creator()) + .global_state_creator(crate::global_state::get_global_state_creator()) .template(crate::templates::index::get_template()) .template(crate::templates::add_game_form::get_template()) .template(crate::templates::one_v_one_board::get_template()) diff --git a/src/models/auth.rs b/src/models/auth.rs index 6ca5771..66ca6b0 100644 --- a/src/models/auth.rs +++ b/src/models/auth.rs @@ -21,3 +21,13 @@ pub struct Claims { pub sub: String, pub exp: usize, } + +// For client local storage and session storage +#[derive(Serialize, Deserialize, Clone)] +pub struct WebAuthInfo { + pub token: String, + #[serde(with = "ts_seconds")] + pub expires: DateTime, + pub username: String, + pub remember_me: bool, +} diff --git a/src/templates/add_game_form.rs b/src/templates/add_game_form.rs index da566c3..95d682e 100644 --- a/src/templates/add_game_form.rs +++ b/src/templates/add_game_form.rs @@ -6,7 +6,7 @@ use web_sys::Event; cfg_if::cfg_if! { if #[cfg(client)] { - use crate::templates::global_state::AppStateRx; + use crate::global_state::AppStateRx; use crate::templates::get_api_path; use chrono::Utc; } diff --git a/src/templates/global_state.rs b/src/templates/global_state.rs deleted file mode 100644 index fb2190c..0000000 --- a/src/templates/global_state.rs +++ /dev/null @@ -1,72 +0,0 @@ -// Not a page, global state that is shared between all pages - -use perseus::{prelude::*, state::GlobalStateCreator}; -use serde::{Deserialize, Serialize}; - -use crate::{ - models::auth::Claims, - state_enums::{LoginState, OpenState}, -}; - -cfg_if::cfg_if! { - if #[cfg(engine)] { - - } -} - -#[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "AppStateRx")] -pub struct AppState { - #[rx(nested)] - pub auth: AuthData, - #[rx(nested)] - pub modals_open: ModalOpenData, -} - -#[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "AuthDataRx")] -pub struct AuthData { - pub state: LoginState, - pub username: Option, - pub claims: Claims, -} - -#[derive(Serialize, Deserialize, ReactiveState, Clone)] -#[rx(alias = "ModalOpenDataRx")] -pub struct ModalOpenData { - pub login: OpenState, -} - -pub fn get_global_state_creator() -> GlobalStateCreator { - GlobalStateCreator::new().build_state_fn(get_build_state) -} - -#[engine_only_fn] -pub async fn get_build_state() -> AppState { - AppState { - auth: AuthData { - state: LoginState::Unknown, - username: None, - claims: Claims { - sub: "".to_owned(), - exp: 0, - }, - }, - modals_open: ModalOpenData { - login: OpenState::Closed, - }, - } -} - -// Client only code to check if they're authenticated -#[cfg(client)] -impl AuthDataRx { - pub fn detect_state(&self) { - // If the user is in a known state, return - if let LoginState::Authenticated | LoginState::NotAuthenticated = *self.state.get() { - return; - } - // TODO -> Get state from storage - self.state.set(LoginState::NotAuthenticated); - } -} diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 8e589c5..a7a618f 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1,5 +1,4 @@ pub mod add_game_form; -pub mod global_state; pub mod index; pub mod one_v_one_board; pub mod overall_board; @@ -7,7 +6,6 @@ pub mod overall_board; #[cfg(client)] use perseus::utils::get_path_prefix_client; -#[allow(dead_code)] pub fn get_api_path(path: &str) -> String { #[cfg(engine)] { diff --git a/src/templates/overall_board.rs b/src/templates/overall_board.rs index cf60a2b..0401a5a 100644 --- a/src/templates/overall_board.rs +++ b/src/templates/overall_board.rs @@ -1,6 +1,4 @@ -use crate::{ - components::layout::Layout, state_enums::GameState, templates::global_state::AppStateRx, -}; +use crate::{components::layout::Layout, global_state::AppStateRx, state_enums::GameState}; use perseus::prelude::*; use serde::{Deserialize, Serialize};