feat: Add copy invite link (#8355)

* feat: Add shared invite link

* refactor: Rename Invite to Team Invite model

* feat: add admin check for team invite link procedures

* Replace TeamInvite with VerificationToken

* Add team invite null checks

* Migrates tRPC procedures to new format

* Type fixes

* Update common.json

---------

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Shane Maglangit 2023-06-07 07:34:14 +08:00 committed by GitHub
parent c24f5200f5
commit 10f965570b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 655 additions and 49 deletions

View File

@ -1685,7 +1685,7 @@
"not_enough_seats": "Not enough seats",
"form_builder_field_already_exists": "A field with this name already exists",
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
"show_on_booking_page":"Show on booking page",
"show_on_booking_page": "Show on booking page",
"get_started_zapier_templates": "Get started with Zapier templates",
"team_is_unpublished": "{{team}} is unpublished",
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
@ -1817,6 +1817,17 @@
"complete_your_booking": "Complete your booking",
"complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}",
"confirm_your_details": "Confirm your details",
"copy_invite_link": "Copy invite link",
"edit_invite_link": "Edit link settings",
"invite_link_copied": "Invite link copied",
"invite_link_deleted": "Invite link deleted",
"invite_link_updated": "Invite link settings saved",
"link_expires_after": "Links set to expire after...",
"one_day": "1 day",
"seven_days": "7 days",
"thirty_days": "30 days",
"never_expire": "Never expires",
"team_invite_received": "You have been invited to join {{teamName}}",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "You are about to charge the attendee {{amount, currency}}. Are you sure you want to continue?",
"charge_attendee": "Charge attendee {{amount, currency}}",

View File

@ -3,14 +3,23 @@ import { useRouter } from "next/router";
import { useState } from "react";
import { z } from "zod";
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL, APP_NAME } from "@calcom/lib/constants";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Badge, Button, showToast, SkeletonContainer, SkeletonText } from "@calcom/ui";
import { Plus, ArrowRight, Trash2 } from "@calcom/ui/components/icon";
import {
Avatar,
Badge,
Button,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { ArrowRight, Plus, Trash2 } from "@calcom/ui/components/icon";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
@ -40,10 +49,16 @@ export const AddNewTeamMembersForm = ({
teamId: number;
}) => {
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const showDialog = router.query.inviteModal === "true";
const [memberInviteModal, setMemberInviteModal] = useState(showDialog);
const utils = trpc.useContext();
const [inviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery({ teamId });
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
await utils.viewer.teams.get.invalidate();
@ -70,6 +85,7 @@ export const AddNewTeamMembersForm = ({
showToast(error.message, "error");
},
});
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
onSuccess(data) {
router.push(data.url);
@ -96,20 +112,44 @@ export const AddNewTeamMembersForm = ({
{t("add_team_member")}
</Button>
</div>
<MemberInvitationModal
isOpen={memberInviteModal}
onExit={() => setMemberInviteModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
});
}}
members={defaultValues.members}
/>
{isLoading ? (
<SkeletonButton />
) : (
<>
<MemberInvitationModal
isOpen={memberInviteModal}
teamId={teamId}
token={team?.inviteToken?.token}
onExit={() => setMemberInviteModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
});
}}
onSettingsOpen={() => {
setMemberInviteModal(false);
setInviteLinkSettingsModal(true);
}}
members={defaultValues.members}
/>
{team?.inviteToken && (
<InviteLinkSettingsModal
isOpen={inviteLinkSettingsModal}
teamId={team.id}
token={team.inviteToken?.token}
expiresInDays={team.inviteToken?.expiresInDays || undefined}
onExit={() => {
setInviteLinkSettingsModal(false);
setMemberInviteModal(true);
}}
/>
)}
</>
)}
<hr className="border-subtle my-6" />
<Button
EndIcon={ArrowRight}

View File

