Display request errors

This commit is contained in:
eraden 2022-05-15 10:30:15 +02:00
parent 353cdd602a
commit 0f88287929
21 changed files with 703 additions and 188 deletions

41
Cargo.lock generated
View File

@ -663,7 +663,7 @@ dependencies = [
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
"uuid", "uuid 0.8.2",
"validator 0.14.0", "validator 0.14.0",
] ]
@ -805,7 +805,7 @@ dependencies = [
"model", "model",
"pretty_env_logger", "pretty_env_logger",
"thiserror", "thiserror",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -834,6 +834,7 @@ dependencies = [
"num-traits", "num-traits",
"serde", "serde",
"time 0.1.43", "time 0.1.43",
"wasm-bindgen",
"winapi", "winapi",
] ]
@ -1124,7 +1125,7 @@ dependencies = [
"sqlx", "sqlx",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -1263,7 +1264,7 @@ dependencies = [
"serde_json", "serde_json",
"thiserror", "thiserror",
"tinytemplate", "tinytemplate",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -1319,7 +1320,7 @@ dependencies = [
"dummy", "dummy",
"http", "http",
"rand", "rand",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -1407,7 +1408,7 @@ dependencies = [
"pretty_env_logger", "pretty_env_logger",
"thiserror", "thiserror",
"tokio", "tokio",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -1804,7 +1805,7 @@ dependencies = [
"serde_derive", "serde_derive",
"termcolor", "termcolor",
"toml", "toml",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -2275,7 +2276,7 @@ dependencies = [
"sqlx", "sqlx",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror",
"uuid", "uuid 0.8.2",
"validator 0.15.0", "validator 0.15.0",
] ]
@ -2504,7 +2505,7 @@ dependencies = [
"model", "model",
"pretty_env_logger", "pretty_env_logger",
"thiserror", "thiserror",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -2622,7 +2623,7 @@ dependencies = [
"pretty_env_logger", "pretty_env_logger",
"serde", "serde",
"thiserror", "thiserror",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -3151,7 +3152,7 @@ dependencies = [
"serde", "serde",
"sonic-channel", "sonic-channel",
"thiserror", "thiserror",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -3197,7 +3198,7 @@ dependencies = [
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"uuid", "uuid 0.8.2",
"version_check 0.9.4", "version_check 0.9.4",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@ -3223,7 +3224,7 @@ dependencies = [
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"uuid", "uuid 0.8.2",
"version_check 0.9.4", "version_check 0.9.4",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@ -3538,7 +3539,7 @@ dependencies = [
"time 0.2.27", "time 0.2.27",
"tokio-stream", "tokio-stream",
"url", "url",
"uuid", "uuid 0.8.2",
"webpki 0.21.4", "webpki 0.21.4",
"webpki-roots 0.21.1", "webpki-roots 0.21.1",
"whoami", "whoami",
@ -3854,7 +3855,7 @@ dependencies = [
"serde", "serde",
"sha2", "sha2",
"thiserror", "thiserror",
"uuid", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -4183,6 +4184,15 @@ dependencies = [
"sha1", "sha1",
] ]
[[package]]
name = "uuid"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfcd319456c4d6ea10087ed423473267e1a071f3bc0aa89f80d60997843c6f0"
dependencies = [
"getrandom",
]
[[package]] [[package]]
name = "validator" name = "validator"
version = "0.14.0" version = "0.14.0"
@ -4360,6 +4370,7 @@ dependencies = [
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
"thiserror", "thiserror",
"uuid 1.0.0",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
] ]

View File

@ -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)] #[derive(Debug, thiserror::Error)]

View File

@ -7,10 +7,11 @@ use std::sync::Arc;
use actix::Addr; use actix::Addr;
use actix_session::Session; use actix_session::Session;
use actix_web::body::BoxBody; use actix_web::body::BoxBody;
use actix_web::http::StatusCode;
use actix_web::web::ServiceConfig; use actix_web::web::ServiceConfig;
use actix_web::{HttpRequest, HttpResponse, Responder, ResponseError}; use actix_web::{HttpRequest, HttpResponse, Responder, ResponseError};
use model::api::Failure;
use model::{AccessTokenString, RecordId, Token}; use model::{AccessTokenString, RecordId, Token};
use serde::Serialize;
use token_manager::{query_tm, TokenManager}; use token_manager::{query_tm, TokenManager};
pub use self::admin::Error as AdminError; pub use self::admin::Error as AdminError;
@ -37,6 +38,7 @@ impl RequireLogin for Session {
pub enum Error { pub enum Error {
#[from(ignore)] #[from(ignore)]
Unauthorized, Unauthorized,
CriticalFailure,
Admin(routes::admin::Error), Admin(routes::admin::Error),
Public(routes::public::Error), Public(routes::public::Error),
} }
@ -53,64 +55,68 @@ impl From<V1ShoppingCartError> for Error {
} }
} }
#[derive(serde::Serialize)]
pub struct Failure {
pub errors: Vec<String>,
}
impl Display for Error { impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let msg = match self { let msg = match self {
Error::Unauthorized => String::from("Unauthorized"), Error::Unauthorized => String::from("Unauthorized"),
Error::Admin(e) => format!("{e}"), Error::Admin(e) => format!("{e}"),
Error::Public(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()) f.write_str(&serde_json::to_string(&Failure { errors: vec![msg] }).unwrap())
} }
} }
#[derive(Serialize)] impl ResponseError for Error {
struct ReqFailure { fn status_code(&self) -> StatusCode {
success: bool, match self {
msg: String, 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 { impl Responder for Error {
type Body = BoxBody; type Body = BoxBody;
fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> { fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {
match self { match self {
Error::Public(PublicError::DatabaseConnection) | Error::CriticalFailure => {
HttpResponse::InternalServerError()
.content_type("application/json")
.json(Failure {
errors: vec![format!("{}", self)],
})
}
Error::Unauthorized => HttpResponse::Unauthorized() Error::Unauthorized => HttpResponse::Unauthorized()
.content_type("application/json") .content_type("application/json")
.json(ReqFailure { .json(Failure {
success: false, errors: vec![format!("{}", self)],
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),
}), }),
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 { Error::Public(PublicError::ApiV1(V1Error::ShoppingCart(ref e))) => match e {
V1ShoppingCartError::Ensure => HttpResponse::InternalServerError() V1ShoppingCartError::Ensure => HttpResponse::InternalServerError()
.content_type("application/json") .content_type("application/json")
.json(ReqFailure { .json(Failure {
success: false, errors: vec![format!("{}", self)],
msg: format!("{}", self),
}), }),
}, },
Error::Public(PublicError::ApiV1( Error::Public(PublicError::ApiV1(
V1Error::AddItem | V1Error::RemoveItem | V1Error::AddOrder, V1Error::AddItem | V1Error::RemoveItem | V1Error::AddOrder,
)) => HttpResponse::BadRequest() )) => HttpResponse::BadRequest()
.content_type("application/json") .content_type("application/json")
.json(ReqFailure { .json(Failure {
success: false, errors: vec![format!("{}", self)],
msg: format!("{}", self),
}), }),
} }
} }

View File

@ -30,7 +30,7 @@ async fn refresh_token(
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
db: Data<Addr<Database>>, db: Data<Addr<Database>>,
credentials: BearerAuth, credentials: BearerAuth,
) -> routes::Result<Json<api::SignInOutput>> { ) -> routes::Result<Json<api::SessionOutput>> {
let account_id: model::AccountId = credentials let account_id: model::AccountId = credentials
.require_user(tm.clone().into_inner()) .require_user(tm.clone().into_inner())
.await? .await?
@ -50,7 +50,7 @@ async fn refresh_token(
refresh_token_string, refresh_token_string,
} = create_auth_pair(tm, account).await?; } = create_auth_pair(tm, account).await?;
Ok(Json(api::SignInOutput { Ok(Json(api::SessionOutput {
access_token: access_token_string, access_token: access_token_string,
refresh_token: refresh_token_string, refresh_token: refresh_token_string,
exp: access_token.expiration_time, exp: access_token.expiration_time,

View File

@ -131,7 +131,8 @@ pub async fn create_account(
db: Data<Addr<Database>>, db: Data<Addr<Database>>,
Json(payload): Json<api::CreateAccountInput>, Json(payload): Json<api::CreateAccountInput>,
config: Data<SharedAppConfig>, config: Data<SharedAppConfig>,
) -> routes::Result<HttpResponse> { tm: Data<Addr<TokenManager>>,
) -> routes::Result<Json<model::api::SessionOutput>> {
if payload.password != payload.password_confirmation { if payload.password != payload.password_confirmation {
return Err(routes::Error::Admin( return Err(routes::Error::Admin(
routes::admin::Error::DifferentPasswords, routes::admin::Error::DifferentPasswords,
@ -147,15 +148,30 @@ pub async fn create_account(
} }
}; };
public_send_db!( let account: model::FullAccount = query_db!(
db, db,
database_manager::CreateAccount { database_manager::CreateAccount {
email: payload.email, email: payload.email,
login: payload.login, login: payload.login,
pass_hash: model::PassHash::from(hash), pass_hash: model::PassHash::from(hash),
role: model::Role::User, 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 { pub(crate) struct AuthPair {
@ -205,7 +221,7 @@ async fn sign_in(
Json(payload): Json<api::SignInInput>, Json(payload): Json<api::SignInInput>,
db: Data<Addr<Database>>, db: Data<Addr<Database>>,
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
) -> Result<Json<api::SignInOutput>> { ) -> Result<Json<api::SessionOutput>> {
let db = db.into_inner(); let db = db.into_inner();
let account: model::FullAccount = query_db!( let account: model::FullAccount = query_db!(
@ -227,7 +243,7 @@ async fn sign_in(
refresh_token_string, refresh_token_string,
} = create_auth_pair(tm, account).await?; } = create_auth_pair(tm, account).await?;
Ok(Json(api::SignInOutput { Ok(Json(api::SessionOutput {
access_token: access_token_string, access_token: access_token_string,
refresh_token: refresh_token_string, refresh_token: refresh_token_string,
exp: access_token.expiration_time, exp: access_token.expiration_time,
@ -253,5 +269,6 @@ pub(crate) fn configure(config: &mut ServiceConfig) {
.service(product) .service(product)
.service(products) .service(products)
.service(stocks) .service(stocks)
.service(sign_in); .service(sign_in)
.service(create_account);
} }

View File

@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize};
use crate::*; use crate::*;
#[derive(Serialize, Deserialize, Debug)]
pub struct Failure {
pub errors: Vec<String>,
}
#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[serde(transparent)] #[serde(transparent)]
@ -307,7 +312,7 @@ pub struct SignInInput {
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct SignInOutput { pub struct SessionOutput {
pub access_token: AccessTokenString, pub access_token: AccessTokenString,
pub refresh_token: RefreshTokenString, pub refresh_token: RefreshTokenString,
pub exp: NaiveDateTime, pub exp: NaiveDateTime,

View File

@ -9,7 +9,7 @@ pub mod encrypt;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::str::FromStr; use std::str::FromStr;
use derive_more::{Deref, Display, From}; use derive_more::{Deref, DerefMut, Display, From};
#[cfg(feature = "dummy")] #[cfg(feature = "dummy")]
use fake::Fake; use fake::Fake;
#[cfg(feature = "dummy")] #[cfg(feature = "dummy")]
@ -305,10 +305,20 @@ impl Login {
#[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))] #[cfg_attr(feature = "db", sqlx(transparent))]
#[derive(Serialize, Debug, Deref, From, Display)] #[derive(Serialize, Debug, Clone, Deref, DerefMut, From, Display)]
#[serde(transparent)] #[serde(transparent)]
pub struct Email(String); pub struct Email(String);
impl Email {
pub fn invalid_empty() -> Self {
Self("".into())
}
pub fn new<S: Into<String>>(s: S) -> Self {
Self(s.into())
}
}
impl FromStr for Email { impl FromStr for Email {
type Err = TransformError; type Err = TransformError;
@ -319,10 +329,6 @@ impl FromStr for Email {
Err(TransformError::NotEmail) Err(TransformError::NotEmail)
} }
} }
fn invalid_empty() -> Self {
Self("".into())
}
} }
impl<'de> serde::Deserialize<'de> for Email { impl<'de> serde::Deserialize<'de> for Email {
@ -582,7 +588,7 @@ impl ResetToken {
#[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))] #[cfg_attr(feature = "db", sqlx(transparent))]
#[derive(Serialize, Deserialize, Debug, Deref, From, Display)] #[derive(Serialize, Deserialize, Debug, Clone, Deref, From, Display)]
#[serde(transparent)] #[serde(transparent)]
pub struct Password(String); pub struct Password(String);
@ -595,10 +601,16 @@ impl Password {
#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))] #[cfg_attr(feature = "db", sqlx(transparent))]
#[derive(Serialize, Deserialize, Debug, Deref, From, Display)] #[derive(Serialize, Deserialize, Debug, Clone, Deref, From, Display)]
#[serde(transparent)] #[serde(transparent)]
pub struct PasswordConfirmation(String); pub struct PasswordConfirmation(String);
impl PasswordConfirmation {
pub fn new<S: Into<String>>(pass: S) -> Self {
Self(pass.into())
}
}
#[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))] #[cfg_attr(feature = "db", sqlx(transparent))]
#[derive(Serialize, Deserialize, Debug, Deref, From, Display)] #[derive(Serialize, Deserialize, Debug, Deref, From, Display)]

View File

@ -12,9 +12,11 @@ model = { path = "../shared/model", features = ["dummy"] }
seed = { version = "0.9.1", features = [] } seed = { version = "0.9.1", features = [] }
seed_heroicons = { git = "https://github.com/mh84/seed_heroicons.git" } seed_heroicons = { git = "https://github.com/mh84/seed_heroicons.git" }
chrono = { version = "*" } chrono = { version = "*", features = ["wasm-bindgen"] }
gloo-timers = { version = "*", features = ["futures"] } gloo-timers = { version = "*", features = ["futures"] }
uuid = { version = "1.0.0", features = ["v4"] }
serde = { version = "1.0.137", features = ["derive"] } serde = { version = "1.0.137", features = ["derive"] }
serde_json = { version = "1.0.81" } serde_json = { version = "1.0.81" }
serde-wasm-bindgen = { version = "0.4.2" } serde-wasm-bindgen = { version = "0.4.2" }

View File

@ -1 +1,45 @@
use std::convert::Infallible;
use std::ops::FromResidual;
use seed::fetch::{FetchError, Request};
pub mod public; pub mod public;
#[derive(Debug)]
pub enum NetRes<S> {
Success(S),
Error(model::api::Failure),
Http(FetchError),
}
impl<S> FromResidual<Result<Infallible, NetRes<S>>> for NetRes<S> {
fn from_residual(residual: Result<Infallible, NetRes<S>>) -> Self {
match residual {
Ok(_s) => unreachable!(),
Err(s) => s,
}
}
}
async fn perform<S: serde::de::DeserializeOwned + 'static>(req: Request<'_>) -> NetRes<S> {
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),
}
}
}

View File

@ -1,68 +1,63 @@
use model::{AccessTokenString, RefreshTokenString}; use model::{AccessTokenString, RefreshTokenString};
use seed::prelude::*; use seed::fetch::{Header, Method, Request};
pub async fn fetch_products() -> fetch::Result<model::api::Products> { use crate::api::perform;
Request::new("/api/v1/products")
.method(Method::Get) pub async fn fetch_products() -> super::NetRes<model::api::Products> {
.fetch() perform(Request::new("/api/v1/products").method(Method::Get)).await
.await?
.check_status()?
.json()
.await
} }
pub async fn fetch_product(product_id: model::ProductId) -> fetch::Result<model::api::Product> { pub async fn fetch_product(product_id: model::ProductId) -> super::NetRes<model::api::Product> {
Request::new(format!("/api/v1/product/{}", product_id)) perform(Request::new(format!("/api/v1/product/{}", product_id)).method(Method::Get)).await
.method(Method::Get)
.fetch()
.await?
.check_status()?
.json()
.await
} }
pub async fn fetch_me(access_token: AccessTokenString) -> fetch::Result<model::Account> { pub async fn fetch_me(access_token: AccessTokenString) -> super::NetRes<model::Account> {
Request::new("/api/v1/me") perform(
.header(fetch::Header::bearer(access_token.as_str())) Request::new("/api/v1/me")
.method(Method::Get) .header(Header::bearer(access_token.as_str()))
.fetch() .method(Method::Get),
.await? )
.check_status()? .await
.json()
.await
} }
pub async fn sign_in(input: model::api::SignInInput) -> fetch::Result<model::api::SignInOutput> { pub async fn sign_in(input: model::api::SignInInput) -> super::NetRes<model::api::SessionOutput> {
Request::new("/api/v1/sign-in") perform(
.method(Method::Post) Request::new("/api/v1/sign-in")
.json(&input)? .method(Method::Post)
.fetch() .json(&input)
.await? .map_err(crate::api::NetRes::Http)?,
.check_status()? )
.json() .await
.await
} }
pub async fn verify_token(access_token: AccessTokenString) -> fetch::Result<String> { pub async fn verify_token(access_token: AccessTokenString) -> super::NetRes<String> {
Request::new("/api/v1/token/verify") perform(
.method(Method::Post) Request::new("/api/v1/token/verify")
.header(fetch::Header::bearer(access_token.as_str())) .method(Method::Post)
.fetch() .header(Header::bearer(access_token.as_str())),
.await? )
.check_status()? .await
.json()
.await
} }
pub async fn refresh_token( pub async fn refresh_token(
access_token: RefreshTokenString, access_token: RefreshTokenString,
) -> fetch::Result<model::api::SignInOutput> { ) -> super::NetRes<model::api::SessionOutput> {
Request::new("/api/v1/token/refresh") perform(
.method(Method::Post) Request::new("/api/v1/token/refresh")
.header(fetch::Header::bearer(access_token.as_str())) .method(Method::Post)
.fetch() .header(Header::bearer(access_token.as_str())),
.await? )
.check_status()? .await
.json() }
.await
pub async fn sign_up(
input: model::api::CreateAccountInput,
) -> super::NetRes<model::api::SessionOutput> {
perform(
Request::new("/api/v1/register")
.method(Method::Post)
.json(&input)
.map_err(crate::api::NetRes::Http)?,
)
.await
} }

View File

@ -1,7 +1,10 @@
#![feature(try_trait_v2)]
pub mod api; pub mod api;
mod i18n; mod i18n;
mod model; mod model;
mod pages; mod pages;
pub mod session;
pub mod shared; pub mod shared;
use seed::empty; use seed::empty;
@ -10,6 +13,7 @@ use seed::prelude::*;
use crate::i18n::I18n; use crate::i18n::I18n;
use crate::model::Model; use crate::model::Model;
use crate::pages::{Msg, Page, PublicPage}; use crate::pages::{Msg, Page, PublicPage};
use crate::session::SessionMsg;
#[macro_export] #[macro_export]
macro_rules! fetch_page { macro_rules! fetch_page {
@ -62,11 +66,9 @@ macro_rules! fetch_page {
} }
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model { fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
orders orders.subscribe(Msg::UrlChanged);
.stream(streams::interval(500, || Msg::CheckAccessToken))
.subscribe(Msg::UrlChanged);
Model { let mut model = Model {
url: url.clone().set_path(&[] as &[&str]), url: url.clone().set_path(&[] as &[&str]),
token: LocalStorage::get("auth-token").ok(), token: LocalStorage::get("auth-token").ok(),
page: Page::init(url, orders), page: Page::init(url, orders),
@ -77,7 +79,11 @@ fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
.and_then(|el: web_sys::Element| el.get_attribute("href")), .and_then(|el: web_sys::Element| el.get_attribute("href")),
shared: shared::Model::default(), shared: shared::Model::default(),
i18n: I18n::load(), i18n: I18n::load(),
} };
session::init(&mut model, orders);
model
} }
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) { fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
@ -85,20 +91,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::Shared(msg) => { Msg::Shared(msg) => {
shared::update(msg, &mut model.shared, orders); 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::UrlChanged(subs::UrlChanged(url)) => model.page.page_changed(url, orders),
Msg::Public(pages::public::Msg::Listing(msg)) => { Msg::Public(pages::public::Msg::Listing(msg)) => {
let page = fetch_page!(public model, Listing); let page = fetch_page!(public model, Listing);
@ -112,11 +104,21 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
let page = fetch_page!(public model, SignIn); let page = fetch_page!(public model, SignIn);
pages::public::sign_in::update(msg, page, &mut orders.proxy(Into::into)) 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)) => { Msg::Public(pages::public::Msg::SignUp(msg)) => {
let page = fetch_page!(public model, SignUp); let page = fetch_page!(public model, SignUp);
pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into)) pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into))
} }
Msg::Admin(_) => {} Msg::Admin(_) => {}
Msg::Session(msg) => {
session::update(msg, model, orders);
}
} }
} }

View File

@ -11,8 +11,8 @@ pub enum Msg {
Public(public::Msg), Public(public::Msg),
Admin(admin::Msg), Admin(admin::Msg),
UrlChanged(subs::UrlChanged), UrlChanged(subs::UrlChanged),
CheckAccessToken,
Shared(shared::Msg), Shared(shared::Msg),
Session(crate::session::SessionMsg),
} }
pub enum AdminPage { pub enum AdminPage {

View File

@ -69,11 +69,11 @@ pub mod layout {
use seed::prelude::*; use seed::prelude::*;
use seed::*; use seed::*;
pub fn view<Msg>( pub fn view(
model: &crate::Model, model: &crate::Model,
content: Node<Msg>, content: Node<crate::Msg>,
categories: Option<&[model::api::Category]>, categories: Option<&[model::api::Category]>,
) -> Node<Msg> { ) -> Node<crate::Msg> {
let sidebar = match categories { let sidebar = match categories {
Some(categories) => { Some(categories) => {
let sidebar = super::sidebar::view(model, categories); let sidebar = super::sidebar::view(model, categories);
@ -84,9 +84,11 @@ pub mod layout {
} }
_ => empty![], _ => empty![],
}; };
let notifications = crate::shared::notification::view(model);
div![ div![
C!["flex"], C!["flex"],
sidebar, sidebar,
notifications,
div![C!["w-full h-full p-4 m-8 overflow-y-auto"], content] div![C!["w-full h-full p-4 m-8 overflow-y-auto"], content]
] ]
} }
@ -108,7 +110,7 @@ pub mod sidebar {
} }
fn item<Msg>(model: &crate::Model, category: &model::api::Category) -> Node<Msg> { fn item<Msg>(model: &crate::Model, category: &model::api::Category) -> Node<Msg> {
let url = Urls::new(model.url.clone()) let url = Urls::new(&model.url)
.listing() .listing()
.add_path_part(category.key.as_str()); .add_path_part(category.key.as_str());
li![ li![

View File

@ -4,6 +4,7 @@ use seed::app::Orders;
use seed::prelude::*; use seed::prelude::*;
use seed::*; use seed::*;
use crate::api::NetRes;
use crate::pages::Urls; use crate::pages::Urls;
#[derive(Debug)] #[derive(Debug)]
@ -19,21 +20,19 @@ pub struct ListingPage {
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
FetchProducts, FetchProducts,
ProductFetched(fetch::Result<model::api::Products>), ProductFetched(crate::api::NetRes<model::api::Products>),
} }
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> ListingPage { pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> ListingPage {
orders.send_msg(Msg::FetchProducts); orders.send_msg(Msg::FetchProducts);
let model = ListingPage { ListingPage {
product_ids: vec![], product_ids: vec![],
filters: url_to_filters(url), filters: url_to_filters(url),
products: Default::default(), products: Default::default(),
errors: vec![], errors: vec![],
categories: vec![], categories: vec![],
visible_products: vec![], visible_products: vec![],
}; }
seed::log!(&model);
model
} }
fn url_to_filters(mut url: Url) -> HashSet<String> { fn url_to_filters(mut url: Url) -> HashSet<String> {
@ -77,7 +76,7 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders<Msg>)
async { Msg::ProductFetched(crate::api::public::fetch_products().await) } async { Msg::ProductFetched(crate::api::public::fetch_products().await) }
}); });
} }
Msg::ProductFetched(Ok(products)) => { Msg::ProductFetched(NetRes::Success(products)) => {
model.categories = products model.categories = products
.0 .0
.iter() .iter()
@ -103,7 +102,7 @@ pub fn update(msg: Msg, model: &mut ListingPage, orders: &mut impl Orders<Msg>)
}; };
filter_products(model); filter_products(model);
} }
Msg::ProductFetched(Err(_e)) => { Msg::ProductFetched(NetRes::Error(_)) | Msg::ProductFetched(NetRes::Http(_)) => {
model.errors.push("Failed to load products".into()); model.errors.push("Failed to load products".into());
} }
} }
@ -148,7 +147,7 @@ fn product(model: &crate::Model, product: &model::api::Product) -> Node<Msg> {
.map(|photo| photo.url.as_str()) .map(|photo| photo.url.as_str())
.unwrap_or_default(); .unwrap_or_default();
let url = Urls::new(model.url.clone()) let url = Urls::new(&model.url)
.product() .product()
.add_path_part((*product.id as i32).to_string()); .add_path_part((*product.id as i32).to_string());

View File

@ -1,9 +1,11 @@
use seed::prelude::*; use seed::prelude::*;
use seed::*; use seed::*;
use crate::api::NetRes;
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
ProductFetched(fetch::Result<model::api::Product>), ProductFetched(crate::api::NetRes<model::api::Product>),
SelectImage(usize), 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<Msg>) { pub fn update(msg: Msg, model: &mut ProductPage, _orders: &mut impl Orders<Msg>) {
match msg { match msg {
Msg::ProductFetched(Ok(product)) => { Msg::ProductFetched(NetRes::Success(product)) => {
model.product = Some(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); seed::error!(e);
} }
Msg::SelectImage(selected) => { Msg::SelectImage(selected) => {

View File

@ -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<Msg>) {} pub fn update(_msg: Msg, _model: &mut SignInPage, _orders: &mut impl Orders<Msg>) {}
pub fn view(model: &crate::Model, page: &SignInPage) -> Node<crate::Msg> { pub fn view(model: &crate::Model, page: &SignInPage) -> Node<crate::Msg> {
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![ let content = div![
C!["relative flex flex-col justify-center overflow-hidden"], C!["relative flex flex-col justify-center overflow-hidden"],
div![ div![
C!["w-full p-6 m-auto bg-white border-t-4 rounded-md shadow-md border-top lg:max-w-md"], 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"], h1![
C!["text-3xl font-semibold text-center text-indigo-700"],
logo
],
sign_in_form(model, page), sign_in_form(model, page),
p![ p![
C!["mt-8 text-xs font-light text-center text-indigo-700"], C!["mt-8 text-xs font-light text-center text-indigo-700"],
model.i18n.t("Don't have an account?"), model.i18n.t("Don't have an account?"),
a![ a![
C!["font-medium text-indigo-600 hover:underline"], 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") model.i18n.t("Sign up")
] ]
@ -48,10 +62,8 @@ pub fn view(model: &crate::Model, page: &SignInPage) -> Node<crate::Msg> {
] ]
.map_msg(Into::into); .map_msg(Into::into);
div![ // crate::shared::view::public_navbar(model),
crate::shared::view::public_navbar(model), div![super::layout::view(model, content, None)]
super::layout::view(model, content, None)
]
} }
fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<Msg> { fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<Msg> {
@ -87,7 +99,7 @@ fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<Msg> {
], ],
a![ a![
C!["text-xs text-indigo-600 hover:underline"], 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?"), model.i18n.t("Forget Password?"),
], ],
div![ div![

View File

@ -1,5 +1,6 @@
use std::str::FromStr; use std::str::FromStr;
use model::{Email, Login, Password, PasswordConfirmation};
use seed::prelude::*; use seed::prelude::*;
use seed::*; use seed::*;
@ -10,7 +11,9 @@ pub enum Msg {
LoginChanged(String), LoginChanged(String),
EmailChanged(String), EmailChanged(String),
PasswordChanged(String), PasswordChanged(String),
PasswordConfirmationChanged(String),
Submit, Submit,
AccountCreated(crate::api::NetRes<model::api::SessionOutput>),
} }
#[derive(Debug)] #[derive(Debug)]
@ -18,6 +21,7 @@ pub struct SignUpPage {
pub login: model::Login, pub login: model::Login,
pub email: model::Email, pub email: model::Email,
pub password: model::Password, pub password: model::Password,
pub password_confirmation: model::PasswordConfirmation,
} }
pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> SignUpPage { pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> SignUpPage {
@ -25,37 +29,86 @@ pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> SignUpPage {
login: model::Login::new(""), login: model::Login::new(""),
email: model::Email::invalid_empty(), email: model::Email::invalid_empty(),
password: model::Password::new(""), password: model::Password::new(""),
password_confirmation: model::PasswordConfirmation::new(""),
} }
} }
pub fn page_changed(_url: Url, _model: &mut SignUpPage) {} pub fn page_changed(_url: Url, _model: &mut SignUpPage) {}
pub fn update(_msg: Msg, _model: &mut SignUpPage, _orders: &mut impl Orders<Msg>) {} pub fn update(msg: Msg, model: &mut SignUpPage, orders: &mut impl Orders<Msg>) {
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<crate::Msg> { pub fn view(model: &crate::Model, page: &SignUpPage) -> Node<crate::Msg> {
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![ let content = div![
C!["relative flex flex-col justify-center overflow-hidden"], C!["relative flex flex-col justify-center overflow-hidden"],
div![ div![
C!["w-full p-6 m-auto bg-white border-t-4 rounded-md shadow-md border-top lg:max-w-md"], 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"], h1![
C!["text-3xl font-semibold text-center text-indigo-700"],
logo
],
sign_up_form(model, page), sign_up_form(model, page),
p![ p![
C!["mt-8 text-xs font-light text-center text-indigo-700"], C!["mt-8 text-xs font-light text-center text-indigo-700"],
model.i18n.t("Have an account?"), model.i18n.t("Have an account?"),
a![ a![
C!["font-medium text-indigo-600 hover:underline"], 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") model.i18n.t("Sign in")
] ]
] ]
] ]
] ]
.map_msg(Into::into); .map_msg(Into::into);
div![ div![super::layout::view(model, content, None)]
crate::shared::view::public_navbar(model),
super::layout::view(model, content, None)
]
} }
fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> { fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> {
@ -73,7 +126,6 @@ fn sign_up_form(model: &crate::Model, _page: &SignUpPage) -> Node<Msg> {
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"], 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(Ev::Change, |ev| {
ev.stop_propagation(); ev.stop_propagation();
ev.prevent_default();
ev.target().map(|target| seed::to_input(&target).value()).map(Msg::EmailChanged) 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<Msg> {
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"], 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(Ev::Change, |ev| {
ev.stop_propagation(); ev.stop_propagation();
ev.prevent_default();
ev.target().map(|target| seed::to_input(&target).value()).map(Msg::LoginChanged) 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<Msg> {
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"]], 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(Ev::Change, |ev| {
ev.stop_propagation(); ev.stop_propagation();
ev.prevent_default();
ev.target().map(|target| seed::to_input(&target).value()).map(Msg::PasswordChanged) 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![ div![
C!["mt-6"], C!["mt-6"],
button![ button![

143
web/src/session.rs Normal file
View File

@ -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<model::api::SessionOutput>),
CheckSession,
}
pub fn init(model: &mut Model, orders: &mut impl Orders<Msg>) {
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<Msg>) {
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> =
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()),
)));
}
},
},
}
}

View File

@ -3,6 +3,7 @@ use seed::app::Orders;
pub use crate::shared::msg::Msg; pub use crate::shared::msg::Msg;
pub mod msg; pub mod msg;
pub mod notification;
pub mod view; pub mod view;
#[derive(Debug, Default)] #[derive(Debug, Default)]
@ -11,6 +12,7 @@ pub struct Model {
pub refresh_token: Option<model::RefreshTokenString>, pub refresh_token: Option<model::RefreshTokenString>,
pub exp: Option<chrono::NaiveDateTime>, pub exp: Option<chrono::NaiveDateTime>,
pub me: Option<model::Account>, pub me: Option<model::Account>,
pub notifications: Vec<notification::Notification>,
} }
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<crate::Msg>) { pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<crate::Msg>) {
@ -22,37 +24,33 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<crate::Msg>)
}); });
} }
} }
Msg::MeLoaded(Ok(account)) => { Msg::MeLoaded(crate::api::NetRes::Success(account)) => {
model.me = Some(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) => { Msg::SignIn(input) => {
orders orders
.skip() .skip()
.perform_cmd(async { Msg::SignedIn(crate::api::public::sign_in(input).await) }); .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); handle_auth_pair(pair, model, orders);
} }
Msg::SignedIn(Err(_err)) => {} Msg::SignedIn(crate::api::NetRes::Error(_err)) => {}
Msg::RefreshToken(token) => { Msg::SignedIn(crate::api::NetRes::Http(_err)) => {}
orders.skip().perform_cmd(async { Msg::Notification(msg) => {
Msg::TokenRefreshed(crate::api::public::refresh_token(token).await) notification::update(msg, model, orders);
});
} }
Msg::TokenRefreshed(Ok(pair)) => {
handle_auth_pair(pair, model, orders);
}
Msg::TokenRefreshed(Err(_err)) => {}
} }
} }
fn handle_auth_pair( fn handle_auth_pair(
pair: model::api::SignInOutput, pair: model::api::SessionOutput,
model: &mut Model, model: &mut Model,
_orders: &mut impl Orders<crate::Msg>, _orders: &mut impl Orders<crate::Msg>,
) { ) {
let model::api::SignInOutput { let model::api::SessionOutput {
access_token, access_token,
refresh_token, refresh_token,
exp, exp,

View File

@ -3,9 +3,8 @@ use seed::fetch::Result;
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
LoadMe, LoadMe,
MeLoaded(Result<model::Account>), MeLoaded(crate::api::NetRes<model::Account>),
SignIn(model::api::SignInInput), SignIn(model::api::SignInInput),
SignedIn(Result<model::api::SignInOutput>), SignedIn(crate::api::NetRes<model::api::SessionOutput>),
RefreshToken(model::RefreshTokenString), Notification(crate::shared::notification::NotificationMsg),
TokenRefreshed(Result<model::api::SignInOutput>),
} }

View File

@ -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<Msg> for Type {
fn into_nodes(self) -> Vec<Node<Msg>> {
match self {
Type::Success => vec![success_icon()],
Type::Info => vec![],
Type::Warning => vec![],
Type::Error => vec![error_icon()],
}
}
}
impl Notification {
pub fn new<S: Into<String>>(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<Msg>) {
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<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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"
]]
]
]
}