feat(users): Signin and Verify Email changes for User Invitation changes (#3420)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Mani Chandra
2024-01-30 12:43:13 +05:30
committed by GitHub
parent 02074dfc23
commit d91da89065
12 changed files with 369 additions and 41 deletions

View File

@ -12,9 +12,9 @@ use crate::user::{
},
AuthorizeResponse, ChangePasswordRequest, ConnectAccountRequest, CreateInternalUserRequest,
DashboardEntryResponse, ForgotPasswordRequest, GetUsersResponse, InviteUserRequest,
InviteUserResponse, ResetPasswordRequest, SendVerifyEmailRequest, SignUpRequest,
SignUpWithMerchantIdRequest, SwitchMerchantIdRequest, UpdateUserAccountDetailsRequest,
UserMerchantCreate, VerifyEmailRequest,
InviteUserResponse, ResetPasswordRequest, SendVerifyEmailRequest, SignInResponse,
SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest,
UpdateUserAccountDetailsRequest, UserMerchantCreate, VerifyEmailRequest,
};
impl ApiEventMetric for DashboardEntryResponse {
@ -56,6 +56,7 @@ common_utils::impl_misc_api_event_type!(
InviteUserResponse,
VerifyEmailRequest,
SendVerifyEmailRequest,
SignInResponse,
UpdateUserAccountDetailsRequest
);

View File

@ -39,7 +39,21 @@ pub struct DashboardEntryResponse {
pub type SignInRequest = SignUpRequest;
pub type SignInResponse = DashboardEntryResponse;
#[derive(Debug, serde::Serialize)]
#[serde(tag = "flow_type", rename_all = "snake_case")]
pub enum SignInResponse {
MerchantSelect(MerchantSelectResponse),
DashboardEntry(DashboardEntryResponse),
}
#[derive(Debug, serde::Serialize)]
pub struct MerchantSelectResponse {
pub token: Secret<String>,
pub name: Secret<String>,
pub email: pii::Email,
pub verification_days_left: Option<i64>,
pub merchants: Vec<UserMerchantAccount>,
}
#[derive(serde::Deserialize, Debug, Clone, serde::Serialize)]
pub struct ConnectAccountRequest {
@ -138,7 +152,7 @@ pub struct VerifyEmailRequest {
pub token: Secret<String>,
}
pub type VerifyEmailResponse = DashboardEntryResponse;
pub type VerifyEmailResponse = SignInResponse;
#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct SendVerifyEmailRequest {
@ -149,6 +163,7 @@ pub struct SendVerifyEmailRequest {
pub struct UserMerchantAccount {
pub merchant_id: String,
pub merchant_name: OptionalEncryptableName,
pub is_active: bool,
}
#[cfg(feature = "recon")]

View File

@ -58,6 +58,8 @@ pub enum UserErrors {
InvalidDeleteOperation,
#[error("MaxInvitationsError")]
MaxInvitationsError,
#[error("RoleNotFound")]
RoleNotFound,
}
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@ -146,6 +148,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::MaxInvitationsError => {
AER::BadRequest(ApiError::new(sub_code, 31, self.get_error_message(), None))
}
Self::RoleNotFound => {
AER::BadRequest(ApiError::new(sub_code, 32, self.get_error_message(), None))
}
}
}
}
@ -178,6 +183,7 @@ impl UserErrors {
Self::ChangePasswordError => "Old and new password cannot be the same",
Self::InvalidDeleteOperation => "Delete Operation Not Supported",
Self::MaxInvitationsError => "Maximum invite count per request exceeded",
Self::RoleNotFound => "Role Not Found",
}
}
}

View File

