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_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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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