feat(users): new routes to accept invite and list merchants (#4591)

This commit is contained in:
Apoorv Dixit
2024-05-09 18:39:13 +05:30
committed by GitHub
parent 366596f14d
commit e70d58afc9
10 changed files with 216 additions and 25 deletions

View File

@ -7,7 +7,7 @@ use crate::user_role::{
UpdateRoleRequest, UpdateRoleRequest,
}, },
AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest, AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest,
TransferOrgOwnershipRequest, UpdateUserRoleRequest, MerchantSelectRequest, TransferOrgOwnershipRequest, UpdateUserRoleRequest,
}; };
common_utils::impl_misc_api_event_type!( common_utils::impl_misc_api_event_type!(
@ -15,6 +15,7 @@ common_utils::impl_misc_api_event_type!(
GetRoleRequest, GetRoleRequest,
AuthorizationInfoResponse, AuthorizationInfoResponse,
UpdateUserRoleRequest, UpdateUserRoleRequest,
MerchantSelectRequest,
AcceptInvitationRequest, AcceptInvitationRequest,
DeleteUserRoleRequest, DeleteUserRoleRequest,
TransferOrgOwnershipRequest, TransferOrgOwnershipRequest,

View File

@ -97,11 +97,15 @@ pub enum UserStatus {
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct AcceptInvitationRequest { pub struct MerchantSelectRequest {
pub merchant_ids: Vec<String>, pub merchant_ids: Vec<String>,
// TODO: Remove this once the token only api is being used // TODO: Remove this once the token only api is being used
pub need_dashboard_entry_response: Option<bool>, 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)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct DeleteUserRoleRequest { pub struct DeleteUserRoleRequest {

View File

@ -7,6 +7,8 @@ use diesel_models::{
user_role::UserRoleNew, user_role::UserRoleNew,
}; };
use error_stack::{report, ResultExt}; use error_stack::{report, ResultExt};
#[cfg(feature = "email")]
use external_services::email::EmailData;
use masking::{ExposeInterface, PeekInterface}; use masking::{ExposeInterface, PeekInterface};
#[cfg(feature = "email")] #[cfg(feature = "email")]
use router_env::env; use router_env::env;
@ -570,6 +572,7 @@ pub async fn invite_multiple_user(
user_from_token: auth::UserFromToken, user_from_token: auth::UserFromToken,
requests: Vec<user_api::InviteUserRequest>, requests: Vec<user_api::InviteUserRequest>,
req_state: ReqState, req_state: ReqState,
is_token_only: Option<bool>,
) -> UserResponse<Vec<InviteMultipleUserResponse>> { ) -> UserResponse<Vec<InviteMultipleUserResponse>> {
if requests.len() > 10 { if requests.len() > 10 {
return Err(report!(UserErrors::MaxInvitationsError)) 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 { 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, Ok(response) => response,
Err(error) => InviteMultipleUserResponse { Err(error) => InviteMultipleUserResponse {
email: request.email.clone(), email: request.email.clone(),
@ -597,6 +601,7 @@ async fn handle_invitation(
user_from_token: &auth::UserFromToken, user_from_token: &auth::UserFromToken,
request: &user_api::InviteUserRequest, request: &user_api::InviteUserRequest,
req_state: &ReqState, req_state: &ReqState,
is_token_only: Option<bool>,
) -> UserResult<InviteMultipleUserResponse> { ) -> UserResult<InviteMultipleUserResponse> {
let inviter_user = user_from_token.get_user_from_db(state).await?; let inviter_user = user_from_token.get_user_from_db(state).await?;
@ -635,7 +640,14 @@ async fn handle_invitation(
.err() .err()
.unwrap_or(false) .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 { } else {
Err(UserErrors::InternalServerError.into()) Err(UserErrors::InternalServerError.into())
} }
@ -718,6 +730,7 @@ async fn handle_new_user_invitation(
user_from_token: &auth::UserFromToken, user_from_token: &auth::UserFromToken,
request: &user_api::InviteUserRequest, request: &user_api::InviteUserRequest,
req_state: ReqState, req_state: ReqState,
is_token_only: Option<bool>,
) -> UserResult<InviteMultipleUserResponse> { ) -> UserResult<InviteMultipleUserResponse> {
let new_user = domain::NewUser::try_from((request.clone(), user_from_token.clone()))?; 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; 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")] #[cfg(feature = "email")]
{ {
// TODO: Adding this to avoid clippy lints // TODO: Adding this to avoid clippy lints
// Will be adding actual usage for this variable later // Will be adding actual usage for this variable later
let _ = req_state.clone(); let _ = req_state.clone();
let invitee_email = domain::UserEmail::from_pii_email(request.email.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
recipient_email: invitee_email, {
user_name: domain::UserName::new(new_user.get_name())?, Box::new(email_types::InviteRegisteredUser {
settings: state.conf.clone(), recipient_email: invitee_email,
subject: "You have been invited to join Hyperswitch Community!", user_name: domain::UserName::new(new_user.get_name())?,
merchant_id: user_from_token.merchant_id.clone(), 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 let send_email_result = state
.email_client .email_client
.compose_and_send_email( .compose_and_send_email(email_contents, state.conf.proxy.https_url.as_ref())
Box::new(email_contents),
state.conf.proxy.https_url.as_ref(),
)
.await; .await;
logger::info!(?send_email_result); logger::info!(?send_email_result);
is_email_sent = send_email_result.is_ok(); 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( pub async fn list_merchants_for_user(
state: AppState, state: AppState,
user_from_token: auth::UserFromToken, user_from_token: Box<dyn auth::GetUserIdFromAuth>,
) -> UserResponse<Vec<user_api::UserMerchantAccount>> { ) -> UserResponse<Vec<user_api::UserMerchantAccount>> {
let user_roles = state let user_roles = state
.store .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 .await
.change_context(UserErrors::InternalServerError)?; .change_context(UserErrors::InternalServerError)?;

View File

@ -170,8 +170,38 @@ pub async fn transfer_org_ownership(
pub async fn accept_invitation( pub async fn accept_invitation(
state: AppState, state: AppState,
user_token: auth::UserFromSinglePurposeToken, user_token: auth::UserFromToken,
req: user_role_api::AcceptInvitationRequest, 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>> { ) -> UserResponse<user_api::TokenOrPayloadResponse<user_api::DashboardEntryResponse>> {
let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async { let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async {
state 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; 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 token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
let response = utils::user::get_dashboard_entry_response( let response = utils::user::get_dashboard_entry_response(
&state, &state,
user_from_db, user_from_db,
@ -223,10 +252,10 @@ pub async fn accept_invitation(
Ok(ApplicationResponse::StatusOk) Ok(ApplicationResponse::StatusOk)
} }
pub async fn accept_invitation_token_only_flow( pub async fn merchant_select_token_only_flow(
state: AppState, state: AppState,
user_token: auth::UserFromSinglePurposeToken, user_token: auth::UserFromSinglePurposeToken,
req: user_role_api::AcceptInvitationRequest, req: user_role_api::MerchantSelectRequest,
) -> UserResponse<user_api::TokenOrPayloadResponse<user_api::DashboardEntryResponse>> { ) -> UserResponse<user_api::TokenOrPayloadResponse<user_api::DashboardEntryResponse>> {
let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async { let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async {
state state

View File

@ -1192,6 +1192,11 @@ impl User {
// TODO: Remove this endpoint once migration to /merchants/list is done // 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("/switch/list").route(web::get().to(list_merchants_for_user)))
.service(web::resource("/merchants/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("/permission_info").route(web::get().to(get_authorization_info)))
.service(web::resource("/update").route(web::post().to(update_user_account_details))) .service(web::resource("/update").route(web::post().to(update_user_account_details)))
.service( .service(
@ -1241,7 +1246,11 @@ impl User {
.service( .service(
web::resource("/invite_multiple").route(web::post().to(invite_multiple_user)), 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("/update_role").route(web::post().to(update_user_role)))
.service( .service(
web::resource("/transfer_ownership") web::resource("/transfer_ownership")

View File

@ -220,6 +220,7 @@ impl From<Flow> for ApiIdentifier {
| Flow::UpdateUserRole | Flow::UpdateUserRole
| Flow::GetAuthorizationInfo | Flow::GetAuthorizationInfo
| Flow::AcceptInvitation | Flow::AcceptInvitation
| Flow::MerchantSelect
| Flow::DeleteUserRole | Flow::DeleteUserRole
| Flow::TransferOrgOwnership | Flow::TransferOrgOwnership
| Flow::CreateRole | Flow::CreateRole

View File

@ -324,6 +324,23 @@ pub async fn list_merchants_for_user(state: web::Data<AppState>, req: HttpReques
.await .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( pub async fn get_user_role_details(
state: web::Data<AppState>, state: web::Data<AppState>,
req: HttpRequest, req: HttpRequest,
@ -435,14 +452,18 @@ pub async fn invite_multiple_user(
state: web::Data<AppState>, state: web::Data<AppState>,
req: HttpRequest, req: HttpRequest,
payload: web::Json<Vec<user_api::InviteUserRequest>>, payload: web::Json<Vec<user_api::InviteUserRequest>>,
query: web::Query<user_api::TokenOnlyQueryParam>,
) -> HttpResponse { ) -> HttpResponse {
let flow = Flow::InviteMultipleUser; let flow = Flow::InviteMultipleUser;
let is_token_only = query.into_inner().token_only;
Box::pin(api::server_wrap( Box::pin(api::server_wrap(
flow, flow,
state.clone(), state.clone(),
&req, &req,
payload.into_inner(), 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), &auth::JWTAuth(Permission::UsersWrite),
api_locking::LockAction::NotApplicable, api_locking::LockAction::NotApplicable,
)) ))

View File

@ -209,10 +209,29 @@ pub async fn accept_invitation(
state: web::Data<AppState>, state: web::Data<AppState>,
req: HttpRequest, req: HttpRequest,
json_payload: web::Json<user_role_api::AcceptInvitationRequest>, json_payload: web::Json<user_role_api::AcceptInvitationRequest>,
query: web::Query<user_api::TokenOnlyQueryParam>,
) -> HttpResponse { ) -> HttpResponse {
let flow = Flow::AcceptInvitation; let flow = Flow::AcceptInvitation;
let payload = json_payload.into_inner(); 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; let is_token_only = query.into_inner().token_only;
Box::pin(api::server_wrap( Box::pin(api::server_wrap(
flow, flow,
@ -221,9 +240,9 @@ pub async fn accept_invitation(
payload, payload,
|state, user, req_body, _| async move { |state, user, req_body, _| async move {
if let Some(true) = is_token_only { 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 { } 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), &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite),

View File

@ -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] #[async_trait]
pub trait AuthenticateAndFetch<T, A> pub trait AuthenticateAndFetch<T, A>
where 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)] #[derive(Debug)]
pub struct AdminApiAuth; 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")] #[cfg(feature = "olap")]
#[async_trait] #[async_trait]
impl<A> AuthenticateAndFetch<(), A> for DashboardNoPermissionAuth impl<A> AuthenticateAndFetch<(), A> for DashboardNoPermissionAuth

View File

@ -386,6 +386,8 @@ pub enum Flow {
UpdateUserAccountDetails, UpdateUserAccountDetails,
/// Accept user invitation /// Accept user invitation
AcceptInvitation, AcceptInvitation,
/// Select merchant from invitations
MerchantSelect,
/// Initiate external authentication for a payment /// Initiate external authentication for a payment
PaymentsExternalAuthentication, PaymentsExternalAuthentication,
/// Authorize the payment after external 3ds authentication /// Authorize the payment after external 3ds authentication