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)]
pub enum Error {
#[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#"
SELECT id, customer_id, role, issuer, subject, audience, expiration_time, not_before_time, issued_at_time, jwt_id
FROM tokens
WHERE jwt_id = $1
WHERE jwt_id = $1 AND expiration_time > now()
"#)
.bind(msg.jti)
.fetch_one(&pool)
@ -69,3 +69,41 @@ RETURNING id, customer_id, role, issuer, subject, audience, expiration_time, not
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) => {
$crate::query_db!($fs, $msg, $fail, $fail)
$crate::query_fs!($fs, $msg, $fail, $fail)
};
($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 hmac::digest::KeyInit;
use hmac::Hmac;
use model::{AccountId, Audience, Role, Token, TokenString};
use model::{AccessTokenString, AccountId, Audience, Role, Token};
use sha2::Sha256;
#[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 {
/// cti (customer id): Customer uuid identifier used by payment service
pub cti: uuid::Uuid,
@ -80,30 +136,33 @@ impl TokenManager {
}
#[derive(Message)]
#[rtype(result = "Result<(Token, TokenString)>")]
#[rtype(result = "Result<(Token, AccessTokenString)>")]
pub struct CreateToken {
pub customer_id: uuid::Uuid,
pub role: Role,
pub subject: AccountId,
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(
msg: CreateToken,
db: Addr<Database>,
config: SharedAppConfig,
) -> Result<(Token, TokenString)> {
) -> Result<(Token, AccessTokenString)> {
let CreateToken {
customer_id,
role,
subject,
audience,
exp,
} = msg;
let audience = audience.unwrap_or_default();
let token: Token = query_db!(
let token: Token = match exp {
None => query_db!(
db,
database_manager::CreateToken {
customer_id,
@ -113,7 +172,20 @@ pub(crate) async fn create_token(
},
Error::Save,
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 = {
use jwt::SignWithKey;
@ -169,7 +241,7 @@ pub(crate) async fn create_token(
return Err(Error::SaveInternal);
}
};
TokenString::from(s)
AccessTokenString::new(s)
};
Ok((token, token_string))
}
@ -177,7 +249,7 @@ pub(crate) async fn create_token(
#[derive(Message)]
#[rtype(result = "Result<(Token, bool)>")]
pub struct Validate {
pub token: TokenString,
pub token: AccessTokenString,
}
token_async_handler!(Validate, validate, (Token, bool));
@ -214,6 +286,10 @@ pub(crate) async fn validate(
Error::ValidateInternal
);
if token.expiration_time < Utc::now().naive_utc() {
return Err(Error::Validate);
}
if !validate_pair(&claims, "cti", token.customer_id, validate_uuid) {
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 config::SharedAppConfig;
use database_manager::{query_db, Database};
use model::{Account, Email, Encrypt, Login, PassHash, Password, PasswordConfirmation, Role};
use serde::{Deserialize, Serialize};
use model::{Encrypt, PassHash};
use crate::routes;
use crate::routes::{RequireLogin, Result};
@ -43,29 +42,19 @@ pub enum Error {
Database(#[from] database_manager::Error),
}
#[derive(Serialize)]
pub struct LogoutResponse {}
#[delete("logout")]
async fn logout(session: Session) -> Result<HttpResponse> {
async fn logout(session: Session) -> Result<Json<model::api::admin::LogoutResponse>> {
session.require_admin()?;
session.clear();
Ok(HttpResponse::NotImplemented().body(""))
}
#[derive(Deserialize, Debug)]
pub struct SignInInput {
login: Option<Login>,
email: Option<Email>,
password: Password,
Ok(Json(model::api::admin::LogoutResponse {}))
}
#[post("/sign-in")]
async fn sign_in(
session: Session,
db: Data<Addr<Database>>,
Json(payload): Json<SignInInput>,
Json(payload): Json<model::api::admin::SignInInput>,
) -> Result<HttpResponse> {
log::debug!("{:?}", payload);
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
#[post("/register")]
async fn register(
session: Session,
Json(input): Json<RegisterInput>,
Json(input): Json<model::api::admin::RegisterInput>,
db: Data<Addr<Database>>,
config: Data<SharedAppConfig>,
) -> Result<HttpResponse> {
let mut response = RegisterResponse::default();
let mut response = model::api::admin::RegisterResponse::default();
session.require_admin()?;
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()) {

View File

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

View File

@ -2,16 +2,60 @@ use actix::Addr;
use actix_web::web::{scope, Data, Json, ServiceConfig};
use actix_web::{delete, get, post, HttpRequest, HttpResponse};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use cart_manager::CartManager;
use cart_manager::{query_cart, CartManager};
use database_manager::{query_db, Database};
use model::{api, AccountId, ProductId, Quantity, QuantityUnit, ShoppingCartItemId};
use model::api;
use payment_manager::{query_pay, PaymentManager};
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::Error as PublicError;
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")]
async fn shopping_cart(
@ -23,7 +67,7 @@ async fn shopping_cart(
let cart: model::ShoppingCart = query_db!(
db,
database_manager::EnsureActiveShoppingCart {
buyer_id: AccountId::from(token.subject),
buyer_id: token.subject.into(),
},
routes::Error::Public(PublicError::ApiV1(ApiV1Error::ShoppingCart(
ShoppingCartError::Ensure
@ -43,60 +87,30 @@ async fn shopping_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")]
async fn create_cart_item(
cart: Data<Addr<CartManager>>,
tm: Data<Addr<TokenManager>>,
credentials: BearerAuth,
Json(payload): Json<CreateItemInput>,
) -> Result<Json<CreateItemOutput>> {
Json(payload): Json<api::CreateItemInput>,
) -> Result<Json<api::CreateItemOutput>> {
let (token, _) = credentials.require_user(tm.into_inner()).await?;
match cart
.send(cart_manager::AddItem {
buyer_id: AccountId::from(token.subject),
let item: model::ShoppingCartItem = query_cart!(
cart,
cart_manager::AddItem {
buyer_id: token.subject.into(),
product_id: payload.product_id,
quantity: payload.quantity,
quantity_unit: payload.quantity_unit,
})
.await
{
Ok(Ok(item)) => Ok(Json(CreateItemOutput {
},
routes::Error::Public(super::Error::AddItem.into()),
routes::Error::Public(PublicError::DatabaseConnection)
);
Ok(Json(api::CreateItemOutput {
success: true,
shopping_cart_item: item,
})),
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,
shopping_cart_item: item.into(),
}))
}
#[delete("/shopping-cart-item")]
@ -105,14 +119,14 @@ async fn delete_cart_item(
cart: Data<Addr<CartManager>>,
tm: Data<Addr<TokenManager>>,
credentials: BearerAuth,
Json(payload): Json<DeleteItemInput>,
Json(payload): Json<api::DeleteItemInput>,
) -> Result<HttpResponse> {
let (token, _) = credentials.require_user(tm.into_inner()).await?;
let sc: model::ShoppingCart = query_db!(
db,
database_manager::EnsureActiveShoppingCart {
buyer_id: AccountId::from(token.subject),
buyer_id: token.subject.into(),
},
routes::Error::Public(super::Error::RemoveItem.into()),
routes::Error::Public(PublicError::DatabaseConnection)
@ -126,10 +140,10 @@ async fn delete_cart_item(
})
.await
{
Ok(Ok(_)) => Ok(HttpResponse::Ok().json(DeleteItemOutput { success: true })),
Ok(Ok(_)) => Ok(HttpResponse::Ok().json(api::DeleteItemOutput { success: true })),
Ok(Err(e)) => {
log::error!("{e}");
Ok(HttpResponse::BadRequest().json(DeleteItemOutput { success: false }))
Ok(HttpResponse::BadRequest().json(api::DeleteItemOutput { success: false }))
}
Err(e) => {
log::error!("{e:?}");
@ -138,51 +152,34 @@ async fn delete_cart_item(
}
}
#[derive(serde::Deserialize)]
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,
#[get("/me")]
pub(crate) async fn me(
db: Data<Addr<Database>>,
tm: Data<Addr<TokenManager>>,
credentials: BearerAuth,
) -> routes::Result<Json<model::Account>> {
let account_id: model::AccountId = credentials
.require_user(tm.into_inner())
.await?
.0
.subject
.into();
let account: model::FullAccount =
public_send_db!(owned, db, database_manager::FindAccount { account_id });
Ok(Json(account.into()))
}
#[post("/order")]
pub(crate) async fn create_order(
req: HttpRequest,
Json(payload): Json<CreateOrderInput>,
Json(payload): Json<api::CreateOrderInput>,
tm: Data<Addr<TokenManager>>,
credentials: BearerAuth,
payment: Data<Addr<PaymentManager>>,
) -> routes::Result<HttpResponse> {
let (
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 subject = credentials.require_user(tm.into_inner()).await?.0.subject;
let buyer_id = model::AccountId::from(subject);
let CreateOrderInput {
let api::CreateOrderInput {
email,
phone,
first_name,
@ -208,7 +205,7 @@ pub(crate) async fn create_order(
language,
},
customer_ip: ip.to_string(),
buyer_id,
buyer_id: subject.into(),
charge_client
},
routes::Error::Public(PublicError::DatabaseConnection)
@ -222,11 +219,15 @@ pub(crate) async fn create_order(
}
pub(crate) fn configure(config: &mut ServiceConfig) {
config.service(scope("")
let scoped = scope("")
.app_data(actix_web_httpauth::extractors::bearer::Config::default()
.realm("user api")
.scope("customer_id role subject audience expiration_time not_before_time issued_at_time"))
.service(shopping_cart)
.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 config::SharedAppConfig;
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 token_manager::TokenManager;
use token_manager::{query_tm, TokenManager};
use crate::public_send_db;
use crate::routes::public::Error as PublicError;
use crate::routes::{self, Result};
use crate::{public_send_db, Login, Password};
#[get("/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)
}
#[derive(serde::Deserialize)]
pub struct CreateAccountInput {
pub email: model::Email,
pub login: Login,
pub password: Password,
pub password_confirmation: model::PasswordConfirmation,
}
#[post("/register")]
pub async fn create_account(
db: Data<Addr<Database>>,
Json(payload): Json<CreateAccountInput>,
Json(payload): Json<api::CreateAccountInput>,
config: Data<SharedAppConfig>,
) -> routes::Result<HttpResponse> {
if payload.password != payload.password_confirmation {
@ -57,12 +49,14 @@ pub async fn create_account(
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,
Err(e) => {
log::error!("{e:?}");
return Err(routes::Error::Admin(routes::admin::Error::HashPass));
}
}
};
public_send_db!(
@ -76,63 +70,78 @@ pub async fn create_account(
);
}
#[derive(serde::Deserialize)]
pub struct SignInInput {
pub login: String,
pub password: String,
pub(crate) struct AuthPair {
pub access_token: Token,
pub access_token_string: AccessTokenString,
pub _refresh_token: Token,
pub refresh_token_string: RefreshTokenString,
}
#[derive(serde::Serialize)]
pub struct SignInOutput {
pub token: TokenString,
}
#[post("/sign-in")]
async fn sign_in(
Json(payload): Json<SignInInput>,
db: Data<Addr<Database>>,
pub(crate) async fn create_auth_pair(
tm: Data<Addr<TokenManager>>,
) -> Result<HttpResponse> {
let db = db.into_inner();
let tm = tm.into_inner();
let account: FullAccount = query_db!(
db,
database_manager::AccountByIdentity {
login: Some(Login::from(payload.login)),
email: None,
},
account: FullAccount,
) -> routes::Result<AuthPair> {
let (access_token, refresh_token) = query_tm!(
multi,
tm,
routes::Error::Public(PublicError::DatabaseConnection),
routes::Error::Public(PublicError::DatabaseConnection)
);
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 {
token_manager::CreateToken {
customer_id: account.customer_id,
role: account.role,
subject: account.id,
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")]
@ -145,7 +154,7 @@ async fn handle_notification(
notification: notify,
});
}
HttpResponse::Ok().body("")
HttpResponse::Ok().finish()
}
pub(crate) fn configure(config: &mut ServiceConfig) {

View File

@ -1,9 +1,10 @@
use chrono::NaiveDateTime;
use derive_more::Deref;
#[cfg(feature = "dummy")]
use fake::Fake;
use serde::{Deserialize, Serialize};
use crate::ProductLinkedPhoto;
use crate::*;
#[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[derive(Serialize, Deserialize, Debug)]
@ -75,20 +76,40 @@ pub struct AccountOrder {
#[cfg_attr(feature = "dummy", derive(fake::Dummy))]
#[derive(Serialize, Deserialize, Debug)]
pub struct ShoppingCartItem {
pub id: crate::ShoppingCartId,
pub product_id: crate::ProductId,
pub shopping_cart_id: crate::ShoppingCartId,
pub quantity: crate::Quantity,
pub quantity_unit: crate::QuantityUnit,
pub id: ShoppingCartId,
pub product_id: ProductId,
pub shopping_cart_id: ShoppingCartId,
pub quantity: Quantity,
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))]
#[derive(Serialize, Deserialize, Debug)]
pub struct ShoppingCart {
pub id: crate::ShoppingCartId,
pub buyer_id: crate::AccountId,
pub payment_method: crate::PaymentMethod,
pub state: crate::ShoppingCartState,
pub id: ShoppingCartId,
pub buyer_id: AccountId,
pub payment_method: PaymentMethod,
pub state: ShoppingCartState,
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 = "db", derive(sqlx::FromRow))]
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
pub struct Account {
pub id: AccountId,
pub email: Email,
@ -744,10 +744,34 @@ pub struct Token {
#[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, Display, From)]
pub struct TokenString(String);
#[derive(Serialize, Deserialize, Debug, Clone, Deref, Display, From)]
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 {
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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="logo" data-trunk rel="icon" href="assets/logo.png" >
<title>Bazzar</title>
<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/>
<link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
</head>
<body>
<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::*;
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()
.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 {
orders.stream(streams::interval(500, || Msg::CheckAccessToken));
Model {
token: LocalStorage::get("auth-token").ok(),
page: Page::Public(PublicPage::Listing(pages::public::listing::init(
url,
&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>) {
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::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)) => {
let page = fetch_page!(public model, 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> {
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))),
_ => empty![],
}

View File

@ -3,4 +3,6 @@ use crate::Page;
pub struct Model {
pub token: Option<String>,
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::{struct_urls, Url};
use crate::shared;
#[derive(Debug)]
pub enum Msg {
Public(public::Msg),
UrlChanged(subs::UrlChanged),
CheckAccessToken,
Shared(shared::Msg),
}
pub enum AdminPage {

View File

@ -12,6 +12,7 @@ pub struct Model {
pub enum Msg {
FetchProducts,
ProductFetched(fetch::Result<model::api::Products>),
Shared(crate::shared::Msg)
}
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)) => {
model.errors.push("Failed to load products".into());
}
Msg::Shared(_) => {}
}
}
pub fn view(model: &Model) -> Node<Msg> {
let products = model.products.iter().map(product);
pub fn view(model: &crate::Model, page: &Model) -> Node<Msg> {
let products = page.products.iter().map(product);
div![
crate::shared::public_navbar(),
crate::shared::view::public_navbar(model),
div![
C!["grid grid-cols-1 gap-4 lg:grid-cols-6 sm:grid-cols-2"],
products

View File

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