diff --git a/.github/workflows/connect-test-local.yml b/.github/workflows/connect-test-local.yml index 0dbb25df..dcc7ad92 100644 --- a/.github/workflows/connect-test-local.yml +++ b/.github/workflows/connect-test-local.yml @@ -37,6 +37,8 @@ jobs: - uses: actions-rs/toolchain@v1 with: toolchain: stable + default: true + override: true - name: install packages if: steps.cache-node-modules.outputs.cache-hit != 'true' run: | diff --git a/Cargo.toml b/Cargo.toml index f1340cad..951aa19a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,14 +19,14 @@ log = "0.4.20" jsonwebtoken = "9.2.0" pwhash = "1.0.0" futures = "0.3.30" -axum = { version = "0.7.4", features = ["ws", "macros"] } +axum = { version = "0.7.5", features = ["ws", "macros"] } tower = { version = "0.4.13", features = [ "util", "timeout", "load-shed", "limit", ] } -tower-http = { version = "0.5.1", features = ["cors", "trace"] } +tower-http = { version = "0.5.2", features = ["cors", "trace"] } reqwest = {version = "0.11.24", features = ["json"]} tokio = { version = "1.35.1", features = ["full"] } async-trait = "0.1.77" diff --git a/database/migrations/0011_events_index.sql b/database/migrations/0011_events_index.sql index d0d6c3c3..3668152e 100644 --- a/database/migrations/0011_events_index.sql +++ b/database/migrations/0011_events_index.sql @@ -1,5 +1,5 @@ CREATE TABLE events( - event_id SERIAL PRIMARY KEY, + event_id BIGSERIAL PRIMARY KEY, app_id TEXT NOT NULL, event_type event_type_enum NOT NULL, creation_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() diff --git a/database/migrations/0013_team_invites.sql b/database/migrations/0013_team_invites.sql new file mode 100644 index 00000000..2832dace --- /dev/null +++ b/database/migrations/0013_team_invites.sql @@ -0,0 +1,11 @@ +CREATE TABLE team_invites( + invite_id BIGSERIAL PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES team(team_id), + user_email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ +); + +CREATE INDEX team_invites_user_email_idx ON team_invites(user_email); +CREATE INDEX team_invites_team_id_idx ON team_invites(team_id); diff --git a/database/migrations/0013_create_hypertables.sql b/database/migrations/0014_create_hypertables.sql similarity index 100% rename from database/migrations/0013_create_hypertables.sql rename to database/migrations/0014_create_hypertables.sql diff --git a/database/migrations/0014_requests_stats.sql b/database/migrations/0015_requests_stats.sql similarity index 100% rename from database/migrations/0014_requests_stats.sql rename to database/migrations/0015_requests_stats.sql diff --git a/database/migrations/0015_connection_stats.sql b/database/migrations/0016_connection_stats.sql similarity index 100% rename from database/migrations/0015_connection_stats.sql rename to database/migrations/0016_connection_stats.sql diff --git a/database/migrations/0016_session_stats.sql b/database/migrations/0017_session_stats.sql similarity index 100% rename from database/migrations/0016_session_stats.sql rename to database/migrations/0017_session_stats.sql diff --git a/database/src/tables/grafana_users/select.rs b/database/src/tables/grafana_users/select.rs index 004dd597..ecfc5548 100644 --- a/database/src/tables/grafana_users/select.rs +++ b/database/src/tables/grafana_users/select.rs @@ -5,13 +5,16 @@ use crate::tables::grafana_users::table_struct::GRAFANA_USERS_TABLE_NAME; use sqlx::query_as; impl Db { - pub async fn get_user_by_user_id(&self, user_id: &String) -> Result { + pub async fn get_user_by_user_id( + &self, + user_id: &String, + ) -> Result, DbError> { let query = format!("SELECT * FROM {GRAFANA_USERS_TABLE_NAME} WHERE user_id = $1"); let typed_query = query_as::<_, GrafanaUser>(&query); return typed_query .bind(&user_id) - .fetch_one(&self.connection_pool) + .fetch_optional(&self.connection_pool) .await .map_err(|e| e.into()); } diff --git a/database/src/tables/grafana_users/update.rs b/database/src/tables/grafana_users/update.rs index b5cef05a..dded9d7f 100644 --- a/database/src/tables/grafana_users/update.rs +++ b/database/src/tables/grafana_users/update.rs @@ -79,6 +79,6 @@ mod tests { db.add_new_user(&user).await.unwrap(); let user_result = db.get_user_by_user_id(&user.user_id).await.unwrap(); - assert_eq!(user_result, user); + assert_eq!(user_result, Some(user)); } } diff --git a/database/src/tables/mod.rs b/database/src/tables/mod.rs index ea8e84f6..9c5ec739 100644 --- a/database/src/tables/mod.rs +++ b/database/src/tables/mod.rs @@ -9,6 +9,7 @@ pub mod registered_app; pub mod session_public_keys; pub mod sessions; pub mod team; +pub mod team_invites; pub mod test_utils; pub mod user_app_privileges; pub mod utils; diff --git a/database/src/tables/team_invites/mod.rs b/database/src/tables/team_invites/mod.rs new file mode 100644 index 00000000..4b2d4aa3 --- /dev/null +++ b/database/src/tables/team_invites/mod.rs @@ -0,0 +1,3 @@ +pub mod select; +pub mod table_struct; +pub mod update; diff --git a/database/src/tables/team_invites/select.rs b/database/src/tables/team_invites/select.rs new file mode 100644 index 00000000..bcfbe83d --- /dev/null +++ b/database/src/tables/team_invites/select.rs @@ -0,0 +1,65 @@ +use super::table_struct::TeamInvite; +use crate::db::Db; +use crate::structs::db_error::DbError; +use crate::tables::team_invites::table_struct::TEAM_INVITES_TABLE_NAME; + +impl Db { + pub async fn get_invites_by_team_id( + &self, + team_id: &String, + active_invites: bool, + ) -> Result, DbError> { + let additional_filter = if active_invites { + "AND ti.accepted_at IS NULL AND ti.cancelled_at IS NULL" + } else { + "" + }; + + let query = format!( + "SELECT ti.invite_id, ti.team_id, ti.user_email, ti.created_at, + ti.accepted_at, ti.cancelled_at, t.team_name, gu.email AS admin_email + FROM {TEAM_INVITES_TABLE_NAME} ti + INNER JOIN team t ON ti.team_id = t.team_id + INNER JOIN grafana_users gu ON t.team_admin_id = gu.user_id + WHERE ti.team_id = $1 {additional_filter} + ORDER BY ti.created_at DESC", + ); + + let typed_query = sqlx::query_as::<_, TeamInvite>(&query).bind(team_id); + + return typed_query + .fetch_all(&self.connection_pool) + .await + .map_err(|e| e.into()); + } + + pub async fn get_invites_by_user_email( + &self, + user_email: &String, + active_invites: bool, + ) -> Result, DbError> { + let additional_filter = if active_invites { + "AND ti.accepted_at IS NULL AND ti.cancelled_at IS NULL" + } else { + "" + }; + + let query = format!( + "SELECT ti.invite_id, ti.team_id, ti.user_email, ti.created_at, \ + ti.accepted_at, ti.cancelled_at, t.team_name, gu.email AS admin_email \ + FROM {TEAM_INVITES_TABLE_NAME} ti \ + INNER JOIN team t ON ti.team_id = t.team_id \ + INNER JOIN grafana_users gu ON t.team_admin_id = gu.user_id \ + WHERE ti.user_email = $1 {additional_filter} \ + ORDER BY ti.created_at DESC", + TEAM_INVITES_TABLE_NAME = TEAM_INVITES_TABLE_NAME + ); + + let typed_query = sqlx::query_as::<_, TeamInvite>(&query).bind(user_email); + + return typed_query + .fetch_all(&self.connection_pool) + .await + .map_err(|e| e.into()); + } +} diff --git a/database/src/tables/team_invites/table_struct.rs b/database/src/tables/team_invites/table_struct.rs new file mode 100644 index 00000000..5f961295 --- /dev/null +++ b/database/src/tables/team_invites/table_struct.rs @@ -0,0 +1,37 @@ +use sqlx::{ + postgres::PgRow, + types::chrono::{DateTime, Utc}, + FromRow, Row, +}; + +pub const TEAM_INVITES_TABLE_NAME: &str = "team_invites"; +pub const TEAM_INVITES_KEYS: &str = + "invite_id, team_id, user_email, created_at, accepted_at, cancelled_at"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TeamInvite { + pub invite_id: i64, + pub team_id: String, + pub user_email: String, + pub created_at: DateTime, + pub accepted_at: Option>, + pub cancelled_at: Option>, + // Not present in the table, queried from the team table + pub team_name: String, + pub admin_email: String, +} + +impl FromRow<'_, PgRow> for TeamInvite { + fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result { + Ok(TeamInvite { + invite_id: row.get("invite_id"), + team_id: row.get("team_id"), + user_email: row.get("user_email"), + created_at: row.get("created_at"), + accepted_at: row.get("accepted_at"), + cancelled_at: row.get("cancelled_at"), + team_name: row.get("team_name"), + admin_email: row.get("admin_email"), + }) + } +} diff --git a/database/src/tables/team_invites/update.rs b/database/src/tables/team_invites/update.rs new file mode 100644 index 00000000..2aab9ad3 --- /dev/null +++ b/database/src/tables/team_invites/update.rs @@ -0,0 +1,74 @@ +use super::table_struct::{TEAM_INVITES_KEYS, TEAM_INVITES_TABLE_NAME}; +use crate::db::Db; +use crate::structs::db_error::DbError; +use crate::tables::utils::get_current_datetime; +use sqlx::query; + +impl Db { + pub async fn create_new_team_invite( + &self, + team_id: &String, + user_email: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "INSERT INTO {TEAM_INVITES_TABLE_NAME} ({TEAM_INVITES_KEYS}) VALUES (DEFAULT, $1, $2, $3, NULL, NULL)" + ); + + let query_result = query(&query_body) + .bind(&team_id) + .bind(&user_email) + .bind(&get_current_datetime()) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } + + pub async fn accept_team_invite( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + team_id: &String, + user_email: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "UPDATE {TEAM_INVITES_TABLE_NAME} SET accepted_at = $1 WHERE team_id = $2 AND user_email = $3 AND accepted_at IS NULL AND cancelled_at IS NULL" + ); + + let query_result = query(&query_body) + .bind(&get_current_datetime()) + .bind(&team_id) + .bind(&user_email) + .execute(&mut **tx) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } + + pub async fn cancel_team_invite( + &self, + team_id: &String, + user_email: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "UPDATE {TEAM_INVITES_TABLE_NAME} SET cancelled_at = $1 WHERE team_id = $2 AND user_email = $3 AND accepted_at IS NULL AND cancelled_at IS NULL" + ); + + let query_result = query(&query_body) + .bind(&get_current_datetime()) + .bind(&team_id) + .bind(&user_email) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } +} diff --git a/database/src/tables/user_app_privileges/update.rs b/database/src/tables/user_app_privileges/update.rs index c2e87459..ff69e769 100644 --- a/database/src/tables/user_app_privileges/update.rs +++ b/database/src/tables/user_app_privileges/update.rs @@ -57,6 +57,7 @@ impl Db { pub async fn add_user_to_the_team( &self, + tx: &mut Transaction<'_, sqlx::Postgres>, user_id: &String, team_id: &String, ) -> Result<(), DbError> { @@ -98,9 +99,7 @@ impl Db { values_str ); - sqlx::query(&insert_query) - .execute(&self.connection_pool) - .await?; + sqlx::query(&insert_query).execute(&mut **tx).await?; Ok(()) } @@ -294,9 +293,11 @@ mod tests { db.register_new_app(&app).await.unwrap(); } - db.add_user_to_the_team(&user.user_id, &team_id) + let mut tx = db.connection_pool.begin().await.unwrap(); + db.add_user_to_the_team(&mut tx, &user.user_id, &team_id) .await .unwrap(); + tx.commit().await.unwrap(); let get_by_user_id = db.get_privileges_by_user_id(&user.user_id).await.unwrap(); assert!(get_by_user_id.len() == 8); @@ -331,9 +332,11 @@ mod tests { } // Add user to the new team - db.add_user_to_the_team(&user.user_id, &team_id) + let mut tx = db.connection_pool.begin().await.unwrap(); + db.add_user_to_the_team(&mut tx, &user.user_id, &team_id) .await .unwrap(); + tx.commit().await.unwrap(); let get_by_user_id = db.get_privileges_by_user_id(&user.user_id).await.unwrap(); assert!(get_by_user_id.len() == 10); diff --git a/server/bindings/CloudApiErrors.ts b/server/bindings/CloudApiErrors.ts index 8dad0957..f7946575 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"; \ 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"; \ No newline at end of file diff --git a/server/bindings/HttpAcceptTeamInviteRequest.ts b/server/bindings/HttpAcceptTeamInviteRequest.ts new file mode 100644 index 00000000..00337cfc --- /dev/null +++ b/server/bindings/HttpAcceptTeamInviteRequest.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 HttpAcceptTeamInviteRequest { teamId: string, } \ No newline at end of file diff --git a/server/bindings/HttpAcceptTeamInviteResponse.ts b/server/bindings/HttpAcceptTeamInviteResponse.ts new file mode 100644 index 00000000..0bdf4a41 --- /dev/null +++ b/server/bindings/HttpAcceptTeamInviteResponse.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 HttpAcceptTeamInviteResponse = null; \ No newline at end of file diff --git a/server/bindings/HttpCancelTeamUserInviteRequest.ts b/server/bindings/HttpCancelTeamUserInviteRequest.ts new file mode 100644 index 00000000..be2e71b3 --- /dev/null +++ b/server/bindings/HttpCancelTeamUserInviteRequest.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 HttpCancelTeamUserInviteRequest { teamId: string, userEmail: string, } \ No newline at end of file diff --git a/server/bindings/HttpCancelTeamUserInviteResponse.ts b/server/bindings/HttpCancelTeamUserInviteResponse.ts new file mode 100644 index 00000000..d59fda24 --- /dev/null +++ b/server/bindings/HttpCancelTeamUserInviteResponse.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 HttpCancelTeamUserInviteResponse = null; \ No newline at end of file diff --git a/server/bindings/HttpCloudEndpoint.ts b/server/bindings/HttpCloudEndpoint.ts index c1de5623..f28f2a20 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" | "/login_with_password" | "/register_new_team" | "/add_user_to_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events"; \ No newline at end of file +export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password" | "/login_with_google" | "/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_invite"; \ No newline at end of file diff --git a/server/bindings/HttpGetTeamUserInvitesRequest.ts b/server/bindings/HttpGetTeamUserInvitesRequest.ts new file mode 100644 index 00000000..4b641992 --- /dev/null +++ b/server/bindings/HttpGetTeamUserInvitesRequest.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 HttpGetTeamUserInvitesRequest { teamId: string, } \ No newline at end of file diff --git a/server/bindings/HttpGetTeamUserInvitesResponse.ts b/server/bindings/HttpGetTeamUserInvitesResponse.ts new file mode 100644 index 00000000..8110bfe9 --- /dev/null +++ b/server/bindings/HttpGetTeamUserInvitesResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TeamInvite } from "./TeamInvite"; + +export interface HttpGetTeamUserInvitesResponse { teamInvites: Array, } \ No newline at end of file diff --git a/server/bindings/HttpGetUserJoinedTeamsResponse.ts b/server/bindings/HttpGetUserJoinedTeamsResponse.ts index 65783cf5..7b54bbea 100644 --- a/server/bindings/HttpGetUserJoinedTeamsResponse.ts +++ b/server/bindings/HttpGetUserJoinedTeamsResponse.ts @@ -3,4 +3,4 @@ import type { AppInfo } from "./AppInfo"; import type { JoinedTeam } from "./JoinedTeam"; import type { UserPrivilege } from "./UserPrivilege"; -export interface HttpGetUserJoinedTeamsResponse { teams: Record, teams_apps: Record>, user_privileges: Record>, } \ No newline at end of file +export interface HttpGetUserJoinedTeamsResponse { teams: Record, teamsApps: Record>, userPrivileges: Record>, } \ No newline at end of file diff --git a/server/bindings/HttpGetUserTeamInvitesResponse.ts b/server/bindings/HttpGetUserTeamInvitesResponse.ts new file mode 100644 index 00000000..a996333f --- /dev/null +++ b/server/bindings/HttpGetUserTeamInvitesResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TeamInvite } from "./TeamInvite"; + +export interface HttpGetUserTeamInvitesResponse { teamInvites: Array, } \ No newline at end of file diff --git a/server/bindings/HttpInviteUserToTeamRequest.ts b/server/bindings/HttpInviteUserToTeamRequest.ts new file mode 100644 index 00000000..a11a6be0 --- /dev/null +++ b/server/bindings/HttpInviteUserToTeamRequest.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 HttpInviteUserToTeamRequest { teamId: string, userEmail: string, } \ No newline at end of file diff --git a/server/bindings/HttpInviteUserToTeamResponse.ts b/server/bindings/HttpInviteUserToTeamResponse.ts new file mode 100644 index 00000000..2b278664 --- /dev/null +++ b/server/bindings/HttpInviteUserToTeamResponse.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 HttpInviteUserToTeamResponse = null; \ No newline at end of file diff --git a/server/bindings/HttpLoginResponse.ts b/server/bindings/HttpLoginResponse.ts index ecf5d957..222f4049 100644 --- a/server/bindings/HttpLoginResponse.ts +++ b/server/bindings/HttpLoginResponse.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 interface HttpLoginResponse { user_id: string, auth_token: string, refresh_token: string, } \ No newline at end of file +export interface HttpLoginResponse { userId: string, authToken: string, refreshToken: string, } \ No newline at end of file diff --git a/server/bindings/HttpLoginWithGoogleRequest.ts b/server/bindings/HttpLoginWithGoogleRequest.ts new file mode 100644 index 00000000..3a3391be --- /dev/null +++ b/server/bindings/HttpLoginWithGoogleRequest.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 HttpLoginWithGoogleRequest { oauthToken: string, email: string, enforceIp: boolean, } \ No newline at end of file diff --git a/server/bindings/HttpRegisterNewAppResponse.ts b/server/bindings/HttpRegisterNewAppResponse.ts index 4491fc30..79bfb3d7 100644 --- a/server/bindings/HttpRegisterNewAppResponse.ts +++ b/server/bindings/HttpRegisterNewAppResponse.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 interface HttpRegisterNewAppResponse { app_id: string, } \ No newline at end of file +export interface HttpRegisterNewAppResponse { appId: string, } \ No newline at end of file diff --git a/server/bindings/HttpRegisterNewTeamResponse.ts b/server/bindings/HttpRegisterNewTeamResponse.ts index d56c4b22..c73cfb55 100644 --- a/server/bindings/HttpRegisterNewTeamResponse.ts +++ b/server/bindings/HttpRegisterNewTeamResponse.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 interface HttpRegisterNewTeamResponse { team_id: string, } \ No newline at end of file +export interface HttpRegisterNewTeamResponse { teamId: string, } \ No newline at end of file diff --git a/server/bindings/HttpRegisterWithPasswordResponse.ts b/server/bindings/HttpRegisterWithPasswordResponse.ts index 6092e7ff..ac9570c3 100644 --- a/server/bindings/HttpRegisterWithPasswordResponse.ts +++ b/server/bindings/HttpRegisterWithPasswordResponse.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 interface HttpRegisterWithPasswordResponse { user_id: string, } \ No newline at end of file +export interface HttpRegisterWithPasswordResponse { userId: string, } \ No newline at end of file diff --git a/server/bindings/LoginWithGoogleResponse.ts b/server/bindings/LoginWithGoogleResponse.ts new file mode 100644 index 00000000..718e6d34 --- /dev/null +++ b/server/bindings/LoginWithGoogleResponse.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 LoginWithGoogleResponse { userId: string, authToken: string, refreshToken: string, } \ No newline at end of file diff --git a/server/bindings/TeamInvite.ts b/server/bindings/TeamInvite.ts new file mode 100644 index 00000000..ae35f807 --- /dev/null +++ b/server/bindings/TeamInvite.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 TeamInvite { creatorEmail: string, teamName: string, userEmail: string, createdAt: string, } \ No newline at end of file diff --git a/server/src/http/cloud/accept_team_invite.rs b/server/src/http/cloud/accept_team_invite.rs new file mode 100644 index 00000000..d92616a9 --- /dev/null +++ b/server/src/http/cloud/accept_team_invite.rs @@ -0,0 +1,318 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::api_cloud_errors::CloudApiErrors, + utils::{custom_validate_uuid, validate_request}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +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 HttpAcceptTeamInviteRequest { + #[garde(custom(custom_validate_uuid))] + pub team_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpAcceptTeamInviteResponse {} + +pub async fn accept_team_invite( + State(db): State>>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_string(), + ))?; + + // Validate request + validate_request(&request, &())?; + + // 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 user_id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Just in case check if user already belongs to the team + match db + .get_teams_and_apps_membership_by_user_id(&user.user_id) + .await + { + Ok(teams) => { + if teams.iter().any(|(team_id, _)| team_id == &request.team_id) { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserAlreadyBelongsToTheTeam.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(), + )); + } + } + + // Check if user was invited to the team + match db.get_invites_by_user_email(&user.email, true).await { + Ok(invites) => { + if !invites + .iter() + .any(|invite| invite.team_id == request.team_id) + { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InviteNotFound.to_string(), + )); + } + } + Err(err) => { + error!("Failed to get invites by user id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Accept invite + let mut tx = match db.connection_pool.begin().await { + Ok(tx) => tx, + Err(err) => { + error!("Failed to start transaction: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Accept invite + if let Err(err) = db + .accept_team_invite(&mut tx, &request.team_id, &user.email) + .await + { + error!("Failed to accept team invite: {:?}", err); + if let Err(err) = tx.rollback().await { + error!("Failed to rollback transaction: {:?}", err); + } + + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + // Add user to the team + if let Err(err) = db + .add_user_to_the_team(&mut tx, &user_id, &request.team_id) + .await + { + error!("Failed to add user to the team: {:?}", err); + if let Err(err) = tx.rollback().await { + error!("Failed to rollback transaction: {:?}", err); + } + + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + // Commit transaction + if let Err(err) = tx.commit().await { + error!("Failed to commit transaction: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + Ok(Json(HttpAcceptTeamInviteResponse {})) +} + +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::accept_invite_to_test_team; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::{ + api_cloud_errors::CloudApiErrors, cloud_http_endpoints::HttpCloudEndpoint, + }, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_add_user_to_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.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // 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; + + // Invite user to the team + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + + // Accept invite to the team + let request = HttpAcceptTeamInviteRequest { + team_id: team_id.clone(), + }; + + 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::AcceptTeamInvite.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 add user to the team again, should fail as user is already in the team + let request = HttpAcceptTeamInviteRequest { + team_id: team_id.clone(), + }; + + 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::AcceptTeamInvite.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::UserAlreadyBelongsToTheTeam.to_string() + ); + } + + #[tokio::test] + async fn test_accept_no_invite_found() { + 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.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // 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; + + // Accept invite to the team + let err = accept_invite_to_test_team(&team_id, &test_user_auth_token, &test_app) + .await + .unwrap_err(); + + assert_eq!(err.to_string(), CloudApiErrors::InviteNotFound.to_string()); + } +} diff --git a/server/src/http/cloud/cancel_team_invite.rs b/server/src/http/cloud/cancel_team_invite.rs new file mode 100644 index 00000000..a44f2a6b --- /dev/null +++ b/server/src/http/cloud/cancel_team_invite.rs @@ -0,0 +1,268 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::{api_cloud_errors::CloudApiErrors, team_invite::TeamInvite}, + utils::{custom_validate_uuid, validate_request}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +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 HttpCancelTeamUserInviteRequest { + #[garde(custom(custom_validate_uuid))] + pub team_id: String, + #[garde(email)] + pub user_email: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpCancelTeamUserInviteResponse {} + +pub async fn cancel_team_user_invite( + State(db): State>>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_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 + if team.team_admin_id != user_id { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InsufficientPermissions.to_string(), + )); + } + + // Check team type + if team.personal { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::ActionForbiddenForPersonalTeam.to_string(), + )); + } + + // Check if invite exists + match db.get_invites_by_team_id(&request.team_id, true).await { + Ok(invites) => { + let team_invites: Vec = invites + .into_iter() + .map(|invite| TeamInvite::from(invite)) + .collect(); + + // Find the invite + let invite = team_invites + .iter() + .find(|invite| invite.user_email == request.user_email); + + if let None = invite { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InviteDoesNotExist.to_string(), + )); + } + } + Err(err) => { + error!("Failed to get team invites: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Cancel invite + // Cancel the invite + match db + .cancel_team_invite(&request.team_id, &request.user_email) + .await + { + Ok(_) => { + return Ok(Json(HttpCancelTeamUserInviteResponse {})); + } + Err(err) => { + error!("Failed to cancel invite: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + } + 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_db_tests")] +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::get_test_team_user_invites; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_cancel_team_user_invite() { + 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.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + let mut users_emails: Vec = vec![]; + // Invite a few users to the team + for _ in 0..3 { + // Register new user + let (_test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + users_emails.push(test_user_email.clone()); + + // Create invite + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + } + + // Check if team invite exists + let team_invites = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(team_invites.team_invites.len(), 3); + assert_eq!(team_invites.team_invites[0].user_email, users_emails[2]); + assert_eq!(team_invites.team_invites[1].user_email, users_emails[1]); + assert_eq!(team_invites.team_invites[2].user_email, users_emails[0]); + + // Cancel team invite for the first user + let request = HttpCancelTeamUserInviteRequest { + team_id: team_id.clone(), + user_email: users_emails[0].clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::CancelTeamInvite.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(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 2); + assert_eq!(resp.team_invites[0].user_email, users_emails[2]); + assert_eq!(resp.team_invites[1].user_email, users_emails[1]); + } + + #[tokio::test] + async fn test_no_invites() { + 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.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 0); + } +} diff --git a/server/src/http/cloud/get_team_user_invites.rs b/server/src/http/cloud/get_team_user_invites.rs new file mode 100644 index 00000000..38ccc23d --- /dev/null +++ b/server/src/http/cloud/get_team_user_invites.rs @@ -0,0 +1,248 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::{api_cloud_errors::CloudApiErrors, team_invite::TeamInvite}, + utils::{custom_validate_uuid, validate_request}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +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 HttpGetTeamUserInvitesRequest { + #[garde(custom(custom_validate_uuid))] + pub team_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpGetTeamUserInvitesResponse { + pub team_invites: Vec, +} + +pub async fn get_team_user_invites( + State(db): State>>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_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 + if team.team_admin_id != user_id { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InsufficientPermissions.to_string(), + )); + } + + // Check team type + if team.personal { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::ActionForbiddenForPersonalTeam.to_string(), + )); + } + + // Get active invites for the team + match db.get_invites_by_team_id(&request.team_id, true).await { + Ok(invites) => { + let team_invites: Vec = invites + .into_iter() + .map(|invite| TeamInvite::from(invite)) + .collect(); + + Ok(Json(HttpGetTeamUserInvitesResponse { team_invites })) + } + Err(err) => { + error!("Failed to get team invites: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + } + 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_db_tests")] +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::get_test_team_user_invites; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_team_user_invites() { + 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.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + let mut users_emails: Vec = vec![]; + // Invite a few users to the team + for _ in 0..3 { + // Register new user + let (_test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + users_emails.push(test_user_email.clone()); + + // Create invite + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + } + + // Get team invites + let request = HttpGetTeamUserInvitesRequest { + team_id: team_id.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetTeamUserInvites.to_string() + )) + .extension(ip.clone()) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + let res = convert_response::(response) + .await + .unwrap(); + + assert_eq!(res.team_invites.len(), 3); + assert_eq!(res.team_invites[0].user_email, users_emails[2]); + assert_eq!(res.team_invites[1].user_email, users_emails[1]); + assert_eq!(res.team_invites[2].user_email, users_emails[0]); + + // Invite another user to the team + // Register new user + let (_test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + // Create invite + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 4); + assert_eq!(resp.team_invites[0].user_email, test_user_email); + assert_eq!(resp.team_invites[1].user_email, users_emails[2]); + assert_eq!(resp.team_invites[2].user_email, users_emails[1]); + assert_eq!(resp.team_invites[3].user_email, users_emails[0]); + } + + #[tokio::test] + async fn test_no_invites() { + 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.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 0); + } +} diff --git a/server/src/http/cloud/get_user_joined_teams.rs b/server/src/http/cloud/get_user_joined_teams.rs index e7737638..058768c8 100644 --- a/server/src/http/cloud/get_user_joined_teams.rs +++ b/server/src/http/cloud/get_user_joined_teams.rs @@ -17,6 +17,7 @@ use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpGetUserJoinedTeamsResponse { pub teams: HashMap, pub teams_apps: HashMap>, @@ -163,18 +164,30 @@ mod tests { // Add user to first team let before_first_join = get_current_datetime(); - add_user_to_test_team(&team_ids[0], &app_user_email, &auth_token, &test_app) - .await - .unwrap(); + add_user_to_test_team( + &team_ids[0], + &app_user_email, + &auth_token, + &app_user_auth_token, + &test_app, + ) + .await + .unwrap(); let after_first_join = get_current_datetime(); // Wait for 1 second tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; // Add user to second team - add_user_to_test_team(&team_ids[1], &app_user_email, &auth_token, &test_app) - .await - .unwrap(); + add_user_to_test_team( + &team_ids[1], + &app_user_email, + &auth_token, + &app_user_auth_token, + &test_app, + ) + .await + .unwrap(); let after_second_join = get_current_datetime(); // Get user joined teams diff --git a/server/src/http/cloud/get_user_team_invites.rs b/server/src/http/cloud/get_user_team_invites.rs new file mode 100644 index 00000000..8f0dad12 --- /dev/null +++ b/server/src/http/cloud/get_user_team_invites.rs @@ -0,0 +1,224 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::{api_cloud_errors::CloudApiErrors, team_invite::TeamInvite}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use log::error; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpGetUserTeamInvitesResponse { + pub team_invites: Vec, +} + +pub async fn get_user_team_invites( + State(db): State>>, + Extension(user_id): Extension, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.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 user_id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Get active invites for the team + match db.get_invites_by_user_email(&user.email, true).await { + Ok(invites) => { + let team_invites: Vec = invites + .into_iter() + .map(|invite| TeamInvite::from(invite)) + .collect(); + + Ok(Json(HttpGetUserTeamInvitesResponse { team_invites })) + } + Err(err) => { + error!("Failed to get user team invites: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } +} + +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use crate::auth::AuthToken; + use crate::http::cloud::get_user_team_invites::HttpGetUserTeamInvitesResponse; + use crate::test_utils::test_utils::get_test_user_team_invites; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_user_team_invites() { + let test_app = create_test_app(false).await; + + // Register 3 teams + let mut team_ids: Vec = vec![]; + let mut admin_auth_tokens: Vec = vec![]; + + for _ in 0..3 { + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + admin_auth_tokens.push(auth_token.clone()); + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + team_ids.push(team_id.clone()); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + 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; + + // Create invites for first two teams + invite_user_to_test_team( + &team_ids[0], + &test_user_email, + &admin_auth_tokens[0], + &test_app, + ) + .await + .unwrap(); + + invite_user_to_test_team( + &team_ids[1], + &test_user_email, + &admin_auth_tokens[1], + &test_app, + ) + .await + .unwrap(); + + // Get user team invites + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let auth = test_user_auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetUserTeamInvites.to_string() + )) + .extension(ip.clone()) + .body(Body::empty()) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + let res = convert_response::(response) + .await + .unwrap(); + + assert_eq!(res.team_invites.len(), 2); + + // Invite user to the last team + invite_user_to_test_team( + &team_ids[2], + &test_user_email, + &admin_auth_tokens[2], + &test_app, + ) + .await + .unwrap(); + + // Get user team invites + let invites = get_test_user_team_invites(&test_user_auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(invites.team_invites.len(), 3); + } + + #[tokio::test] + async fn test_no_invites() { + 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.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_user_team_invites(&auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 0); + } +} diff --git a/server/src/http/cloud/add_user_to_team.rs b/server/src/http/cloud/invite_user_to_team.rs similarity index 76% rename from server/src/http/cloud/add_user_to_team.rs rename to server/src/http/cloud/invite_user_to_team.rs index 30b7fb9f..3fc62b96 100644 --- a/server/src/http/cloud/add_user_to_team.rs +++ b/server/src/http/cloud/invite_user_to_team.rs @@ -15,7 +15,7 @@ use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] #[ts(export)] #[serde(rename_all = "camelCase")] -pub struct HttpAddUserToTeamRequest { +pub struct HttpInviteUserToTeamRequest { #[garde(custom(custom_validate_uuid))] pub team_id: String, #[garde(email)] @@ -24,13 +24,13 @@ pub struct HttpAddUserToTeamRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct HttpAddUserToTeamResponse {} +pub struct HttpInviteUserToTeamResponse {} -pub async fn add_user_to_team( +pub async fn invite_user_to_team( State(db): State>>, Extension(user_id): Extension, - Json(request): Json, -) -> Result, (StatusCode, String)> { + Json(request): Json, +) -> Result, (StatusCode, String)> { // Db connection has already been checked in the middleware let db = db.as_ref().ok_or(( StatusCode::INTERNAL_SERVER_ERROR, @@ -51,6 +51,14 @@ pub async fn add_user_to_team( )); } + // Check team type + if team.personal { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::ActionForbiddenForPersonalTeam.to_string(), + )); + } + // Check if team has at least one registered app match db.get_registered_apps_by_team_id(&request.team_id).await { Ok(apps) => { @@ -70,30 +78,6 @@ pub async fn add_user_to_team( } } - // Check if limit of users in the team has been reached - match db.get_privileges_by_team_id(&request.team_id).await { - Ok(privileges) => { - let users = privileges - .iter() - .map(|privilege| privilege.user_id.clone()) - .collect::>(); - - if users.len() >= USERS_AMOUNT_LIMIT_PER_TEAM { - return Err(( - StatusCode::BAD_REQUEST, - CloudApiErrors::MaximumUsersPerTeamReached.to_string(), - )); - } - } - Err(err) => { - error!("Failed to get privileges by team id: {:?}", err); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::DatabaseError.to_string(), - )); - } - } - // Get user data and perform checks let user = match db.get_user_by_email(&request.user_email).await { Ok(Some(user)) => user, @@ -138,16 +122,75 @@ pub async fn add_user_to_team( } } - // Add user to the team + // Check if limit of users in the team has been reached + match db.get_privileges_by_team_id(&request.team_id).await { + Ok(privileges) => { + let users = privileges + .iter() + .map(|privilege| privilege.user_id.clone()) + .collect::>(); + + if users.len() >= USERS_AMOUNT_LIMIT_PER_TEAM { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::MaximumUsersPerTeamReached.to_string(), + )); + } + + // Check the amount of invites to the team, team can only have one invite per user at a time + // Limit amount of possible invites to the team to the USERS_AMOUNT_LIMIT_PER_TEAM - amount of users in the team + // Get active invites + match db.get_invites_by_team_id(&request.team_id, true).await { + Ok(invites) => { + let invites = invites + .iter() + .map(|invite| invite.user_email.clone()) + .collect::>(); + + // Additional check if user has already been invited to the team + if invites.contains(&request.user_email) { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserAlreadyInvitedToTheTeam.to_string(), + )); + } + + // Check if invites limit has been reached + if invites.len() >= USERS_AMOUNT_LIMIT_PER_TEAM - users.len() { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::MaximumInvitesPerTeamReached.to_string(), + )); + } + } + Err(err) => { + error!("Failed to get invites by team id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + } + Err(err) => { + error!("Failed to get privileges by team id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Add invite to the team match db - .add_user_to_the_team(&user.user_id, &request.team_id) + .create_new_team_invite(&request.team_id, &request.user_email) .await { Ok(_) => { - return Ok(Json(HttpAddUserToTeamResponse {})); + return Ok(Json(HttpInviteUserToTeamResponse {})); } Err(err) => { - error!("Failed to add user to the team: {:?}", err); + error!("Failed to invite user to the team: {:?}", err); return Err(( StatusCode::INTERNAL_SERVER_ERROR, CloudApiErrors::DatabaseError.to_string(), @@ -177,7 +220,7 @@ mod tests { use crate::{ env::JWT_SECRET, http::cloud::{ - add_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, + invite_user_to_team::{HttpInviteUserToTeamRequest, HttpInviteUserToTeamResponse}, register_new_app::HttpRegisterNewAppRequest, }, statics::USERS_AMOUNT_LIMIT_PER_TEAM, @@ -185,8 +228,8 @@ mod tests { 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, + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, }, }; use axum::{ @@ -198,7 +241,7 @@ mod tests { use tower::ServiceExt; #[tokio::test] - async fn test_add_user_to_team() { + async fn test_invite_user_to_team() { let test_app = create_test_app(false).await; let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; @@ -227,8 +270,8 @@ mod tests { let (_test_user_auth_token, test_user_email, _test_user_password) = register_and_login_random_user(&test_app).await; - // Add user to the team - let request = HttpAddUserToTeamRequest { + // Invite user to the team + let request = HttpInviteUserToTeamRequest { team_id: team_id.clone(), user_email: test_user_email.clone(), }; @@ -243,7 +286,7 @@ mod tests { .header("authorization", format!("Bearer {auth}")) .uri(format!( "/cloud/private{}", - HttpCloudEndpoint::AddUserToTeam.to_string() + HttpCloudEndpoint::InviteUserToTeam.to_string() )) .extension(ip.clone()) .body(Body::from(json)) @@ -252,12 +295,12 @@ mod tests { // Send request let response = test_app.clone().oneshot(req).await.unwrap(); // Validate response - convert_response::(response) + convert_response::(response) .await .unwrap(); - // Try to add user to the team again, should fail as user is already in the team - let request = HttpAddUserToTeamRequest { + // Try to invite user to the team again, should fail as user is already invited to the team + let request = HttpInviteUserToTeamRequest { team_id: team_id.clone(), user_email: test_user_email.clone(), }; @@ -271,7 +314,7 @@ mod tests { .header("authorization", format!("Bearer {auth}")) .uri(format!( "/cloud/private{}", - HttpCloudEndpoint::AddUserToTeam.to_string() + HttpCloudEndpoint::InviteUserToTeam.to_string() )) .extension(ip) .body(Body::from(json)) @@ -280,24 +323,24 @@ mod tests { // Send request let response = test_app.clone().oneshot(req).await.unwrap(); // Validate response - let err = convert_response::(response) + let err = convert_response::(response) .await .unwrap_err(); assert_eq!( err.to_string(), - CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string() + CloudApiErrors::UserAlreadyInvitedToTheTeam.to_string() ); } #[tokio::test] - async fn test_add_user_to_team_team_not_found() { + async fn test_invite_user_to_team_team_not_found() { let test_app = create_test_app(false).await; let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; // Team does not exist, use random uuid - let resp = add_user_to_test_team( + let resp = invite_user_to_test_team( &uuid7::uuid7().to_string(), &"test_user_email@gmail.com".to_string(), &auth_token, @@ -324,8 +367,8 @@ mod tests { .await .unwrap(); - // Team does not exist, use random uuid - let resp = add_user_to_test_team( + // Team does not have any apps + let resp = invite_user_to_test_team( &team_id.to_string(), &"test_user_email@gmail.com".to_string(), &auth_token, @@ -341,7 +384,7 @@ mod tests { } #[tokio::test] - async fn test_add_user_to_team_user_limit_reached() { + async fn test_invite_user_to_team_user_limit_reached() { let test_app = create_test_app(false).await; let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; @@ -370,7 +413,7 @@ mod tests { let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; // Add user to the team - add_user_to_test_team( + invite_user_to_test_team( &team_id.to_string(), &test_user_email.to_string(), &auth_token, @@ -384,7 +427,7 @@ mod tests { let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; // Add user to the team - let resp = add_user_to_test_team( + let resp = invite_user_to_test_team( &team_id.to_string(), &test_user_email.to_string(), &auth_token, @@ -395,7 +438,7 @@ mod tests { assert_eq!( resp.to_string(), - CloudApiErrors::MaximumUsersPerTeamReached.to_string() + CloudApiErrors::MaximumInvitesPerTeamReached.to_string() ); } @@ -427,7 +470,7 @@ mod tests { // Try to add non-existing user to the team, should fail as user limit has been reached // Add user to the team - let resp = add_user_to_test_team( + let resp = invite_user_to_test_team( &team_id.to_string(), &"non-existing-user@gmail.com".to_string(), &auth_token, diff --git a/server/src/http/cloud/login_with_password.rs b/server/src/http/cloud/login_with_password.rs index 11da3b95..b99e62d2 100644 --- a/server/src/http/cloud/login_with_password.rs +++ b/server/src/http/cloud/login_with_password.rs @@ -31,6 +31,7 @@ pub struct HttpLoginRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpLoginResponse { pub user_id: String, pub auth_token: String, diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index 10714ee8..eacae1f5 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -1,6 +1,10 @@ -pub mod add_user_to_team; +pub mod accept_team_invite; +pub mod cancel_team_invite; pub mod events; +pub mod get_team_user_invites; pub mod get_user_joined_teams; +pub mod get_user_team_invites; +pub mod invite_user_to_team; pub mod login_with_google; pub mod login_with_password; pub mod register_new_app; diff --git a/server/src/http/cloud/register_new_app.rs b/server/src/http/cloud/register_new_app.rs index f88012a9..5b188775 100644 --- a/server/src/http/cloud/register_new_app.rs +++ b/server/src/http/cloud/register_new_app.rs @@ -31,6 +31,7 @@ pub struct HttpRegisterNewAppRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpRegisterNewAppResponse { pub app_id: String, } diff --git a/server/src/http/cloud/register_new_team.rs b/server/src/http/cloud/register_new_team.rs index 63a7c4a7..643ac682 100644 --- a/server/src/http/cloud/register_new_team.rs +++ b/server/src/http/cloud/register_new_team.rs @@ -28,6 +28,7 @@ pub struct HttpRegisterNewTeamRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpRegisterNewTeamResponse { pub team_id: String, } diff --git a/server/src/http/cloud/register_with_password.rs b/server/src/http/cloud/register_with_password.rs index 57abb26a..4a38cc15 100644 --- a/server/src/http/cloud/register_with_password.rs +++ b/server/src/http/cloud/register_with_password.rs @@ -28,6 +28,7 @@ pub struct HttpRegisterWithPasswordRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpRegisterWithPasswordResponse { pub user_id: String, } diff --git a/server/src/http/cloud/remove_user_from_team.rs b/server/src/http/cloud/remove_user_from_team.rs index b3b63078..fae4f962 100644 --- a/server/src/http/cloud/remove_user_from_team.rs +++ b/server/src/http/cloud/remove_user_from_team.rs @@ -181,13 +181,19 @@ mod tests { .unwrap(); // Register new user - let (_test_user_auth_token, test_user_email, _test_user_password) = + 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, &test_user_email, &auth_token, &test_app) - .await - .unwrap(); + add_user_to_test_team( + &team_id, + &test_user_email, + &auth_token, + &test_user_auth_token, + &test_app, + ) + .await + .unwrap(); // Remove user from the team let request = HttpRemoveUserFromTeamRequest { diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index 9b28afe3..2f79326b 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -1,7 +1,9 @@ use crate::{ http::cloud::{ - add_user_to_team::add_user_to_team, events::events, - get_user_joined_teams::get_user_joined_teams, login_with_google::login_with_google, + accept_team_invite::accept_team_invite, cancel_team_invite::cancel_team_user_invite, + events::events, get_team_user_invites::get_team_user_invites, + get_user_joined_teams::get_user_joined_teams, get_user_team_invites::get_user_team_invites, + invite_user_to_team::invite_user_to_team, login_with_google::login_with_google, login_with_password::login_with_password, register_new_app::register_new_app, register_new_team::register_new_team, register_with_password::register_with_password, remove_user_from_team::remove_user_from_team, @@ -59,8 +61,12 @@ pub fn private_router(state: ServerState) -> Router { post(register_new_team), ) .route( - &HttpCloudEndpoint::AddUserToTeam.to_string(), - post(add_user_to_team), + &HttpCloudEndpoint::InviteUserToTeam.to_string(), + post(invite_user_to_team), + ) + .route( + &HttpCloudEndpoint::AcceptTeamInvite.to_string(), + post(accept_team_invite), ) .route( &HttpCloudEndpoint::RemoveUserFromTeam.to_string(), @@ -70,5 +76,17 @@ pub fn private_router(state: ServerState) -> Router { &HttpCloudEndpoint::GetUserJoinedTeams.to_string(), get(get_user_joined_teams), ) + .route( + &HttpCloudEndpoint::GetTeamUserInvites.to_string(), + get(get_team_user_invites), + ) + .route( + &HttpCloudEndpoint::GetUserTeamInvites.to_string(), + get(get_user_team_invites), + ) + .route( + &HttpCloudEndpoint::CancelTeamInvite.to_string(), + get(cancel_team_user_invite), + ) .with_state(state) } diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 1c7283ee..c3ba6038 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -26,4 +26,9 @@ pub enum CloudApiErrors { InvalidName, UnauthorizedOriginError, AppDoesNotExist, + UserAlreadyInvitedToTheTeam, + MaximumInvitesPerTeamReached, + InviteNotFound, + ActionForbiddenForPersonalTeam, + InviteDoesNotExist, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index 5048d605..ebbac3ae 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -14,14 +14,22 @@ pub enum HttpCloudEndpoint { LoginWithGoogle, #[serde(rename = "/register_new_team")] RegisterNewTeam, - #[serde(rename = "/add_user_to_team")] - AddUserToTeam, #[serde(rename = "/remove_user_from_team")] RemoveUserFromTeam, #[serde(rename = "/get_user_joined_teams")] GetUserJoinedTeams, #[serde(rename = "/events")] Events, + #[serde(rename = "/invite_user_to_team")] + InviteUserToTeam, + #[serde(rename = "/accept_team_invite")] + AcceptTeamInvite, + #[serde(rename = "/get_team_user_invites")] + GetTeamUserInvites, + #[serde(rename = "/get_user_team_invites")] + GetUserTeamInvites, + #[serde(rename = "/cancel_team_invite")] + CancelTeamInvite, } impl HttpCloudEndpoint { @@ -32,10 +40,14 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::LoginWithPassword => "/login_with_password".to_string(), HttpCloudEndpoint::LoginWithGoogle => "/login_with_google".to_string(), HttpCloudEndpoint::RegisterNewTeam => "/register_new_team".to_string(), - HttpCloudEndpoint::AddUserToTeam => "/add_user_to_team".to_string(), HttpCloudEndpoint::RemoveUserFromTeam => "/remove_user_from_team".to_string(), HttpCloudEndpoint::GetUserJoinedTeams => "/get_user_joined_teams".to_string(), HttpCloudEndpoint::Events => "/events".to_string(), + HttpCloudEndpoint::InviteUserToTeam => "/invite_user_to_team".to_string(), + HttpCloudEndpoint::AcceptTeamInvite => "/accept_team_invite".to_string(), + HttpCloudEndpoint::GetTeamUserInvites => "/get_team_user_invites".to_string(), + HttpCloudEndpoint::GetUserTeamInvites => "/get_user_team_invites".to_string(), + HttpCloudEndpoint::CancelTeamInvite => "/cancel_team_invite".to_string(), } } } diff --git a/server/src/structs/cloud/mod.rs b/server/src/structs/cloud/mod.rs index 610711d7..fc119b04 100644 --- a/server/src/structs/cloud/mod.rs +++ b/server/src/structs/cloud/mod.rs @@ -4,4 +4,5 @@ pub mod cloud_events; pub mod cloud_http_endpoints; pub mod device_metadata; pub mod joined_team; +pub mod team_invite; pub mod user_privilege; diff --git a/server/src/structs/cloud/team_invite.rs b/server/src/structs/cloud/team_invite.rs new file mode 100644 index 00000000..fbc84441 --- /dev/null +++ b/server/src/structs/cloud/team_invite.rs @@ -0,0 +1,24 @@ +use database::tables::team_invites::table_struct::TeamInvite as DbTeamInvite; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct TeamInvite { + pub creator_email: String, + pub team_name: String, + pub user_email: String, + pub created_at: String, +} + +impl From for TeamInvite { + fn from(db_team_invite: DbTeamInvite) -> Self { + Self { + creator_email: db_team_invite.admin_email, + team_name: db_team_invite.team_name, + user_email: db_team_invite.user_email, + created_at: db_team_invite.created_at.to_string(), + } + } +} diff --git a/server/src/test_utils.rs b/server/src/test_utils.rs index 8745563b..1c67b0e3 100644 --- a/server/src/test_utils.rs +++ b/server/src/test_utils.rs @@ -4,8 +4,13 @@ pub mod test_utils { auth::AuthToken, env::{JWT_PUBLIC_KEY, JWT_SECRET}, http::cloud::{ - add_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, + accept_team_invite::{HttpAcceptTeamInviteRequest, HttpAcceptTeamInviteResponse}, + get_team_user_invites::{ + HttpGetTeamUserInvitesRequest, HttpGetTeamUserInvitesResponse, + }, get_user_joined_teams::HttpGetUserJoinedTeamsResponse, + get_user_team_invites::HttpGetUserTeamInvitesResponse, + invite_user_to_team::{HttpInviteUserToTeamRequest, HttpInviteUserToTeamResponse}, login_with_password::{HttpLoginRequest, HttpLoginResponse}, register_new_app::{HttpRegisterNewAppRequest, HttpRegisterNewAppResponse}, register_new_team::{HttpRegisterNewTeamRequest, HttpRegisterNewTeamResponse}, @@ -16,7 +21,7 @@ pub mod test_utils { }, routes::router::get_router, statics::NAME_REGEX, - structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + structs::cloud::{cloud_http_endpoints::HttpCloudEndpoint, team_invite::TeamInvite}, }; use anyhow::bail; use axum::{ @@ -225,14 +230,14 @@ pub mod test_utils { .map(|response| Ok(response.app_id))? } - pub async fn add_user_to_test_team( + pub async fn invite_user_to_test_team( team_id: &String, user_email: &String, admin_token: &AuthToken, app: &Router, ) -> anyhow::Result<()> { - // Add user to test team - let request = HttpAddUserToTeamRequest { + // Invite user to test team + let request = HttpInviteUserToTeamRequest { team_id: team_id.clone(), user_email: user_email.clone(), }; @@ -247,7 +252,41 @@ pub mod test_utils { .header("authorization", format!("Bearer {auth}")) .uri(format!( "/cloud/private{}", - HttpCloudEndpoint::AddUserToTeam.to_string() + HttpCloudEndpoint::InviteUserToTeam.to_string() + )) + .extension(ip) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response) + .await + .map(|_| Ok(()))? + } + + pub async fn accept_invite_to_test_team( + team_id: &String, + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result<()> { + // Invite user to test team + let request = HttpAcceptTeamInviteRequest { + team_id: team_id.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = user_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::AcceptTeamInvite.to_string() )) .extension(ip) .body(Body::from(json)) @@ -256,11 +295,84 @@ pub mod test_utils { // Send request let response = app.clone().oneshot(req).await.unwrap(); // Validate response - convert_response::(response) + convert_response::(response) .await .map(|_| Ok(()))? } + pub async fn add_user_to_test_team( + team_id: &String, + user_email: &String, + admin_token: &AuthToken, + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result<()> { + // Add user to test team + match invite_user_to_test_team(team_id, user_email, admin_token, app).await { + Ok(_) => accept_invite_to_test_team(team_id, user_token, app).await, + Err(e) => bail!("Failed to invite user to the team: {}", e), + } + } + + pub async fn get_test_team_user_invites( + team_id: &String, + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result { + // Get team invites for users + let request = HttpGetTeamUserInvitesRequest { + team_id: team_id.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = user_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetTeamUserInvites.to_string() + )) + .extension(ip) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response).await + } + + pub async fn get_test_user_team_invites( + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result { + // Get use team invites + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let auth = user_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetUserTeamInvites.to_string() + )) + .extension(ip) + .body(Body::empty()) + .unwrap(); + + // Send request + let response = app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response).await + } + pub async fn remove_user_from_test_team( team_id: &String, user_email: &String,