diff --git a/crates/api_models/src/events/user_role.rs b/crates/api_models/src/events/user_role.rs index 0d42d1de7d..34375a2284 100644 --- a/crates/api_models/src/events/user_role.rs +++ b/crates/api_models/src/events/user_role.rs @@ -7,7 +7,7 @@ use crate::user_role::{ UpdateRoleRequest, }, AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest, - TransferOrgOwnershipRequest, UpdateUserRoleRequest, + MerchantSelectRequest, TransferOrgOwnershipRequest, UpdateUserRoleRequest, }; common_utils::impl_misc_api_event_type!( @@ -15,6 +15,7 @@ common_utils::impl_misc_api_event_type!( GetRoleRequest, AuthorizationInfoResponse, UpdateUserRoleRequest, + MerchantSelectRequest, AcceptInvitationRequest, DeleteUserRoleRequest, TransferOrgOwnershipRequest, diff --git a/crates/api_models/src/user_role.rs b/crates/api_models/src/user_role.rs index 08d63d9097..6dde3eb888 100644 --- a/crates/api_models/src/user_role.rs +++ b/crates/api_models/src/user_role.rs @@ -97,11 +97,15 @@ pub enum UserStatus { } #[derive(Debug, serde::Deserialize, serde::Serialize)] -pub struct AcceptInvitationRequest { +pub struct MerchantSelectRequest { pub merchant_ids: Vec, // TODO: Remove this once the token only api is being used pub need_dashboard_entry_response: Option, } +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct AcceptInvitationRequest { + pub merchant_ids: Vec, +} #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct DeleteUserRoleRequest { diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 83cdd1d318..bb30d30759 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -7,6 +7,8 @@ use diesel_models::{ user_role::UserRoleNew, }; use error_stack::{report, ResultExt}; +#[cfg(feature = "email")] +use external_services::email::EmailData; use masking::{ExposeInterface, PeekInterface}; #[cfg(feature = "email")] use router_env::env; @@ -570,6 +572,7 @@ pub async fn invite_multiple_user( user_from_token: auth::UserFromToken, requests: Vec, req_state: ReqState, + is_token_only: Option, ) -> UserResponse> { if requests.len() > 10 { return Err(report!(UserErrors::MaxInvitationsError)) @@ -577,7 +580,8 @@ pub async fn invite_multiple_user( } let responses = futures::future::join_all(requests.iter().map(|request| async { - match handle_invitation(&state, &user_from_token, request, &req_state).await { + match handle_invitation(&state, &user_from_token, request, &req_state, is_token_only).await + { Ok(response) => response, Err(error) => InviteMultipleUserResponse { email: request.email.clone(), @@ -597,6 +601,7 @@ async fn handle_invitation( user_from_token: &auth::UserFromToken, request: &user_api::InviteUserRequest, req_state: &ReqState, + is_token_only: Option, ) -> UserResult { let inviter_user = user_from_token.get_user_from_db(state).await?; @@ -635,7 +640,14 @@ async fn handle_invitation( .err() .unwrap_or(false) { - handle_new_user_invitation(state, user_from_token, request, req_state.clone()).await + handle_new_user_invitation( + state, + user_from_token, + request, + req_state.clone(), + is_token_only, + ) + .await } else { Err(UserErrors::InternalServerError.into()) } @@ -718,6 +730,7 @@ async fn handle_new_user_invitation( user_from_token: &auth::UserFromToken, request: &user_api::InviteUserRequest, req_state: ReqState, + is_token_only: Option, ) -> UserResult { let new_user = domain::NewUser::try_from((request.clone(), user_from_token.clone()))?; @@ -756,25 +769,36 @@ async fn handle_new_user_invitation( })?; let is_email_sent; + // TODO: Adding this to avoid clippy lints, remove this once the token only flow is being used + let _ = is_token_only; + #[cfg(feature = "email")] { // TODO: Adding this to avoid clippy lints // Will be adding actual usage for this variable later let _ = req_state.clone(); let invitee_email = domain::UserEmail::from_pii_email(request.email.clone())?; - let email_contents = email_types::InviteUser { - recipient_email: invitee_email, - user_name: domain::UserName::new(new_user.get_name())?, - settings: state.conf.clone(), - subject: "You have been invited to join Hyperswitch Community!", - merchant_id: user_from_token.merchant_id.clone(), + let email_contents: Box = if let Some(true) = is_token_only + { + Box::new(email_types::InviteRegisteredUser { + recipient_email: invitee_email, + user_name: domain::UserName::new(new_user.get_name())?, + settings: state.conf.clone(), + subject: "You have been invited to join Hyperswitch Community!", + merchant_id: user_from_token.merchant_id.clone(), + }) + } else { + Box::new(email_types::InviteUser { + recipient_email: invitee_email, + user_name: domain::UserName::new(new_user.get_name())?, + settings: state.conf.clone(), + subject: "You have been invited to join Hyperswitch Community!", + merchant_id: user_from_token.merchant_id.clone(), + }) }; let send_email_result = state .email_client - .compose_and_send_email( - Box::new(email_contents), - state.conf.proxy.https_url.as_ref(), - ) + .compose_and_send_email(email_contents, state.conf.proxy.https_url.as_ref()) .await; logger::info!(?send_email_result); is_email_sent = send_email_result.is_ok(); @@ -1203,11 +1227,11 @@ pub async fn create_merchant_account( pub async fn list_merchants_for_user( state: AppState, - user_from_token: auth::UserFromToken, + user_from_token: Box, ) -> UserResponse> { let user_roles = state .store - .list_user_roles_by_user_id(user_from_token.user_id.as_str()) + .list_user_roles_by_user_id(user_from_token.get_user_id().as_str()) .await .change_context(UserErrors::InternalServerError)?; diff --git a/crates/router/src/core/user_role.rs b/crates/router/src/core/user_role.rs index ae20ec51ad..0d196e1c4a 100644 --- a/crates/router/src/core/user_role.rs +++ b/crates/router/src/core/user_role.rs @@ -170,8 +170,38 @@ pub async fn transfer_org_ownership( pub async fn accept_invitation( state: AppState, - user_token: auth::UserFromSinglePurposeToken, + user_token: auth::UserFromToken, req: user_role_api::AcceptInvitationRequest, +) -> UserResponse<()> { + futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async { + state + .store + .update_user_role_by_user_id_merchant_id( + user_token.user_id.as_str(), + merchant_id, + UserRoleUpdate::UpdateStatus { + status: UserStatus::Active, + modified_by: user_token.user_id.clone(), + }, + ) + .await + .map_err(|e| { + logger::error!("Error while accepting invitation {}", e); + }) + .ok() + })) + .await + .into_iter() + .reduce(Option::or) + .flatten() + .ok_or(UserErrors::MerchantIdNotFound.into()) + .map(|_| ApplicationResponse::StatusOk) +} + +pub async fn merchant_select( + state: AppState, + user_token: auth::UserFromSinglePurposeToken, + req: user_role_api::MerchantSelectRequest, ) -> UserResponse> { let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async { state @@ -207,7 +237,6 @@ pub async fn accept_invitation( utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &user_role).await; let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?; - let response = utils::user::get_dashboard_entry_response( &state, user_from_db, @@ -223,10 +252,10 @@ pub async fn accept_invitation( Ok(ApplicationResponse::StatusOk) } -pub async fn accept_invitation_token_only_flow( +pub async fn merchant_select_token_only_flow( state: AppState, user_token: auth::UserFromSinglePurposeToken, - req: user_role_api::AcceptInvitationRequest, + req: user_role_api::MerchantSelectRequest, ) -> UserResponse> { let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async { state diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 49a8e06318..1c680b8bc8 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1192,6 +1192,11 @@ impl User { // TODO: Remove this endpoint once migration to /merchants/list is done .service(web::resource("/switch/list").route(web::get().to(list_merchants_for_user))) .service(web::resource("/merchants/list").route(web::get().to(list_merchants_for_user))) + // The route is utilized to select an invitation from a list of merchants in an intermediate state + .service( + web::resource("/merchants_select/list") + .route(web::get().to(list_merchants_for_user_with_spt)), + ) .service(web::resource("/permission_info").route(web::get().to(get_authorization_info))) .service(web::resource("/update").route(web::post().to(update_user_account_details))) .service( @@ -1241,7 +1246,11 @@ impl User { .service( web::resource("/invite_multiple").route(web::post().to(invite_multiple_user)), ) - .service(web::resource("/invite/accept").route(web::post().to(accept_invitation))) + .service( + web::resource("/invite/accept") + .route(web::post().to(merchant_select)) + .route(web::put().to(accept_invitation)), + ) .service(web::resource("/update_role").route(web::post().to(update_user_role))) .service( web::resource("/transfer_ownership") diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index ee42cc50fe..7b10247dde 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -220,6 +220,7 @@ impl From for ApiIdentifier { | Flow::UpdateUserRole | Flow::GetAuthorizationInfo | Flow::AcceptInvitation + | Flow::MerchantSelect | Flow::DeleteUserRole | Flow::TransferOrgOwnership | Flow::CreateRole diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index a901988e51..6de38bd55a 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -324,6 +324,23 @@ pub async fn list_merchants_for_user(state: web::Data, req: HttpReques .await } +pub async fn list_merchants_for_user_with_spt( + state: web::Data, + req: HttpRequest, +) -> HttpResponse { + let flow = Flow::UserMerchantAccountList; + Box::pin(api::server_wrap( + flow, + state, + &req, + (), + |state, user, _, _| user_core::list_merchants_for_user(state, user), + &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite), + api_locking::LockAction::NotApplicable, + )) + .await +} + pub async fn get_user_role_details( state: web::Data, req: HttpRequest, @@ -435,14 +452,18 @@ pub async fn invite_multiple_user( state: web::Data, req: HttpRequest, payload: web::Json>, + query: web::Query, ) -> HttpResponse { let flow = Flow::InviteMultipleUser; + let is_token_only = query.into_inner().token_only; Box::pin(api::server_wrap( flow, state.clone(), &req, payload.into_inner(), - user_core::invite_multiple_user, + |state, user, payload, req_state| { + user_core::invite_multiple_user(state, user, payload, req_state, is_token_only) + }, &auth::JWTAuth(Permission::UsersWrite), api_locking::LockAction::NotApplicable, )) diff --git a/crates/router/src/routes/user_role.rs b/crates/router/src/routes/user_role.rs index d406783cc7..4239f2d381 100644 --- a/crates/router/src/routes/user_role.rs +++ b/crates/router/src/routes/user_role.rs @@ -209,10 +209,29 @@ pub async fn accept_invitation( state: web::Data, req: HttpRequest, json_payload: web::Json, - query: web::Query, ) -> HttpResponse { let flow = Flow::AcceptInvitation; let payload = json_payload.into_inner(); + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + payload, + |state, user, req_body, _| user_role_core::accept_invitation(state, user, req_body), + &auth::DashboardNoPermissionAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn merchant_select( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, + query: web::Query, +) -> HttpResponse { + let flow = Flow::MerchantSelect; + let payload = json_payload.into_inner(); let is_token_only = query.into_inner().token_only; Box::pin(api::server_wrap( flow, @@ -221,9 +240,9 @@ pub async fn accept_invitation( payload, |state, user, req_body, _| async move { if let Some(true) = is_token_only { - user_role_core::accept_invitation_token_only_flow(state, user, req_body).await + user_role_core::merchant_select_token_only_flow(state, user, req_body).await } else { - user_role_core::accept_invitation(state, user, req_body).await + user_role_core::merchant_select(state, user, req_body).await } }, &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite), diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index 9aac92acd2..c7280261c2 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -206,6 +206,23 @@ impl AuthInfo for AuthenticationData { } } +pub trait GetUserIdFromAuth { + fn get_user_id(&self) -> String; +} + +impl GetUserIdFromAuth for UserFromToken { + fn get_user_id(&self) -> String { + self.user_id.clone() + } +} + +#[cfg(feature = "olap")] +impl GetUserIdFromAuth for UserFromSinglePurposeToken { + fn get_user_id(&self) -> String { + self.user_id.clone() + } +} + #[async_trait] pub trait AuthenticateAndFetch where @@ -347,6 +364,39 @@ where } } +#[cfg(feature = "olap")] +#[async_trait] +impl AuthenticateAndFetch, A> for SinglePurposeJWTAuth +where + A: AppStateInfo + Sync, +{ + async fn authenticate_and_fetch( + &self, + request_headers: &HeaderMap, + state: &A, + ) -> RouterResult<(Box, AuthenticationType)> { + let payload = parse_jwt_payload::(request_headers, state).await?; + if payload.check_in_blacklist(state).await? { + return Err(errors::ApiErrorResponse::InvalidJwtToken.into()); + } + + if self.0 != payload.purpose { + return Err(errors::ApiErrorResponse::InvalidJwtToken.into()); + } + + Ok(( + Box::new(UserFromSinglePurposeToken { + user_id: payload.user_id.clone(), + origin: payload.origin.clone(), + }), + AuthenticationType::SinglePurposeJWT { + user_id: payload.user_id, + purpose: payload.purpose, + }, + )) + } +} + #[derive(Debug)] pub struct AdminApiAuth; @@ -786,6 +836,37 @@ where } } +#[cfg(feature = "olap")] +#[async_trait] +impl AuthenticateAndFetch, A> for DashboardNoPermissionAuth +where + A: AppStateInfo + Sync, +{ + async fn authenticate_and_fetch( + &self, + request_headers: &HeaderMap, + state: &A, + ) -> RouterResult<(Box, AuthenticationType)> { + let payload = parse_jwt_payload::(request_headers, state).await?; + if payload.check_in_blacklist(state).await? { + return Err(errors::ApiErrorResponse::InvalidJwtToken.into()); + } + + Ok(( + Box::new(UserFromToken { + user_id: payload.user_id.clone(), + merchant_id: payload.merchant_id.clone(), + org_id: payload.org_id, + role_id: payload.role_id, + }), + AuthenticationType::MerchantJwt { + merchant_id: payload.merchant_id, + user_id: Some(payload.user_id), + }, + )) + } +} + #[cfg(feature = "olap")] #[async_trait] impl AuthenticateAndFetch<(), A> for DashboardNoPermissionAuth diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 9ea86167fc..a893386ab8 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -386,6 +386,8 @@ pub enum Flow { UpdateUserAccountDetails, /// Accept user invitation AcceptInvitation, + /// Select merchant from invitations + MerchantSelect, /// Initiate external authentication for a payment PaymentsExternalAuthentication, /// Authorize the payment after external 3ds authentication