Refresh tokens, sign in and so on

This commit is contained in:
Adrian Woźniak 2022-05-10 16:20:37 +02:00
parent 9072819de6
commit da218adcbd
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
21 changed files with 741 additions and 327 deletions

View File

@ -19,6 +19,41 @@ macro_rules! cart_async_handler {
}; };
} }
#[macro_export]
macro_rules! query_cart {
($cart: expr, $msg: expr, default $fail: expr) => {
match $cart.send($msg).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("{e}");
$fail
}
Err(e) => {
log::error!("{e:?}");
$fail
}
}
};
($cart: expr, $msg: expr, $fail: expr) => {
$crate::query_cart!($cart, $msg, $fail, $fail)
};
($cart: expr, $msg: expr, $db_fail: expr, $act_fail: expr) => {
match $cart.send($msg).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("{e}");
return Err($db_fail);
}
Err(e) => {
log::error!("{e:?}");
return Err($act_fail);
}
}
};
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("System can't ensure shopping cart existence")] #[error("System can't ensure shopping cart existence")]

View File

@ -24,7 +24,7 @@ pub(crate) async fn token_by_jti(msg: TokenByJti, pool: PgPool) -> Result<Token>
sqlx::query_as(r#" sqlx::query_as(r#"
SELECT id, customer_id, role, issuer, subject, audience, expiration_time, not_before_time, issued_at_time, jwt_id SELECT id, customer_id, role, issuer, subject, audience, expiration_time, not_before_time, issued_at_time, jwt_id
FROM tokens FROM tokens
WHERE jwt_id = $1 WHERE jwt_id = $1 AND expiration_time > now()
"#) "#)
.bind(msg.jti) .bind(msg.jti)
.fetch_one(&pool) .fetch_one(&pool)
@ -69,3 +69,41 @@ RETURNING id, customer_id, role, issuer, subject, audience, expiration_time, not
crate::Error::Token(Error::Create) crate::Error::Token(Error::Create)
}) })
} }
#[derive(Message)]
#[rtype(result = "Result<Token>")]
pub struct CreateExtendedToken {
pub customer_id: uuid::Uuid,
pub role: model::Role,
pub subject: AccountId,
pub audience: Audience,
pub expiration_time: chrono::NaiveDateTime,
}
db_async_handler!(CreateExtendedToken, create_extended_token, Token);
pub(crate) async fn create_extended_token(msg: CreateExtendedToken, pool: PgPool) -> Result<Token> {
let CreateExtendedToken {
customer_id,
role,
subject,
audience,
expiration_time,
} = msg;
sqlx::query_as(r#"
INSERT INTO tokens (customer_id, role, subject, audience, expiration_time)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, customer_id, role, issuer, subject, audience, expiration_time, not_before_time, issued_at_time, jwt_id
"#)
.bind(customer_id)
.bind(role)
.bind(subject)
.bind(audience)
.bind(expiration_time)
.fetch_one(&pool)
.await
.map_err(|e| {
log::error!("{e:?}");
crate::Error::Token(Error::Create)
})
}

View File

@ -51,7 +51,7 @@ macro_rules! query_fs {
}; };
($fs: expr, $msg: expr, $fail: expr) => { ($fs: expr, $msg: expr, $fail: expr) => {
$crate::query_db!($fs, $msg, $fail, $fail) $crate::query_fs!($fs, $msg, $fail, $fail)
}; };
($fs: expr, $msg: expr, $db_fail: expr, $act_fail: expr) => { ($fs: expr, $msg: expr, $db_fail: expr, $act_fail: expr) => {

View File

@ -7,7 +7,7 @@ use config::SharedAppConfig;
use database_manager::{query_db, Database}; use database_manager::{query_db, Database};
use hmac::digest::KeyInit; use hmac::digest::KeyInit;
use hmac::Hmac; use hmac::Hmac;
use model::{AccountId, Audience, Role, Token, TokenString}; use model::{AccessTokenString, AccountId, Audience, Role, Token};
use sha2::Sha256; use sha2::Sha256;
#[macro_export] #[macro_export]
@ -26,6 +26,62 @@ macro_rules! token_async_handler {
}; };
} }
#[macro_export]
macro_rules! query_tm {
($tm: expr, $msg: expr, default $fail: expr) => {
match $tm.send($msg).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("{e}");
$fail
}
Err(e) => {
log::error!("{e:?}");
$fail
}
}
};
(multi, $tm: expr, $fail: expr, $($msg: expr),*) => {{
use futures_util::TryFutureExt;
tokio::join!(
$(
$tm.send($msg).map_ok_or_else(
|e| {
log::error!("{e:?}");
Err($fail)
},
|res| match res {
Ok(rec) => Ok(rec),
Err(e) => {
log::error!("{e}");
Err($fail)
}
},
)
),*
)
}};
($tm: expr, $msg: expr, $fail: expr) => {
$crate::query_tm!($tm, $msg, $fail, $fail)
};
($tm: expr, $msg: expr, $db_fail: expr, $act_fail: expr) => {
match $tm.send($msg).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("{e}");
return Err($db_fail);
}
Err(e) => {
log::error!("{e:?}");
return Err($act_fail);
}
}
};
}
/*struct Jwt { /*struct Jwt {
/// cti (customer id): Customer uuid identifier used by payment service /// cti (customer id): Customer uuid identifier used by payment service
pub cti: uuid::Uuid, pub cti: uuid::Uuid,
@ -80,30 +136,33 @@ impl TokenManager {
} }
#[derive(Message)] #[derive(Message)]
#[rtype(result = "Result<(Token, TokenString)>")] #[rtype(result = "Result<(Token, AccessTokenString)>")]
pub struct CreateToken { pub struct CreateToken {
pub customer_id: uuid::Uuid, pub customer_id: uuid::Uuid,
pub role: Role, pub role: Role,
pub subject: AccountId, pub subject: AccountId,
pub audience: Option<Audience>, pub audience: Option<Audience>,
pub exp: Option<NaiveDateTime>,
} }
token_async_handler!(CreateToken, create_token, (Token, TokenString)); token_async_handler!(CreateToken, create_token, (Token, AccessTokenString));
pub(crate) async fn create_token( pub(crate) async fn create_token(
msg: CreateToken, msg: CreateToken,
db: Addr<Database>, db: Addr<Database>,
config: SharedAppConfig, config: SharedAppConfig,
) -> Result<(Token, TokenString)> { ) -> Result<(Token, AccessTokenString)> {
let CreateToken { let CreateToken {
customer_id, customer_id,
role, role,
subject, subject,
audience, audience,
exp,
} = msg; } = msg;
let audience = audience.unwrap_or_default(); let audience = audience.unwrap_or_default();
let token: Token = query_db!( let token: Token = match exp {
None => query_db!(
db, db,
database_manager::CreateToken { database_manager::CreateToken {
customer_id, customer_id,
@ -113,7 +172,20 @@ pub(crate) async fn create_token(
}, },
Error::Save, Error::Save,
Error::SaveInternal Error::SaveInternal
); ),
Some(exp) => query_db!(
db,
database_manager::CreateExtendedToken {
customer_id,
role,
subject,
audience,
expiration_time: exp
},
Error::Save,
Error::SaveInternal
),
};
let token_string = { let token_string = {
use jwt::SignWithKey; use jwt::SignWithKey;
@ -169,7 +241,7 @@ pub(crate) async fn create_token(
return Err(Error::SaveInternal); return Err(Error::SaveInternal);
} }
}; };
TokenString::from(s) AccessTokenString::new(s)
}; };
Ok((token, token_string)) Ok((token, token_string))
} }
@ -177,7 +249,7 @@ pub(crate) async fn create_token(
#[derive(Message)] #[derive(Message)]
#[rtype(result = "Result<(Token, bool)>")] #[rtype(result = "Result<(Token, bool)>")]
pub struct Validate { pub struct Validate {
pub token: TokenString, pub token: AccessTokenString,
} }
token_async_handler!(Validate, validate, (Token, bool)); token_async_handler!(Validate, validate, (Token, bool));
@ -214,6 +286,10 @@ pub(crate) async fn validate(
Error::ValidateInternal Error::ValidateInternal
); );
if token.expiration_time < Utc::now().naive_utc() {
return Err(Error::Validate);
}
if !validate_pair(&claims, "cti", token.customer_id, validate_uuid) { if !validate_pair(&claims, "cti", token.customer_id, validate_uuid) {
return Ok((token, false)); return Ok((token, false));
} }

View File

@ -6,8 +6,7 @@ use actix_web::web::{scope, Data, Json, ServiceConfig};
use actix_web::{delete, get, post, HttpResponse}; use actix_web::{delete, get, post, HttpResponse};
use config::SharedAppConfig; use config::SharedAppConfig;
use database_manager::{query_db, Database}; use database_manager::{query_db, Database};
use model::{Account, Email, Encrypt, Login, PassHash, Password, PasswordConfirmation, Role}; use model::{Encrypt, PassHash};
use serde::{Deserialize, Serialize};
use crate::routes; use crate::routes;
use crate::routes::{RequireLogin, Result}; use crate::routes::{RequireLogin, Result};
@ -43,29 +42,19 @@ pub enum Error {
Database(#[from] database_manager::Error), Database(#[from] database_manager::Error),
} }
#[derive(Serialize)]
pub struct LogoutResponse {}
#[delete("logout")] #[delete("logout")]
async fn logout(session: Session) -> Result<HttpResponse> { async fn logout(session: Session) -> Result<Json<model::api::admin::LogoutResponse>> {
session.require_admin()?; session.require_admin()?;
session.clear(); session.clear();
Ok(HttpResponse::NotImplemented().body("")) Ok(Json(model::api::admin::LogoutResponse {}))
}
#[derive(Deserialize, Debug)]
pub struct SignInInput {
login: Option<Login>,
email: Option<Email>,
password: Password,
} }
#[post("/sign-in")] #[post("/sign-in")]
async fn sign_in( async fn sign_in(
session: Session, session: Session,
db: Data<Addr<Database>>, db: Data<Addr<Database>>,
Json(payload): Json<SignInInput>, Json(payload): Json<model::api::admin::SignInInput>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
log::debug!("{:?}", payload); log::debug!("{:?}", payload);
let db = db.into_inner(); let db = db.into_inner();
@ -88,40 +77,21 @@ async fn sign_in(
} }
} }
#[derive(Deserialize)]
pub struct RegisterInput {
pub login: Login,
pub email: Email,
pub password: Password,
pub password_confirmation: PasswordConfirmation,
pub role: Role,
}
#[derive(Serialize, Default)]
pub struct RegisterResponse {
pub success: bool,
pub errors: Vec<RegisterError>,
pub account: Option<Account>,
}
#[derive(Serialize)]
pub enum RegisterError {
PasswordDiffer,
}
// login_required // login_required
#[post("/register")] #[post("/register")]
async fn register( async fn register(
session: Session, session: Session,
Json(input): Json<RegisterInput>, Json(input): Json<model::api::admin::RegisterInput>,
db: Data<Addr<Database>>, db: Data<Addr<Database>>,
config: Data<SharedAppConfig>, config: Data<SharedAppConfig>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let mut response = RegisterResponse::default(); let mut response = model::api::admin::RegisterResponse::default();
session.require_admin()?; session.require_admin()?;
if input.password != input.password_confirmation { if input.password != input.password_confirmation {
response.errors.push(RegisterError::PasswordDiffer); response
.errors
.push(model::api::admin::RegisterError::PasswordDiffer);
} }
let hash = match input.password.encrypt(&config.lock().web().pass_salt()) { let hash = match input.password.encrypt(&config.lock().web().pass_salt()) {

View File

@ -9,9 +9,9 @@ use actix_session::Session;
use actix_web::body::BoxBody; use actix_web::body::BoxBody;
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::{RecordId, Token, TokenString}; use model::{AccessTokenString, RecordId, Token};
use serde::Serialize; use serde::Serialize;
use token_manager::TokenManager; use token_manager::{query_tm, TokenManager};
pub use self::admin::Error as AdminError; pub use self::admin::Error as AdminError;
pub use self::public::{Error as PublicError, V1Error, V1ShoppingCartError}; pub use self::public::{Error as PublicError, V1Error, V1ShoppingCartError};
@ -132,15 +132,12 @@ pub trait RequireUser {
#[async_trait::async_trait] #[async_trait::async_trait]
impl RequireUser for actix_web_httpauth::extractors::bearer::BearerAuth { impl RequireUser for actix_web_httpauth::extractors::bearer::BearerAuth {
async fn require_user(&self, tm: Arc<Addr<TokenManager>>) -> Result<(Token, bool)> { async fn require_user(&self, tm: Arc<Addr<TokenManager>>) -> Result<(Token, bool)> {
match tm Ok(query_tm!(
.send(token_manager::Validate { tm,
token: TokenString::from(String::from(self.token())), token_manager::Validate {
}) token: AccessTokenString::new(self.token()),
.await },
{ Error::Unauthorized
Ok(Ok(res)) => Ok(res), ))
Ok(Err(_e)) => Err(Error::Unauthorized),
Err(_) => Err(Error::Unauthorized),
}
} }
} }

View File

@ -2,16 +2,60 @@ use actix::Addr;
use actix_web::web::{scope, Data, Json, ServiceConfig}; use actix_web::web::{scope, Data, Json, ServiceConfig};
use actix_web::{delete, get, post, HttpRequest, HttpResponse}; use actix_web::{delete, get, post, HttpRequest, HttpResponse};
use actix_web_httpauth::extractors::bearer::BearerAuth; use actix_web_httpauth::extractors::bearer::BearerAuth;
use cart_manager::CartManager; use cart_manager::{query_cart, CartManager};
use database_manager::{query_db, Database}; use database_manager::{query_db, Database};
use model::{api, AccountId, ProductId, Quantity, QuantityUnit, ShoppingCartItemId}; use model::api;
use payment_manager::{query_pay, PaymentManager}; use payment_manager::{query_pay, PaymentManager};
use token_manager::TokenManager; use token_manager::TokenManager;
use crate::routes; use crate::routes::public::api_v1::unrestricted::{create_auth_pair, AuthPair};
use crate::routes::public::api_v1::{Error as ApiV1Error, ShoppingCartError}; use crate::routes::public::api_v1::{Error as ApiV1Error, ShoppingCartError};
use crate::routes::public::Error as PublicError; use crate::routes::public::Error as PublicError;
use crate::routes::{RequireUser, Result}; use crate::routes::{RequireUser, Result};
use crate::{public_send_db, routes};
/// This requires [model::AccessTokenString] to be set as bearer
#[post("/token/verify")]
async fn verify_token(
tm: Data<Addr<TokenManager>>,
credentials: BearerAuth,
) -> routes::Result<String> {
let _token = credentials.require_user(tm.into_inner()).await?.0;
Ok("".into())
}
/// This requires [model::RefreshTokenString] to be set as bearer
#[post("/token/refresh")]
async fn refresh_token(
tm: Data<Addr<TokenManager>>,
db: Data<Addr<Database>>,
credentials: BearerAuth,
) -> routes::Result<Json<api::SignInOutput>> {
let account_id: model::AccountId = credentials
.require_user(tm.clone().into_inner())
.await?
.0
.subject
.into();
let account: model::FullAccount = query_db!(
db,
database_manager::FindAccount { account_id },
routes::Error::Unauthorized
);
let AuthPair {
access_token,
access_token_string,
_refresh_token: _,
refresh_token_string,
} = create_auth_pair(tm, account).await?;
Ok(Json(api::SignInOutput {
access_token: access_token_string,
refresh_token: refresh_token_string,
exp: access_token.expiration_time,
}))
}
#[get("/shopping-cart")] #[get("/shopping-cart")]
async fn shopping_cart( async fn shopping_cart(
@ -23,7 +67,7 @@ async fn shopping_cart(
let cart: model::ShoppingCart = query_db!( let cart: model::ShoppingCart = query_db!(
db, db,
database_manager::EnsureActiveShoppingCart { database_manager::EnsureActiveShoppingCart {
buyer_id: AccountId::from(token.subject), buyer_id: token.subject.into(),
}, },
routes::Error::Public(PublicError::ApiV1(ApiV1Error::ShoppingCart( routes::Error::Public(PublicError::ApiV1(ApiV1Error::ShoppingCart(
ShoppingCartError::Ensure ShoppingCartError::Ensure
@ -43,60 +87,30 @@ async fn shopping_cart(
Ok(Json(cart)) Ok(Json(cart))
} }
#[derive(serde::Deserialize)]
pub struct CreateItemInput {
pub product_id: ProductId,
pub quantity: Quantity,
pub quantity_unit: QuantityUnit,
}
#[derive(serde::Serialize)]
pub struct CreateItemOutput {
pub success: bool,
pub shopping_cart_item: model::ShoppingCartItem,
}
#[post("/shopping-cart-item")] #[post("/shopping-cart-item")]
async fn create_cart_item( async fn create_cart_item(
cart: Data<Addr<CartManager>>, cart: Data<Addr<CartManager>>,
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
credentials: BearerAuth, credentials: BearerAuth,
Json(payload): Json<CreateItemInput>, Json(payload): Json<api::CreateItemInput>,
) -> Result<Json<CreateItemOutput>> { ) -> Result<Json<api::CreateItemOutput>> {
let (token, _) = credentials.require_user(tm.into_inner()).await?; let (token, _) = credentials.require_user(tm.into_inner()).await?;
match cart let item: model::ShoppingCartItem = query_cart!(
.send(cart_manager::AddItem { cart,
buyer_id: AccountId::from(token.subject), cart_manager::AddItem {
buyer_id: token.subject.into(),
product_id: payload.product_id, product_id: payload.product_id,
quantity: payload.quantity, quantity: payload.quantity,
quantity_unit: payload.quantity_unit, quantity_unit: payload.quantity_unit,
}) },
.await routes::Error::Public(super::Error::AddItem.into()),
{ routes::Error::Public(PublicError::DatabaseConnection)
Ok(Ok(item)) => Ok(Json(CreateItemOutput { );
Ok(Json(api::CreateItemOutput {
success: true, success: true,
shopping_cart_item: item, shopping_cart_item: item.into(),
})), }))
Ok(Err(e)) => {
log::error!("{e:}");
Err(routes::Error::Public(super::Error::AddItem.into()))
}
Err(e) => {
log::error!("{e:?}");
Err(routes::Error::Public(PublicError::DatabaseConnection))
}
}
}
#[derive(serde::Deserialize)]
pub struct DeleteItemInput {
pub shopping_cart_item_id: ShoppingCartItemId,
}
#[derive(serde::Serialize)]
pub struct DeleteItemOutput {
pub success: bool,
} }
#[delete("/shopping-cart-item")] #[delete("/shopping-cart-item")]
@ -105,14 +119,14 @@ async fn delete_cart_item(
cart: Data<Addr<CartManager>>, cart: Data<Addr<CartManager>>,
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
credentials: BearerAuth, credentials: BearerAuth,
Json(payload): Json<DeleteItemInput>, Json(payload): Json<api::DeleteItemInput>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let (token, _) = credentials.require_user(tm.into_inner()).await?; let (token, _) = credentials.require_user(tm.into_inner()).await?;
let sc: model::ShoppingCart = query_db!( let sc: model::ShoppingCart = query_db!(
db, db,
database_manager::EnsureActiveShoppingCart { database_manager::EnsureActiveShoppingCart {
buyer_id: AccountId::from(token.subject), buyer_id: token.subject.into(),
}, },
routes::Error::Public(super::Error::RemoveItem.into()), routes::Error::Public(super::Error::RemoveItem.into()),
routes::Error::Public(PublicError::DatabaseConnection) routes::Error::Public(PublicError::DatabaseConnection)
@ -126,10 +140,10 @@ async fn delete_cart_item(
}) })
.await .await
{ {
Ok(Ok(_)) => Ok(HttpResponse::Ok().json(DeleteItemOutput { success: true })), Ok(Ok(_)) => Ok(HttpResponse::Ok().json(api::DeleteItemOutput { success: true })),
Ok(Err(e)) => { Ok(Err(e)) => {
log::error!("{e}"); log::error!("{e}");
Ok(HttpResponse::BadRequest().json(DeleteItemOutput { success: false })) Ok(HttpResponse::BadRequest().json(api::DeleteItemOutput { success: false }))
} }
Err(e) => { Err(e) => {
log::error!("{e:?}"); log::error!("{e:?}");
@ -138,51 +152,34 @@ async fn delete_cart_item(
} }
} }
#[derive(serde::Deserialize)] #[get("/me")]
pub struct CreateOrderInput { pub(crate) async fn me(
/// Required customer e-mail db: Data<Addr<Database>>,
pub email: String, tm: Data<Addr<TokenManager>>,
/// Required customer phone number credentials: BearerAuth,
pub phone: String, ) -> routes::Result<Json<model::Account>> {
/// Required customer first name let account_id: model::AccountId = credentials
pub first_name: String, .require_user(tm.into_inner())
/// Required customer last name .await?
pub last_name: String, .0
/// Required customer language .subject
pub language: String, .into();
/// False if customer is allowed to be charged on site. let account: model::FullAccount =
/// Otherwise it should be true to use payment service for charging public_send_db!(owned, db, database_manager::FindAccount { account_id });
pub charge_client: bool, Ok(Json(account.into()))
/// User currency
pub currency: String,
} }
#[post("/order")] #[post("/order")]
pub(crate) async fn create_order( pub(crate) async fn create_order(
req: HttpRequest, req: HttpRequest,
Json(payload): Json<CreateOrderInput>, Json(payload): Json<api::CreateOrderInput>,
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
credentials: BearerAuth, credentials: BearerAuth,
payment: Data<Addr<PaymentManager>>, payment: Data<Addr<PaymentManager>>,
) -> routes::Result<HttpResponse> { ) -> routes::Result<HttpResponse> {
let ( let subject = credentials.require_user(tm.into_inner()).await?.0.subject;
model::Token {
id: _,
customer_id: _,
role: _,
issuer: _,
subject,
audience: _,
expiration_time: _,
not_before_time: _,
issued_at_time: _,
jwt_id: _,
},
_,
) = credentials.require_user(tm.into_inner()).await?;
let buyer_id = model::AccountId::from(subject); let api::CreateOrderInput {
let CreateOrderInput {
email, email,
phone, phone,
first_name, first_name,
@ -208,7 +205,7 @@ pub(crate) async fn create_order(
language, language,
}, },
customer_ip: ip.to_string(), customer_ip: ip.to_string(),
buyer_id, buyer_id: subject.into(),
charge_client charge_client
}, },
routes::Error::Public(PublicError::DatabaseConnection) routes::Error::Public(PublicError::DatabaseConnection)
@ -222,11 +219,15 @@ pub(crate) async fn create_order(
} }
pub(crate) fn configure(config: &mut ServiceConfig) { pub(crate) fn configure(config: &mut ServiceConfig) {
config.service(scope("") let scoped = scope("")
.app_data(actix_web_httpauth::extractors::bearer::Config::default() .app_data(actix_web_httpauth::extractors::bearer::Config::default()
.realm("user api") .realm("user api")
.scope("customer_id role subject audience expiration_time not_before_time issued_at_time")) .scope("customer_id role subject audience expiration_time not_before_time issued_at_time"))
.service(shopping_cart) .service(shopping_cart)
.service(delete_cart_item) .service(delete_cart_item)
.service(create_order)); .service(create_order)
.service(me)
.service(verify_token)
.service(refresh_token);
config.service(scoped);
} }

View File

@ -3,13 +3,13 @@ use actix_web::web::{Data, Json, ServiceConfig};
use actix_web::{get, post, HttpResponse}; use actix_web::{get, post, HttpResponse};
use config::SharedAppConfig; use config::SharedAppConfig;
use database_manager::{query_db, Database}; use database_manager::{query_db, Database};
use model::{api, Audience, Encrypt, FullAccount, Token, TokenString}; use model::{api, AccessTokenString, Audience, Encrypt, FullAccount, RefreshTokenString, Token};
use payment_manager::{PaymentManager, PaymentNotification}; use payment_manager::{PaymentManager, PaymentNotification};
use token_manager::TokenManager; use token_manager::{query_tm, TokenManager};
use crate::public_send_db;
use crate::routes::public::Error as PublicError; use crate::routes::public::Error as PublicError;
use crate::routes::{self, Result}; use crate::routes::{self, Result};
use crate::{public_send_db, Login, Password};
#[get("/products")] #[get("/products")]
async fn products( async fn products(
@ -38,18 +38,10 @@ async fn stocks(db: Data<Addr<Database>>) -> Result<HttpResponse> {
public_send_db!(db.into_inner(), database_manager::AllStocks) public_send_db!(db.into_inner(), database_manager::AllStocks)
} }
#[derive(serde::Deserialize)]
pub struct CreateAccountInput {
pub email: model::Email,
pub login: Login,
pub password: Password,
pub password_confirmation: model::PasswordConfirmation,
}
#[post("/register")] #[post("/register")]
pub async fn create_account( pub async fn create_account(
db: Data<Addr<Database>>, db: Data<Addr<Database>>,
Json(payload): Json<CreateAccountInput>, Json(payload): Json<api::CreateAccountInput>,
config: Data<SharedAppConfig>, config: Data<SharedAppConfig>,
) -> routes::Result<HttpResponse> { ) -> routes::Result<HttpResponse> {
if payload.password != payload.password_confirmation { if payload.password != payload.password_confirmation {
@ -57,12 +49,14 @@ pub async fn create_account(
routes::admin::Error::DifferentPasswords, routes::admin::Error::DifferentPasswords,
)); ));
} }
let hash = match payload.password.encrypt(&config.lock().web().pass_salt()) { let hash = {
match payload.password.encrypt(&config.lock().web().pass_salt()) {
Ok(hash) => hash, Ok(hash) => hash,
Err(e) => { Err(e) => {
log::error!("{e:?}"); log::error!("{e:?}");
return Err(routes::Error::Admin(routes::admin::Error::HashPass)); return Err(routes::Error::Admin(routes::admin::Error::HashPass));
} }
}
}; };
public_send_db!( public_send_db!(
@ -76,63 +70,78 @@ pub async fn create_account(
); );
} }
#[derive(serde::Deserialize)] pub(crate) struct AuthPair {
pub struct SignInInput { pub access_token: Token,
pub login: String, pub access_token_string: AccessTokenString,
pub password: String, pub _refresh_token: Token,
pub refresh_token_string: RefreshTokenString,
} }
#[derive(serde::Serialize)] pub(crate) async fn create_auth_pair(
pub struct SignInOutput {
pub token: TokenString,
}
#[post("/sign-in")]
async fn sign_in(
Json(payload): Json<SignInInput>,
db: Data<Addr<Database>>,
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
) -> Result<HttpResponse> { account: FullAccount,
let db = db.into_inner(); ) -> routes::Result<AuthPair> {
let tm = tm.into_inner(); let (access_token, refresh_token) = query_tm!(
multi,
let account: FullAccount = query_db!( tm,
db,
database_manager::AccountByIdentity {
login: Some(Login::from(payload.login)),
email: None,
},
routes::Error::Public(PublicError::DatabaseConnection), routes::Error::Public(PublicError::DatabaseConnection),
routes::Error::Public(PublicError::DatabaseConnection) token_manager::CreateToken {
);
if Password::from(payload.password)
.validate(&account.pass_hash)
.is_err()
{
return Err(routes::Error::Unauthorized);
}
let (_token, string): (Token, TokenString) = match tm
.send(token_manager::CreateToken {
customer_id: account.customer_id, customer_id: account.customer_id,
role: account.role, role: account.role,
subject: account.id, subject: account.id,
audience: Some(Audience::Web), audience: Some(Audience::Web),
exp: None
},
token_manager::CreateToken {
customer_id: account.customer_id,
role: account.role,
subject: account.id,
audience: Some(Audience::Web),
exp: Some((chrono::Utc::now() + chrono::Duration::days(31)).naive_utc())
}
);
let (access_token, access_token_string): (Token, AccessTokenString) = access_token?;
let (refresh_token, refresh_token_string): (Token, AccessTokenString) = refresh_token?;
Ok(AuthPair {
access_token,
access_token_string,
_refresh_token: refresh_token,
refresh_token_string: refresh_token_string.into(),
}) })
.await
{
Ok(Ok(token)) => token,
Ok(Err(token_err)) => {
log::error!("{token_err}");
return Err(routes::Error::Public(PublicError::DatabaseConnection));
} }
Err(db_err) => {
log::error!("{db_err}");
return Err(routes::Error::Public(PublicError::DatabaseConnection));
}
};
Ok(HttpResponse::Created().json(SignInOutput { token: string })) #[post("/sign-in")]
async fn sign_in(
Json(payload): Json<api::SignInInput>,
db: Data<Addr<Database>>,
tm: Data<Addr<TokenManager>>,
) -> Result<Json<api::SignInOutput>> {
let db = db.into_inner();
let account: FullAccount = query_db!(
db,
database_manager::AccountByIdentity {
login: Some(payload.login),
email: None,
},
routes::Error::Public(PublicError::DatabaseConnection)
);
if payload.password.validate(&account.pass_hash).is_err() {
return Err(routes::Error::Unauthorized);
}
let AuthPair {
access_token,
access_token_string,
_refresh_token: _,
refresh_token_string,
} = create_auth_pair(tm, account).await?;
Ok(Json(api::SignInOutput {
access_token: access_token_string,
refresh_token: refresh_token_string,
exp: access_token.expiration_time,
}))
} }
#[post("/payment/notify")] #[post("/payment/notify")]
@ -145,7 +154,7 @@ async fn handle_notification(
notification: notify, notification: notify,
}); });
} }
HttpResponse::Ok().body("") HttpResponse::Ok().finish()
} }
pub(crate) fn configure(config: &mut ServiceConfig) { pub(crate) fn configure(config: &mut ServiceConfig) {

View File

@ -1,9 +1,10 @@
use chrono::NaiveDateTime;
use derive_more::Deref; use derive_more::Deref;
#[cfg(feature = "dummy")] #[cfg(feature = "dummy")]
use fake::Fake; use fake::Fake;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ProductLinkedPhoto; use crate::*;
#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -75,20 +76,40 @@ pub struct AccountOrder {
#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct ShoppingCartItem { pub struct ShoppingCartItem {
pub id: crate::ShoppingCartId, pub id: ShoppingCartId,
pub product_id: crate::ProductId, pub product_id: ProductId,
pub shopping_cart_id: crate::ShoppingCartId, pub shopping_cart_id: ShoppingCartId,
pub quantity: crate::Quantity, pub quantity: Quantity,
pub quantity_unit: crate::QuantityUnit, pub quantity_unit: QuantityUnit,
}
impl From<crate::ShoppingCartItem> for ShoppingCartItem {
fn from(
crate::ShoppingCartItem {
id,
product_id,
shopping_cart_id,
quantity,
quantity_unit,
}: crate::ShoppingCartItem,
) -> Self {
Self {
id,
product_id,
shopping_cart_id,
quantity,
quantity_unit,
}
}
} }
#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct ShoppingCart { pub struct ShoppingCart {
pub id: crate::ShoppingCartId, pub id: ShoppingCartId,
pub buyer_id: crate::AccountId, pub buyer_id: AccountId,
pub payment_method: crate::PaymentMethod, pub payment_method: PaymentMethod,
pub state: crate::ShoppingCartState, pub state: ShoppingCartState,
pub items: Vec<ShoppingCartItem>, pub items: Vec<ShoppingCartItem>,
} }
@ -215,3 +236,103 @@ impl From<(Vec<crate::Product>, Vec<ProductLinkedPhoto>, String)> for Products {
) )
} }
} }
#[derive(Serialize, Deserialize, Debug)]
pub struct SignInInput {
pub login: Login,
pub password: Password,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SignInOutput {
pub access_token: AccessTokenString,
pub refresh_token: RefreshTokenString,
pub exp: NaiveDateTime,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateAccountInput {
pub email: Email,
pub login: Login,
pub password: Password,
pub password_confirmation: PasswordConfirmation,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateOrderInput {
/// Required customer e-mail
pub email: String,
/// Required customer phone number
pub phone: String,
/// Required customer first name
pub first_name: String,
/// Required customer last name
pub last_name: String,
/// Required customer language
pub language: String,
/// False if customer is allowed to be charged on site.
/// Otherwise it should be true to use payment service for charging
pub charge_client: bool,
/// User currency
pub currency: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DeleteItemInput {
pub shopping_cart_item_id: ShoppingCartItemId,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DeleteItemOutput {
pub success: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateItemInput {
pub product_id: ProductId,
pub quantity: Quantity,
pub quantity_unit: QuantityUnit,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateItemOutput {
pub success: bool,
pub shopping_cart_item: ShoppingCartItem,
}
pub mod admin {
use serde::{Deserialize, Serialize};
use crate::*;
#[derive(Deserialize, Serialize, Debug)]
pub struct RegisterInput {
pub login: Login,
pub email: Email,
pub password: Password,
pub password_confirmation: PasswordConfirmation,
pub role: Role,
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct RegisterResponse {
pub success: bool,
pub errors: Vec<RegisterError>,
pub account: Option<Account>,
}
#[derive(Serialize, Deserialize, Debug)]
pub enum RegisterError {
PasswordDiffer,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SignInInput {
pub login: Option<Login>,
pub email: Option<Email>,
pub password: Password,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LogoutResponse {}
}

View File

@ -494,7 +494,7 @@ pub struct FullAccount {
#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[cfg_attr(feature = "db", derive(sqlx::FromRow))] #[cfg_attr(feature = "db", derive(sqlx::FromRow))]
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, Debug)]
pub struct Account { pub struct Account {
pub id: AccountId, pub id: AccountId,
pub email: Email, pub email: Email,
@ -744,10 +744,34 @@ pub struct Token {
#[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, Display, From)] #[derive(Serialize, Deserialize, Debug, Clone, Deref, Display, From)]
pub struct TokenString(String); pub struct AccessTokenString(String);
impl TokenString { impl AccessTokenString {
pub fn new<S: Into<String>>(s: S) -> Self {
Self(s.into())
}
}
impl From<RefreshTokenString> for AccessTokenString {
fn from(r: RefreshTokenString) -> Self {
Self(r.0)
}
}
#[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[cfg_attr(feature = "db", derive(sqlx::Type))]
#[cfg_attr(feature = "db", sqlx(transparent))]
#[derive(Serialize, Deserialize, Debug, Clone, Deref, Display, From)]
pub struct RefreshTokenString(String);
impl From<AccessTokenString> for RefreshTokenString {
fn from(r: AccessTokenString) -> Self {
Self(r.0)
}
}
impl RefreshTokenString {
pub fn new<S: Into<String>>(s: S) -> Self { pub fn new<S: Into<String>>(s: S) -> Self {
Self(s.into()) Self(s.into())
} }

BIN
web/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -3,10 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link id="logo" data-trunk rel="icon" href="assets/logo.png" >
<title>Bazzar</title> <title>Bazzar</title>
<link data-trunk rel="css" href="tmp/tailwind.css"/> <link data-trunk rel="css" href="tmp/tailwind.css"/>
<link rel="copy-file" href="assets/logo.png">
<link rel="copy-file" href="tmp/tailwind.css">
<base data-trunk-public-url/> <base data-trunk-public-url/>
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
</head> </head>
<body> <body>
<main id="main"> <main id="main">

View File

@ -1,54 +0,0 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

View File

@ -1,3 +1,4 @@
use model::{AccessTokenString, RefreshTokenString};
use seed::prelude::*; use seed::prelude::*;
pub async fn fetch_products() -> fetch::Result<model::api::Products> { pub async fn fetch_products() -> fetch::Result<model::api::Products> {
@ -9,3 +10,49 @@ pub async fn fetch_products() -> fetch::Result<model::api::Products> {
.json() .json()
.await .await
} }
pub async fn fetch_me(access_token: AccessTokenString) -> fetch::Result<model::Account> {
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 sign_in(input: model::api::SignInInput) -> fetch::Result<model::api::SignInOutput> {
Request::new("/api/v1/sign-in")
.method(Method::Post)
.json(&input)?
.fetch()
.await?
.check_status()?
.json()
.await
}
pub async fn verify_token(access_token: AccessTokenString) -> fetch::Result<String> {
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 refresh_token(
access_token: RefreshTokenString,
) -> fetch::Result<model::api::SignInOutput> {
Request::new("/api/v1/token/refresh")
.method(Method::Post)
.header(fetch::Header::bearer(access_token.as_str()))
.fetch()
.await?
.check_status()?
.json()
.await
}

View File

@ -24,18 +24,43 @@ 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.stream(streams::interval(500, || Msg::CheckAccessToken));
Model { Model {
token: LocalStorage::get("auth-token").ok(), token: LocalStorage::get("auth-token").ok(),
page: Page::Public(PublicPage::Listing(pages::public::listing::init( page: Page::Public(PublicPage::Listing(pages::public::listing::init(
url, url,
&mut orders.proxy(proxy_public_listing), &mut orders.proxy(proxy_public_listing),
))), ))),
logo: seed::document()
.query_selector("link[rel=icon]")
.ok()
.flatten()
.and_then(|el: web_sys::Element| el.get_attribute("href")),
shared: shared::Model::default(),
} }
} }
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) { fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg { match msg {
Msg::Shared(msg) => {
shared::update(msg, &mut model.shared, orders);
}
Msg::CheckAccessToken => {
orders.skip();
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.as_ref().cloned() {
orders.send_msg(Msg::Shared(shared::Msg::RefreshToken(token)));
}
}
}
Msg::UrlChanged(subs::UrlChanged(url)) => model.page = Page::init(url, orders), Msg::UrlChanged(subs::UrlChanged(url)) => model.page = Page::init(url, orders),
Msg::Public(pages::public::Msg::Listing(pages::public::listing::Msg::Shared(msg))) => {
shared::update(msg, &mut model.shared, 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, ());
pages::public::listing::update(msg, page, &mut orders.proxy(proxy_public_listing)); pages::public::listing::update(msg, page, &mut orders.proxy(proxy_public_listing));
@ -45,7 +70,7 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
fn view(model: &Model) -> Node<Msg> { fn view(model: &Model) -> Node<Msg> {
match &model.page { match &model.page {
Page::Public(PublicPage::Listing(page)) => pages::public::listing::view(&page) Page::Public(PublicPage::Listing(page)) => pages::public::listing::view(model, page)
.map_msg(|msg| Msg::Public(pages::public::Msg::Listing(msg))), .map_msg(|msg| Msg::Public(pages::public::Msg::Listing(msg))),
_ => empty![], _ => empty![],
} }

View File

@ -3,4 +3,6 @@ use crate::Page;
pub struct Model { pub struct Model {
pub token: Option<String>, pub token: Option<String>,
pub page: Page, pub page: Page,
pub logo: Option<String>,
pub shared: crate::shared::Model,
} }

View File

@ -4,10 +4,14 @@ pub mod public;
use seed::app::{subs, Orders}; use seed::app::{subs, Orders};
use seed::{struct_urls, Url}; use seed::{struct_urls, Url};
use crate::shared;
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
Public(public::Msg), Public(public::Msg),
UrlChanged(subs::UrlChanged), UrlChanged(subs::UrlChanged),
CheckAccessToken,
Shared(shared::Msg),
} }
pub enum AdminPage { pub enum AdminPage {

View File

@ -12,6 +12,7 @@ pub struct Model {
pub enum Msg { pub enum Msg {
FetchProducts, FetchProducts,
ProductFetched(fetch::Result<model::api::Products>), ProductFetched(fetch::Result<model::api::Products>),
Shared(crate::shared::Msg)
} }
pub fn init(_url: Url, orders: &mut impl Orders<Msg>) -> Model { pub fn init(_url: Url, orders: &mut impl Orders<Msg>) -> Model {
@ -35,14 +36,15 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::ProductFetched(Err(_e)) => { Msg::ProductFetched(Err(_e)) => {
model.errors.push("Failed to load products".into()); model.errors.push("Failed to load products".into());
} }
Msg::Shared(_) => {}
} }
} }
pub fn view(model: &Model) -> Node<Msg> { pub fn view(model: &crate::Model, page: &Model) -> Node<Msg> {
let products = model.products.iter().map(product); let products = page.products.iter().map(product);
div![ div![
crate::shared::public_navbar(), crate::shared::view::public_navbar(model),
div![ div![
C!["grid grid-cols-1 gap-4 lg:grid-cols-6 sm:grid-cols-2"], C!["grid grid-cols-1 gap-4 lg:grid-cols-6 sm:grid-cols-2"],
products products

View File

@ -1,35 +1,64 @@
use seed::prelude::*; use seed::app::Orders;
use seed::*;
pub fn public_navbar<Msg>() -> Node<Msg> { pub use crate::shared::msg::Msg;
header![
C!["sticky top-0 z-30 w-full px-2 py-4 bg-white sm:px-4 shadow-xl"], pub mod msg;
div![ pub mod view;
C!["flex items-center justify-between mx-auto max-w-7xl"],
logo(), #[derive(Debug, Default)]
div![ pub struct Model {
C!["flex items-center space-x-1"], pub access_token: Option<model::AccessTokenString>,
ul![ pub refresh_token: Option<model::RefreshTokenString>,
C!["hidden space-x-2 md:inline-flex"], pub exp: Option<chrono::NaiveDateTime>,
navbar_item("Home", "/"), pub me: Option<model::Account>,
navbar_item("Sign In", "/sign-in"),
]
]
]
]
} }
fn navbar_item<Msg>(name: &str, path: &str) -> Node<Msg> { pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<crate::Msg>) {
li![a![ match msg {
attrs!["href"=>path], Msg::LoadMe => {
C!["px-4 py-2 font-semibold text-gray-600 rounded"], if let Some(token) = model.access_token.as_ref().cloned() {
name orders.skip().perform_cmd(async {
]] Msg::MeLoaded(crate::api::public::fetch_me(token).await)
});
}
}
Msg::MeLoaded(Ok(account)) => {
model.me = Some(account);
}
Msg::MeLoaded(Err(_err)) => {}
Msg::SignIn(input) => {
orders
.skip()
.perform_cmd(async { Msg::SignedIn(crate::api::public::sign_in(input).await) });
}
Msg::SignedIn(Ok(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::TokenRefreshed(Ok(pair)) => {
handle_auth_pair(pair, model, orders);
}
Msg::TokenRefreshed(Err(_err)) => {}
}
} }
fn logo<Msg>() -> Node<Msg> { fn handle_auth_pair(
a![ pair: model::api::SignInOutput,
attrs!["a" => "#"], model: &mut Model,
span![C!["text-2xl font-extrabold text-blue-600"], "Logo"] _orders: &mut impl Orders<crate::Msg>,
] ) {
let model::api::SignInOutput {
access_token,
refresh_token,
exp,
} = pair;
model.access_token = Some(access_token);
model.refresh_token = Some(refresh_token);
model.exp = Some(exp);
} }

11
web/src/shared/msg.rs Normal file
View File

@ -0,0 +1,11 @@
use seed::fetch::Result;
#[derive(Debug)]
pub enum Msg {
LoadMe,
MeLoaded(Result<model::Account>),
SignIn(model::api::SignInInput),
SignedIn(Result<model::api::SignInOutput>),
RefreshToken(model::RefreshTokenString),
TokenRefreshed(Result<model::api::SignInOutput>),
}

75
web/src/shared/view.rs Normal file
View File

@ -0,0 +1,75 @@
use seed::prelude::*;
use seed::*;
pub fn public_navbar<Msg>(model: &crate::Model) -> Node<Msg> {
header![
C!["container flex justify-around py-8 mx-auto bg-white"],
div![C!["flex items-center"], logo(model),],
div![
C!["items-center hidden space-x-8 lg:flex"],
navbar_item(div![C![""], "Home"], "/"),
],
div![
C!["flex items-center space-x-2"],
navbar_item(account(), "/sign-in"),
navbar_item(bag(), "/cart")
]
]
}
fn navbar_item<Msg>(name: Node<Msg>, path: &str) -> Node<Msg> {
a![
attrs!["href" => path],
C!["px-4 py-2 font-semibold text-gray-600 rounded"],
name
]
}
fn logo<Msg>(model: &crate::Model) -> Node<Msg> {
a![
attrs!["href" => "/"],
match model.logo.as_deref() {
Some(url) => img![
C!["text-2xl font-extrabold text-blue-600"],
attrs!["alt" => "logo", "src" => url, "height" => "32", "style" => "height: 64px;"]
],
_ => span![C!["text-2xl font-extrabold text-blue-600"], "logo"],
}
]
}
fn bag<Msg>() -> Node<Msg> {
svg![
attrs![
"width" => "32px",
"height" => "32px",
"viewBox" => "0 0 32 32",
"xmlns" => "http://www.w3.org/2000/svg",
"class"=>"w-6 h-6",
"fill" => "none",
"stroke" => "currentColor",
"stroke-linecap" => "round",
"stroke-linejoin" => "round",
"stroke-width" => "2"
],
path![attrs!["d" => "M5 9 L5 29 27 29 27 9 Z M10 9 C10 9 10 3 16 3 22 3 22 9 22 9"]]
]
}
fn account<Msg>() -> Node<Msg> {
svg![
attrs![
"xmlns" => "http://www.w3.org/2000/svg",
"class" => "w-6 h-6",
"fill" => "none",
"viewBox" => "0 0 24 24",
"stroke" => "currentColor",
],
path![attrs![
"stroke-linecap" => "round",
"stroke-linejoin" => "round",
"stroke-width" => "2",
"d" => "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
]],
]
}