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 (
<>
@@ -118,6 +135,23 @@ export function TableActions({
)}
+ {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;