diff --git a/.sqlx/query-2c1152b8867bebcb63b637820eb44ac932ee88c6326ecaf9b4b4c7f690eff41c.json b/.sqlx/query-2c1152b8867bebcb63b637820eb44ac932ee88c6326ecaf9b4b4c7f690eff41c.json deleted file mode 100644 index c62ab8d1..00000000 --- a/.sqlx/query-2c1152b8867bebcb63b637820eb44ac932ee88c6326ecaf9b4b4c7f690eff41c.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id AS invite_id,\n workspace_id,\n (SELECT workspace_name FROM public.af_workspace WHERE workspace_id = af_workspace_invitation.workspace_id),\n (SELECT email FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_email,\n (SELECT name FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_name,\n status,\n updated_at\n FROM\n public.af_workspace_invitation\n WHERE af_workspace_invitation.invitee_email = (SELECT email FROM public.af_user WHERE uuid = $1)\n AND ($2::SMALLINT IS NULL OR status = $2)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "invite_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "workspace_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "workspace_name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "inviter_email", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "inviter_name", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "status", - "type_info": "Int2" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Int2" - ] - }, - "nullable": [ - false, - false, - null, - null, - null, - false, - false - ] - }, - "hash": "2c1152b8867bebcb63b637820eb44ac932ee88c6326ecaf9b4b4c7f690eff41c" -} diff --git a/.sqlx/query-2ee385e58e042071290226289646965553938838c83085273f68f687c976767a.json b/.sqlx/query-2ee385e58e042071290226289646965553938838c83085273f68f687c976767a.json new file mode 100644 index 00000000..c11d4afc --- /dev/null +++ b/.sqlx/query-2ee385e58e042071290226289646965553938838c83085273f68f687c976767a.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n i.id AS invite_id,\n i.workspace_id,\n w.workspace_name,\n u_inviter.email AS inviter_email,\n u_inviter.name AS inviter_name,\n i.status,\n i.updated_at,\n u_inviter.metadata->>'icon_url' AS inviter_icon,\n w.icon AS workspace_icon,\n (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n FROM\n public.af_workspace_invitation i\n JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n WHERE\n i.invitee_email = u_invitee.email\n AND i.id = $2;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "invite_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "workspace_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "inviter_email", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "inviter_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "inviter_icon", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "workspace_icon", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "member_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + null, + false, + null + ] + }, + "hash": "2ee385e58e042071290226289646965553938838c83085273f68f687c976767a" +} diff --git a/.sqlx/query-6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319.json b/.sqlx/query-6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319.json deleted file mode 100644 index 6c269e95..00000000 --- a/.sqlx/query-6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id AS invite_id,\n workspace_id,\n (SELECT workspace_name FROM public.af_workspace WHERE workspace_id = af_workspace_invitation.workspace_id),\n (SELECT email FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_email,\n (SELECT name FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_name,\n status,\n updated_at\n FROM public.af_workspace_invitation\n WHERE af_workspace_invitation.invitee_email = (SELECT email FROM public.af_user WHERE uuid = $1)\n AND id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "invite_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "workspace_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "workspace_name", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "inviter_email", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "inviter_name", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "status", - "type_info": "Int2" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false, - false, - null, - null, - null, - false, - false - ] - }, - "hash": "6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319" -} diff --git a/.sqlx/query-7c8b84da6d70cb4ae59ae618e6f7aa7bde3dbd6630bd7e7fcafe1606d63651c8.json b/.sqlx/query-7c8b84da6d70cb4ae59ae618e6f7aa7bde3dbd6630bd7e7fcafe1606d63651c8.json new file mode 100644 index 00000000..f775bb11 --- /dev/null +++ b/.sqlx/query-7c8b84da6d70cb4ae59ae618e6f7aa7bde3dbd6630bd7e7fcafe1606d63651c8.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM af_workspace_invitation\n WHERE id = $1 AND invitee_email = (SELECT email FROM af_user WHERE uuid = $2)\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7c8b84da6d70cb4ae59ae618e6f7aa7bde3dbd6630bd7e7fcafe1606d63651c8" +} diff --git a/.sqlx/query-de595bd6554d8e1f58c9c4bb94ea14b5ae2fd15b3c5d2b84d5dd5a551954ecde.json b/.sqlx/query-de595bd6554d8e1f58c9c4bb94ea14b5ae2fd15b3c5d2b84d5dd5a551954ecde.json new file mode 100644 index 00000000..0dc790db --- /dev/null +++ b/.sqlx/query-de595bd6554d8e1f58c9c4bb94ea14b5ae2fd15b3c5d2b84d5dd5a551954ecde.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n i.id AS invite_id,\n i.workspace_id,\n w.workspace_name,\n u_inviter.email AS inviter_email,\n u_inviter.name AS inviter_name,\n i.status,\n i.updated_at,\n u_inviter.metadata->>'icon_url' AS inviter_icon,\n w.icon AS workspace_icon,\n (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n FROM\n public.af_workspace_invitation i\n JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n WHERE\n i.invitee_email = u_invitee.email\n AND ($2::SMALLINT IS NULL OR i.status = $2);\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "invite_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "workspace_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "inviter_email", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "inviter_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "inviter_icon", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "workspace_icon", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "member_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int2" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + null, + false, + null + ] + }, + "hash": "de595bd6554d8e1f58c9c4bb94ea14b5ae2fd15b3c5d2b84d5dd5a551954ecde" +} diff --git a/admin_frontend/templates/components/invite.html b/admin_frontend/templates/components/invite.html index 1cf55635..dce7f619 100644 --- a/admin_frontend/templates/components/invite.html +++ b/admin_frontend/templates/components/invite.html @@ -66,7 +66,7 @@
-

