mirror of
				https://github.com/juspay/hyperswitch.git
				synced 2025-10-31 18:17:13 +08:00 
			
		
		
		
	feat(users): new routes to accept invite and list merchants (#4591)
This commit is contained in:
		| @ -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, | ||||
|  | ||||
| @ -97,11 +97,15 @@ pub enum UserStatus { | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Deserialize, serde::Serialize)] | ||||
| pub struct AcceptInvitationRequest { | ||||
| pub struct MerchantSelectRequest { | ||||
|     pub merchant_ids: Vec<String>, | ||||
|     // TODO: Remove this once the token only api is being used | ||||
|     pub need_dashboard_entry_response: Option<bool>, | ||||
| } | ||||
| #[derive(Debug, serde::Deserialize, serde::Serialize)] | ||||
| pub struct AcceptInvitationRequest { | ||||
|     pub merchant_ids: Vec<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Deserialize, serde::Serialize)] | ||||
| pub struct DeleteUserRoleRequest { | ||||
|  | ||||
| @ -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<user_api::InviteUserRequest>, | ||||
|     req_state: ReqState, | ||||
|     is_token_only: Option<bool>, | ||||
| ) -> UserResponse<Vec<InviteMultipleUserResponse>> { | ||||
|     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<bool>, | ||||
| ) -> UserResult<InviteMultipleUserResponse> { | ||||
|     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<bool>, | ||||
| ) -> UserResult<InviteMultipleUserResponse> { | ||||
|     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 { | ||||
|         let email_contents: Box<dyn EmailData + Send + 'static> = 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<dyn auth::GetUserIdFromAuth>, | ||||
| ) -> UserResponse<Vec<user_api::UserMerchantAccount>> { | ||||
|     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)?; | ||||
|  | ||||
|  | ||||
| @ -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<user_api::TokenOrPayloadResponse<user_api::DashboardEntryResponse>> { | ||||
|     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<user_api::TokenOrPayloadResponse<user_api::DashboardEntryResponse>> { | ||||
|     let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async { | ||||
|         state | ||||
|  | ||||
| @ -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") | ||||
|  | ||||
| @ -220,6 +220,7 @@ impl From<Flow> for ApiIdentifier { | ||||
|             | Flow::UpdateUserRole | ||||
|             | Flow::GetAuthorizationInfo | ||||
|             | Flow::AcceptInvitation | ||||
|             | Flow::MerchantSelect | ||||
|             | Flow::DeleteUserRole | ||||
|             | Flow::TransferOrgOwnership | ||||
|             | Flow::CreateRole | ||||
|  | ||||
| @ -324,6 +324,23 @@ pub async fn list_merchants_for_user(state: web::Data<AppState>, req: HttpReques | ||||
|     .await | ||||
| } | ||||
|  | ||||
| pub async fn list_merchants_for_user_with_spt( | ||||
|     state: web::Data<AppState>, | ||||
|     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<AppState>, | ||||
|     req: HttpRequest, | ||||
| @ -435,14 +452,18 @@ pub async fn invite_multiple_user( | ||||
|     state: web::Data<AppState>, | ||||
|     req: HttpRequest, | ||||
|     payload: web::Json<Vec<user_api::InviteUserRequest>>, | ||||
|     query: web::Query<user_api::TokenOnlyQueryParam>, | ||||
| ) -> 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, | ||||
|     )) | ||||
|  | ||||
| @ -209,10 +209,29 @@ pub async fn accept_invitation( | ||||
|     state: web::Data<AppState>, | ||||
|     req: HttpRequest, | ||||
|     json_payload: web::Json<user_role_api::AcceptInvitationRequest>, | ||||
|     query: web::Query<user_api::TokenOnlyQueryParam>, | ||||
| ) -> 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<AppState>, | ||||
|     req: HttpRequest, | ||||
|     json_payload: web::Json<user_role_api::MerchantSelectRequest>, | ||||
|     query: web::Query<user_api::TokenOnlyQueryParam>, | ||||
| ) -> 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), | ||||
|  | ||||
| @ -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<T, A> | ||||
| where | ||||
| @ -347,6 +364,39 @@ where | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(feature = "olap")] | ||||
| #[async_trait] | ||||
| impl<A> AuthenticateAndFetch<Box<dyn GetUserIdFromAuth>, A> for SinglePurposeJWTAuth | ||||
| where | ||||
|     A: AppStateInfo + Sync, | ||||
| { | ||||
|     async fn authenticate_and_fetch( | ||||
|         &self, | ||||
|         request_headers: &HeaderMap, | ||||
|         state: &A, | ||||
|     ) -> RouterResult<(Box<dyn GetUserIdFromAuth>, AuthenticationType)> { | ||||
|         let payload = parse_jwt_payload::<A, SinglePurposeToken>(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<A> AuthenticateAndFetch<Box<dyn GetUserIdFromAuth>, A> for DashboardNoPermissionAuth | ||||
| where | ||||
|     A: AppStateInfo + Sync, | ||||
| { | ||||
|     async fn authenticate_and_fetch( | ||||
|         &self, | ||||
|         request_headers: &HeaderMap, | ||||
|         state: &A, | ||||
|     ) -> RouterResult<(Box<dyn GetUserIdFromAuth>, AuthenticationType)> { | ||||
|         let payload = parse_jwt_payload::<A, AuthToken>(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<A> AuthenticateAndFetch<(), A> for DashboardNoPermissionAuth | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Apoorv Dixit
					Apoorv Dixit