From 41938350dec12d55fc7cde243032e9d19fa07fdd Mon Sep 17 00:00:00 2001 From: Julia Seweryn Date: Fri, 13 Sep 2024 13:42:39 +0200 Subject: [PATCH] leave team --- sdk/bindings/CloudApiErrors.ts | 2 +- sdk/bindings/HttpCloudEndpoint.ts | 2 +- sdk/bindings/HttpLeaveTeamRequest.ts | 3 + sdk/bindings/HttpLeaveTeamResponse.ts | 3 + sdk/bindings/index.ts | 2 + sdk/packages/cloud/package.json | 2 +- sdk/packages/cloud/src/app.ts | 14 +- server/bindings/CloudApiErrors.ts | 2 +- server/bindings/HttpCloudEndpoint.ts | 2 +- server/bindings/HttpLeaveTeamRequest.ts | 3 + server/bindings/HttpLeaveTeamResponse.ts | 3 + server/src/http/cloud/leave_team.rs | 262 +++++++++++++++ server/src/http/cloud/mod.rs | 1 + server/src/mailer/mail_requests.rs | 7 + .../mailer/request_handler/handle_requests.rs | 9 + .../mailer/request_handler/processors/mod.rs | 1 + .../processors/team_leaving_notification.rs | 61 ++++ server/src/mailer/templates/mod.rs | 1 + .../templates/teamLeavingNotification.rs | 303 ++++++++++++++++++ server/src/mailer/templates/templates.rs | 6 + server/src/routes/cloud_router.rs | 2 + server/src/structs/cloud/api_cloud_errors.rs | 1 + .../src/structs/cloud/cloud_http_endpoints.rs | 3 + 23 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 sdk/bindings/HttpLeaveTeamRequest.ts create mode 100644 sdk/bindings/HttpLeaveTeamResponse.ts create mode 100644 server/bindings/HttpLeaveTeamRequest.ts create mode 100644 server/bindings/HttpLeaveTeamResponse.ts create mode 100644 server/src/http/cloud/leave_team.rs create mode 100644 server/src/mailer/request_handler/processors/team_leaving_notification.rs create mode 100644 server/src/mailer/templates/teamLeavingNotification.rs diff --git a/sdk/bindings/CloudApiErrors.ts b/sdk/bindings/CloudApiErrors.ts index ccc4e187..6bc434ad 100644 --- a/sdk/bindings/CloudApiErrors.ts +++ b/sdk/bindings/CloudApiErrors.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam" | "InviteDoesNotExist" | "InvalidPaginationCursor" | "InvalidOrExpiredVerificationCode" | "InvalidOrExpiredAuthCode" | "InvalidDomainName" | "DomainAlreadyVerified" | "DomainVerificationFailure" | "DomainNotFound" | "DomainVerificationNotStarted" | "NoPendingDomainVerification" | "WebAuthnError" | "PasswordNotSet" | "UserDoesNotHavePasskey" | "PasskeyAlreadyExists" | "InvalidPasskeyCredential" | "PasskeyDoesNotExist" | "FailedToCreateTeam" | "DashboardImportFail" | "OriginHeaderRequired" | "InvalidOrigin" | "InvalidAction"; \ No newline at end of file +export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam" | "InviteDoesNotExist" | "InvalidPaginationCursor" | "InvalidOrExpiredVerificationCode" | "InvalidOrExpiredAuthCode" | "InvalidDomainName" | "DomainAlreadyVerified" | "DomainVerificationFailure" | "DomainNotFound" | "DomainVerificationNotStarted" | "NoPendingDomainVerification" | "WebAuthnError" | "PasswordNotSet" | "UserDoesNotHavePasskey" | "PasskeyAlreadyExists" | "InvalidPasskeyCredential" | "PasskeyDoesNotExist" | "FailedToCreateTeam" | "DashboardImportFail" | "OriginHeaderRequired" | "InvalidOrigin" | "InvalidAction" | "AdminCannotLeaveTeam"; \ No newline at end of file diff --git a/sdk/bindings/HttpCloudEndpoint.ts b/sdk/bindings/HttpCloudEndpoint.ts index eb5a7588..7c628271 100644 --- a/sdk/bindings/HttpCloudEndpoint.ts +++ b/sdk/bindings/HttpCloudEndpoint.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password_start" | "/register_with_password_finish" | "/login_with_password" | "/login_with_google" | "/refresh_token" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites" | "/cancel_team_user_invite" | "/cancel_user_team_invite" | "/get_app_events" | "/reset_password_start" | "/reset_password_finish" | "/verify_domain_start" | "/verify_domain_finish" | "/remove_whitelisted_domain" | "/cancel_pending_domain_verification" | "/register_with_passkey_start" | "/register_with_passkey_finish" | "/reset_passkey_start" | "/reset_passkey_finish" | "/get_passkey_challenge" | "/delete_passkey" | "/add_passkey_start" | "/add_passkey_finish" | "/get_user_metadata" | "/get_team_metadata" | "/get_team_users_privileges" | "/change_user_privileges" | "/login_with_passkey_start" | "/login_with_passkey_finish" | "/verify_code"; \ No newline at end of file +export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password_start" | "/register_with_password_finish" | "/login_with_password" | "/login_with_google" | "/refresh_token" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites" | "/cancel_team_user_invite" | "/cancel_user_team_invite" | "/get_app_events" | "/reset_password_start" | "/reset_password_finish" | "/verify_domain_start" | "/verify_domain_finish" | "/remove_whitelisted_domain" | "/cancel_pending_domain_verification" | "/register_with_passkey_start" | "/register_with_passkey_finish" | "/reset_passkey_start" | "/reset_passkey_finish" | "/get_passkey_challenge" | "/delete_passkey" | "/add_passkey_start" | "/add_passkey_finish" | "/get_user_metadata" | "/get_team_metadata" | "/get_team_users_privileges" | "/change_user_privileges" | "/login_with_passkey_start" | "/login_with_passkey_finish" | "/verify_code" | "/leave_team"; \ No newline at end of file diff --git a/sdk/bindings/HttpLeaveTeamRequest.ts b/sdk/bindings/HttpLeaveTeamRequest.ts new file mode 100644 index 00000000..1a3a628d --- /dev/null +++ b/sdk/bindings/HttpLeaveTeamRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpLeaveTeamRequest { teamId: string, } \ No newline at end of file diff --git a/sdk/bindings/HttpLeaveTeamResponse.ts b/sdk/bindings/HttpLeaveTeamResponse.ts new file mode 100644 index 00000000..abf576a1 --- /dev/null +++ b/sdk/bindings/HttpLeaveTeamResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HttpLeaveTeamResponse = null; \ No newline at end of file diff --git a/sdk/bindings/index.ts b/sdk/bindings/index.ts index 2cd64e59..56446ddd 100644 --- a/sdk/bindings/index.ts +++ b/sdk/bindings/index.ts @@ -74,6 +74,8 @@ export * from './HttpGetUserJoinedTeamsResponse'; export * from './HttpGetUserTeamInvitesResponse'; export * from './HttpInviteUserToTeamRequest'; export * from './HttpInviteUserToTeamResponse'; +export * from './HttpLeaveTeamRequest'; +export * from './HttpLeaveTeamResponse'; export * from './HttpLoginRequest'; export * from './HttpLoginResponse'; export * from './HttpLoginWithGoogleRequest'; diff --git a/sdk/packages/cloud/package.json b/sdk/packages/cloud/package.json index a8950b92..d232b783 100644 --- a/sdk/packages/cloud/package.json +++ b/sdk/packages/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@nightlylabs/nightly-cloud", - "version": "0.0.16", + "version": "0.0.17", "type": "module", "exports": { ".": { diff --git a/sdk/packages/cloud/src/app.ts b/sdk/packages/cloud/src/app.ts index 438a9b48..87cf9083 100644 --- a/sdk/packages/cloud/src/app.ts +++ b/sdk/packages/cloud/src/app.ts @@ -21,6 +21,8 @@ import { HttpGetUserTeamInvitesResponse, HttpInviteUserToTeamRequest, HttpInviteUserToTeamResponse, + HttpLeaveTeamRequest, + HttpLeaveTeamResponse, HttpLoginRequest, HttpLoginResponse, HttpLoginWithGoogleRequest, @@ -446,7 +448,17 @@ export class NightlyCloud { '/remove_user_from_team', EndpointType.Private, request - )) as HttpAcceptTeamInviteResponse + )) as HttpRemoveUserFromTeamResponse + + return response + } + + leaveTeam = async (request: HttpLeaveTeamRequest): Promise => { + const response = (await this.sendPostJson( + '/leave_team', + EndpointType.Private, + request + )) as HttpLeaveTeamResponse return response } diff --git a/server/bindings/CloudApiErrors.ts b/server/bindings/CloudApiErrors.ts index ccc4e187..6bc434ad 100644 --- a/server/bindings/CloudApiErrors.ts +++ b/server/bindings/CloudApiErrors.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam" | "InviteDoesNotExist" | "InvalidPaginationCursor" | "InvalidOrExpiredVerificationCode" | "InvalidOrExpiredAuthCode" | "InvalidDomainName" | "DomainAlreadyVerified" | "DomainVerificationFailure" | "DomainNotFound" | "DomainVerificationNotStarted" | "NoPendingDomainVerification" | "WebAuthnError" | "PasswordNotSet" | "UserDoesNotHavePasskey" | "PasskeyAlreadyExists" | "InvalidPasskeyCredential" | "PasskeyDoesNotExist" | "FailedToCreateTeam" | "DashboardImportFail" | "OriginHeaderRequired" | "InvalidOrigin" | "InvalidAction"; \ No newline at end of file +export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam" | "InviteDoesNotExist" | "InvalidPaginationCursor" | "InvalidOrExpiredVerificationCode" | "InvalidOrExpiredAuthCode" | "InvalidDomainName" | "DomainAlreadyVerified" | "DomainVerificationFailure" | "DomainNotFound" | "DomainVerificationNotStarted" | "NoPendingDomainVerification" | "WebAuthnError" | "PasswordNotSet" | "UserDoesNotHavePasskey" | "PasskeyAlreadyExists" | "InvalidPasskeyCredential" | "PasskeyDoesNotExist" | "FailedToCreateTeam" | "DashboardImportFail" | "OriginHeaderRequired" | "InvalidOrigin" | "InvalidAction" | "AdminCannotLeaveTeam"; \ No newline at end of file diff --git a/server/bindings/HttpCloudEndpoint.ts b/server/bindings/HttpCloudEndpoint.ts index eb5a7588..7c628271 100644 --- a/server/bindings/HttpCloudEndpoint.ts +++ b/server/bindings/HttpCloudEndpoint.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password_start" | "/register_with_password_finish" | "/login_with_password" | "/login_with_google" | "/refresh_token" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites" | "/cancel_team_user_invite" | "/cancel_user_team_invite" | "/get_app_events" | "/reset_password_start" | "/reset_password_finish" | "/verify_domain_start" | "/verify_domain_finish" | "/remove_whitelisted_domain" | "/cancel_pending_domain_verification" | "/register_with_passkey_start" | "/register_with_passkey_finish" | "/reset_passkey_start" | "/reset_passkey_finish" | "/get_passkey_challenge" | "/delete_passkey" | "/add_passkey_start" | "/add_passkey_finish" | "/get_user_metadata" | "/get_team_metadata" | "/get_team_users_privileges" | "/change_user_privileges" | "/login_with_passkey_start" | "/login_with_passkey_finish" | "/verify_code"; \ No newline at end of file +export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password_start" | "/register_with_password_finish" | "/login_with_password" | "/login_with_google" | "/refresh_token" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites" | "/cancel_team_user_invite" | "/cancel_user_team_invite" | "/get_app_events" | "/reset_password_start" | "/reset_password_finish" | "/verify_domain_start" | "/verify_domain_finish" | "/remove_whitelisted_domain" | "/cancel_pending_domain_verification" | "/register_with_passkey_start" | "/register_with_passkey_finish" | "/reset_passkey_start" | "/reset_passkey_finish" | "/get_passkey_challenge" | "/delete_passkey" | "/add_passkey_start" | "/add_passkey_finish" | "/get_user_metadata" | "/get_team_metadata" | "/get_team_users_privileges" | "/change_user_privileges" | "/login_with_passkey_start" | "/login_with_passkey_finish" | "/verify_code" | "/leave_team"; \ No newline at end of file diff --git a/server/bindings/HttpLeaveTeamRequest.ts b/server/bindings/HttpLeaveTeamRequest.ts new file mode 100644 index 00000000..1a3a628d --- /dev/null +++ b/server/bindings/HttpLeaveTeamRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpLeaveTeamRequest { teamId: string, } \ No newline at end of file diff --git a/server/bindings/HttpLeaveTeamResponse.ts b/server/bindings/HttpLeaveTeamResponse.ts new file mode 100644 index 00000000..abf576a1 --- /dev/null +++ b/server/bindings/HttpLeaveTeamResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HttpLeaveTeamResponse = null; \ No newline at end of file diff --git a/server/src/http/cloud/leave_team.rs b/server/src/http/cloud/leave_team.rs new file mode 100644 index 00000000..c45926e2 --- /dev/null +++ b/server/src/http/cloud/leave_team.rs @@ -0,0 +1,262 @@ +use super::{ + grafana_utils::remove_user_from_the_team::handle_grafana_remove_user_from_team, + utils::{custom_validate_team_id, validate_request}, +}; +use crate::{ + mailer::{ + mail_requests::{SendEmailRequest, TeamLeavingNotification}, + mailer::Mailer, + }, + middlewares::auth_middleware::UserId, + structs::cloud::api_cloud_errors::CloudApiErrors, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +use openapi::apis::configuration::Configuration; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpLeaveTeamRequest { + #[garde(custom(custom_validate_team_id))] + pub team_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpLeaveTeamResponse {} + +pub async fn leave_team( + State(db): State>, + State(grafana_conf): State>, + State(mailer): State>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Validate request + validate_request(&request, &())?; + + // Get team data and perform checks + match db.get_team_by_team_id(None, &request.team_id).await { + Ok(Some(team)) => { + // Check if user is a admin of this team (admin cannot leave team) + if team.team_admin_id == user_id { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::AdminCannotLeaveTeam.to_string(), + )); + } + + // Get user data and perform checks + let user = match db.get_user_by_user_id(&user_id).await { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get user by email: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Check if user already belongs to the team + match db.get_teams_and_apps_membership_by_user_id(&user_id).await { + Ok(teams) => { + // This won't check if user has permissions to all apps in the team + if !teams.iter().any(|(team_id, _)| team_id == &request.team_id) { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotBelongsToTheTeam.to_string(), + )); + } + } + Err(err) => { + error!( + "Failed to get teams and apps membership by user id: {:?}", + err + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Grafana, remove user from the team + handle_grafana_remove_user_from_team(&grafana_conf, &request.team_id, &user.email) + .await?; + + // Remove user from the team + if let Err(err) = db + .remove_user_from_the_team(&user_id, &request.team_id) + .await + { + error!("Failed to remove user from the team: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + // Send email notification + let request = SendEmailRequest::LeaveTeam(TeamLeavingNotification { + email: user.email.clone(), + team_name: team.team_name.clone(), + }); + + // It doesn't matter if this fails + if let Some(err) = mailer.handle_email_request(&request).error_message { + error!("Failed to send email: {:?}, request: {:?}", err, request); + } + + // Return response + Ok(Json(HttpLeaveTeamResponse {})) + } + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::TeamDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get team: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } +} + +#[cfg(feature = "cloud_integration_tests")] +#[cfg(test)] +mod tests { + use crate::{ + env::JWT_SECRET, + http::cloud::{ + leave_team::{HttpLeaveTeamRequest, HttpLeaveTeamResponse}, + register_new_app::HttpRegisterNewAppRequest, + }, + structs::cloud::{ + api_cloud_errors::CloudApiErrors, cloud_http_endpoints::HttpCloudEndpoint, + }, + test_utils::test_utils::{ + add_test_app, add_test_team, add_user_to_test_team, convert_response, create_test_app, + generate_valid_name, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::{ConnectInfo, Request}, + http::Method, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_leave_the_team() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + // Register new team + let team_name = generate_valid_name(); + + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.to_string(), + app_name: app_name.clone(), + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Register new user + let (test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + // Add user to the team + add_user_to_test_team( + &team_id.to_string(), + &test_user_email, + &auth_token, + &test_user_auth_token, + &test_app, + ) + .await + .unwrap(); + + // Remove user from the team + let request = HttpLeaveTeamRequest { + team_id: team_id.to_string(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = test_user_auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::POST) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::LeaveTeam.to_string() + )) + .extension(ip.clone()) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response) + .await + .unwrap(); + + // Try to remove user from the team again, should fail as user is not in the team + let json = serde_json::to_string(&request).unwrap(); + let auth = test_user_auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::POST) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::LeaveTeam.to_string() + )) + .extension(ip) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + let err = convert_response::(response) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + CloudApiErrors::UserDoesNotBelongsToTheTeam.to_string() + ); + } +} diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index db12c409..f311eef2 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -17,6 +17,7 @@ pub mod get_user_metadata; pub mod get_user_team_invites; pub mod grafana_utils; pub mod invite_user_to_team; +pub mod leave_team; pub mod login; pub mod register; pub mod register_new_app; diff --git a/server/src/mailer/mail_requests.rs b/server/src/mailer/mail_requests.rs index 512f3f10..73e277cc 100644 --- a/server/src/mailer/mail_requests.rs +++ b/server/src/mailer/mail_requests.rs @@ -6,6 +6,7 @@ pub enum SendEmailRequest { ResetPassword(ResetPasswordRequest), TeamInvite(TeamInviteNotification), TeamRemoval(TeamRemovalNotification), + LeaveTeam(TeamLeavingNotification), } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -38,3 +39,9 @@ pub struct TeamRemovalNotification { pub team_name: String, pub remover_email: String, } + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TeamLeavingNotification { + pub email: String, + pub team_name: String, +} diff --git a/server/src/mailer/request_handler/handle_requests.rs b/server/src/mailer/request_handler/handle_requests.rs index 971fb784..e9de2cb2 100644 --- a/server/src/mailer/request_handler/handle_requests.rs +++ b/server/src/mailer/request_handler/handle_requests.rs @@ -1,6 +1,7 @@ use super::processors::{ email_confirmation::send_email_confirmation, reset_password::send_password_reset, team_invite_notification::send_team_invite_notification, + team_leaving_notification::send_team_leaving_notification, team_removal_notification::send_team_removal_notification, }; use crate::{ @@ -53,6 +54,14 @@ impl Mailer { &request, ); } + SendEmailRequest::LeaveTeam(request) => { + return send_team_leaving_notification( + &templates, + mailbox.clone(), + &mail_sender, + &request, + ); + } } } } diff --git a/server/src/mailer/request_handler/processors/mod.rs b/server/src/mailer/request_handler/processors/mod.rs index ffdec443..aa57d0a3 100644 --- a/server/src/mailer/request_handler/processors/mod.rs +++ b/server/src/mailer/request_handler/processors/mod.rs @@ -1,4 +1,5 @@ pub mod email_confirmation; pub mod reset_password; pub mod team_invite_notification; +pub mod team_leaving_notification; pub mod team_removal_notification; diff --git a/server/src/mailer/request_handler/processors/team_leaving_notification.rs b/server/src/mailer/request_handler/processors/team_leaving_notification.rs new file mode 100644 index 00000000..68d6c279 --- /dev/null +++ b/server/src/mailer/request_handler/processors/team_leaving_notification.rs @@ -0,0 +1,61 @@ +use crate::mailer::{ + mail_requests::{SendEmailResponse, TeamLeavingNotification}, + request_handler::utils::create_message, + templates::templates::Templates, +}; +use lettre::{message::Mailbox, SmtpTransport, Transport}; +use log::{error, warn}; +use std::{collections::HashMap, sync::Arc}; + +pub fn send_team_leaving_notification( + templates: &Arc>, + mailbox: Mailbox, + mail_sender: &Arc, + request: &TeamLeavingNotification, +) -> SendEmailResponse { + let html = match templates.get(&Templates::TeamLeavingNotification) { + Some(template) => template.replace( + "TEAM_LEAVING_MESSAGE_TO_REPLACE", + format!("You left the team {}", request.team_name).as_str(), + ), + None => { + // Only possible if someone messes with the templates, print error and go along + error!( + "MAILER: Could not find team leaving notification template under: {:?}", + Templates::TeamLeavingNotification + ); + return SendEmailResponse { + error_message: Some("Internal Error".to_string()), + }; + } + }; + + match create_message( + html, + mailbox, + &request.email, + "Nightly Connect Cloud - Left the team".to_string(), + ) { + Ok(message) => { + if let Err(e) = mail_sender.send(&message) { + warn!("MAILER: Failed to send team leaving notification: {:?}", e); + return SendEmailResponse { + error_message: Some("Internal Error".to_string()), + }; + } else { + return SendEmailResponse { + error_message: None, + }; + } + } + Err(err) => { + warn!( + "MAILER: Failed to create team leaving notification: {:?}", + err + ); + return SendEmailResponse { + error_message: Some("Internal Error".to_string()), + }; + } + } +} diff --git a/server/src/mailer/templates/mod.rs b/server/src/mailer/templates/mod.rs index 52599a7c..e00f5224 100644 --- a/server/src/mailer/templates/mod.rs +++ b/server/src/mailer/templates/mod.rs @@ -2,5 +2,6 @@ pub mod emailConfirmation; pub mod resetPassword; pub mod teamInviteNotification; +pub mod teamLeavingNotification; pub mod teamRemovalNotification; pub mod templates; diff --git a/server/src/mailer/templates/teamLeavingNotification.rs b/server/src/mailer/templates/teamLeavingNotification.rs new file mode 100644 index 00000000..f0ea06e2 --- /dev/null +++ b/server/src/mailer/templates/teamLeavingNotification.rs @@ -0,0 +1,303 @@ +pub static TEAM_LEAVING_NOTIFICATION_TEMPLATE: &str = r##" + + + + + + Email Confirmation + + + + + + + + +
+ +
+ + + + + + + +
+