Pending Invitation

+

Invitation(s) from other user(s)

diff --git a/libs/app-error/src/lib.rs b/libs/app-error/src/lib.rs index 69f1f397..5f911fda 100644 --- a/libs/app-error/src/lib.rs +++ b/libs/app-error/src/lib.rs @@ -142,6 +142,9 @@ pub enum AppError { #[error("{0}")] InvalidFolderView(String), + + #[error("{0}")] + NotInviteeOfWorkspaceInvitation(String), } impl AppError { @@ -208,6 +211,7 @@ impl AppError { AppError::InvalidContentType(_) => ErrorCode::InvalidContentType, AppError::InvalidPublishedOutline(_) => ErrorCode::InvalidPublishedOutline, AppError::InvalidFolderView(_) => ErrorCode::InvalidFolderView, + AppError::NotInviteeOfWorkspaceInvitation(_) => ErrorCode::NotInviteeOfWorkspaceInvitation, } } } @@ -328,6 +332,7 @@ pub enum ErrorCode { AppleRevokeTokenError = 1038, InvalidPublishedOutline = 1039, InvalidFolderView = 1040, + NotInviteeOfWorkspaceInvitation = 1041, } impl ErrorCode { diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index e197ef98..38744f89 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -601,6 +601,9 @@ pub struct AFWorkspaceInvitation { pub inviter_name: Option, pub status: AFWorkspaceInvitationStatus, pub updated_at: DateTime, + pub inviter_icon: Option, + pub workspace_icon: String, + pub member_count: Option, // use unwrap_or(0) to get the value } #[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)] diff --git a/libs/database/src/workspace.rs b/libs/database/src/workspace.rs index 85d2e692..2c90b108 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -409,22 +409,31 @@ pub async fn select_workspace_invitations_for_user( let res = sqlx::query_as!( AFWorkspaceInvitation, r#" - SELECT - id AS invite_id, - workspace_id, - (SELECT workspace_name FROM public.af_workspace WHERE workspace_id = af_workspace_invitation.workspace_id), - (SELECT email FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_email, - (SELECT name FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_name, - status, - updated_at - FROM - public.af_workspace_invitation - WHERE af_workspace_invitation.invitee_email = (SELECT email FROM public.af_user WHERE uuid = $1) - AND ($2::SMALLINT IS NULL OR status = $2) + SELECT + i.id AS invite_id, + i.workspace_id, + w.workspace_name, + u_inviter.email AS inviter_email, + u_inviter.name AS inviter_name, + i.status, + i.updated_at, + u_inviter.metadata->>'icon_url' AS inviter_icon, + w.icon AS workspace_icon, + (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count + FROM + public.af_workspace_invitation i + JOIN public.af_workspace w ON i.workspace_id = w.workspace_id + JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid + JOIN public.af_user u_invitee ON u_invitee.uuid = $1 + WHERE + i.invitee_email = u_invitee.email + AND ($2::SMALLINT IS NULL OR i.status = $2); "#, invitee_uuid, status_filter.map(|s| s as i16) - ).fetch_all(pg_pool).await?; + ) + .fetch_all(pg_pool) + .await?; Ok(res) } @@ -437,21 +446,31 @@ pub async fn select_workspace_invitation_for_user( let res = sqlx::query_as!( AFWorkspaceInvitation, r#" - SELECT - id AS invite_id, - workspace_id, - (SELECT workspace_name FROM public.af_workspace WHERE workspace_id = af_workspace_invitation.workspace_id), - (SELECT email FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_email, - (SELECT name FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_name, - status, - updated_at - FROM public.af_workspace_invitation - WHERE af_workspace_invitation.invitee_email = (SELECT email FROM public.af_user WHERE uuid = $1) - AND id = $2 + SELECT + i.id AS invite_id, + i.workspace_id, + w.workspace_name, + u_inviter.email AS inviter_email, + u_inviter.name AS inviter_name, + i.status, + i.updated_at, + u_inviter.metadata->>'icon_url' AS inviter_icon, + w.icon AS workspace_icon, + (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count + FROM + public.af_workspace_invitation i + JOIN public.af_workspace w ON i.workspace_id = w.workspace_id + JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid + JOIN public.af_user u_invitee ON u_invitee.uuid = $1 + WHERE + i.invitee_email = u_invitee.email + AND i.id = $2; "#, invitee_uuid, invite_id, - ).fetch_one(pg_pool).await?; + ) + .fetch_one(pg_pool) + .await?; Ok(res) } @@ -1357,3 +1376,24 @@ pub async fn delete_reaction_from_comment<'a, E: Executor<'a, Database = Postgre Ok(()) } + +pub async fn select_user_is_invitee_for_workspace_invitation( + pg_pool: &PgPool, + invitee_uuid: &Uuid, + invite_id: &Uuid, +) -> Result { + let res = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 + FROM af_workspace_invitation + WHERE id = $1 AND invitee_email = (SELECT email FROM af_user WHERE uuid = $2) + ) + "#, + invite_id, + invitee_uuid, + ) + .fetch_one(pg_pool) + .await?; + res.map_or(Ok(false), Ok) +} diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 3c2807f6..9b594d24 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -21,7 +21,7 @@ use access_control::collab::CollabAccessControl; use app_error::AppError; use appflowy_collaborate::actix_ws::entities::ClientStreamMessage; use appflowy_collaborate::indexer::IndexerProvider; -use authentication::jwt::{OptionalUserUuid, UserUuid}; +use authentication::jwt::{Authorization, OptionalUserUuid, UserUuid}; use collab_rt_entity::realtime_proto::HttpRealtimeMessage; use collab_rt_entity::RealtimeMessage; use collab_rt_protocol::validate_encode_collab; @@ -41,6 +41,7 @@ use crate::biz; use crate::biz::collab::ops::{ get_user_favorite_folder_views, get_user_recent_folder_views, get_user_trash_folder_views, }; +use crate::biz::user::user_verify::verify_token; use crate::biz::workspace; use crate::biz::workspace::ops::{ create_comment_on_published_view, create_reaction_on_comment, get_comments_on_published_view, @@ -333,16 +334,20 @@ async fn get_workspace_invite_by_id_handler( } async fn post_accept_workspace_invite_handler( - user_uuid: UserUuid, + auth: Authorization, invite_id: web::Path, state: Data, ) -> Result> { + let _is_new = verify_token(&auth.token, state.as_ref()).await?; + let user_uuid = auth.uuid()?; + let user_uid = state.user_cache.get_user_uid(&user_uuid).await?; let invite_id = invite_id.into_inner(); // TODO(zack): insert a workspace member in the af_workspace_member by calling workspace::ops::add_workspace_members. // Currently, when the server get restarted, the policy in access control will be lost. workspace::ops::accept_workspace_invite( &state.pg_pool, &state.workspace_access_control, + user_uid, &user_uuid, &invite_id, ) diff --git a/src/biz/workspace/ops.rs b/src/biz/workspace/ops.rs index 9534bfd0..b82f7cf6 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -302,12 +302,21 @@ pub async fn open_workspace( pub async fn accept_workspace_invite( pg_pool: &PgPool, workspace_access_control: &impl WorkspaceAccessControl, + user_uid: i64, user_uuid: &Uuid, invite_id: &Uuid, ) -> Result<(), AppError> { let mut txn = pg_pool.begin().await?; - update_workspace_invitation_set_status_accepted(&mut txn, user_uuid, invite_id).await?; let inv = get_invitation_by_id(&mut txn, invite_id).await?; + if let Some(invitee_uid) = inv.invitee_uid { + if invitee_uid != user_uid { + return Err(AppError::NotInviteeOfWorkspaceInvitation(format!( + "User with uid {} is not the invitee for invite_id {}", + user_uid, invite_id + ))); + } + } + update_workspace_invitation_set_status_accepted(&mut txn, user_uuid, invite_id).await?; let invited_uid = inv .invitee_uid .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invitee uid is missing for {:?}", inv)))?; @@ -400,7 +409,7 @@ pub async fn invite_workspace_members( // Generate a link such that when clicked, the user is added to the workspace. let accept_url = { match appflowy_web_url { - Some(appflowy_web_url) => format!("{}/accept-invitation?invitated_id={}", appflowy_web_url, invite_id), + Some(appflowy_web_url) => format!("{}/accept-invitation?invited_id={}", appflowy_web_url, invite_id), None => { gotrue_client .admin_generate_link( @@ -469,6 +478,14 @@ pub async fn get_workspace_invitations_for_user( user_uuid: &Uuid, invite_id: &Uuid, ) -> Result { + let user_is_invitee = + select_user_is_invitee_for_workspace_invitation(pg_pool, user_uuid, invite_id).await?; + if !user_is_invitee { + return Err(AppError::NotInviteeOfWorkspaceInvitation(format!( + "User with uuid {} is not the invitee for invite_id {}", + user_uuid, invite_id + ))); + } let invitation = select_workspace_invitation_for_user(pg_pool, user_uuid, invite_id).await?; Ok(invitation) } diff --git a/src/mailer.rs b/src/mailer.rs index c3976e58..8d4a797b 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -36,9 +36,9 @@ impl Mailer { HANDLEBARS .write() - .unwrap() + .map_err(|err| anyhow::anyhow!(format!("Failed to write handlebars: {}", err)))? .register_template_string("workspace_invite", workspace_invite_template) - .unwrap(); + .map_err(|err| anyhow::anyhow!(format!("Failed to register handlebars template: {}", err)))?; Ok(Self { smtp_transport, @@ -51,10 +51,10 @@ impl Mailer { email: String, param: WorkspaceInviteMailerParam, ) -> Result<(), anyhow::Error> { - let rendered = HANDLEBARS - .read() - .unwrap() - .render("workspace_invite", ¶m)?; + let rendered = match HANDLEBARS.read() { + Ok(registory) => registory.render("workspace_invite", ¶m)?, + Err(err) => anyhow::bail!(format!("Failed to render handlebars template: {}", err)), + }; let email = Message::builder() .from(lettre::message::Mailbox::new( @@ -63,7 +63,7 @@ impl Mailer { )) .to(lettre::message::Mailbox::new( Some(param.username.clone()), - email.parse().unwrap(), + email.parse()?, )) .subject(format!( "Action required: {} invited you to {} in AppFlowy", diff --git a/tests/workspace/invitation_crud.rs b/tests/workspace/invitation_crud.rs index e84cc655..b258c47c 100644 --- a/tests/workspace/invitation_crud.rs +++ b/tests/workspace/invitation_crud.rs @@ -1,3 +1,4 @@ +use app_error::ErrorCode; use client_api_test::generate_unique_registered_user_client; use database_entity::dto::{AFRole, AFWorkspaceInvitationStatus}; use shared_entity::dto::workspace_dto::{QueryWorkspaceParam, WorkspaceMemberInvitation}; @@ -59,12 +60,33 @@ async fn invite_workspace_crud() { assert_eq!(invitation.inviter_email, Some(alice.email)); assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Pending); + assert_eq!(invitation.member_count.unwrap_or(0), 1); + + let (charlie_client, _charlie) = generate_unique_registered_user_client().await; + let err = charlie_client + .get_workspace_invitation(&invite_id) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::NotInviteeOfWorkspaceInvitation); + let err = charlie_client + .accept_workspace_invitation(&invite_id) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::NotInviteeOfWorkspaceInvitation); bob_client .accept_workspace_invitation(&invite_id) .await .unwrap(); + let invitation = bob_client + .get_workspace_invitation(&invite_id) + .await + .unwrap(); + + assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Accepted); + assert_eq!(invitation.member_count.unwrap_or(0), 2); + // list invitation with accepted filter let accepted_invs = bob_client .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Accepted))