@ -0,0 +1,115 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, Dialog, DialogContent, DialogFooter, Form, Label, Select, showToast } from "@calcom/ui";
type InvitationLinkSettingsModalProps = {
isOpen: boolean;
teamId: number;
token: string;
expiresInDays?: number;
onExit: () => void;
};
export interface LinkSettingsForm {
expiresInDays: number | undefined;
}
export default function InviteLinkSettingsModal(props: InvitationLinkSettingsModalProps) {
const { t } = useLocale();
const trpcContext = trpc.useContext();
const deleteInviteMutation = trpc.viewer.teams.deleteInvite.useMutation({
onSuccess: () => {
showToast(t("invite_link_deleted"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
props.onExit();
},
onError: (e) => {
showToast(e.message, "error");
},
});
const setInviteExpirationMutation = trpc.viewer.teams.setInviteExpiration.useMutation({
onSuccess: () => {
showToast(t("invite_link_updated"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
},
onError: (e) => {
showToast(e.message, "error");
},
});
const expiresInDaysOption = useMemo(() => {
return [
{ value: 1, label: t("one_day") },
{ value: 7, label: t("seven_days") },
{ value: 30, label: t("thirty_days") },
{ value: undefined, label: t("never_expire") },
];
}, [t]);
const linkSettingsFormMethods = useForm<LinkSettingsForm>();
const handleSubmit = (values: LinkSettingsForm) => {
setInviteExpirationMutation.mutate({
token: props.token,
expiresInDays: values.expiresInDays,
});
};
return (
<Dialog
open={props.isOpen}
onOpenChange={() => {
props.onExit();
linkSettingsFormMethods.reset();
}}>
<DialogContent type="creation" title="Invite link settings">
<Form form={linkSettingsFormMethods} handleSubmit={handleSubmit}>
<Controller
name="expiresInDays"
control={linkSettingsFormMethods.control}
render={({ field: { onChange } }) => (
<div>
<Label className="text-emphasis font-medium" htmlFor="expiresInDays">
{t("link_expires_after")}
</Label>
<Select
options={expiresInDaysOption}
defaultValue={expiresInDaysOption.find((option) => option.value === props.expiresInDays)}
className="w-full"
onChange={(val) => onChange(val?.value)}
/>
</div>
)}
/>
<DialogFooter>
<Button
type="button"
color="secondary"
onClick={() => deleteInviteMutation.mutate({ token: props.token })}
className="mr-auto"
data-testid="copy-invite-link-button">
{t("delete")}
</Button>
<Button type="button" color="minimal" onClick={props.onExit}>
{t("back")}
</Button>
<Button
type="submit"
color="primary"
className="ms-2 me-2"
data-testid="invite-new-member-button">
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -3,9 +3,11 @@ import { Trans } from "next-i18next";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { classNames } from "@calcom/lib";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import {
Button,
Checkbox as CheckboxField,
@ -13,12 +15,14 @@ import {
DialogContent,
DialogFooter,
Form,
TextField,
Label,
showToast,
TextField,
ToggleGroup,
Select,
TextAreaField,
} from "@calcom/ui";
import { Link } from "@calcom/ui/components/icon";
import type { PendingMember } from "../lib/types";
import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
@ -27,7 +31,10 @@ type MemberInvitationModalProps = {
isOpen: boolean;
onExit: () => void;
onSubmit: (values: NewMemberForm) => void;
onSettingsOpen: () => void;
teamId: number;
members: PendingMember[];
token?: string;
};
type MembershipRoleOption = {
@ -45,7 +52,27 @@ type ModalMode = "INDIVIDUAL" | "BULK";
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const { t } = useLocale();
const trpcContext = trpc.useContext();
const [modalImportMode, setModalInputMode] = useState<ModalMode>("INDIVIDUAL");
const createInviteMutation = trpc.viewer.teams.createInvite.useMutation({
onSuccess(token) {
copyInviteLinkToClipboard(token);
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const copyInviteLinkToClipboard = async (token: string) => {
const inviteLink = `${WEBAPP_URL}/teams?token=${token}`;
await navigator.clipboard.writeText(inviteLink);
showToast(t("invite_link_copied"), "success");
};
const options: MembershipRoleOption[] = useMemo(() => {
return [
{ value: MembershipRole.MEMBER, label: t("member") },
@ -215,6 +242,35 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
/>
</div>
<DialogFooter showDivider>
<div className="mr-auto flex">
<Button
type="button"
color="minimal"
variant="icon"
onClick={() =>
props.token
? copyInviteLinkToClipboard(props.token)
: createInviteMutation.mutate({ teamId: props.teamId })
}
className={classNames("gap-2", props.token && "opacity-50")}
data-testid="copy-invite-link-button">
<Link className="text-default h-4 w-4" aria-hidden="true" />
{t("copy_invite_link")}
</Button>
{props.token && (
<Button
type="button"
color="minimal"
className="ms-2 me-2"
onClick={() => {
props.onSettingsOpen();
newMemberFormMethods.reset();
}}
data-testid="edit-invite-link-button">
{t("edit_invite_link")}
</Button>
)}
</div>
<Button
type="button"
color="minimal"

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import InviteLinkSettingsModal from "@calcom/ee/teams/components/InviteLinkSettingsModal";
import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@ -26,16 +27,16 @@ import {
Tooltip,
} from "@calcom/ui";
import {
MoreHorizontal,
Check,
X,
Link as LinkIcon,
Edit2,
ExternalLink,
Trash,
LogOut,
Globe,
Link as LinkIcon,
LogOut,
MoreHorizontal,
Send,
Trash,
X,
} from "@calcom/ui/components/icon";
import { TeamRole } from "./TeamPill";
@ -51,11 +52,15 @@ interface Props {
export default function TeamListItem(props: Props) {
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const team = props.team;
const router = useRouter();
const showDialog = router.query.inviteModal === "true";
const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(showDialog);
const [openInviteLinkSettingsModal, setOpenInviteLinkSettingsModal] = useState(false);
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId: team?.id });
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
@ -129,6 +134,8 @@ export default function TeamListItem(props: Props) {
<li className="">
<MemberInvitationModal
isOpen={openMemberInvitationModal}
teamId={team.id}
token={team.inviteToken?.token}
onExit={() => {
setOpenMemberInvitationModal(false);
}}
@ -141,8 +148,24 @@ export default function TeamListItem(props: Props) {
sendEmailInvitation: values.sendInviteEmail,
});
}}
onSettingsOpen={() => {
setOpenMemberInvitationModal(false);
setOpenInviteLinkSettingsModal(true);
}}
members={teamQuery?.data?.members || []}
/>
{team.inviteToken && (
<InviteLinkSettingsModal
isOpen={openInviteLinkSettingsModal}
teamId={team.id}
token={team.inviteToken?.token}
expiresInDays={team.inviteToken?.expiresInDays || undefined}
onExit={() => {
setOpenInviteLinkSettingsModal(false);
setOpenMemberInvitationModal(true);
}}
/>
)}
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
{!isInvitee ? (
<Link

View File

@ -1,10 +1,11 @@
import { useState, useMemo } from "react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { WEBAPP_URL, APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, ButtonGroup, Label } from "@calcom/ui";
import { Users, RefreshCcw, UserPlus, Mail, Video, EyeOff } from "@calcom/ui/components/icon";
import { Alert, Button, ButtonGroup, Label, showToast } from "@calcom/ui";
import { EyeOff, Mail, RefreshCcw, UserPlus, Users, Video } from "@calcom/ui/components/icon";
import { UpgradeTip } from "../../../tips";
import SkeletonLoaderTeamList from "./SkeletonloaderTeamList";
@ -12,14 +13,32 @@ import TeamList from "./TeamList";
export function TeamsListing() {
const { t } = useLocale();
const trpcContext = trpc.useContext();
const router = useRouter();
const [inviteTokenChecked, setInviteTokenChecked] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const { data, isLoading } = trpc.viewer.teams.list.useQuery(undefined, {
enabled: inviteTokenChecked,
onError: (e) => {
setErrorMessage(e.message);
},
});
const { mutate: inviteMemberByToken } = trpc.viewer.teams.inviteMemberByToken.useMutation({
onSuccess: (teamName) => {
trpcContext.viewer.teams.list.invalidate();
showToast(t("team_invite_received", { teamName }), "success");
},
onError: (e) => {
showToast(e.message, "error");
},
onSettled: () => {
setInviteTokenChecked(true);
},
});
const teams = useMemo(() => data?.filter((m) => m.accepted) || [], [data]);
const invites = useMemo(() => data?.filter((m) => !m.accepted) || [], [data]);
@ -56,7 +75,13 @@ export function TeamsListing() {
},
];
if (isLoading) {
useEffect(() => {
if (!router) return;
if (router.query.token) inviteMemberByToken({ token: router.query.token as string });
else setInviteTokenChecked(true);
}, [router, inviteMemberByToken, setInviteTokenChecked]);
if (isLoading || !inviteTokenChecked) {
return <SkeletonLoaderTeamList />;
}

View File

@ -11,6 +11,7 @@ import { Plus } from "@calcom/ui/components/icon";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
import InviteLinkSettingsModal from "../components/InviteLinkSettingsModal";
import MemberInvitationModal from "../components/MemberInvitationModal";
import MemberListItem from "../components/MemberListItem";
import TeamInviteList from "../components/TeamInviteList";
@ -63,12 +64,16 @@ function MembersList(props: MembersListProps) {
const MembersView = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const session = useSession();
const utils = trpc.useContext();
const teamId = Number(router.query.id);
const showDialog = router.query.inviteModal === "true";
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
const teamId = Number(router.query.id);
const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
{ teamId },
@ -169,6 +174,8 @@ const MembersView = () => {
<MemberInvitationModal
isOpen={showMemberInvitationModal}
members={team.members}
teamId={team.id}
token={team.inviteToken?.token}
onExit={() => setShowMemberInvitationModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
@ -179,6 +186,22 @@ const MembersView = () => {
sendEmailInvitation: values.sendInviteEmail,
});
}}
onSettingsOpen={() => {
setShowMemberInvitationModal(false);
setInviteLinkSettingsModal(true);
}}
/>
)}
{showInviteLinkSettingsModal && team?.inviteToken && (
<InviteLinkSettingsModal
isOpen={showInviteLinkSettingsModal}
teamId={team.id}
token={team.inviteToken.token}
expiresInDays={team.inviteToken.expiresInDays || undefined}
onExit={() => {
setInviteLinkSettingsModal(false);
setShowMemberInvitationModal(true);
}}
/>
)}
</>

View File

@ -7,6 +7,7 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
@ -52,6 +53,13 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
...baseEventTypeSelect,
},
},
inviteToken: {
select: {
token: true,
expires: true,
expiresInDays: true,
},
},
});
const where: Prisma.TeamFindFirstArgs["where"] = {};
@ -82,6 +90,7 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
}));
return { ...team, eventTypes, members };
}
// also returns team
export async function isTeamAdmin(userId: number, teamId: number) {
return (
@ -95,6 +104,7 @@ export async function isTeamAdmin(userId: number, teamId: number) {
})) || false
);
}
export async function isTeamOwner(userId: number, teamId: number) {
return !!(await prisma.membership.findFirst({
where: {

View File

@ -0,0 +1,15 @@
/*
Warnings:
- A unique constraint covering the columns `[teamId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "expiresInDays" INTEGER,
ADD COLUMN "teamId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_teamId_key" ON "VerificationToken"("teamId");
-- AddForeignKey
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -234,27 +234,28 @@ model User {
}
model Team {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
/// @zod.min(1)
name String
/// @zod.min(1)
slug String? @unique
slug String? @unique
logo String?
appLogo String?
appIconLogo String?
bio String?
hideBranding Boolean @default(false)
hideBookATeamMember Boolean @default(false)
hideBranding Boolean @default(false)
hideBookATeamMember Boolean @default(false)
members Membership[]
eventTypes EventType[]
workflows Workflow[]
createdAt DateTime @default(now())
createdAt DateTime @default(now())
/// @zod.custom(imports.teamMetadataSchema)
metadata Json?
theme String?
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
verifiedNumbers VerifiedNumber[]
inviteToken VerificationToken?
webhooks Webhook[]
}
@ -279,12 +280,15 @@ model Membership {
}
model VerificationToken {
id Int @id @default(autoincrement())
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
identifier String
token String @unique
expires DateTime
expiresInDays Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teamId Int? @unique
team Team? @relation(fields: [teamId], references: [id])
@@unique([identifier, token])
@@index([token])

View File

@ -3,14 +3,18 @@ import { router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateInviteInputSchema } from "./createInvite.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDeleteInviteInputSchema } from "./deleteInvite.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema";
import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema";
import { ZInviteMemberInputSchema } from "./inviteMember.schema";
import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
import { ZListMembersInputSchema } from "./listMembers.schema";
import { ZPublishInputSchema } from "./publish.schema";
import { ZRemoveMemberInputSchema } from "./removeMember.schema";
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
@ -32,6 +36,10 @@ type TeamsRouterHandlerCache = {
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler;
listInvites?: typeof import("./listInvites.handler").listInvitesHandler;
createInvite?: typeof import("./createInvite.handler").createInviteHandler;
setInviteExpiration?: typeof import("./setInviteExpiration.handler").setInviteExpirationHandler;
deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler;
inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler;
};
const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {};
@ -334,4 +342,76 @@ export const viewerTeamsRouter = router({
ctx,
});
}),
createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
UNSTABLE_HANDLER_CACHE.createInvite = await import("./createInvite.handler").then(
(mod) => mod.createInviteHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.createInvite({
ctx,
input,
});
}),
setInviteExpiration: authedProcedure
.input(ZSetInviteExpirationInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
UNSTABLE_HANDLER_CACHE.setInviteExpiration = await import("./setInviteExpiration.handler").then(
(mod) => mod.setInviteExpirationHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.setInviteExpiration({
ctx,
input,
});
}),
deleteInvite: authedProcedure.input(ZDeleteInviteInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
UNSTABLE_HANDLER_CACHE.deleteInvite = await import("./deleteInvite.handler").then(
(mod) => mod.deleteInviteHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.deleteInvite({
ctx,
input,
});
}),
inviteMemberByToken: authedProcedure
.input(ZInviteMemberByTokenSchemaInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
UNSTABLE_HANDLER_CACHE.inviteMemberByToken = await import("./inviteMemberByToken.handler").then(
(mod) => mod.inviteMemberByTokenHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.inviteMemberByToken({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,32 @@
import { randomBytes } from "crypto";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TCreateInviteInputSchema } from "./createInvite.schema";
type CreateInviteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateInviteInputSchema;
};
export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) => {
const { teamId } = input;
if (!(await isTeamAdmin(ctx.user.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: "",
token,
expires: new Date(),
teamId,
},
});
return token;
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZCreateInviteInputSchema = z.object({
teamId: z.number(),
});
export type TCreateInviteInputSchema = z.infer<typeof ZCreateInviteInputSchema>;

View File

@ -0,0 +1,33 @@
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TDeleteInviteInputSchema } from "./deleteInvite.schema";
type DeleteInviteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TDeleteInviteInputSchema;
};
export const deleteInviteHandler = async ({ ctx, input }: DeleteInviteOptions) => {
const { token } = input;
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token: token,
},
select: {
teamId: true,
id: true,
},
});
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND" });
if (!verificationToken.teamId || !(await isTeamAdmin(ctx.user.id, verificationToken.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
await prisma.verificationToken.delete({ where: { id: verificationToken.id } });
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZDeleteInviteInputSchema = z.object({
token: z.string(),
});
export type TDeleteInviteInputSchema = z.infer<typeof ZDeleteInviteInputSchema>;

View File

@ -19,8 +19,7 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
/** We only need to return teams that don't have a `subscriptionId` on their metadata */
teams = teams.filter((m) => {
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
if (metadata.success && metadata.data?.subscriptionId) return false;
return true;
return !(metadata.success && metadata.data?.subscriptionId);
});
return teams;
};

View File

@ -0,0 +1,66 @@
import { Prisma } from "@prisma/client";
import { updateQuantitySubscriptionFromStripe } from "@calcom/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
type InviteMemberByTokenOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TInviteMemberByTokenSchemaInputSchema;
};
export const inviteMemberByTokenHandler = async ({ ctx, input }: InviteMemberByTokenOptions) => {
const { token } = input;
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
OR: [{ expiresInDays: null }, { expires: { gte: new Date() } }],
},
include: {
team: {
select: {
name: true,
},
},
},
});
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
if (!verificationToken.teamId || !verificationToken.team)
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite token is not associated with any team",
});
try {
await prisma.membership.create({
data: {
teamId: verificationToken.teamId,
userId: ctx.user.id,
role: MembershipRole.MEMBER,
accepted: false,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This user is a member of this team / has a pending invitation.",
});
}
} else throw e;
}
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(verificationToken.teamId);
return verificationToken.team.name;
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZInviteMemberByTokenSchemaInputSchema = z.object({
token: z.string(),
});
export type TInviteMemberByTokenSchemaInputSchema = z.infer<typeof ZInviteMemberByTokenSchemaInputSchema>;

View File

@ -14,7 +14,11 @@ export const listHandler = async ({ ctx }: ListOptions) => {
userId: ctx.user.id,
},
include: {
team: true,
team: {
include: {
inviteToken: true,
},
},
},
orderBy: { role: "desc" },
});

View File

@ -43,7 +43,7 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) =>
});
type UserMap = Record<number, (typeof teams)[number]["members"][number]["user"]>;
// flattern users to be unique by id
// flatten users to be unique by id
const users = teams
.flatMap((t) => t.members)
.reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap);

View File

@ -0,0 +1,41 @@
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
type SetInviteExpirationOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TSetInviteExpirationInputSchema;
};
export const setInviteExpirationHandler = async ({ ctx, input }: SetInviteExpirationOptions) => {
const { token, expiresInDays } = input;
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token: token,
},
select: {
teamId: true,
},
});
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND" });
if (!verificationToken.teamId || !(await isTeamAdmin(ctx.user.id, verificationToken.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
const oneDay = 24 * 60 * 60 * 1000;
const expires = expiresInDays ? new Date(Date.now() + expiresInDays * oneDay) : new Date();
await prisma.verificationToken.update({
where: { token },
data: {
expires,
expiresInDays,
},
});
};

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const ZSetInviteExpirationInputSchema = z.object({
token: z.string(),
expiresInDays: z.number().optional(),
});
export type TSetInviteExpirationInputSchema = z.infer<typeof ZSetInviteExpirationInputSchema>;