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_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.", "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}}", "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "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 classNames from "classnames";
import { SendIcon } from "lucide-react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useState } from "react"; import { useState } from "react";
@ -52,7 +53,7 @@ const checkIsOrg = (team: Props["team"]) => {
}; };
export default function MemberListItem(props: Props) { export default function MemberListItem(props: Props) {
const { t } = useLocale(); const { t, i18n } = useLocale();
const utils = trpc.useContext(); const utils = trpc.useContext();
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false); 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 ownersInTeam = () => {
const { members } = props.team; const { members } = props.team;
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]); 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.disableImpersonation &&
props.member.accepted && props.member.accepted &&
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true"; process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true";
const resendInvitation = editMode && !props.member.accepted;
const bookerUrl = useBookerUrl(); const bookerUrl = useBookerUrl();
const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, ""); const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, "");
@ -224,6 +235,22 @@ export default function MemberListItem(props: Props) {
<DropdownMenuSeparator /> <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> <DropdownMenuItem>
<DropdownItem <DropdownItem
type="button" type="button"

View File

@ -132,6 +132,7 @@ export function UserListTable() {
const permissions = { const permissions = {
canEdit: adminOrOwner, canEdit: adminOrOwner,
canRemove: adminOrOwner, canRemove: adminOrOwner,
canResendInvitation: adminOrOwner,
canImpersonate: false, canImpersonate: false,
}; };
const cols: ColumnDef<User>[] = [ const cols: ColumnDef<User>[] = [
@ -233,6 +234,7 @@ export function UserListTable() {
canRemove: permissionsRaw.canRemove && !isSelf, canRemove: permissionsRaw.canRemove && !isSelf,
canImpersonate: user.accepted && !user.disableImpersonation && !isSelf, canImpersonate: user.accepted && !user.disableImpersonation && !isSelf,
canLeave: user.accepted && isSelf, canLeave: user.accepted && isSelf,
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
}; };
return ( 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 { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { import {
ButtonGroup, ButtonGroup,
Tooltip, Tooltip,
@ -12,6 +14,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownItem, DropdownItem,
DropdownMenuSeparator, DropdownMenuSeparator,
showToast,
} from "@calcom/ui"; } from "@calcom/ui";
import type { Action } from "./UserListTable"; import type { Action } from "./UserListTable";
@ -30,12 +33,26 @@ export function TableActions({
canEdit: boolean; canEdit: boolean;
canRemove: boolean; canRemove: boolean;
canImpersonate: 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; const usersProfileUrl = domain + "/" + user.username;
if (!session?.user.org?.id) return null;
const orgId = session?.user?.org?.id;
return ( return (
<> <>
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}> <ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
@ -118,6 +135,23 @@ export function TableActions({
</DropdownItem> </DropdownItem>
</DropdownMenuItem> </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> </DropdownMenuContent>
</Dropdown> </Dropdown>
)} )}

View File

@ -15,6 +15,7 @@ import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.sch
import { ZListMembersInputSchema } from "./listMembers.schema"; import { ZListMembersInputSchema } from "./listMembers.schema";
import { ZPublishInputSchema } from "./publish.schema"; import { ZPublishInputSchema } from "./publish.schema";
import { ZRemoveMemberInputSchema } from "./removeMember.schema"; import { ZRemoveMemberInputSchema } from "./removeMember.schema";
import { ZResendInvitationInputSchema } from "./resendInvitation.schema";
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema"; import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZUpdateInputSchema } from "./update.schema"; import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema"; import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
@ -43,6 +44,7 @@ type TeamsRouterHandlerCache = {
deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler; deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler;
inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler; inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler;
hasEditPermissionForUser?: typeof import("./hasEditPermissionForUser.handler").hasEditPermissionForUser; hasEditPermissionForUser?: typeof import("./hasEditPermissionForUser.handler").hasEditPermissionForUser;
resendInvitation?: typeof import("./resendInvitation.handler").resendInvitationHandler;
}; };
const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {}; const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {};
@ -454,4 +456,21 @@ export const viewerTeamsRouter = router({
input, 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>;