From c592ff00a695faecc1b251fee98f456919d4fbbb Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 17 Jan 2025 01:42:35 +0800 Subject: [PATCH] fix: address org invite resend --- .../src/server/routes/v1/invite-org-router.ts | 34 ++++++++++ backend/src/services/org/org-service.ts | 65 ++++++++++++++++++- backend/src/services/org/org-types.ts | 4 ++ frontend/src/hooks/api/users/mutation.tsx | 15 +++++ .../OrgMembersSection/OrgMembersTable.tsx | 32 ++++----- .../components/UserDetailsSection.tsx | 39 +++++------ 6 files changed, 149 insertions(+), 40 deletions(-) diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 9991f6032..f546f0484 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -73,6 +73,40 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/signup/resend", + config: { + rateLimit: inviteUserRateLimit + }, + method: "POST", + schema: { + body: z.object({ + membershipId: z.string() + }), + response: { + 200: z.object({ + signupToken: z + .object({ + email: z.string(), + link: z.string() + }) + .optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.org.resendOrgMemberInvitation({ + orgId: req.permission.orgId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + membershipId: req.body.membershipId + }); + } + }); + server.route({ url: "/verify", method: "POST", diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index f660f511f..b0e5c1f67 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -69,6 +69,7 @@ import { TGetOrgMembershipDTO, TInviteUserToOrgDTO, TListProjectMembershipsByOrgMembershipIdDTO, + TResendOrgMemberInvitationDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, TVerifyUserToOrgDTO @@ -584,6 +585,66 @@ export const orgServiceFactory = ({ }); return membership; }; + + const resendOrgMemberInvitation = async ({ + orgId, + actorId, + actor, + actorAuthMethod, + actorOrgId, + membershipId + }: TResendOrgMemberInvitationDTO) => { + const appCfg = getConfig(); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + + const org = await orgDAL.findOrgById(orgId); + + const [inviteeOrgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.id` as "id"]: membershipId + }); + + if (inviteeOrgMembership.status !== OrgMembershipStatus.Invited) { + throw new BadRequestError({ + message: "Organization invitation already accepted" + }); + } + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: inviteeOrgMembership.userId, + orgId + }); + + if (!appCfg.isSmtpConfigured) { + return { + signupToken: { + email: inviteeOrgMembership.email as string, + link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${inviteeOrgMembership.email}&organization_id=${org?.id}` + } + }; + } + + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [inviteeOrgMembership.email as string], + substitutions: { + inviterFirstName: inviteeOrgMembership.firstName, + inviterUsername: inviteeOrgMembership.email, + organizationName: org?.name, + email: inviteeOrgMembership.email, + organizationId: org?.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + + return { signupToken: undefined }; + }; + /* * Invite user to organization */ @@ -627,6 +688,7 @@ export const orgServiceFactory = ({ } }) : []; + if (projectsToInvite.length !== invitedProjects?.length) { throw new ForbiddenRequestError({ message: "Access denied to one or more of the specified projects" @@ -1221,6 +1283,7 @@ export const orgServiceFactory = ({ deleteIncidentContact, getOrgGroups, listProjectMembershipsByOrgMembershipId, - findOrgBySlug + findOrgBySlug, + resendOrgMemberInvitation }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 1df4b422b..b9228377b 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -35,6 +35,10 @@ export type TInviteUserToOrgDTO = { }[]; } & TOrgPermission; +export type TResendOrgMemberInvitationDTO = { + membershipId: string; +} & TOrgPermission; + export type TVerifyUserToOrgDTO = { email: string; orgId: string; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 3e181d713..97c628154 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -156,3 +156,18 @@ export const useCreateNewTotpRecoveryCodes = () => { } }); }; + +export const useResendOrgMemberInvitation = () => { + return useMutation({ + mutationFn: async (dto: { membershipId: string }) => { + const { data } = await apiRequest.post<{ + signupToken?: { + email: string; + link: string; + }; + }>("/api/v1/invite-org/signup/resend", dto); + + return data.signupToken; + } + }); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 335b4cdb7..cde8f5790 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -44,13 +44,13 @@ import { } from "@app/context"; import { usePagination, useResetPageHelper } from "@app/hooks"; import { - useAddUsersToOrg, useFetchServerStatus, useGetOrgRoles, useGetOrgUsers, useUpdateOrgMembership } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useResendOrgMemberInvitation } from "@app/hooks/api/users/mutation"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -83,7 +83,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro const { data: serverDetails } = useFetchServerStatus(); const { data: members = [], isPending: isMembersLoading } = useGetOrgUsers(orgId); - const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); + const { mutateAsync: resendOrgMemberInvitation, isPending } = useResendOrgMemberInvitation(); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const onRoleChange = async (membershipId: string, role: string) => { @@ -119,26 +119,25 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro } }; - const onResendInvite = async (email: string) => { + const onResendInvite = async (membershipId: string) => { try { - const { data } = await addUsersMutateAsync({ - organizationId: orgId, - inviteeEmails: [email], - organizationRoleSlug: "member" + const signupToken = await resendOrgMemberInvitation({ + membershipId }); - setCompleteInviteLinks(data?.completeInviteLinks || null); - - if (!data.completeInviteLinks) { - createNotification({ - text: `Successfully resent invite to ${email}`, - type: "success" - }); + if (signupToken) { + setCompleteInviteLinks([signupToken]); + return; } + + createNotification({ + text: "Successfully resent org invitation", + type: "success" + }); } catch (err) { console.error(err); createNotification({ - text: `Failed to resend invite to ${email}`, + text: "Failed to resend org invitation", type: "error" }); } @@ -370,7 +369,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro className="w-48" colorSchema="primary" variant="outline_bg" - onClick={() => onResendInvite(email)} + isLoading={isPending} + onClick={() => onResendInvite(orgMembershipId)} > Resend invite diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserDetailsSection.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserDetailsSection.tsx index cd430dedd..be0d71ddd 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserDetailsSection.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserDetailsSection.tsx @@ -18,13 +18,9 @@ import { useUser } from "@app/context"; import { useTimedReset } from "@app/hooks"; -import { - useAddUsersToOrg, - useFetchServerStatus, - useGetOrgMembership, - useGetOrgRoles -} from "@app/hooks/api"; +import { useFetchServerStatus, useGetOrgMembership, useGetOrgRoles } from "@app/hooks/api"; import { OrgUser } from "@app/hooks/api/types"; +import { useResendOrgMemberInvitation } from "@app/hooks/api/users/mutation"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -45,28 +41,27 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => const { data: roles } = useGetOrgRoles(orgId); const { data: serverDetails } = useFetchServerStatus(); const { data: membership } = useGetOrgMembership(orgId, membershipId); - const { mutateAsync: inviteUsers, isPending } = useAddUsersToOrg(); - const onResendInvite = async (email: string) => { + const { mutateAsync: resendOrgMemberInvitation, isPending } = useResendOrgMemberInvitation(); + + const onResendInvite = async () => { try { - const { data } = await inviteUsers({ - organizationId: orgId, - inviteeEmails: [email], - organizationRoleSlug: "member" + const signupToken = await resendOrgMemberInvitation({ + membershipId }); - // setCompleteInviteLink(data?.completeInviteLink || ""); - - if (!data.completeInviteLinks) { - createNotification({ - text: `Successfully resent invite to ${email}`, - type: "success" - }); + if (signupToken) { + return; } + + createNotification({ + text: "Successfully resent org invitation", + type: "success" + }); } catch (err) { console.error(err); createNotification({ - text: `Failed to resend invite to ${email}`, + text: "Failed to resend org invitation", type: "error" }); } @@ -210,9 +205,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => colorSchema="primary" type="submit" isLoading={isPending} - onClick={() => { - onResendInvite(membership.user.email as string); - }} + onClick={onResendInvite} > Resend Invite