feat: resend invitation (#11428)

This commit is contained in:
Leo Giovanetti 2023-09-18 21:24:43 -03:00 committed by GitHub
parent d9c3c184c4
commit 72907e5e1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 151 additions and 3 deletions

View File

@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -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) {
<DropdownMenuSeparator />
</>
)}
{resendInvitation && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
resendInvitationMutation.mutate({
teamId: props.team?.id,
email: props.member.email,
language: i18n.language,
});
}}
StartIcon={SendIcon}>
{t("resend_invitation")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<DropdownItem
type="button"

View File

@ -132,6 +132,7 @@ export function UserListTable() {
const permissions = {
canEdit: adminOrOwner,
canRemove: adminOrOwner,
canResendInvitation: adminOrOwner,
canImpersonate: false,
};
const cols: ColumnDef<User>[] = [
@ -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 (

View File

@ -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 (
<>
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
@ -118,6 +135,23 @@ export function TableActions({
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResendInvitation && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
resendInvitationMutation.mutate({
teamId: orgId,
language: i18n.language,
email: user.email,
isOrg: true,
});
}}
StartIcon={SendIcon}>
{t("resend_invitation")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
)}

View File

@ -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,
});
}),
});

View File

@ -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<TrpcSessionUser>;
};
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;
};

View File

@ -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<typeof ZResendInvitationInputSchema>;