From 72907e5e1b2818283d014e72d71c7faf6e3882f6 Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Mon, 18 Sep 2023 21:24:43 -0300 Subject: [PATCH] feat: resend invitation (#11428) --- apps/web/public/static/locales/en/common.json | 2 + .../ee/teams/components/MemberListItem.tsx | 29 +++++++++- .../components/UserTable/UserListTable.tsx | 2 + .../components/UserTable/UserTableActions.tsx | 38 ++++++++++++- .../server/routers/viewer/teams/_router.tsx | 19 +++++++ .../viewer/teams/resendInvitation.handler.ts | 54 +++++++++++++++++++ .../viewer/teams/resendInvitation.schema.ts | 10 ++++ 7 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/resendInvitation.schema.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 278afdba9e..79d3075891 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2053,5 +2053,7 @@ "view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.", "view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.", "edit_users_availability":"Edit user's availability: {{username}}", + "resend_invitation": "Resend invitation", + "invitation_resent": "The invitation was resent.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index 7b6b89c2c0..0808d3a1f6 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -1,4 +1,5 @@ import classNames from "classnames"; +import { SendIcon } from "lucide-react"; import { signIn } from "next-auth/react"; import { useState } from "react"; @@ -52,7 +53,7 @@ const checkIsOrg = (team: Props["team"]) => { }; export default function MemberListItem(props: Props) { - const { t } = useLocale(); + const { t, i18n } = useLocale(); const utils = trpc.useContext(); const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false); @@ -72,6 +73,15 @@ export default function MemberListItem(props: Props) { }, }); + const resendInvitationMutation = trpc.viewer.teams.resendInvitation.useMutation({ + onSuccess: () => { + showToast(t("invitation_resent"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + const ownersInTeam = () => { const { members } = props.team; const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]); @@ -105,6 +115,7 @@ export default function MemberListItem(props: Props) { !props.member.disableImpersonation && props.member.accepted && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true"; + const resendInvitation = editMode && !props.member.accepted; const bookerUrl = useBookerUrl(); const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, ""); @@ -224,6 +235,22 @@ export default function MemberListItem(props: Props) { )} + {resendInvitation && ( + + { + resendInvitationMutation.mutate({ + teamId: props.team?.id, + email: props.member.email, + language: i18n.language, + }); + }} + StartIcon={SendIcon}> + {t("resend_invitation")} + + + )} [] = [ @@ -233,6 +234,7 @@ export function UserListTable() { canRemove: permissionsRaw.canRemove && !isSelf, canImpersonate: user.accepted && !user.disableImpersonation && !isSelf, canLeave: user.accepted && isSelf, + canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted, }; return ( diff --git a/packages/features/users/components/UserTable/UserTableActions.tsx b/packages/features/users/components/UserTable/UserTableActions.tsx index 6d8407ae32..3f659e556f 100644 --- a/packages/features/users/components/UserTable/UserTableActions.tsx +++ b/packages/features/users/components/UserTable/UserTableActions.tsx @@ -1,7 +1,9 @@ -import { ExternalLink, MoreHorizontal, Edit2, UserX, Lock } from "lucide-react"; +import { ExternalLink, MoreHorizontal, Edit2, UserX, Lock, SendIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; import { ButtonGroup, Tooltip, @@ -12,6 +14,7 @@ import { DropdownMenuItem, DropdownItem, DropdownMenuSeparator, + showToast, } from "@calcom/ui"; import type { Action } from "./UserListTable"; @@ -30,12 +33,26 @@ export function TableActions({ canEdit: boolean; canRemove: boolean; canImpersonate: boolean; + canResendInvitation: boolean; }; }) { - const { t } = useLocale(); + const { t, i18n } = useLocale(); + const { data: session } = useSession(); + const resendInvitationMutation = trpc.viewer.teams.resendInvitation.useMutation({ + onSuccess: () => { + showToast(t("invitation_resent"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); const usersProfileUrl = domain + "/" + user.username; + if (!session?.user.org?.id) return null; + + const orgId = session?.user?.org?.id; + return ( <> )} + {permissionsForUser.canResendInvitation && ( + + { + resendInvitationMutation.mutate({ + teamId: orgId, + language: i18n.language, + email: user.email, + isOrg: true, + }); + }} + StartIcon={SendIcon}> + {t("resend_invitation")} + + + )} )} diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 3ddc6b3003..5c43eb899b 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -15,6 +15,7 @@ import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.sch import { ZListMembersInputSchema } from "./listMembers.schema"; import { ZPublishInputSchema } from "./publish.schema"; import { ZRemoveMemberInputSchema } from "./removeMember.schema"; +import { ZResendInvitationInputSchema } from "./resendInvitation.schema"; import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema"; import { ZUpdateInputSchema } from "./update.schema"; import { ZUpdateMembershipInputSchema } from "./updateMembership.schema"; @@ -43,6 +44,7 @@ type TeamsRouterHandlerCache = { deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler; inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler; hasEditPermissionForUser?: typeof import("./hasEditPermissionForUser.handler").hasEditPermissionForUser; + resendInvitation?: typeof import("./resendInvitation.handler").resendInvitationHandler; }; const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {}; @@ -454,4 +456,21 @@ export const viewerTeamsRouter = router({ input, }); }), + resendInvitation: authedProcedure.input(ZResendInvitationInputSchema).mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.resendInvitation) { + UNSTABLE_HANDLER_CACHE.resendInvitation = await import("./resendInvitation.handler").then( + (mod) => mod.resendInvitationHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.resendInvitation) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.resendInvitation({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts new file mode 100644 index 0000000000..d32e7f03e9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -0,0 +1,54 @@ +import { sendTeamInviteEmail } from "@calcom/emails"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import { checkPermissions, getTeamOrThrow } from "./inviteMember/utils"; +import type { TResendInvitationInputSchema } from "./resendInvitation.schema"; + +type InviteMemberOptions = { + ctx: { + user: NonNullable; + }; + input: TResendInvitationInputSchema; +}; + +export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOptions) => { + const team = await getTeamOrThrow(input.teamId, input.isOrg); + + await checkPermissions({ + userId: ctx.user.id, + teamId: + ctx.user.organization.id && ctx.user.organization.isOrgAdmin ? ctx.user.organization.id : input.teamId, + isOrg: input.isOrg, + }); + + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + identifier: input.email, + teamId: input.teamId, + }, + select: { + token: true, + }, + }); + + if (!verificationToken) + throw new TRPCError({ code: "NOT_FOUND", message: "Couldn't resend, no existing invitation found." }); + + const translation = await getTranslation(input.language ?? "en", "common"); + + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name || `${team.name}'s admin`, + to: input.email, + teamName: team?.parent?.name || team.name, + joinLink: `${WEBAPP_URL}/signup?token=${verificationToken.token}&callbackUrl=/getting-started`, + isCalcomMember: false, + isOrg: input.isOrg, + }); + + return input; +}; diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.schema.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.schema.ts new file mode 100644 index 0000000000..b494294630 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZResendInvitationInputSchema = z.object({ + teamId: z.number(), + email: z.string().email(), + language: z.string(), + isOrg: z.boolean().default(false), +}); + +export type TResendInvitationInputSchema = z.infer;