diff --git a/Cargo.lock b/Cargo.lock index e2dfc98..fb25260 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,7 +663,7 @@ dependencies = [ "tokio", "toml", "tracing", - "uuid", + "uuid 0.8.2", "validator 0.14.0", ] @@ -805,7 +805,7 @@ dependencies = [ "model", "pretty_env_logger", "thiserror", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -834,6 +834,7 @@ dependencies = [ "num-traits", "serde", "time 0.1.43", + "wasm-bindgen", "winapi", ] @@ -1124,7 +1125,7 @@ dependencies = [ "sqlx", "sqlx-core", "thiserror", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -1263,7 +1264,7 @@ dependencies = [ "serde_json", "thiserror", "tinytemplate", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -1319,7 +1320,7 @@ dependencies = [ "dummy", "http", "rand", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -1407,7 +1408,7 @@ dependencies = [ "pretty_env_logger", "thiserror", "tokio", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -1804,7 +1805,7 @@ dependencies = [ "serde_derive", "termcolor", "toml", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -2275,7 +2276,7 @@ dependencies = [ "sqlx", "sqlx-core", "thiserror", - "uuid", + "uuid 0.8.2", "validator 0.15.0", ] @@ -2504,7 +2505,7 @@ dependencies = [ "model", "pretty_env_logger", "thiserror", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -2622,7 +2623,7 @@ dependencies = [ "pretty_env_logger", "serde", "thiserror", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -3151,7 +3152,7 @@ dependencies = [ "serde", "sonic-channel", "thiserror", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -3197,7 +3198,7 @@ dependencies = [ "rand", "serde", "serde_json", - "uuid", + "uuid 0.8.2", "version_check 0.9.4", "wasm-bindgen", "wasm-bindgen-futures", @@ -3223,7 +3224,7 @@ dependencies = [ "rand", "serde", "serde_json", - "uuid", + "uuid 0.8.2", "version_check 0.9.4", "wasm-bindgen", "wasm-bindgen-futures", @@ -3538,7 +3539,7 @@ dependencies = [ "time 0.2.27", "tokio-stream", "url", - "uuid", + "uuid 0.8.2", "webpki 0.21.4", "webpki-roots 0.21.1", "whoami", @@ -3854,7 +3855,7 @@ dependencies = [ "serde", "sha2", "thiserror", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -4183,6 +4184,15 @@ dependencies = [ "sha1", ] +[[package]] +name = "uuid" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfcd319456c4d6ea10087ed423473267e1a071f3bc0aa89f80d60997843c6f0" +dependencies = [ + "getrandom", +] + [[package]] name = "validator" version = "0.14.0" @@ -4360,6 +4370,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "thiserror", + "uuid 1.0.0", "wasm-bindgen", "web-sys", ] diff --git a/actors/database_manager/src/lib.rs b/actors/database_manager/src/lib.rs index 666d1dd..20c6dee 100644 --- a/actors/database_manager/src/lib.rs +++ b/actors/database_manager/src/lib.rs @@ -98,6 +98,20 @@ macro_rules! query_db { } } }; + + ($db: expr, $msg: expr, passthrough $db_fail: expr, $act_fail: expr) => { + match $db.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("{e}"); + return Err($db_fail(e.into())); + } + Err(e) => { + log::error!("{e:?}"); + return Err($act_fail); + } + } + }; } #[derive(Debug, thiserror::Error)] diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 8cea125..1f287c9 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -7,10 +7,11 @@ use std::sync::Arc; use actix::Addr; use actix_session::Session; use actix_web::body::BoxBody; +use actix_web::http::StatusCode; use actix_web::web::ServiceConfig; use actix_web::{HttpRequest, HttpResponse, Responder, ResponseError}; +use model::api::Failure; use model::{AccessTokenString, RecordId, Token}; -use serde::Serialize; use token_manager::{query_tm, TokenManager}; pub use self::admin::Error as AdminError; @@ -37,6 +38,7 @@ impl RequireLogin for Session { pub enum Error { #[from(ignore)] Unauthorized, + CriticalFailure, Admin(routes::admin::Error), Public(routes::public::Error), } @@ -53,64 +55,68 @@ impl From for Error { } } -#[derive(serde::Serialize)] -pub struct Failure { - pub errors: Vec, -} - impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let msg = match self { Error::Unauthorized => String::from("Unauthorized"), Error::Admin(e) => format!("{e}"), Error::Public(e) => format!("{e}"), + Error::CriticalFailure => String::from("Something went wrong"), }; f.write_str(&serde_json::to_string(&Failure { errors: vec![msg] }).unwrap()) } } -#[derive(Serialize)] -struct ReqFailure { - success: bool, - msg: String, +impl ResponseError for Error { + fn status_code(&self) -> StatusCode { + match self { + Error::Unauthorized => StatusCode::UNAUTHORIZED, + Error::Public(PublicError::DatabaseConnection) | Error::CriticalFailure => { + StatusCode::INTERNAL_SERVER_ERROR + } + Error::Admin(_) => StatusCode::BAD_REQUEST, + Error::Public(_) => StatusCode::BAD_REQUEST, + } + } } -impl ResponseError for Error {} - impl Responder for Error { type Body = BoxBody; fn respond_to(self, _req: &HttpRequest) -> HttpResponse { match self { + Error::Public(PublicError::DatabaseConnection) | Error::CriticalFailure => { + HttpResponse::InternalServerError() + .content_type("application/json") + .json(Failure { + errors: vec![format!("{}", self)], + }) + } Error::Unauthorized => HttpResponse::Unauthorized() .content_type("application/json") - .json(ReqFailure { - success: false, - msg: format!("{}", self), - }), - Error::Public(PublicError::DatabaseConnection) - | Error::Public(PublicError::Database(..)) - | Error::Admin(..) => HttpResponse::InternalServerError() - .content_type("application/json") - .json(ReqFailure { - success: false, - msg: format!("{}", self), + .json(Failure { + errors: vec![format!("{}", self)], }), + Error::Public(PublicError::Database(..)) | Error::Admin(..) => { + HttpResponse::BadRequest() + .content_type("application/json") + .json(Failure { + errors: vec![format!("{}", self)], + }) + } Error::Public(PublicError::ApiV1(V1Error::ShoppingCart(ref e))) => match e { V1ShoppingCartError::Ensure => HttpResponse::InternalServerError() .content_type("application/json") - .json(ReqFailure { - success: false, - msg: format!("{}", self), + .json(Failure { + errors: vec![format!("{}", self)], }), }, Error::Public(PublicError::ApiV1( V1Error::AddItem | V1Error::RemoveItem | V1Error::AddOrder, )) => HttpResponse::BadRequest() .content_type("application/json") - .json(ReqFailure { - success: false, - msg: format!("{}", self), + .json(Failure { + errors: vec![format!("{}", self)], }), } } diff --git a/api/src/routes/public/api_v1/restricted.rs b/api/src/routes/public/api_v1/restricted.rs index 8ff1da6..cfd2a5b 100644 --- a/api/src/routes/public/api_v1/restricted.rs +++ b/api/src/routes/public/api_v1/restricted.rs @@ -30,7 +30,7 @@ async fn refresh_token( tm: Data>, db: Data>, credentials: BearerAuth, -) -> routes::Result> { +) -> routes::Result> { let account_id: model::AccountId = credentials .require_user(tm.clone().into_inner()) .await? @@ -50,7 +50,7 @@ async fn refresh_token( refresh_token_string, } = create_auth_pair(tm, account).await?; - Ok(Json(api::SignInOutput { + Ok(Json(api::SessionOutput { access_token: access_token_string, refresh_token: refresh_token_string, exp: access_token.expiration_time, diff --git a/api/src/routes/public/api_v1/unrestricted.rs b/api/src/routes/public/api_v1/unrestricted.rs index 8320f9f..8c26ff7 100644 --- a/api/src/routes/public/api_v1/unrestricted.rs +++ b/api/src/routes/public/api_v1/unrestricted.rs @@ -131,7 +131,8 @@ pub async fn create_account( db: Data>, Json(payload): Json, config: Data, -) -> routes::Result { + tm: Data>, +) -> routes::Result> { if payload.password != payload.password_confirmation { return Err(routes::Error::Admin( routes::admin::Error::DifferentPasswords, @@ -147,15 +148,30 @@ pub async fn create_account( } }; - public_send_db!( + let account: model::FullAccount = query_db!( db, database_manager::CreateAccount { email: payload.email, login: payload.login, pass_hash: model::PassHash::from(hash), role: model::Role::User, - } + }, + passthrough routes::Error::Public, + routes::Error::CriticalFailure ); + + let AuthPair { + access_token, + access_token_string, + _refresh_token: _, + refresh_token_string, + } = create_auth_pair(tm, account).await?; + + Ok(Json(api::SessionOutput { + access_token: access_token_string, + refresh_token: refresh_token_string, + exp: access_token.expiration_time, + })) } pub(crate) struct AuthPair { @@ -205,7 +221,7 @@ async fn sign_in( Json(payload): Json, db: Data>, tm: Data>, -) -> Result> { +) -> Result> { let db = db.into_inner(); let account: model::FullAccount = query_db!( @@ -227,7 +243,7 @@ async fn sign_in( refresh_token_string, } = create_auth_pair(tm, account).await?; - Ok(Json(api::SignInOutput { + Ok(Json(api::SessionOutput { access_token: access_token_string, refresh_token: refresh_token_string, exp: access_token.expiration_time, @@ -253,5 +269,6 @@ pub(crate) fn configure(config: &mut ServiceConfig) { .service(product) .service(products) .service(stocks) - .service(sign_in); + .service(sign_in) + .service(create_account); } diff --git a/shared/model/src/api.rs b/shared/model/src/api.rs index 89a99a8..e62d315 100644 --- a/shared/model/src/api.rs +++ b/shared/model/src/api.rs @@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize}; use crate::*; +#[derive(Serialize, Deserialize, Debug)] +pub struct Failure { + pub errors: Vec, +} + #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[derive(Serialize, Deserialize, Debug)] #[serde(transparent)] @@ -307,7 +312,7 @@ pub struct SignInInput { } #[derive(Serialize, Deserialize, Debug)] -pub struct SignInOutput { +pub struct SessionOutput { pub access_token: AccessTokenString, pub refresh_token: RefreshTokenString, pub exp: NaiveDateTime, diff --git a/shared/model/src/lib.rs b/shared/model/src/lib.rs index 45ce2f9..b8a8735 100644 --- a/shared/model/src/lib.rs +++ b/shared/model/src/lib.rs @@ -9,7 +9,7 @@ pub mod encrypt; use std::fmt::{Display, Formatter}; use std::str::FromStr; -use derive_more::{Deref, Display, From}; +use derive_more::{Deref, DerefMut, Display, From}; #[cfg(feature = "dummy")] use fake::Fake; #[cfg(feature = "dummy")] @@ -305,10 +305,20 @@ impl Login { #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] -#[derive(Serialize, Debug, Deref, From, Display)] +#[derive(Serialize, Debug, Clone, Deref, DerefMut, From, Display)] #[serde(transparent)] pub struct Email(String); +impl Email { + pub fn invalid_empty() -> Self { + Self("".into()) + } + + pub fn new>(s: S) -> Self { + Self(s.into()) + } +} + impl FromStr for Email { type Err = TransformError; @@ -319,10 +329,6 @@ impl FromStr for Email { Err(TransformError::NotEmail) } } - - fn invalid_empty() -> Self { - Self("".into()) - } } impl<'de> serde::Deserialize<'de> for Email { @@ -582,7 +588,7 @@ impl ResetToken { #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] -#[derive(Serialize, Deserialize, Debug, Deref, From, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, Deref, From, Display)] #[serde(transparent)] pub struct Password(String); @@ -595,10 +601,16 @@ impl Password { #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] -#[derive(Serialize, Deserialize, Debug, Deref, From, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, Deref, From, Display)] #[serde(transparent)] pub struct PasswordConfirmation(String); +impl PasswordConfirmation { + pub fn new>(pass: S) -> Self { + Self(pass.into()) + } +} + #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Deref, From, Display)] diff --git a/web/Cargo.toml b/web/Cargo.toml index 50c1a09..84dc14a 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -12,9 +12,11 @@ model = { path = "../shared/model", features = ["dummy"] } seed = { version = "0.9.1", features = [] } seed_heroicons = { git = "https://github.com/mh84/seed_heroicons.git" } -chrono = { version = "*" } +chrono = { version = "*", features = ["wasm-bindgen"] } gloo-timers = { version = "*", features = ["futures"] } +uuid = { version = "1.0.0", features = ["v4"] } + serde = { version = "1.0.137", features = ["derive"] } serde_json = { version = "1.0.81" } serde-wasm-bindgen = { version = "0.4.2" } diff --git a/web/src/api.rs b/web/src/api.rs index 86cbda1..ff601f7 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -1 +1,45 @@ +use std::convert::Infallible; +use std::ops::FromResidual; + +use seed::fetch::{FetchError, Request}; + pub mod public; + +#[derive(Debug)] +pub enum NetRes { + Success(S), + Error(model::api::Failure), + Http(FetchError), +} + +impl FromResidual>> for NetRes { + fn from_residual(residual: Result>) -> Self { + match residual { + Ok(_s) => unreachable!(), + Err(s) => s, + } + } +} + +async fn perform(req: Request<'_>) -> NetRes { + let res = match req.fetch().await { + Ok(res) => res, + Err(err) => return NetRes::Http(err), + }; + if res.status().is_error() { + NetRes::Error(match res.json().await { + Ok(json) => json, + Err(_err) => match res.text().await { + Ok(text) => model::api::Failure { errors: vec![text] }, + Err(err) => model::api::Failure { + errors: vec![format!("{:?}", err)], + }, + }, + }) + } else { + match res.json().await { + Ok(json) => NetRes::Success(json), + Err(err) => NetRes::Http(err), + } + } +} diff --git a/web/src/api/public.rs b/web/src/api/public.rs index af586b8..4c05850 100644 --- a/web/src/api/public.rs +++ b/web/src/api/public.rs @@ -1,68 +1,63 @@ use model::{AccessTokenString, RefreshTokenString}; -use seed::prelude::*; +use seed::fetch::{Header, Method, Request}; -pub async fn fetch_products() -> fetch::Result { - Request::new("/api/v1/products") - .method(Method::Get) - .fetch() - .await? - .check_status()? - .json() - .await +use crate::api::perform; + +pub async fn fetch_products() -> super::NetRes { + perform(Request::new("/api/v1/products").method(Method::Get)).await } -pub async fn fetch_product(product_id: model::ProductId) -> fetch::Result { - Request::new(format!("/api/v1/product/{}", product_id)) - .method(Method::Get) - .fetch() - .await? - .check_status()? - .json() - .await +pub async fn fetch_product(product_id: model::ProductId) -> super::NetRes { + perform(Request::new(format!("/api/v1/product/{}", product_id)).method(Method::Get)).await } -pub async fn fetch_me(access_token: AccessTokenString) -> fetch::Result { - Request::new("/api/v1/me") - .header(fetch::Header::bearer(access_token.as_str())) - .method(Method::Get) - .fetch() - .await? - .check_status()? - .json() - .await +pub async fn fetch_me(access_token: AccessTokenString) -> super::NetRes { + perform( + Request::new("/api/v1/me") + .header(Header::bearer(access_token.as_str())) + .method(Method::Get), + ) + .await } -pub async fn sign_in(input: model::api::SignInInput) -> fetch::Result { - Request::new("/api/v1/sign-in") - .method(Method::Post) - .json(&input)? - .fetch() - .await? - .check_status()? - .json() - .await +pub async fn sign_in(input: model::api::SignInInput) -> super::NetRes { + perform( + Request::new("/api/v1/sign-in") + .method(Method::Post) + .json(&input) + .map_err(crate::api::NetRes::Http)?, + ) + .await } -pub async fn verify_token(access_token: AccessTokenString) -> fetch::Result { - Request::new("/api/v1/token/verify") - .method(Method::Post) - .header(fetch::Header::bearer(access_token.as_str())) - .fetch() - .await? - .check_status()? - .json() - .await +pub async fn verify_token(access_token: AccessTokenString) -> super::NetRes { + perform( + Request::new("/api/v1/token/verify") + .method(Method::Post) + .header(Header::bearer(access_token.as_str())), + ) + .await } pub async fn refresh_token( access_token: RefreshTokenString, -) -> fetch::Result { - Request::new("/api/v1/token/refresh") - .method(Method::Post) - .header(fetch::Header::bearer(access_token.as_str())) - .fetch() - .await? - .check_status()? - .json() - .await +) -> super::NetRes { + perform( + Request::new("/api/v1/token/refresh") + .method(Method::Post) + .header(Header::bearer(access_token.as_str())), + ) + .await +} + +pub async fn sign_up( + input: model::api::CreateAccountInput, +) -> super::NetRes { + perform( + Request::new("/api/v1/register") + .method(Method::Post) + .json(&input) + .map_err(crate::api::NetRes::Http)?, + ) + .await } diff --git a/web/src/lib.rs b/web/src/lib.rs index b9ee2bf..fa43049 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,7 +1,10 @@ +#![feature(try_trait_v2)] + pub mod api; mod i18n; mod model; mod pages; +pub mod session; pub mod shared; use seed::empty; @@ -10,6 +13,7 @@ use seed::prelude::*; use crate::i18n::I18n; use crate::model::Model; use crate::pages::{Msg, Page, PublicPage}; +use crate::session::SessionMsg; #[macro_export] macro_rules! fetch_page { @@ -62,11 +66,9 @@ macro_rules! fetch_page { } fn init(url: Url, orders: &mut impl Orders) -> Model { - orders - .stream(streams::interval(500, || Msg::CheckAccessToken)) - .subscribe(Msg::UrlChanged); + orders.subscribe(Msg::UrlChanged); - Model { + let mut model = Model { url: url.clone().set_path(&[] as &[&str]), token: LocalStorage::get("auth-token").ok(), page: Page::init(url, orders), @@ -77,7 +79,11 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { .and_then(|el: web_sys::Element| el.get_attribute("href")), shared: shared::Model::default(), i18n: I18n::load(), - } + }; + + session::init(&mut model, orders); + + model } fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { @@ -85,20 +91,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { Msg::Shared(msg) => { shared::update(msg, &mut model.shared, orders); } - Msg::CheckAccessToken => { - orders.skip(); - if model.shared.refresh_token.is_none() { - return; - } - if let Some(exp) = model.shared.exp { - if exp > chrono::Utc::now().naive_utc() - chrono::Duration::seconds(1) { - return; - } - if let Some(token) = model.shared.refresh_token.take() { - orders.send_msg(Msg::Shared(shared::Msg::RefreshToken(token))); - } - } - } Msg::UrlChanged(subs::UrlChanged(url)) => model.page.page_changed(url, orders), Msg::Public(pages::public::Msg::Listing(msg)) => { let page = fetch_page!(public model, Listing); @@ -112,11 +104,21 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { let page = fetch_page!(public model, SignIn); pages::public::sign_in::update(msg, page, &mut orders.proxy(Into::into)) } + Msg::Public(pages::public::Msg::SignUp(pages::public::sign_up::Msg::AccountCreated( + res, + ))) => { + orders + .skip() + .send_msg(Msg::Session(SessionMsg::TokenRefreshed(res))); + } Msg::Public(pages::public::Msg::SignUp(msg)) => { let page = fetch_page!(public model, SignUp); pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into)) } Msg::Admin(_) => {} + Msg::Session(msg) => { + session::update(msg, model, orders); + } } } diff --git a/web/src/pages.rs b/web/src/pages.rs index 5717eab..5560eca 100644 --- a/web/src/pages.rs +++ b/web/src/pages.rs @@ -11,8 +11,8 @@ pub enum Msg { Public(public::Msg), Admin(admin::Msg), UrlChanged(subs::UrlChanged), - CheckAccessToken, Shared(shared::Msg), + Session(crate::session::SessionMsg), } pub enum AdminPage { diff --git a/web/src/pages/public.rs b/web/src/pages/public.rs index 4c37939..9f01e8b 100644 --- a/web/src/pages/public.rs +++ b/web/src/pages/public.rs @@ -69,11 +69,11 @@ pub mod layout { use seed::prelude::*; use seed::*; - pub fn view( + pub fn view( model: &crate::Model, - content: Node, + content: Node, categories: Option<&[model::api::Category]>, - ) -> Node { + ) -> Node { let sidebar = match categories { Some(categories) => { let sidebar = super::sidebar::view(model, categories); @@ -84,9 +84,11 @@ pub mod layout { } _ => empty![], }; + let notifications = crate::shared::notification::view(model); div![ C!["flex"], sidebar, + notifications, div![C!["w-full h-full p-4 m-8 overflow-y-auto"], content] ] } @@ -108,7 +110,7 @@ pub mod sidebar { } fn item(model: &crate::Model, category: &model::api::Category) -> Node { - let url = Urls::new(model.url.clone()) + let url = Urls::new(&model.url) .listing() .add_path_part(category.key.as_str()); li![ diff --git a/web/src/pages/public/listing.rs b/web/src/pages/public/listing.rs index 3cac86d..98cf670 100644 --- a/web/src/pages/public/listing.rs +++ b/web/src/pages/public/listing.rs @@ -4,6 +4,7 @@ use seed::app::Orders; use seed::prelude::*; use seed::*; +use crate::api::NetRes; use crate::pages::Urls; #[derive(Debug)] @@ -19,21 +20,19 @@ pub struct ListingPage { #[derive(Debug)] pub enum Msg { FetchProducts, - ProductFetched(fetch::Result), + ProductFetched(crate::api::NetRes), } pub fn init(url: Url, orders: &mut impl Orders) -> ListingPage { orders.send_msg(Msg::FetchProducts); - let model = ListingPage { + ListingPage { product_ids: vec![], filters: url_to_filters(url), products: Default::default(), errors: vec![], categories: vec![], visible_products: vec![], - }; - seed::log!(&model); - model + } } fn url_to_filters(mut url: Url) -> HashSet { @@ -77,7 +76,7 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders) async { Msg::ProductFetched(crate::api::public::fetch_products().await) } }); } - Msg::ProductFetched(Ok(products)) => { + Msg::ProductFetched(NetRes::Success(products)) => { model.categories = products .0 .iter() @@ -103,7 +102,7 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders) }; filter_products(model); } - Msg::ProductFetched(Err(_e)) => { + Msg::ProductFetched(NetRes::Error(_)) | Msg::ProductFetched(NetRes::Http(_)) => { model.errors.push("Failed to load products".into()); } } @@ -148,7 +147,7 @@ fn product(model: &crate::Model, product: &model::api::Product) -> Node { .map(|photo| photo.url.as_str()) .unwrap_or_default(); - let url = Urls::new(model.url.clone()) + let url = Urls::new(&model.url) .product() .add_path_part((*product.id as i32).to_string()); diff --git a/web/src/pages/public/product.rs b/web/src/pages/public/product.rs index 64a6827..4986020 100644 --- a/web/src/pages/public/product.rs +++ b/web/src/pages/public/product.rs @@ -1,9 +1,11 @@ use seed::prelude::*; use seed::*; +use crate::api::NetRes; + #[derive(Debug)] pub enum Msg { - ProductFetched(fetch::Result), + ProductFetched(crate::api::NetRes), SelectImage(usize), } @@ -33,10 +35,13 @@ pub fn page_changed(_url: Url, _model: &mut ProductPage) {} pub fn update(msg: Msg, model: &mut ProductPage, _orders: &mut impl Orders) { match msg { - Msg::ProductFetched(Ok(product)) => { + Msg::ProductFetched(NetRes::Success(product)) => { model.product = Some(product); } - Msg::ProductFetched(Err(e)) => { + Msg::ProductFetched(NetRes::Error(e)) => { + seed::error!(e); + } + Msg::ProductFetched(NetRes::Http(e)) => { seed::error!(e); } Msg::SelectImage(selected) => { diff --git a/web/src/pages/public/sign_in.rs b/web/src/pages/public/sign_in.rs index a7488cf..7f07583 100644 --- a/web/src/pages/public/sign_in.rs +++ b/web/src/pages/public/sign_in.rs @@ -28,18 +28,32 @@ pub fn page_changed(_url: Url, _model: &mut SignInPage) {} pub fn update(_msg: Msg, _model: &mut SignInPage, _orders: &mut impl Orders) {} pub fn view(model: &crate::Model, page: &SignInPage) -> Node { + let home = Urls::new(&model.url).home(); + let logo = model + .logo + .as_deref() + .map(|src| { + a![ + attrs![At::Href => home], + img![attrs![At::Src => src], C!["m-auto"]] + ] + }) + .unwrap_or_else(|| a![attrs![At::Href => home], "Home"]); let content = div![ C!["relative flex flex-col justify-center overflow-hidden"], div![ - C!["w-full p-6 m-auto bg-white border-t-4 rounded-md shadow-md border-top lg:max-w-md"], - h1![C!["text-3xl font-semibold text-center text-indigo-700"], "Logo"], + C!["w-full p-6 m-auto bg-white lg:max-w-md"], + h1![ + C!["text-3xl font-semibold text-center text-indigo-700"], + logo + ], sign_in_form(model, page), p![ C!["mt-8 text-xs font-light text-center text-indigo-700"], model.i18n.t("Don't have an account?"), a![ C!["font-medium text-indigo-600 hover:underline"], - attrs![At::Href => Urls::new(model.url.clone().set_path(&[] as &[&str])).sign_up()], + attrs![At::Href => Urls::new(&model.url).sign_up()], " ", model.i18n.t("Sign up") ] @@ -48,10 +62,8 @@ pub fn view(model: &crate::Model, page: &SignInPage) -> Node { ] .map_msg(Into::into); - div![ - crate::shared::view::public_navbar(model), - super::layout::view(model, content, None) - ] + // crate::shared::view::public_navbar(model), + div![super::layout::view(model, content, None)] } fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node { @@ -87,7 +99,7 @@ fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node { ], a![ C!["text-xs text-indigo-600 hover:underline"], - attrs![At::Href => Urls::new(model.url.clone().set_path(&[] as &[&str])).forgot_password()], + attrs![At::Href => Urls::new(&model.url).forgot_password()], model.i18n.t("Forget Password?"), ], div![ diff --git a/web/src/pages/public/sign_up.rs b/web/src/pages/public/sign_up.rs index 0c32796..eb697c3 100644 --- a/web/src/pages/public/sign_up.rs +++ b/web/src/pages/public/sign_up.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +use model::{Email, Login, Password, PasswordConfirmation}; use seed::prelude::*; use seed::*; @@ -10,7 +11,9 @@ pub enum Msg { LoginChanged(String), EmailChanged(String), PasswordChanged(String), + PasswordConfirmationChanged(String), Submit, + AccountCreated(crate::api::NetRes), } #[derive(Debug)] @@ -18,6 +21,7 @@ pub struct SignUpPage { pub login: model::Login, pub email: model::Email, pub password: model::Password, + pub password_confirmation: model::PasswordConfirmation, } pub fn init(mut _url: Url, _orders: &mut impl Orders) -> SignUpPage { @@ -25,37 +29,86 @@ pub fn init(mut _url: Url, _orders: &mut impl Orders) -> SignUpPage { login: model::Login::new(""), email: model::Email::invalid_empty(), password: model::Password::new(""), + password_confirmation: model::PasswordConfirmation::new(""), } } pub fn page_changed(_url: Url, _model: &mut SignUpPage) {} -pub fn update(_msg: Msg, _model: &mut SignUpPage, _orders: &mut impl Orders) {} +pub fn update(msg: Msg, model: &mut SignUpPage, orders: &mut impl Orders) { + match msg { + Msg::LoginChanged(value) => { + model.login = Login::new(value); + } + Msg::EmailChanged(value) => { + if let Ok(email) = Email::from_str(&value) { + model.email = email; + } + } + Msg::PasswordChanged(value) => { + model.password = Password::new(value); + } + Msg::PasswordConfirmationChanged(value) => { + model.password_confirmation = PasswordConfirmation::new(value); + } + Msg::Submit => { + let email = model.email.clone(); + let login = model.login.clone(); + let password = model.password.clone(); + let password_confirmation = model.password_confirmation.clone(); + + orders.perform_cmd(async move { + Msg::AccountCreated( + crate::api::public::sign_up(model::api::CreateAccountInput { + email, + login, + password, + password_confirmation, + }) + .await, + ) + }); + } + Msg::AccountCreated(_) => {} + } +} pub fn view(model: &crate::Model, page: &SignUpPage) -> Node { + let home = Urls::new(&model.url).home(); + let logo = model + .logo + .as_deref() + .map(|src| { + a![ + attrs![At::Href => home], + img![attrs![At::Src => src], C!["m-auto"]] + ] + }) + .unwrap_or_else(|| a![attrs![At::Href => home], "Logo"]); + let content = div![ C!["relative flex flex-col justify-center overflow-hidden"], div![ - C!["w-full p-6 m-auto bg-white border-t-4 rounded-md shadow-md border-top lg:max-w-md"], - h1![C!["text-3xl font-semibold text-center text-indigo-700"], "Logo"], + C!["w-full p-6 m-auto bg-white rounded-md lg:max-w-md"], + h1![ + C!["text-3xl font-semibold text-center text-indigo-700"], + logo + ], sign_up_form(model, page), p![ C!["mt-8 text-xs font-light text-center text-indigo-700"], model.i18n.t("Have an account?"), a![ C!["font-medium text-indigo-600 hover:underline"], - attrs![At::Href => Urls::new(model.url.clone().set_path(&[] as &[&str])).sign_in()], + attrs![At::Href => Urls::new(&model.url).sign_in()], " ", model.i18n.t("Sign in") ] ] ] ] - .map_msg(Into::into); - div![ - crate::shared::view::public_navbar(model), - super::layout::view(model, content, None) - ] + .map_msg(Into::into); + div![super::layout::view(model, content, None)] } fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node { @@ -73,7 +126,6 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node { C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"], ev(Ev::Change, |ev| { ev.stop_propagation(); - ev.prevent_default(); ev.target().map(|target| seed::to_input(&target).value()).map(Msg::EmailChanged) }) ] @@ -85,7 +137,6 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node { C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"], ev(Ev::Change, |ev| { ev.stop_propagation(); - ev.prevent_default(); ev.target().map(|target| seed::to_input(&target).value()).map(Msg::LoginChanged) }) ] @@ -97,10 +148,24 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node { input![attrs!["type" => "password", "id" => "password"], C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"]], ev(Ev::Change, |ev| { ev.stop_propagation(); - ev.prevent_default(); ev.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordChanged) }) ], + div![ + label![ + attrs!["for" => "password-confirmation"], + C!["block text-sm text-indigo-800"], + model.i18n.t("Password confirmation") + ], + input![ + attrs!["type" => "password", "id" => "password-confirmation"], + C!["block w-full px-4 py-2 mt-2 text-indigo-700 bg-white border rounded-md focus:border-indigo-400 focus:ring-indigo-300 focus:outline-none focus:ring focus:ring-opacity-40"] + ], + ev(Ev::Change, |ev| { + ev.stop_propagation(); + ev.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordConfirmationChanged) + }) + ], div![ C!["mt-6"], button![ diff --git a/web/src/session.rs b/web/src/session.rs new file mode 100644 index 0000000..2e6294f --- /dev/null +++ b/web/src/session.rs @@ -0,0 +1,143 @@ +use chrono::{NaiveDateTime, TimeZone}; +use model::{AccessTokenString, RefreshTokenString}; +use seed::prelude::*; + +use crate::shared::notification::NotificationMsg; +use crate::{Model, Msg}; + +#[derive(Debug)] +pub enum SessionMsg { + SessionReceived { + access_token: AccessTokenString, + refresh_token: RefreshTokenString, + exp: NaiveDateTime, + }, + SessionExpired, + RefreshToken(RefreshTokenString), + TokenRefreshed(crate::api::NetRes), + CheckSession, +} + +pub fn init(model: &mut Model, orders: &mut impl Orders) { + orders.stream(streams::interval(500, || { + Msg::Session(SessionMsg::CheckSession) + })); + model.shared.access_token = LocalStorage::get::<_, String>("at") + .ok() + .map(model::AccessTokenString::new); + model.shared.refresh_token = LocalStorage::get::<_, String>("rt") + .ok() + .map(model::RefreshTokenString::new); + model.shared.exp = LocalStorage::get::<_, String>("exp").ok().and_then(|s| { + seed::log!("Parsing ", s); + chrono::DateTime::parse_from_rfc3339(&s) + .ok() + .map(|t| t.naive_utc()) + }) +} + +pub fn update(msg: SessionMsg, model: &mut Model, orders: &mut impl Orders) { + match msg { + SessionMsg::SessionReceived { + access_token, + refresh_token, + exp, + } => { + LocalStorage::insert("at", access_token.as_str()).ok(); + LocalStorage::insert("rt", refresh_token.as_str()).ok(); + let encoded = { + let l: chrono::DateTime = + chrono::Local.from_local_datetime(&exp).unwrap(); + l.to_rfc3339() + }; + LocalStorage::insert("exp", &encoded).ok(); + + model.shared.access_token = Some(access_token); + model.shared.refresh_token = Some(refresh_token); + model.shared.exp = Some(exp); + } + SessionMsg::SessionExpired => { + LocalStorage::remove("at").ok(); + LocalStorage::remove("rt").ok(); + LocalStorage::remove("exp").ok(); + + orders.force_render_now(); + } + SessionMsg::CheckSession => { + orders.skip(); + if model.shared.refresh_token.is_none() { + return; + } + if let Some(exp) = model.shared.exp { + if exp > chrono::Utc::now().naive_utc() - chrono::Duration::seconds(1) { + return; + } + if let Some(token) = model.shared.refresh_token.take() { + orders + .skip() + .send_msg(Msg::Session(SessionMsg::RefreshToken(token))); + } + } + } + SessionMsg::RefreshToken(token) => { + orders.skip().perform_cmd(async { + Msg::Session(SessionMsg::TokenRefreshed( + crate::api::public::refresh_token(token).await, + )) + }); + } + SessionMsg::TokenRefreshed(crate::api::NetRes::Success(model::api::SessionOutput { + access_token, + refresh_token, + exp, + })) => { + orders + .skip() + .send_msg(Msg::Session(SessionMsg::SessionReceived { + access_token, + refresh_token, + exp, + })); + } + SessionMsg::TokenRefreshed(crate::api::NetRes::Error(model::api::Failure { errors })) => { + errors.into_iter().for_each(|msg| { + orders + .skip() + .send_msg(Msg::Shared(crate::shared::Msg::Notification( + NotificationMsg::Error(msg), + ))); + }); + } + SessionMsg::TokenRefreshed(crate::api::NetRes::Http(e)) => match e { + FetchError::JsonError(json) => { + seed::error!("invalid data in response", json); + } + FetchError::DomException(dom) => { + seed::error!("dom", dom); + } + FetchError::PromiseError(res) => { + seed::error!("promise", res); + } + FetchError::NetworkError(net) => { + seed::error!("net", net); + } + FetchError::RequestError(e) => { + seed::log!(e); + } + FetchError::StatusError(status) => match status.code { + 401 | 403 => { + orders + .skip() + .send_msg(Msg::Session(SessionMsg::SessionExpired)); + } + _ => { + orders + .skip() + .send_msg(Msg::Shared(crate::shared::Msg::Notification( + NotificationMsg::Error("Request failed".into()), + ))); + } + }, + }, + } +} diff --git a/web/src/shared.rs b/web/src/shared.rs index 76d3f52..74ce7f3 100644 --- a/web/src/shared.rs +++ b/web/src/shared.rs @@ -3,6 +3,7 @@ use seed::app::Orders; pub use crate::shared::msg::Msg; pub mod msg; +pub mod notification; pub mod view; #[derive(Debug, Default)] @@ -11,6 +12,7 @@ pub struct Model { pub refresh_token: Option, pub exp: Option, pub me: Option, + pub notifications: Vec, } pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { @@ -22,37 +24,33 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) }); } } - Msg::MeLoaded(Ok(account)) => { + Msg::MeLoaded(crate::api::NetRes::Success(account)) => { model.me = Some(account); } - Msg::MeLoaded(Err(_err)) => {} + Msg::MeLoaded(crate::api::NetRes::Error(_error)) => {} + Msg::MeLoaded(crate::api::NetRes::Http(_error)) => {} Msg::SignIn(input) => { orders .skip() .perform_cmd(async { Msg::SignedIn(crate::api::public::sign_in(input).await) }); } - Msg::SignedIn(Ok(pair)) => { + Msg::SignedIn(crate::api::NetRes::Success(pair)) => { handle_auth_pair(pair, model, orders); } - Msg::SignedIn(Err(_err)) => {} - Msg::RefreshToken(token) => { - orders.skip().perform_cmd(async { - Msg::TokenRefreshed(crate::api::public::refresh_token(token).await) - }); + Msg::SignedIn(crate::api::NetRes::Error(_err)) => {} + Msg::SignedIn(crate::api::NetRes::Http(_err)) => {} + Msg::Notification(msg) => { + notification::update(msg, model, orders); } - Msg::TokenRefreshed(Ok(pair)) => { - handle_auth_pair(pair, model, orders); - } - Msg::TokenRefreshed(Err(_err)) => {} } } fn handle_auth_pair( - pair: model::api::SignInOutput, + pair: model::api::SessionOutput, model: &mut Model, _orders: &mut impl Orders, ) { - let model::api::SignInOutput { + let model::api::SessionOutput { access_token, refresh_token, exp, diff --git a/web/src/shared/msg.rs b/web/src/shared/msg.rs index 11cead6..7dc5d9e 100644 --- a/web/src/shared/msg.rs +++ b/web/src/shared/msg.rs @@ -3,9 +3,8 @@ use seed::fetch::Result; #[derive(Debug)] pub enum Msg { LoadMe, - MeLoaded(Result), + MeLoaded(crate::api::NetRes), SignIn(model::api::SignInInput), - SignedIn(Result), - RefreshToken(model::RefreshTokenString), - TokenRefreshed(Result), + SignedIn(crate::api::NetRes), + Notification(crate::shared::notification::NotificationMsg), } diff --git a/web/src/shared/notification.rs b/web/src/shared/notification.rs new file mode 100644 index 0000000..6b7f0b5 --- /dev/null +++ b/web/src/shared/notification.rs @@ -0,0 +1,184 @@ +use seed::prelude::*; +use seed::*; + +use crate::{shared, Msg}; + +#[derive(Debug)] +pub enum NotificationMsg { + Success(String), + Info(String), + Warning(String), + Error(String), + Close(uuid::Uuid), + Clear, +} + +#[derive(Debug)] +pub struct Notification { + id: uuid::Uuid, + message: String, + ty: Type, +} + +#[derive(Debug, Copy, Clone)] +pub enum Type { + Success, + Info, + Warning, + Error, +} + +impl Type { + pub fn into_color(self) -> &'static str { + match self { + Type::Success => "text-green-600", + Type::Info => "text-blue-600", + Type::Warning => "text-yellow-600", + Type::Error => "text-red-600", + } + } +} + +impl IntoNodes for Type { + fn into_nodes(self) -> Vec> { + match self { + Type::Success => vec![success_icon()], + Type::Info => vec![], + Type::Warning => vec![], + Type::Error => vec![error_icon()], + } + } +} + +impl Notification { + pub fn new>(s: S, ty: Type) -> Self { + Self { + message: s.into(), + id: uuid::Uuid::new_v4(), + ty, + } + } +} + +pub fn update(msg: NotificationMsg, model: &mut shared::Model, _orders: &mut impl Orders) { + match msg { + NotificationMsg::Success(message) => { + model + .notifications + .push(Notification::new(message, Type::Success)); + } + NotificationMsg::Info(message) => { + model + .notifications + .push(Notification::new(message, Type::Info)); + } + NotificationMsg::Warning(message) => { + model + .notifications + .push(Notification::new(message, Type::Warning)); + } + NotificationMsg::Error(message) => { + model + .notifications + .push(Notification::new(message, Type::Error)); + } + NotificationMsg::Close(id) => { + if let Some(pos) = model.notifications.iter().position(|n| n.id == id) { + model.notifications.remove(pos); + } + } + NotificationMsg::Clear => { + model.notifications.clear(); + } + } +} + +pub fn view(model: &crate::Model) -> Node { + if model.shared.notifications.is_empty() { + return empty![]; + } + let notifications = model.shared.notifications.iter().map(|notification| { + message( + notification.id, + notification.message.as_str(), + notification.ty, + ) + }); + div![ + C!["absolute inline-block top-0 right-0 bottom-auto left-auto p-2.5 text-xs z-10"], + notifications + ] +} + +pub fn message(id: uuid::Uuid, message: &str, icon: Type) -> Node { + div![ + C!["flex items-center justify-between max-w-xs p-4 bg-white border rounded-md shadow-sm"], + div![ + C!["flex items-center"], + icon.into_nodes(), + p![C!["ml-3 text-sm font-bold"], C![icon.into_color()], message], + a![ + C!["ml-4"], + close_icon(), + ev(Ev::Click, move |ev| { + ev.prevent_default(); + ev.stop_propagation(); + Msg::Shared(shared::Msg::Notification(NotificationMsg::Close(id))) + }) + ] + ] + ] +} + +fn success_icon() -> Node { + svg![ + attrs![ + "xmlns"=>"http://www.w3.org/2000/svg", + "class" => "w-8 h-8 text-green-500", + "viewBox" => "0 0 20 20", + "fill" => "currentColor" + ], + path![attrs![ + "fill-rule" => "evenodd", + "d" => "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + "clip-rule" => "evenodd" + ]] + ] +} + +fn error_icon() -> Node { + svg![ + attrs![ + "xmlns" => "http://www.w3.org/2000/svg", + "class" => "w-8 h-8 text-red-600", + "viewBox" => "0 0 20 20", + "fill" => "currentColor" + ], + path![attrs![ + "fill-rule" => "evenodd", + "d" => "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z", + "clip-rule" => "evenodd" + ]] + ] +} + +fn close_icon() -> Node { + span![ + C!["inline-flex items-center cursor-pointer"], + svg![ + attrs![ + "xmlns" => "http://www.w3.org/2000/svg", + "class" => "w-4 h-4 text-gray-600", + "fill" => "none", + "viewBox" => "0 0 24 24", + "stroke" => "currentColor" + ], + path![attrs![ + "stroke-linecap" => "round", + "stroke-linejoin" => "round", + "stroke-width" => "2", + "d" => "M6 18L18 6M6 6l12 12" + ]] + ] + ] +}