diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index 40d082d1ca..04aabc071a 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -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 ); diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index 056d1b593d..89f42f58c3 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -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, + pub name: Secret, + pub email: pii::Email, + pub verification_days_left: Option, + pub merchants: Vec, +} #[derive(serde::Deserialize, Debug, Clone, serde::Serialize)] pub struct ConnectAccountRequest { @@ -138,7 +152,7 @@ pub struct VerifyEmailRequest { pub token: Secret, } -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")] diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index 389cb10d7b..d3b1679378 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -58,6 +58,8 @@ pub enum UserErrors { InvalidDeleteOperation, #[error("MaxInvitationsError")] MaxInvitationsError, + #[error("RoleNotFound")] + RoleNotFound, } impl common_utils::errors::ErrorSwitch for UserErrors { @@ -146,6 +148,9 @@ impl common_utils::errors::ErrorSwitch { 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", } } } diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index c2ed78f866..ae66728e14 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -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 { +) -> UserResponse { 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 { + 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> { - 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 { + let token = auth::decode_jwt::(&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 { +) -> UserResponse { let token = auth::decode_jwt::(&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?, )) } diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index f490cee8da..e18e4d85c7 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -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)), diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 6df8c7fb7a..07894afe73 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -161,6 +161,7 @@ impl From for ApiIdentifier { Flow::UserConnectAccount | Flow::UserSignUp + | Flow::UserSignInWithoutInviteChecks | Flow::UserSignIn | Flow::ChangePassword | Flow::SetDashboardMetadata @@ -179,6 +180,7 @@ impl From for ApiIdentifier { | Flow::InviteMultipleUser | Flow::DeleteUser | Flow::UserSignUpWithMerchantId + | Flow::VerifyEmailWithoutInviteChecks | Flow::VerifyEmail | Flow::VerifyEmailRequest | Flow::UpdateUserAccountDetails => Self::User, diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index 02704cf701..88e19ddf75 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -58,6 +58,25 @@ pub async fn user_signup( .await } +pub async fn user_signin_without_invite_checks( + state: web::Data, + http_req: HttpRequest, + json_payload: web::Json, +) -> 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, 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, + http_req: HttpRequest, + json_payload: web::Json, +) -> 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, diff --git a/crates/router/src/routes/user_role.rs b/crates/router/src/routes/user_role.rs index f83134e582..3f9ccda865 100644 --- a/crates/router/src/routes/user_role.rs +++ b/crates/router/src/routes/user_role.rs @@ -29,7 +29,7 @@ pub async fn get_authorization_info( .await } -pub async fn list_roles(state: web::Data, req: HttpRequest) -> HttpResponse { +pub async fn list_all_roles(state: web::Data, req: HttpRequest) -> HttpResponse { let flow = Flow::ListRoles; Box::pin(api::server_wrap( flow, diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs index bbe21f289a..d3ea69ecd8 100644 --- a/crates/router/src/types/domain/user.rs +++ b/crates/router/src/types/domain/user.rs @@ -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 { 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> { + 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 { + self.0.preferred_merchant_id.clone() + } + + pub async fn get_role_from_db_by_merchant_id( + &self, + state: &AppState, + merchant_id: &str, + ) -> UserResult { + 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 for user_role_api::ModuleInfo { @@ -828,3 +860,101 @@ impl TryFrom 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, + ) -> UserResult { + 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 { + 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 { + 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, +} + +impl SignInWithMultipleRolesStrategy { + async fn get_signin_response(self, state: &AppState) -> UserResult { + 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)?, + }, + )) + } +} diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs index a3f9e7978a..697d10f772 100644 --- a/crates/router/src/utils/user.rs +++ b/crates/router/src/utils/user.rs @@ -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, + merchant_accounts: Vec, +) -> UserResult> { + 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() +} diff --git a/crates/router/src/utils/user_role.rs b/crates/router/src/utils/user_role.rs index 65ead92ad3..7ca06aeda0 100644 --- a/crates/router/src/utils/user_role.rs +++ b/crates/router/src/utils/user_role.rs @@ -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> { +pub async fn get_active_user_roles_for_user( + state: &AppState, + user_id: &str, +) -> UserResult> { 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()) } diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 998c52f2c1..0d5710820e 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -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