+ Email confirmation +

+

+ To complete your profile and start trading, you’ll need to verify + your email address: +

+
+ + + + +
+ + + + +
+ Confirm email +
+
+
+ + + + +
+

+ Button not working? Try the verification link: +

+ EMAIL_CONFIRMATION_LINK_TO_REPLACE +

+ It’s not you? Please, contact our support as soon as possible. +

+
+ + + + +
+

+ Stay in touch! +

+ + + + +
+ + + + + +
+ + discordIcon + Discord + + + + twitterIcon + Twitter + +
+
+ +

+ ©2023 - Nightly Exchange. All rights reserved. +

+
+ +"##; diff --git a/server/src/mailer/templates/templates.rs b/server/src/mailer/templates/templates.rs index bc833a58..2354d46e 100644 --- a/server/src/mailer/templates/templates.rs +++ b/server/src/mailer/templates/templates.rs @@ -1,6 +1,7 @@ use super::{ emailConfirmation::EMAIL_CONFIRMATION_TEMPLATE, resetPassword::RESET_PASSWORD_TEMPLATE, teamInviteNotification::TEAM_INVITE_NOTIFICATION_TEMPLATE, + teamLeavingNotification::TEAM_LEAVING_NOTIFICATION_TEMPLATE, teamRemovalNotification::TEAM_REMOVAL_NOTIFICATION_TEMPLATE, }; use std::collections::HashMap; @@ -12,6 +13,7 @@ pub enum Templates { ResetPassword, TeamInviteNotification, TeamRemovalNotification, + TeamLeavingNotification, } pub fn get_templates() -> HashMap { @@ -33,6 +35,10 @@ pub fn get_templates() -> HashMap { Templates::TeamRemovalNotification, TEAM_REMOVAL_NOTIFICATION_TEMPLATE.to_string(), ); + templates.insert( + Templates::TeamLeavingNotification, + TEAM_LEAVING_NOTIFICATION_TEMPLATE.to_string(), + ); templates } diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index 6dbf487f..81b59ac1 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -22,6 +22,7 @@ use crate::{ get_user_metadata::get_user_metadata, get_user_team_invites::get_user_team_invites, invite_user_to_team::invite_user_to_team, + leave_team::leave_team, login::{ login_with_google::login_with_google, login_with_passkey_finish::login_with_passkey_finish, @@ -220,5 +221,6 @@ pub fn private_router(state: ServerState) -> Router { &HttpCloudEndpoint::ChangeUserPrivileges.to_string(), post(change_user_privileges), ) + .route(&HttpCloudEndpoint::LeaveTeam.to_string(), post(leave_team)) .with_state(state) } diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 7d631578..2c09ab5d 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -51,4 +51,5 @@ pub enum CloudApiErrors { OriginHeaderRequired, InvalidOrigin, InvalidAction, + AdminCannotLeaveTeam, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index 44e0fe02..d6456e59 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -80,6 +80,8 @@ pub enum HttpCloudEndpoint { LoginWithPasskeyFinish, #[serde(rename = "/verify_code")] VerifyCode, + #[serde(rename = "/leave_team")] + LeaveTeam, } impl HttpCloudEndpoint { @@ -133,6 +135,7 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::LoginWithPasskeyFinish => "/login_with_passkey_finish".to_string(), HttpCloudEndpoint::RefreshToken => "/refresh_token".to_string(), HttpCloudEndpoint::VerifyCode => "/verify_code".to_string(), + HttpCloudEndpoint::LeaveTeam => "/leave_team".to_string(), } } }