feat: resend invitation (#11428)
This commit is contained in:
parent
d9c3c184c4
commit
72907e5e1b
|
@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
Loading…
Reference in New Issue
Block a user