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",
"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",
]

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)]

View File

@ -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<V1ShoppingCartError> for Error {
}
}
#[derive(serde::Serialize)]
pub struct Failure {
pub errors: Vec<String>,
}
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<Self::Body> {
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),
.json(Failure {
errors: vec![format!("{}", self)],
}),
Error::Public(PublicError::DatabaseConnection)
| Error::Public(PublicError::Database(..))
| Error::Admin(..) => HttpResponse::InternalServerError()
Error::Public(PublicError::Database(..)) | Error::Admin(..) => {
HttpResponse::BadRequest()
.content_type("application/json")
.json(ReqFailure {
success: false,
msg: format!("{}", self),
}),
.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)],
}),
}
}

View File

@ -30,7 +30,7 @@ async fn refresh_token(
tm: Data<Addr<TokenManager>>,
db: Data<Addr<Database>>,
credentials: BearerAuth,
) -> routes::Result<Json<api::SignInOutput>> {
) -> routes::Result<Json<api::SessionOutput>> {
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,

View File

@ -131,7 +131,8 @@ pub async fn create_account(
db: Data<Addr<Database>>,
Json(payload): Json<api::CreateAccountInput>,
config: Data<SharedAppConfig>,
) -> routes::Result<HttpResponse> {
tm: Data<Addr<TokenManager>>,
) -> routes::Result<Json<model::api::SessionOutput>> {
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<api::SignInInput>,
db: Data<Addr<Database>>,
tm: Data<Addr<TokenManager>>,
) -> Result<Json<api::SignInOutput>> {
) -> Result<Json<api::SessionOutput>> {
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);
}

View File

@ -6,6 +6,11 @@ use serde::{Deserialize, Serialize};
use crate::*;
#[derive(Serialize, Deserialize, Debug)]
pub struct Failure {
pub errors: Vec<String>,
}
#[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,

View File

@ -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: Into<String>>(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<S: Into<String>>(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)]

View File

@ -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" }

View File

@ -1 +1,45 @@
use std::convert::Infallible;
use std::ops::FromResidual;
use seed::fetch::{FetchError, Request};
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 seed::prelude::*;
use seed::fetch::{Header, Method, Request};
pub async fn fetch_products() -> fetch::Result<model::api::Products> {
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<model::api::Products> {
perform(Request::new("/api/v1/products").method(Method::Get)).await
}
pub async fn fetch_product(product_id: model::ProductId) -> fetch::Result<model::api::Product> {
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<model::api::Product> {
perform(Request::new(format!("/api/v1/product/{}", product_id)).method(Method::Get)).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> {
perform(
Request::new("/api/v1/me")
.header(fetch::Header::bearer(access_token.as_str()))
.method(Method::Get)
.fetch()
.await?
.check_status()?
.json()
.header(Header::bearer(access_token.as_str()))
.method(Method::Get),
)
.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> {
perform(
Request::new("/api/v1/sign-in")
.method(Method::Post)
.json(&input)?
.fetch()
.await?
.check_status()?
.json()
.json(&input)
.map_err(crate::api::NetRes::Http)?,
)
.await
}
pub async fn verify_token(access_token: AccessTokenString) -> fetch::Result<String> {
pub async fn verify_token(access_token: AccessTokenString) -> super::NetRes<String> {
perform(
Request::new("/api/v1/token/verify")
.method(Method::Post)
.header(fetch::Header::bearer(access_token.as_str()))
.fetch()
.await?
.check_status()?
.json()
.header(Header::bearer(access_token.as_str())),
)
.await
}
pub async fn refresh_token(
access_token: RefreshTokenString,
) -> fetch::Result<model::api::SignInOutput> {
) -> super::NetRes<model::api::SessionOutput> {
perform(
Request::new("/api/v1/token/refresh")
.method(Method::Post)
.header(fetch::Header::bearer(access_token.as_str()))
.fetch()
.await?
.check_status()?
.json()
.header(Header::bearer(access_token.as_str())),
)
.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;
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<Msg>) -> 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<Msg>) -> 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<Msg>) {
@ -85,20 +91,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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<Msg>) {
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);
}
}
}

View File

@ -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 {

View File

@ -69,11 +69,11 @@ pub mod layout {
use seed::prelude::*;
use seed::*;
pub fn view<Msg>(
pub fn view(
model: &crate::Model,
content: Node<Msg>,
content: Node<crate::Msg>,
categories: Option<&[model::api::Category]>,
) -> Node<Msg> {
) -> Node<crate::Msg> {
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<Msg>(model: &crate::Model, category: &model::api::Category) -> Node<Msg> {
let url = Urls::new(model.url.clone())
let url = Urls::new(&model.url)
.listing()
.add_path_part(category.key.as_str());
li![

View File

@ -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<model::api::Products>),
ProductFetched(crate::api::NetRes<model::api::Products>),
}
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> 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<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) }
});
}
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<Msg>)
};
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<Msg> {
.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());

View File

@ -1,9 +1,11 @@
use seed::prelude::*;
use seed::*;
use crate::api::NetRes;
#[derive(Debug)]
pub enum Msg {
ProductFetched(fetch::Result<model::api::Product>),
ProductFetched(crate::api::NetRes<model::api::Product>),
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>) {
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) => {

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 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![
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<crate::Msg> {
]
.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<Msg> {
@ -87,7 +99,7 @@ fn sign_in_form(model: &crate::Model, _page: &SignInPage) -> Node<Msg> {
],
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![

View File

@ -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<model::api::SessionOutput>),
}
#[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<Msg>) -> SignUpPage {
@ -25,26 +29,78 @@ pub fn init(mut _url: Url, _orders: &mut impl Orders<Msg>) -> 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<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> {
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")
]
@ -52,10 +108,7 @@ pub fn view(model: &crate::Model, page: &SignUpPage) -> Node<crate::Msg> {
]
]
.map_msg(Into::into);
div![
crate::shared::view::public_navbar(model),
super::layout::view(model, content, None)
]
div![super::layout::view(model, content, None)]
}
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"],
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<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"],
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<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"]],
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![

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 mod msg;
pub mod notification;
pub mod view;
#[derive(Debug, Default)]
@ -11,6 +12,7 @@ pub struct Model {
pub refresh_token: Option<model::RefreshTokenString>,
pub exp: Option<chrono::NaiveDateTime>,
pub me: Option<model::Account>,
pub notifications: Vec<notification::Notification>,
}
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);
}
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<crate::Msg>,
) {
let model::api::SignInOutput {
let model::api::SessionOutput {
access_token,
refresh_token,
exp,

View File

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

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"
]]
]
]
}