feat(users): Add profile level invites (#5793)

This commit is contained in:
Mani Chandra
2024-09-04 20:26:50 +05:30
committed by GitHub
parent eea5c4e7ee
commit 28e7a7fc5e
19 changed files with 686 additions and 394 deletions

View File

@ -18,8 +18,6 @@ use diesel_models::{
user_authentication_method::{UserAuthenticationMethodNew, UserAuthenticationMethodUpdate},
};
use error_stack::{report, ResultExt};
#[cfg(feature = "email")]
use external_services::email::EmailData;
use masking::{ExposeInterface, PeekInterface, Secret};
#[cfg(feature = "email")]
use router_env::env;
@ -64,7 +62,7 @@ pub async fn signup_with_merchant_id(
.insert_user_and_merchant_in_db(state.clone())
.await?;
let user_role = new_user
let _user_role = new_user
.insert_org_level_user_role_in_db(
state.clone(),
common_utils::consts::ROLE_ID_ORGANIZATION_ADMIN.to_string(),
@ -89,16 +87,10 @@ pub async fn signup_with_merchant_id(
)
.await;
let Some(merchant_id) = user_role.merchant_id else {
return Err(report!(UserErrors::InternalServerError)
.attach_printable("merchant_id not found for user_role"));
};
logger::info!(?send_email_result);
Ok(ApplicationResponse::Json(user_api::AuthorizeResponse {
is_email_sent: send_email_result.is_ok(),
user_id: user_from_db.get_user_id().to_string(),
merchant_id,
}))
}
@ -195,7 +187,6 @@ pub async fn connect_account(
if let Ok(found_user) = find_user {
let user_from_db: domain::UserFromStorage = found_user.into();
let user_role = user_from_db.get_role_from_db(state.clone()).await?;
let email_contents = email_types::MagicLink {
recipient_email: domain::UserEmail::from_pii_email(user_from_db.get_email())?,
@ -212,18 +203,12 @@ pub async fn connect_account(
state.conf.proxy.https_url.as_ref(),
)
.await;
let Some(merchant_id) = user_role.merchant_id else {
return Err(report!(UserErrors::InternalServerError)
.attach_printable("merchant_id not found for user_role"));
};
logger::info!(?send_email_result);
return Ok(ApplicationResponse::Json(
user_api::ConnectAccountResponse {
is_email_sent: send_email_result.is_ok(),
user_id: user_from_db.get_user_id().to_string(),
merchant_id,
},
));
} else if find_user
@ -245,7 +230,7 @@ pub async fn connect_account(
let user_from_db = new_user
.insert_user_and_merchant_in_db(state.clone())
.await?;
let user_role = new_user
let _user_role = new_user
.insert_org_level_user_role_in_db(
state.clone(),
common_utils::consts::ROLE_ID_ORGANIZATION_ADMIN.to_string(),
@ -269,18 +254,12 @@ pub async fn connect_account(
)
.await;
let Some(merchant_id) = user_role.merchant_id else {
return Err(report!(UserErrors::InternalServerError)
.attach_printable("merchant_id not found for user_role"));
};
logger::info!(?send_email_result);
return Ok(ApplicationResponse::Json(
user_api::ConnectAccountResponse {
is_email_sent: send_email_result.is_ok(),
user_id: user_from_db.get_user_id().to_string(),
merchant_id,
},
));
} else {
@ -512,15 +491,19 @@ pub async fn invite_multiple_user(
.attach_printable("Number of invite requests must not exceed 10");
}
let responses = futures::future::join_all(requests.iter().map(|request| async {
match handle_invitation(&state, &user_from_token, request, &req_state, &auth_id).await {
let responses = futures::future::join_all(requests.into_iter().map(|request| async {
match handle_invitation(&state, &user_from_token, &request, &req_state, &auth_id).await {
Ok(response) => response,
Err(error) => InviteMultipleUserResponse {
email: request.email.clone(),
is_email_sent: false,
password: None,
error: Some(error.current_context().get_error_message().to_string()),
},
Err(error) => {
logger::error!(invite_error=?error);
InviteMultipleUserResponse {
email: request.email,
is_email_sent: false,
password: None,
error: Some(error.current_context().get_error_message().to_string()),
}
}
}
}))
.await;
@ -570,6 +553,7 @@ async fn handle_invitation(
user_from_token,
request,
invitee_user.into(),
role_info,
auth_id,
)
.await
@ -579,8 +563,15 @@ async fn handle_invitation(
.err()
.unwrap_or(false)
{
handle_new_user_invitation(state, user_from_token, request, req_state.clone(), auth_id)
.await
handle_new_user_invitation(
state,
user_from_token,
request,
role_info,
req_state.clone(),
auth_id,
)
.await
} else {
Err(UserErrors::InternalServerError.into())
}
@ -592,6 +583,7 @@ async fn handle_existing_user_invitation(
user_from_token: &auth::UserFromToken,
request: &user_api::InviteUserRequest,
invitee_user_from_db: domain::UserFromStorage,
role_info: roles::RoleInfo,
auth_id: &Option<String>,
) -> UserResult<InviteMultipleUserResponse> {
let now = common_utils::date_time::now();
@ -642,24 +634,66 @@ async fn handle_existing_user_invitation(
last_modified_by: user_from_token.user_id.clone(),
created_at: now,
last_modified: now,
entity: domain::MerchantLevel {
org_id: user_from_token.org_id.clone(),
merchant_id: user_from_token.merchant_id.clone(),
},
}
.insert_in_v1_and_v2(state)
.await?;
entity: domain::NoLevel,
};
let _user_role = match role_info.get_entity_type() {
EntityType::Internal => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Organization => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Merchant => {
user_role
.add_entity(domain::MerchantLevel {
org_id: user_from_token.org_id.clone(),
merchant_id: user_from_token.merchant_id.clone(),
})
.insert_in_v1_and_v2(state)
.await?
}
EntityType::Profile => {
let profile_id = user_from_token
.profile_id
.clone()
.ok_or(UserErrors::InternalServerError)?;
user_role
.add_entity(domain::ProfileLevel {
org_id: user_from_token.org_id.clone(),
merchant_id: user_from_token.merchant_id.clone(),
profile_id: profile_id.clone(),
})
.insert_in_v2(state)
.await?
}
};
let is_email_sent;
#[cfg(feature = "email")]
{
let invitee_email = domain::UserEmail::from_pii_email(request.email.clone())?;
let email_contents = email_types::InviteRegisteredUser {
let entity = match role_info.get_entity_type() {
EntityType::Internal => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Organization => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Merchant => email_types::Entity {
entity_id: user_from_token.merchant_id.get_string_repr().to_owned(),
entity_type: EntityType::Merchant,
},
EntityType::Profile => {
let profile_id = user_from_token
.profile_id
.clone()
.ok_or(UserErrors::InternalServerError)?;
email_types::Entity {
entity_id: profile_id.get_string_repr().to_owned(),
entity_type: EntityType::Profile,
}
}
};
let email_contents = email_types::InviteUser {
recipient_email: invitee_email,
user_name: domain::UserName::new(invitee_user_from_db.get_name())?,
settings: state.conf.clone(),
subject: "You have been invited to join Hyperswitch Community!",
merchant_id: user_from_token.merchant_id.clone(),
entity,
auth_id: auth_id.clone(),
};
@ -692,6 +726,7 @@ async fn handle_new_user_invitation(
state: &SessionState,
user_from_token: &auth::UserFromToken,
request: &user_api::InviteUserRequest,
role_info: roles::RoleInfo,
req_state: ReqState,
auth_id: &Option<String>,
) -> UserResult<InviteMultipleUserResponse> {
@ -718,13 +753,36 @@ async fn handle_new_user_invitation(
last_modified_by: user_from_token.user_id.clone(),
created_at: now,
last_modified: now,
entity: domain::MerchantLevel {
merchant_id: user_from_token.merchant_id.clone(),
org_id: user_from_token.org_id.clone(),
},
}
.insert_in_v1_and_v2(state)
.await?;
entity: domain::NoLevel,
};
let _user_role = match role_info.get_entity_type() {
EntityType::Internal => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Organization => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Merchant => {
user_role
.add_entity(domain::MerchantLevel {
org_id: user_from_token.org_id.clone(),
merchant_id: user_from_token.merchant_id.clone(),
})
.insert_in_v1_and_v2(state)
.await?
}
EntityType::Profile => {
let profile_id = user_from_token
.profile_id
.clone()
.ok_or(UserErrors::InternalServerError)?;
user_role
.add_entity(domain::ProfileLevel {
org_id: user_from_token.org_id.clone(),
merchant_id: user_from_token.merchant_id.clone(),
profile_id: profile_id.clone(),
})
.insert_in_v2(state)
.await?
}
};
let is_email_sent;
@ -734,18 +792,39 @@ async fn handle_new_user_invitation(
// 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: Box<dyn EmailData + Send + 'static> =
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(),
auth_id: auth_id.clone(),
});
let entity = match role_info.get_entity_type() {
EntityType::Internal => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Organization => return Err(UserErrors::InvalidRoleId.into()),
EntityType::Merchant => email_types::Entity {
entity_id: user_from_token.merchant_id.get_string_repr().to_owned(),
entity_type: EntityType::Merchant,
},
EntityType::Profile => {
let profile_id = user_from_token
.profile_id
.clone()
.ok_or(UserErrors::InternalServerError)?;
email_types::Entity {
entity_id: profile_id.get_string_repr().to_owned(),
entity_type: EntityType::Profile,
}
}
};
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!",
entity,
auth_id: auth_id.clone(),
};
let send_email_result = state
.email_client
.compose_and_send_email(email_contents, state.conf.proxy.https_url.as_ref())
.compose_and_send_email(
Box::new(email_contents),
state.conf.proxy.https_url.as_ref(),
)
.await;
logger::info!(?send_email_result);
is_email_sent = send_email_result.is_ok();
@ -803,40 +882,66 @@ pub async fn resend_invite(
}
})?
.into();
let user_role = state
let user_role = match state
.store
.find_user_role_by_user_id_merchant_id(
.find_user_role_by_user_id_and_lineage(
user.get_user_id(),
&user_from_token.org_id,
&user_from_token.merchant_id,
UserRoleVersion::V1,
user_from_token.profile_id.as_ref(),
UserRoleVersion::V2,
)
.await
.map_err(|e| {
if e.current_context().is_db_not_found() {
e.change_context(UserErrors::InvalidRoleOperation)
.attach_printable(format!(
"User role with user_id = {} and merchant_id = {:?} is not found",
user.get_user_id(),
user_from_token.merchant_id
))
{
Ok(user_role) => Some(user_role),
Err(err) => {
if err.current_context().is_db_not_found() {
None
} else {
e.change_context(UserErrors::InternalServerError)
return Err(report!(UserErrors::InternalServerError));
}
})?;
}
};
let user_role = match user_role {
Some(user_role) => user_role,
None => state
.store
.find_user_role_by_user_id_and_lineage(
user.get_user_id(),
&user_from_token.org_id,
&user_from_token.merchant_id,
user_from_token.profile_id.as_ref(),
UserRoleVersion::V1,
)
.await
.to_not_found_response(UserErrors::InvalidRoleOperationWithMessage(
"User not found in records".to_string(),
))?,
};
if !matches!(user_role.status, UserStatus::InvitationSent) {
return Err(report!(UserErrors::InvalidRoleOperation))
.attach_printable("User status is not InvitationSent".to_string());
}
let (entity_id, entity_type) = user_role
.get_entity_id_and_type()
.ok_or(UserErrors::InternalServerError)?;
let email_contents = email_types::InviteUser {
recipient_email: invitee_email,
user_name: domain::UserName::new(user.get_name())?,
settings: state.conf.clone(),
subject: "You have been invited to join Hyperswitch Community!",
merchant_id: user_from_token.merchant_id,
auth_id,
entity: email_types::Entity {
entity_id,
entity_type,
},
auth_id: auth_id.clone(),
};
state
.email_client
.compose_and_send_email(
@ -878,36 +983,25 @@ pub async fn accept_invite_from_email_token_only_flow(
return Err(UserErrors::LinkInvalid.into());
}
let merchant_id = email_token
.get_merchant_id()
.ok_or(UserErrors::LinkInvalid)?;
let entity = email_token.get_entity().ok_or(UserErrors::LinkInvalid)?;
let key_manager_state = &(&state).into();
let key_store = state
.store
.get_merchant_key_store_by_merchant_id(
key_manager_state,
merchant_id,
&state.store.get_master_key().to_vec().into(),
let (org_id, merchant_id, profile_id) =
utils::user_role::get_lineage_for_user_id_and_entity_for_accepting_invite(
&state,
&user_token.user_id,
entity.entity_id.clone(),
entity.entity_type,
)
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("merchant_key_store not found")?;
let merchant_account = state
.store
.find_merchant_account_by_merchant_id(key_manager_state, merchant_id, &key_store)
.await
.change_context(UserErrors::InternalServerError)
.attach_printable("merchant_account not found")?;
.change_context(UserErrors::InternalServerError)?
.ok_or(UserErrors::InternalServerError)?;
let (update_v1_result, update_v2_result) = utils::user_role::update_v1_and_v2_user_roles_in_db(
&state,
user_from_db.get_user_id(),
&merchant_account.organization_id,
merchant_id,
None,
&org_id,
&merchant_id,
profile_id.as_ref(),
UserRoleUpdate::UpdateStatus {
status: UserStatus::Active,
modified_by: user_from_db.get_user_id().to_owned(),
@ -951,14 +1045,7 @@ pub async fn accept_invite_from_email_token_only_flow(
)?;
let next_flow = current_flow.next(user_from_db.clone(), &state).await?;
let user_role = user_from_db
.get_preferred_or_active_user_role_from_db(&state)
.await
.change_context(UserErrors::InternalServerError)?;
let token = next_flow
.get_token_with_user_role(&state, &user_role)
.await?;
let token = next_flow.get_token(&state).await?;
let response = user_api::TokenResponse {
token: token.clone(),
@ -1534,28 +1621,9 @@ pub async fn update_user_details(
let name = req.name.map(domain::UserName::new).transpose()?;
if let Some(ref preferred_merchant_id) = req.preferred_merchant_id {
let _ = state
.store
.find_user_role_by_user_id_merchant_id(
user.get_user_id(),
preferred_merchant_id,
UserRoleVersion::V1,
)
.await
.map_err(|e| {
if e.current_context().is_db_not_found() {
e.change_context(UserErrors::MerchantIdNotFound)
} else {
e.change_context(UserErrors::InternalServerError)
}
})?;
}
let user_update = storage_user::UserUpdate::AccountUpdate {
name: name.map(|x| x.get_secret().expose()),
name: name.map(|name| name.get_secret().expose()),
is_verified: None,
preferred_merchant_id: req.preferred_merchant_id,
};
state
@ -2283,23 +2351,35 @@ pub async fn list_orgs_for_user(
state: SessionState,
user_from_token: auth::UserFromToken,
) -> UserResponse<Vec<user_api::ListOrgsForUserResponse>> {
let orgs = state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
user_id: user_from_token.user_id.as_str(),
org_id: None,
merchant_id: None,
profile_id: None,
entity_id: None,
version: None,
status: Some(UserStatus::Active),
limit: None,
})
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.filter_map(|user_role| user_role.org_id)
.collect::<HashSet<_>>();
let role_info = roles::RoleInfo::from_role_id(
&state,
&user_from_token.role_id,
&user_from_token.merchant_id,
&user_from_token.org_id,
)
.await
.change_context(UserErrors::InternalServerError)?;
let orgs = match role_info.get_entity_type() {
EntityType::Internal => return Err(UserErrors::InvalidRoleOperation.into()),
EntityType::Organization | EntityType::Merchant | EntityType::Profile => state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
user_id: user_from_token.user_id.as_str(),
org_id: None,
merchant_id: None,
profile_id: None,
entity_id: None,
version: None,
status: Some(UserStatus::Active),
limit: None,
})
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.filter_map(|user_role| user_role.org_id)
.collect::<HashSet<_>>(),
};
let resp = futures::future::try_join_all(
orgs.iter()
@ -2333,63 +2413,59 @@ pub async fn list_merchants_for_user_in_org(
)
.await
.change_context(UserErrors::InternalServerError)?;
let merchant_accounts = if role_info.get_entity_type() == EntityType::Organization {
state
let merchant_accounts = match role_info.get_entity_type() {
EntityType::Organization | EntityType::Internal => state
.store
.list_merchant_accounts_by_organization_id(
&(&state).into(),
user_from_token.org_id.get_string_repr(),
)
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.map(
|merchant_account| user_api::ListMerchantsForUserInOrgResponse {
merchant_name: merchant_account.merchant_name.clone(),
merchant_id: merchant_account.get_id().to_owned(),
},
)
.collect::<Vec<_>>()
} else {
let merchant_ids = state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
user_id: user_from_token.user_id.as_str(),
org_id: Some(&user_from_token.org_id),
merchant_id: None,
profile_id: None,
entity_id: None,
version: None,
status: Some(UserStatus::Active),
limit: None,
})
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.filter_map(|user_role| user_role.merchant_id)
.collect::<HashSet<_>>()
.into_iter()
.collect();
state
.store
.list_multiple_merchant_accounts(&(&state).into(), merchant_ids)
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.map(
|merchant_account| user_api::ListMerchantsForUserInOrgResponse {
merchant_name: merchant_account.merchant_name.clone(),
merchant_id: merchant_account.get_id().to_owned(),
},
)
.collect::<Vec<_>>()
.change_context(UserErrors::InternalServerError)?,
EntityType::Merchant | EntityType::Profile => {
let merchant_ids = state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
user_id: user_from_token.user_id.as_str(),
org_id: Some(&user_from_token.org_id),
merchant_id: None,
profile_id: None,
entity_id: None,
version: None,
status: Some(UserStatus::Active),
limit: None,
})
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.filter_map(|user_role| user_role.merchant_id)
.collect::<HashSet<_>>()
.into_iter()
.collect();
state
.store
.list_multiple_merchant_accounts(&(&state).into(), merchant_ids)
.await
.change_context(UserErrors::InternalServerError)?
}
};
if merchant_accounts.is_empty() {
Err(UserErrors::InternalServerError).attach_printable("No merchant found for a user")?;
}
Ok(ApplicationResponse::Json(merchant_accounts))
Ok(ApplicationResponse::Json(
merchant_accounts
.into_iter()
.map(
|merchant_account| user_api::ListMerchantsForUserInOrgResponse {
merchant_name: merchant_account.merchant_name.clone(),
merchant_id: merchant_account.get_id().to_owned(),
},
)
.collect::<Vec<_>>(),
))
}
pub async fn list_profiles_for_user_in_org_and_merchant_account(
@ -2415,27 +2491,17 @@ pub async fn list_profiles_for_user_in_org_and_merchant_account(
)
.await
.change_context(UserErrors::InternalServerError)?;
let user_role_level = role_info.get_entity_type();
let profiles =
if user_role_level == EntityType::Organization || user_role_level == EntityType::Merchant {
state
.store
.list_business_profile_by_merchant_id(
key_manager_state,
&key_store,
&user_from_token.merchant_id,
)
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.map(
|profile| user_api::ListProfilesForUserInOrgAndMerchantAccountResponse {
profile_id: profile.get_id().to_owned(),
profile_name: profile.profile_name,
},
)
.collect::<Vec<_>>()
} else {
let profiles = match role_info.get_entity_type() {
EntityType::Organization | EntityType::Merchant | EntityType::Internal => state
.store
.list_business_profile_by_merchant_id(
key_manager_state,
&key_store,
&user_from_token.merchant_id,
)
.await
.change_context(UserErrors::InternalServerError)?,
EntityType::Profile => {
let profile_ids = state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
@ -2463,6 +2529,15 @@ pub async fn list_profiles_for_user_in_org_and_merchant_account(
}))
.await
.change_context(UserErrors::InternalServerError)?
}
};
if profiles.is_empty() {
Err(UserErrors::InternalServerError).attach_printable("No profile found for a user")?;
}
Ok(ApplicationResponse::Json(
profiles
.into_iter()
.map(
|profile| user_api::ListProfilesForUserInOrgAndMerchantAccountResponse {
@ -2470,14 +2545,8 @@ pub async fn list_profiles_for_user_in_org_and_merchant_account(
profile_name: profile.profile_name,
},
)
.collect::<Vec<_>>()
};
if profiles.is_empty() {
Err(UserErrors::InternalServerError).attach_printable("No profile found for a user")?;
}
Ok(ApplicationResponse::Json(profiles))
.collect::<Vec<_>>(),
))
}
pub async fn switch_org_for_user(

View File

@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use api_models::{user as user_api, user_role as user_role_api};
use diesel_models::{
enums::{UserRoleVersion, UserStatus},
user_role::{get_entity_id_and_type, UserRoleUpdate},
user_role::UserRoleUpdate,
};
use error_stack::{report, ResultExt};
use once_cell::sync::Lazy;
@ -287,7 +287,59 @@ pub async fn accept_invitation(
}))
.await;
if update_result.iter().all(Result::is_err) {
if update_result.is_empty() || update_result.iter().all(Result::is_err) {
return Err(UserErrors::MerchantIdNotFound.into());
}
Ok(ApplicationResponse::StatusOk)
}
pub async fn accept_invitations_v2(
state: SessionState,
user_from_token: auth::UserFromToken,
req: user_role_api::AcceptInvitationsV2Request,
) -> UserResponse<()> {
let lineages = futures::future::try_join_all(req.into_iter().map(|entity| {
utils::user_role::get_lineage_for_user_id_and_entity_for_accepting_invite(
&state,
&user_from_token.user_id,
entity.entity_id,
entity.entity_type,
)
}))
.await?
.into_iter()
.flatten()
.collect::<Vec<_>>();
let update_results = futures::future::join_all(lineages.iter().map(
|(org_id, merchant_id, profile_id)| async {
let (update_v1_result, update_v2_result) =
utils::user_role::update_v1_and_v2_user_roles_in_db(
&state,
user_from_token.user_id.as_str(),
org_id,
merchant_id,
profile_id.as_ref(),
UserRoleUpdate::UpdateStatus {
status: UserStatus::Active,
modified_by: user_from_token.user_id.clone(),
},
)
.await;
if update_v1_result.is_err_and(|err| !err.current_context().is_db_not_found())
|| update_v2_result.is_err_and(|err| !err.current_context().is_db_not_found())
{
Err(report!(UserErrors::InternalServerError))
} else {
Ok(())
}
},
))
.await;
if update_results.is_empty() || update_results.iter().all(Result::is_err) {
return Err(UserErrors::MerchantIdNotFound.into());
}
@ -331,7 +383,7 @@ pub async fn merchant_select_token_only_flow(
}))
.await;
if update_result.iter().all(Result::is_err) {
if update_result.is_empty() || update_result.iter().all(Result::is_err) {
return Err(UserErrors::MerchantIdNotFound.into());
}
@ -342,18 +394,80 @@ pub async fn merchant_select_token_only_flow(
.change_context(UserErrors::InternalServerError)?
.into();
let user_role = user_from_db
.get_preferred_or_active_user_role_from_db(&state)
let current_flow =
domain::CurrentFlow::new(user_token, domain::SPTFlow::MerchantSelect.into())?;
let next_flow = current_flow.next(user_from_db.clone(), &state).await?;
let token = next_flow.get_token(&state).await?;
let response = user_api::TokenResponse {
token: token.clone(),
token_type: next_flow.get_flow().into(),
};
auth::cookies::set_cookie_response(response, token)
}
pub async fn accept_invitations_pre_auth(
state: SessionState,
user_token: auth::UserFromSinglePurposeToken,
req: user_role_api::AcceptInvitationsPreAuthRequest,
) -> UserResponse<user_api::TokenResponse> {
let lineages = futures::future::try_join_all(req.into_iter().map(|entity| {
utils::user_role::get_lineage_for_user_id_and_entity_for_accepting_invite(
&state,
&user_token.user_id,
entity.entity_id,
entity.entity_type,
)
}))
.await?
.into_iter()
.flatten()
.collect::<Vec<_>>();
let update_results = futures::future::join_all(lineages.iter().map(
|(org_id, merchant_id, profile_id)| async {
let (update_v1_result, update_v2_result) =
utils::user_role::update_v1_and_v2_user_roles_in_db(
&state,
user_token.user_id.as_str(),
org_id,
merchant_id,
profile_id.as_ref(),
UserRoleUpdate::UpdateStatus {
status: UserStatus::Active,
modified_by: user_token.user_id.clone(),
},
)
.await;
if update_v1_result.is_err_and(|err| !err.current_context().is_db_not_found())
|| update_v2_result.is_err_and(|err| !err.current_context().is_db_not_found())
{
Err(report!(UserErrors::InternalServerError))
} else {
Ok(())
}
},
))
.await;
if update_results.is_empty() || update_results.iter().all(Result::is_err) {
return Err(UserErrors::MerchantIdNotFound.into());
}
let user_from_db: domain::UserFromStorage = state
.global_store
.find_user_by_id(user_token.user_id.as_str())
.await
.change_context(UserErrors::InternalServerError)?;
.change_context(UserErrors::InternalServerError)?
.into();
let current_flow =
domain::CurrentFlow::new(user_token, domain::SPTFlow::MerchantSelect.into())?;
let next_flow = current_flow.next(user_from_db.clone(), &state).await?;
let token = next_flow
.get_token_with_user_role(&state, &user_role)
.await?;
let token = next_flow.get_token(&state).await?;
let response = user_api::TokenResponse {
token: token.clone(),
@ -709,14 +823,12 @@ pub async fn list_invitations_for_user(
.collect::<HashSet<_>>()
.into_iter()
.filter_map(|user_role| {
let (entity_id, entity_type) = get_entity_id_and_type(&user_role);
entity_id.zip(entity_type).map(|(entity_id, entity_type)| {
user_role_api::ListInvitationForUserResponse {
entity_id,
entity_type,
entity_name: None,
role_id: user_role.role_id,
}
let (entity_id, entity_type) = user_role.get_entity_id_and_type()?;
Some(user_role_api::ListInvitationForUserResponse {
entity_id,
entity_type,
entity_name: None,
role_id: user_role.role_id,
})
})
.collect();

View File

@ -159,7 +159,6 @@ impl UserInterface for MockDb {
is_verified: user_data.is_verified,
created_at: user_data.created_at.unwrap_or(time_now),
last_modified_at: user_data.created_at.unwrap_or(time_now),
preferred_merchant_id: user_data.preferred_merchant_id,
totp_status: user_data.totp_status,
totp_secret: user_data.totp_secret,
totp_recovery_codes: user_data.totp_recovery_codes,
@ -218,16 +217,9 @@ impl UserInterface for MockDb {
is_verified: true,
..user.to_owned()
},
storage::UserUpdate::AccountUpdate {
name,
is_verified,
preferred_merchant_id,
} => storage::User {
storage::UserUpdate::AccountUpdate { name, is_verified } => storage::User {
name: name.clone().map(Secret::new).unwrap_or(user.name.clone()),
is_verified: is_verified.unwrap_or(user.is_verified),
preferred_merchant_id: preferred_merchant_id
.clone()
.or(user.preferred_merchant_id.clone()),
..user.to_owned()
},
storage::UserUpdate::TotpUpdate {
@ -273,16 +265,9 @@ impl UserInterface for MockDb {
is_verified: true,
..user.to_owned()
},
storage::UserUpdate::AccountUpdate {
name,
is_verified,
preferred_merchant_id,
} => storage::User {
storage::UserUpdate::AccountUpdate { name, is_verified } => storage::User {
name: name.clone().map(Secret::new).unwrap_or(user.name.clone()),
is_verified: is_verified.unwrap_or(user.is_verified),
preferred_merchant_id: preferred_merchant_id
.clone()
.or(user.preferred_merchant_id.clone()),
..user.to_owned()
},
storage::UserUpdate::TotpUpdate {

View File

@ -1825,9 +1825,22 @@ impl User {
web::resource("/invite_multiple").route(web::post().to(invite_multiple_user)),
)
.service(
web::resource("/invite/accept")
.route(web::post().to(merchant_select))
.route(web::put().to(accept_invitation)),
web::scope("/invite/accept")
.service(
web::resource("")
.route(web::post().to(merchant_select))
.route(web::put().to(accept_invitation)),
)
.service(
web::scope("/v2")
.service(
web::resource("").route(web::post().to(accept_invitations_v2)),
)
.service(
web::resource("/pre_auth")
.route(web::post().to(accept_invitations_pre_auth)),
),
),
)
.service(web::resource("/update_role").route(web::post().to(update_user_role)))
.service(web::resource("/delete").route(web::delete().to(delete_user_role))),

View File

@ -263,7 +263,9 @@ impl From<Flow> for ApiIdentifier {
| Flow::GetAuthorizationInfo
| Flow::GetRolesInfo
| Flow::AcceptInvitation
| Flow::AcceptInvitationsV2
| Flow::MerchantSelect
| Flow::AcceptInvitationsPreAuth
| Flow::DeleteUserRole
| Flow::CreateRole
| Flow::UpdateRole

View File

@ -168,6 +168,25 @@ pub async fn accept_invitation(
.await
}
pub async fn accept_invitations_v2(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<user_role_api::AcceptInvitationsV2Request>,
) -> HttpResponse {
let flow = Flow::AcceptInvitationsV2;
let payload = json_payload.into_inner();
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
payload,
|state, user, req_body, _| user_role_core::accept_invitations_v2(state, user, req_body),
&auth::DashboardNoPermissionAuth,
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn merchant_select(
state: web::Data<AppState>,
req: HttpRequest,
@ -189,6 +208,27 @@ pub async fn merchant_select(
.await
}
pub async fn accept_invitations_pre_auth(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<user_role_api::AcceptInvitationsPreAuthRequest>,
) -> HttpResponse {
let flow = Flow::AcceptInvitationsPreAuth;
let payload = json_payload.into_inner();
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
payload,
|state, user, req_body, _| async move {
user_role_core::accept_invitations_pre_auth(state, user, req_body).await
},
&auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite),
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn delete_user_role(
state: web::Data<AppState>,
req: HttpRequest,

View File

@ -1,4 +1,5 @@
use api_models::user::dashboard_metadata::ProdIntent;
use common_enums::EntityType;
use common_utils::{
errors::{self, CustomResult},
pii,
@ -151,15 +152,31 @@ Email : {user_email}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct EmailToken {
email: String,
merchant_id: Option<common_utils::id_type::MerchantId>,
flow: domain::Origin,
exp: u64,
entity: Option<Entity>,
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct Entity {
pub entity_id: String,
pub entity_type: EntityType,
}
impl Entity {
pub fn get_entity_type(&self) -> EntityType {
self.entity_type
}
pub fn get_entity_id(&self) -> &str {
&self.entity_id
}
}
impl EmailToken {
pub async fn new_token(
email: domain::UserEmail,
merchant_id: Option<common_utils::id_type::MerchantId>,
entity: Option<Entity>,
flow: domain::Origin,
settings: &configs::Settings,
) -> CustomResult<String, UserErrors> {
@ -167,9 +184,9 @@ impl EmailToken {
let exp = jwt::generate_exp(expiration_duration)?.as_secs();
let token_payload = Self {
email: email.get_secret().expose(),
merchant_id,
flow,
exp,
entity,
};
jwt::generate_jwt(&token_payload, settings).await
}
@ -178,8 +195,8 @@ impl EmailToken {
pii::Email::try_from(self.email.clone())
}
pub fn get_merchant_id(&self) -> Option<&common_utils::id_type::MerchantId> {
self.merchant_id.as_ref()
pub fn get_entity(&self) -> Option<&Entity> {
self.entity.as_ref()
}
pub fn get_flow(&self) -> domain::Origin {
@ -320,13 +337,12 @@ impl EmailData for MagicLink {
}
}
// TODO: Deprecate this and use InviteRegisteredUser for new invites
pub struct InviteUser {
pub recipient_email: domain::UserEmail,
pub user_name: domain::UserName,
pub settings: std::sync::Arc<configs::Settings>,
pub subject: &'static str,
pub merchant_id: common_utils::id_type::MerchantId,
pub entity: Entity,
pub auth_id: Option<String>,
}
@ -335,48 +351,7 @@ impl EmailData for InviteUser {
async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
let token = EmailToken::new_token(
self.recipient_email.clone(),
Some(self.merchant_id.clone()),
domain::Origin::ResetPassword,
&self.settings,
)
.await
.change_context(EmailError::TokenGenerationFailure)?;
let invite_user_link = get_link_with_token(
&self.settings.user.base_url,
token,
"set_password",
&self.auth_id,
);
let body = html::get_html_body(EmailBody::InviteUser {
link: invite_user_link,
user_name: self.user_name.clone().get_secret().expose(),
});
Ok(EmailContents {
subject: self.subject.to_string(),
body: external_services::email::IntermediateString::new(body),
recipient: self.recipient_email.clone().into_inner(),
})
}
}
pub struct InviteRegisteredUser {
pub recipient_email: domain::UserEmail,
pub user_name: domain::UserName,
pub settings: std::sync::Arc<configs::Settings>,
pub subject: &'static str,
pub merchant_id: common_utils::id_type::MerchantId,
pub auth_id: Option<String>,
}
#[async_trait::async_trait]
impl EmailData for InviteRegisteredUser {
async fn get_email_data(&self) -> CustomResult<EmailContents, EmailError> {
let token = EmailToken::new_token(
self.recipient_email.clone(),
Some(self.merchant_id.clone()),
Some(self.entity.clone()),
domain::Origin::AcceptInvitationFromEmail,
&self.settings,
)

View File

@ -703,7 +703,6 @@ impl TryFrom<NewUser> for storage_user::UserNew {
is_verified: false,
created_at: Some(now),
last_modified_at: Some(now),
preferred_merchant_id: None,
totp_status: TotpStatus::NotSet,
totp_secret: None,
totp_recovery_codes: None,
@ -931,10 +930,6 @@ impl UserFromStorage {
Ok(days_left_for_password_rotate.whole_days() < 0)
}
pub fn get_preferred_merchant_id(&self) -> Option<id_type::MerchantId> {
self.0.preferred_merchant_id.clone()
}
pub async fn get_role_from_db_by_merchant_id(
&self,
state: &SessionState,
@ -950,29 +945,6 @@ impl UserFromStorage {
.await
}
pub async fn get_preferred_or_active_user_role_from_db(
&self,
state: &SessionState,
) -> CustomResult<UserRole, errors::StorageError> {
if let Some(preferred_merchant_id) = self.get_preferred_merchant_id() {
self.get_role_from_db_by_merchant_id(state, &preferred_merchant_id)
.await
} else {
state
.store
.list_user_roles_by_user_id_and_version(&self.0.user_id, UserRoleVersion::V1)
.await?
.into_iter()
.find(|role| role.status == UserStatus::Active)
.ok_or(
errors::StorageError::ValueNotFound(
"No active role found for user".to_string(),
)
.into(),
)
}
}
pub async fn get_or_create_key_store(&self, state: &SessionState) -> UserResult<UserKeyStore> {
let master_key = state.store.get_master_key();
let key_manager_state = &state.into();

View File

@ -1,11 +1,15 @@
use common_enums::TokenPurpose;
use diesel_models::{enums::UserStatus, user_role::UserRole};
use diesel_models::{
enums::{UserRoleVersion, UserStatus},
user_role::UserRole,
};
use error_stack::{report, ResultExt};
use masking::Secret;
use super::UserFromStorage;
use crate::{
core::errors::{StorageErrorExt, UserErrors, UserResult},
core::errors::{UserErrors, UserResult},
db::user_role::ListUserRolesByUserIdPayload,
routes::SessionState,
services::authentication as auth,
utils,
@ -284,11 +288,22 @@ impl NextFlow {
{
self.user.get_verification_days_left(state)?;
}
let user_role = self
.user
.get_preferred_or_active_user_role_from_db(state)
let user_role = state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
user_id: self.user.get_user_id(),
org_id: None,
merchant_id: None,
profile_id: None,
entity_id: None,
version: Some(UserRoleVersion::V1),
status: Some(UserStatus::Active),
limit: Some(1),
})
.await
.to_not_found_response(UserErrors::InternalServerError)?;
.change_context(UserErrors::InternalServerError)?
.pop()
.ok_or(UserErrors::InternalServerError)?;
utils::user_role::set_role_permissions_in_cache_by_user_role(state, &user_role)
.await;

View File

@ -4,7 +4,7 @@ use api_models::user_role as user_role_api;
use common_enums::{EntityType, PermissionGroup};
use common_utils::id_type;
use diesel_models::{
enums::UserRoleVersion,
enums::{UserRoleVersion, UserStatus},
user_role::{UserRole, UserRoleUpdate},
};
use error_stack::{report, Report, ResultExt};
@ -14,6 +14,7 @@ use storage_impl::errors::StorageError;
use crate::{
consts,
core::errors::{UserErrors, UserResult},
db::user_role::ListUserRolesByUserIdPayload,
routes::SessionState,
services::authorization::{self as authz, permissions::Permission, roles},
types::domain,
@ -247,3 +248,113 @@ pub async fn get_single_merchant_id(
.attach_printable("merchant_id not found"),
}
}
pub async fn get_lineage_for_user_id_and_entity_for_accepting_invite(
state: &SessionState,
user_id: &str,
entity_id: String,
entity_type: EntityType,
) -> UserResult<
Option<(
id_type::OrganizationId,
id_type::MerchantId,
Option<id_type::ProfileId>,
)>,
> {
match entity_type {
EntityType::Internal | EntityType::Organization => {
Err(UserErrors::InvalidRoleOperation.into())
}
EntityType::Merchant => {
let Ok(merchant_id) = id_type::MerchantId::wrap(entity_id) else {
return Ok(None);
};
let user_roles = state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
user_id,
org_id: None,
merchant_id: Some(&merchant_id),
profile_id: None,
entity_id: None,
version: None,
status: Some(UserStatus::InvitationSent),
limit: None,
})
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.collect::<HashSet<_>>();
if user_roles.len() > 1 {
return Ok(None);
}
if let Some(user_role) = user_roles.into_iter().next() {
let (_entity_id, entity_type) = user_role
.get_entity_id_and_type()
.ok_or(UserErrors::InternalServerError)?;
if entity_type != EntityType::Merchant {
return Ok(None);
}
return Ok(Some((
user_role.org_id.ok_or(UserErrors::InternalServerError)?,
merchant_id,
None,
)));
}
Ok(None)
}
EntityType::Profile => {
let Ok(profile_id) = id_type::ProfileId::try_from(std::borrow::Cow::from(entity_id))
else {
return Ok(None);
};
let user_roles = state
.store
.list_user_roles_by_user_id(ListUserRolesByUserIdPayload {
user_id,
org_id: None,
merchant_id: None,
profile_id: Some(&profile_id),
entity_id: None,
version: None,
status: Some(UserStatus::InvitationSent),
limit: None,
})
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.collect::<HashSet<_>>();
if user_roles.len() > 1 {
return Ok(None);
}
if let Some(user_role) = user_roles.into_iter().next() {
let (_entity_id, entity_type) = user_role
.get_entity_id_and_type()
.ok_or(UserErrors::InternalServerError)?;
if entity_type != EntityType::Profile {
return Ok(None);
}
return Ok(Some((
user_role.org_id.ok_or(UserErrors::InternalServerError)?,
user_role
.merchant_id
.ok_or(UserErrors::InternalServerError)?,
Some(profile_id),
)));
}
Ok(None)
}
}
}