@ -97,10 +97,10 @@ pub async fn signup(
))
}
pub async fn signin(
pub async fn signin_without_invite_checks(
state: AppState,
request: user_api::SignInRequest,
) -> UserResponse<user_api::SignInResponse> {
) -> UserResponse<user_api::DashboardEntryResponse> {
let user_from_db: domain::UserFromStorage = state
.store
.find_user_by_email(request.email.clone().expose().expose().as_str())
@ -124,6 +124,50 @@ pub async fn signin(
))
}
pub async fn signin(
state: AppState,
request: user_api::SignInRequest,
) -> UserResponse<user_api::SignInResponse> {
let user_from_db: domain::UserFromStorage = state
.store
.find_user_by_email(request.email.clone().expose().expose().as_str())
.await
.map_err(|e| {
if e.current_context().is_db_not_found() {
e.change_context(UserErrors::InvalidCredentials)
} else {
e.change_context(UserErrors::InternalServerError)
}
})?
.into();
user_from_db.compare_password(request.password)?;
let signin_strategy =
if let Some(preferred_merchant_id) = user_from_db.get_preferred_merchant_id() {
let preferred_role = user_from_db
.get_role_from_db_by_merchant_id(&state, preferred_merchant_id.as_str())
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("User role with preferred_merchant_id not found")?;
domain::SignInWithRoleStrategyType::SingleRole(domain::SignInWithSingleRoleStrategy {
user: user_from_db,
user_role: preferred_role,
})
} else {
let user_roles = user_from_db.get_roles_from_db(&state).await?;
domain::SignInWithRoleStrategyType::decide_signin_strategy_by_user_roles(
user_from_db,
user_roles,
)
.await?
};
Ok(ApplicationResponse::Json(
signin_strategy.get_signin_response(&state).await?,
))
}
#[cfg(feature = "email")]
pub async fn connect_account(
state: AppState,
@ -832,22 +876,22 @@ pub async fn list_merchant_ids_for_user(
state: AppState,
user: auth::UserFromToken,
) -> UserResponse<Vec<user_api::UserMerchantAccount>> {
let merchant_ids = utils::user_role::get_merchant_ids_for_user(&state, &user.user_id).await?;
let user_roles =
utils::user_role::get_active_user_roles_for_user(&state, &user.user_id).await?;
let merchant_accounts = state
.store
.list_multiple_merchant_accounts(merchant_ids)
.list_multiple_merchant_accounts(
user_roles
.iter()
.map(|role| role.merchant_id.clone())
.collect(),
)
.await
.change_context(UserErrors::InternalServerError)?;
Ok(ApplicationResponse::Json(
merchant_accounts
.into_iter()
.map(|acc| user_api::UserMerchantAccount {
merchant_id: acc.merchant_id,
merchant_name: acc.merchant_name,
})
.collect(),
utils::user::get_multiple_merchant_details_with_status(user_roles, merchant_accounts)?,
))
}
@ -868,11 +912,38 @@ pub async fn get_users_for_merchant_account(
Ok(ApplicationResponse::Json(user_api::GetUsersResponse(users)))
}
#[cfg(feature = "email")]
pub async fn verify_email_without_invite_checks(
state: AppState,
req: user_api::VerifyEmailRequest,
) -> UserResponse<user_api::DashboardEntryResponse> {
let token = auth::decode_jwt::<email_types::EmailToken>(&req.token.clone().expose(), &state)
.await
.change_context(UserErrors::LinkInvalid)?;
let user = state
.store
.find_user_by_email(token.get_email())
.await
.change_context(UserErrors::InternalServerError)?;
let user = state
.store
.update_user_by_user_id(user.user_id.as_str(), storage_user::UserUpdate::VerifyUser)
.await
.change_context(UserErrors::InternalServerError)?;
let user_from_db: domain::UserFromStorage = user.into();
let user_role = user_from_db.get_role_from_db(state.clone()).await?;
let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
Ok(ApplicationResponse::Json(
utils::user::get_dashboard_entry_response(&state, user_from_db, user_role, token)?,
))
}
#[cfg(feature = "email")]
pub async fn verify_email(
state: AppState,
req: user_api::VerifyEmailRequest,
) -> UserResponse<user_api::VerifyEmailResponse> {
) -> UserResponse<user_api::SignInResponse> {
let token = auth::decode_jwt::<email_types::EmailToken>(&req.token.clone().expose(), &state)
.await
.change_context(UserErrors::LinkInvalid)?;
@ -890,11 +961,29 @@ pub async fn verify_email(
.change_context(UserErrors::InternalServerError)?;
let user_from_db: domain::UserFromStorage = user.into();
let user_role = user_from_db.get_role_from_db(state.clone()).await?;
let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
let signin_strategy =
if let Some(preferred_merchant_id) = user_from_db.get_preferred_merchant_id() {
let preferred_role = user_from_db
.get_role_from_db_by_merchant_id(&state, preferred_merchant_id.as_str())
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("User role with preferred_merchant_id not found")?;
domain::SignInWithRoleStrategyType::SingleRole(domain::SignInWithSingleRoleStrategy {
user: user_from_db,
user_role: preferred_role,
})
} else {
let user_roles = user_from_db.get_roles_from_db(&state).await?;
domain::SignInWithRoleStrategyType::decide_signin_strategy_by_user_roles(
user_from_db,
user_roles,
)
.await?
};
Ok(ApplicationResponse::Json(
utils::user::get_dashboard_entry_response(&state, user_from_db, user_role, token)?,
signin_strategy.get_signin_response(&state).await?,
))
}

View File

@ -952,7 +952,10 @@ impl User {
let mut route = web::scope("/user").app_data(web::Data::new(state));
route = route
.service(web::resource("/signin").route(web::post().to(user_signin)))
.service(
web::resource("/signin").route(web::post().to(user_signin_without_invite_checks)),
)
.service(web::resource("/v2/signin").route(web::post().to(user_signin)))
.service(web::resource("/change_password").route(web::post().to(change_password)))
.service(web::resource("/internal_signup").route(web::post().to(internal_user_signup)))
.service(web::resource("/switch_merchant").route(web::post().to(switch_merchant_id)))
@ -961,14 +964,7 @@ impl User {
.route(web::post().to(user_merchant_account_create)),
)
.service(web::resource("/switch/list").route(web::get().to(list_merchant_ids_for_user)))
.service(web::resource("/user/list").route(web::get().to(get_user_details)))
.service(web::resource("/permission_info").route(web::get().to(get_authorization_info)))
.service(web::resource("/user/update_role").route(web::post().to(update_user_role)))
.service(web::resource("/role/list").route(web::get().to(list_roles)))
.service(web::resource("/role").route(web::get().to(get_role_from_token)))
.service(web::resource("/role/{role_id}").route(web::get().to(get_role)))
.service(web::resource("/user/invite").route(web::post().to(invite_user)))
.service(web::resource("/user/invite/accept").route(web::post().to(accept_invitation)))
.service(web::resource("/update").route(web::post().to(update_user_account_details)))
.service(
web::resource("/user/invite_multiple").route(web::post().to(invite_multiple_user)),
@ -980,6 +976,23 @@ impl User {
)
.service(web::resource("/user/delete").route(web::delete().to(delete_user_role)));
// User management
route = route.service(
web::scope("/user")
.service(web::resource("/list").route(web::get().to(get_user_details)))
.service(web::resource("/invite").route(web::post().to(invite_user)))
.service(web::resource("/invite/accept").route(web::post().to(accept_invitation)))
.service(web::resource("/update_role").route(web::post().to(update_user_role))),
);
// Role information
route = route.service(
web::scope("/role")
.service(web::resource("").route(web::get().to(get_role_from_token)))
.service(web::resource("/list").route(web::get().to(list_all_roles)))
.service(web::resource("/{role_id}").route(web::get().to(get_role))),
);
#[cfg(feature = "dummy_connector")]
{
route = route.service(
@ -1000,7 +1013,11 @@ impl User {
web::resource("/signup_with_merchant_id")
.route(web::post().to(user_signup_with_merchant_id)),
)
.service(web::resource("/verify_email").route(web::post().to(verify_email)))
.service(
web::resource("/verify_email")
.route(web::post().to(verify_email_without_invite_checks)),
)
.service(web::resource("/v2/verify_email").route(web::post().to(verify_email)))
.service(
web::resource("/verify_email_request")
.route(web::post().to(verify_email_request)),

View File

@ -161,6 +161,7 @@ impl From<Flow> for ApiIdentifier {
Flow::UserConnectAccount
| Flow::UserSignUp
| Flow::UserSignInWithoutInviteChecks
| Flow::UserSignIn
| Flow::ChangePassword
| Flow::SetDashboardMetadata
@ -179,6 +180,7 @@ impl From<Flow> for ApiIdentifier {
| Flow::InviteMultipleUser
| Flow::DeleteUser
| Flow::UserSignUpWithMerchantId
| Flow::VerifyEmailWithoutInviteChecks
| Flow::VerifyEmail
| Flow::VerifyEmailRequest
| Flow::UpdateUserAccountDetails => Self::User,

View File

@ -58,6 +58,25 @@ pub async fn user_signup(
.await
}
pub async fn user_signin_without_invite_checks(
state: web::Data<AppState>,
http_req: HttpRequest,
json_payload: web::Json<user_api::SignInRequest>,
) -> HttpResponse {
let flow = Flow::UserSignInWithoutInviteChecks;
let req_payload = json_payload.into_inner();
Box::pin(api::server_wrap(
flow.clone(),
state,
&http_req,
req_payload.clone(),
|state, _, req_body| user_core::signin_without_invite_checks(state, req_body),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn user_signin(
state: web::Data<AppState>,
http_req: HttpRequest,
@ -368,6 +387,25 @@ pub async fn invite_multiple_user(
.await
}
#[cfg(feature = "email")]
pub async fn verify_email_without_invite_checks(
state: web::Data<AppState>,
http_req: HttpRequest,
json_payload: web::Json<user_api::VerifyEmailRequest>,
) -> HttpResponse {
let flow = Flow::VerifyEmailWithoutInviteChecks;
Box::pin(api::server_wrap(
flow.clone(),
state,
&http_req,
json_payload.into_inner(),
|state, _, req_payload| user_core::verify_email_without_invite_checks(state, req_payload),
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
.await
}
#[cfg(feature = "email")]
pub async fn verify_email(
state: web::Data<AppState>,

View File

@ -29,7 +29,7 @@ pub async fn get_authorization_info(
.await
}
pub async fn list_roles(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
pub async fn list_all_roles(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let flow = Flow::ListRoles;
Box::pin(api::server_wrap(
flow,

View File

@ -26,11 +26,12 @@ use crate::{
db::StorageInterface,
routes::AppState,
services::{
authentication as auth,
authentication::UserFromToken,
authorization::{info, predefined_permissions},
},
types::transformers::ForeignFrom,
utils::user::password,
utils::{self, user::password},
};
pub mod dashboard_metadata;
@ -733,7 +734,15 @@ impl UserFromStorage {
pub async fn get_role_from_db(&self, state: AppState) -> UserResult<UserRole> {
state
.store
.find_user_role_by_user_id(self.get_user_id())
.find_user_role_by_user_id(&self.0.user_id)
.await
.change_context(UserErrors::InternalServerError)
}
pub async fn get_roles_from_db(&self, state: &AppState) -> UserResult<Vec<UserRole>> {
state
.store
.list_user_roles_by_user_id(&self.0.user_id)
.await
.change_context(UserErrors::InternalServerError)
}
@ -760,6 +769,29 @@ impl UserFromStorage {
let days_left_for_verification = last_date_for_verification - today;
Ok(Some(days_left_for_verification.whole_days()))
}
pub fn get_preferred_merchant_id(&self) -> Option<String> {
self.0.preferred_merchant_id.clone()
}
pub async fn get_role_from_db_by_merchant_id(
&self,
state: &AppState,
merchant_id: &str,
) -> UserResult<UserRole> {
state
.store
.find_user_role_by_user_id_merchant_id(self.get_user_id(), merchant_id)
.await
.map_err(|e| {
if e.current_context().is_db_not_found() {
UserErrors::RoleNotFound
} else {
UserErrors::InternalServerError
}
})
.into_report()
}
}
impl From<info::ModuleInfo> for user_role_api::ModuleInfo {
@ -828,3 +860,101 @@ impl TryFrom<UserAndRoleJoined> for user_api::UserDetails {
})
}
}
pub enum SignInWithRoleStrategyType {
SingleRole(SignInWithSingleRoleStrategy),
MultipleRoles(SignInWithMultipleRolesStrategy),
}
impl SignInWithRoleStrategyType {
pub async fn decide_signin_strategy_by_user_roles(
user: UserFromStorage,
user_roles: Vec<UserRole>,
) -> UserResult<Self> {
if user_roles.is_empty() {
return Err(UserErrors::InternalServerError.into());
}
if let Some(user_role) = user_roles
.iter()
.find(|role| role.status == UserStatus::Active)
{
Ok(Self::SingleRole(SignInWithSingleRoleStrategy {
user,
user_role: user_role.clone(),
}))
} else {
Ok(Self::MultipleRoles(SignInWithMultipleRolesStrategy {
user,
user_roles,
}))
}
}
pub async fn get_signin_response(
self,
state: &AppState,
) -> UserResult<user_api::SignInResponse> {
match self {
Self::SingleRole(strategy) => strategy.get_signin_response(state).await,
Self::MultipleRoles(strategy) => strategy.get_signin_response(state).await,
}
}
}
pub struct SignInWithSingleRoleStrategy {
pub user: UserFromStorage,
pub user_role: UserRole,
}
impl SignInWithSingleRoleStrategy {
async fn get_signin_response(self, state: &AppState) -> UserResult<user_api::SignInResponse> {
let token =
utils::user::generate_jwt_auth_token(state, &self.user, &self.user_role).await?;
let dashboard_entry_response =
utils::user::get_dashboard_entry_response(state, self.user, self.user_role, token)?;
Ok(user_api::SignInResponse::DashboardEntry(
dashboard_entry_response,
))
}
}
pub struct SignInWithMultipleRolesStrategy {
pub user: UserFromStorage,
pub user_roles: Vec<UserRole>,
}
impl SignInWithMultipleRolesStrategy {
async fn get_signin_response(self, state: &AppState) -> UserResult<user_api::SignInResponse> {
let merchant_accounts = state
.store
.list_multiple_merchant_accounts(
self.user_roles
.iter()
.map(|role| role.merchant_id.clone())
.collect(),
)
.await
.change_context(UserErrors::InternalServerError)?;
let merchant_details = utils::user::get_multiple_merchant_details_with_status(
self.user_roles,
merchant_accounts,
)?;
Ok(user_api::SignInResponse::MerchantSelect(
user_api::MerchantSelectResponse {
name: self.user.get_name(),
email: self.user.get_email(),
token: auth::UserAuthToken::new_token(
self.user.get_user_id().to_string(),
&state.conf,
)
.await?
.into(),
merchants: merchant_details,
verification_days_left: utils::user::get_verification_days_left(state, &self.user)?,
},
))
}
}

View File

@ -1,5 +1,7 @@
use std::collections::HashMap;
use api_models::user as user_api;
use diesel_models::user_role::UserRole;
use diesel_models::{enums::UserStatus, user_role::UserRole};
use error_stack::ResultExt;
use masking::Secret;
@ -118,3 +120,29 @@ pub fn get_verification_days_left(
#[cfg(not(feature = "email"))]
return Ok(None);
}
pub fn get_multiple_merchant_details_with_status(
user_roles: Vec<UserRole>,
merchant_accounts: Vec<MerchantAccount>,
) -> UserResult<Vec<user_api::UserMerchantAccount>> {
let roles: HashMap<_, _> = user_roles
.into_iter()
.map(|user_role| (user_role.merchant_id.clone(), user_role))
.collect();
merchant_accounts
.into_iter()
.map(|merchant| {
let role = roles
.get(merchant.merchant_id.as_str())
.ok_or(UserErrors::InternalServerError.into())
.attach_printable("Merchant exists but user role doesn't")?;
Ok(user_api::UserMerchantAccount {
merchant_id: merchant.merchant_id.clone(),
merchant_name: merchant.merchant_name.clone(),
is_active: role.status == UserStatus::Active,
})
})
.collect()
}

View File

@ -1,5 +1,5 @@
use api_models::user_role as user_role_api;
use diesel_models::enums::UserStatus;
use diesel_models::{enums::UserStatus, user_role::UserRole};
use error_stack::ResultExt;
use crate::{
@ -17,19 +17,17 @@ pub fn is_internal_role(role_id: &str) -> bool {
|| role_id == consts::user_role::ROLE_ID_INTERNAL_VIEW_ONLY_USER
}
pub async fn get_merchant_ids_for_user(state: &AppState, user_id: &str) -> UserResult<Vec<String>> {
pub async fn get_active_user_roles_for_user(
state: &AppState,
user_id: &str,
) -> UserResult<Vec<UserRole>> {
Ok(state
.store
.list_user_roles_by_user_id(user_id)
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.filter_map(|ele| {
if ele.status == UserStatus::Active {
return Some(ele.merchant_id);
}
None
})
.filter(|ele| ele.status == UserStatus::Active)
.collect())
}

View File

@ -267,6 +267,8 @@ pub enum Flow {
UserSignUp,
/// User Sign Up
UserSignUpWithMerchantId,
/// User Sign In without invite checks
UserSignInWithoutInviteChecks,
/// User Sign In
UserSignIn,
/// User connect account
@ -333,6 +335,8 @@ pub enum Flow {
SyncOnboardingStatus,
/// Reset tracking id
ResetTrackingId,
/// Verify email token without invite checks
VerifyEmailWithoutInviteChecks,
/// Verify email Token
VerifyEmail,
/// Send verify email