diff --git a/apps/web/playwright/fixtures/clipboard.ts b/apps/web/playwright/fixtures/clipboard.ts deleted file mode 100644 index 47cc92d95c..0000000000 --- a/apps/web/playwright/fixtures/clipboard.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Page } from "@playwright/test"; - -declare global { - interface Window { - E2E_CLIPBOARD_VALUE?: string; - } -} - -export type Window = typeof window; -// creates the single server fixture -export const createClipboardFixture = (page: Page) => { - return { - reset: async () => { - await page.evaluate(() => { - delete window.E2E_CLIPBOARD_VALUE; - }); - }, - get: async () => { - return getClipboardValue({ page }); - }, - }; -}; - -function getClipboardValue({ page }: { page: Page }) { - return page.evaluate(() => { - return new Promise((resolve, reject) => { - setInterval(() => { - if (!window.E2E_CLIPBOARD_VALUE) return; - resolve(window.E2E_CLIPBOARD_VALUE); - }, 500); - setTimeout(() => reject(new Error("Timeout")), 1000); - }); - }); -} diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index cf66ebb2f9..21956d49ba 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -9,7 +9,6 @@ import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; -import { createClipboardFixture } from "../fixtures/clipboard"; import { createEmbedsFixture } from "../fixtures/embeds"; import { createOrgsFixture } from "../fixtures/orgs"; import { createPaymentsFixture } from "../fixtures/payments"; @@ -30,7 +29,6 @@ export interface Fixtures { emails?: API; routingForms: ReturnType; bookingPage: ReturnType; - clipboard: ReturnType; } declare global { @@ -97,8 +95,4 @@ export const test = base.extend({ const bookingPage = createBookingPageFixture(page); await use(bookingPage); }, - clipboard: async ({ page }, use) => { - const clipboard = createClipboardFixture(page); - await use(clipboard); - }, }); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 7038b656b1..1deefb206f 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -203,6 +203,12 @@ export async function installAppleCalendar(page: Page) { await page.click('[data-testid="install-app-button"]'); } +export async function getInviteLink(page: Page) { + const response = await page.waitForResponse("**/api/trpc/teams/createInvite?batch=1"); + const json = await response.json(); + return json[0].result.data.json.inviteLink as string; +} + export async function getEmailsReceivedByUser({ emails, userEmail, diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 58ae4120c8..f84c639665 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -1,18 +1,18 @@ import { expect } from "@playwright/test"; import { test } from "../lib/fixtures"; +import { getInviteLink } from "../lib/testUtils"; import { expectInvitationEmailToBeReceived } from "./expects"; test.describe.configure({ mode: "parallel" }); -test.afterEach(async ({ users, emails, clipboard }) => { - clipboard.reset(); +test.afterEach(async ({ users, emails }) => { await users.deleteAll(); emails?.deleteAll(); }); test.describe("Organization", () => { - test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { + test("Invitation (non verified)", async ({ browser, page, users, emails }) => { const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); const { team: org } = await orgOwner.getOrgMembership(); await orgOwner.apiLogin(); @@ -69,9 +69,8 @@ test.describe("Organization", () => { // Get the invite link await page.locator('button:text("Add")').click(); await page.locator(`[data-testid="copy-invite-link-button"]`).click(); - const inviteLink = await clipboard.get(); - await page.waitForLoadState("networkidle"); + const inviteLink = await getInviteLink(page); // Follow invite link in new window const context = await browser.newContext(); const inviteLinkPage = await context.newPage(); @@ -98,7 +97,7 @@ test.describe("Organization", () => { await page.waitForLoadState("networkidle"); await test.step("To the organization by email (internal user)", async () => { - const invitedUserEmail = `rick@example.com`; + const invitedUserEmail = `rick-${Date.now()}@example.com`; await page.locator('button:text("Add")').click(); await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); await page.locator('button:text("Send invite")').click(); @@ -110,7 +109,7 @@ test.describe("Organization", () => { `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com` ); - // Check newly invited member exists and is pending + // Check newly invited member exists and is not pending await expect( page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) ).toHaveCount(0); diff --git a/apps/web/playwright/team/team-invitation.e2e.ts b/apps/web/playwright/team/team-invitation.e2e.ts index 95505bf279..40308fdd71 100644 --- a/apps/web/playwright/team/team-invitation.e2e.ts +++ b/apps/web/playwright/team/team-invitation.e2e.ts @@ -3,19 +3,18 @@ import { expect } from "@playwright/test"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { test } from "../lib/fixtures"; -import { localize } from "../lib/testUtils"; +import { localize, getInviteLink } from "../lib/testUtils"; import { expectInvitationEmailToBeReceived } from "./expects"; test.describe.configure({ mode: "parallel" }); -test.afterEach(async ({ users, emails, clipboard }) => { - clipboard.reset(); +test.afterEach(async ({ users, emails }) => { await users.deleteAll(); emails?.deleteAll(); }); test.describe("Team", () => { - test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { + test("Invitation (non verified)", async ({ browser, page, users, emails }) => { const t = await localize("en"); const teamOwner = await users.create(undefined, { hasTeam: true }); const { team } = await teamOwner.getFirstTeam(); @@ -76,8 +75,7 @@ test.describe("Team", () => { }); await page.locator(`button:text("${t("add")}")`).click(); await page.locator(`[data-testid="copy-invite-link-button"]`).click(); - const inviteLink = await clipboard.get(); - await page.waitForLoadState("networkidle"); + const inviteLink = await getInviteLink(page); const context = await browser.newContext(); const inviteLinkPage = await context.newPage(); diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index fc8fcf90fc..bde9365f72 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -6,7 +6,7 @@ import { Controller, useForm } from "react-hook-form"; import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg"; import { classNames } from "@calcom/lib"; -import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc"; @@ -25,7 +25,6 @@ import { TextAreaField, } from "@calcom/ui"; import { Link } from "@calcom/ui/components/icon"; -import type { Window as WindowWithClipboardValue } from "@calcom/web/playwright/fixtures/clipboard"; import type { PendingMember } from "../lib/types"; import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton"; @@ -76,8 +75,8 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) ); const createInviteMutation = trpc.viewer.teams.createInvite.useMutation({ - onSuccess(token) { - copyInviteLinkToClipboard(token); + async onSuccess({ inviteLink }) { + await copyInviteLinkToClipboard(inviteLink); trpcContext.viewer.teams.get.invalidate(); trpcContext.viewer.teams.list.invalidate(); }, @@ -86,20 +85,11 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) }, }); - const copyInviteLinkToClipboard = async (token: string) => { - const isOrgInvite = isOrg; - const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`; - const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; - - const inviteLink = - isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink; + const copyInviteLinkToClipboard = async (inviteLink: string) => { try { await navigator.clipboard.writeText(inviteLink); showToast(t("invite_link_copied"), "success"); } catch (e) { - if (process.env.NEXT_PUBLIC_IS_E2E) { - (window as WindowWithClipboardValue).E2E_CLIPBOARD_VALUE = inviteLink; - } console.error(e); } }; @@ -373,11 +363,9 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) type="button" color="minimal" variant="icon" - onClick={() => - props.token - ? copyInviteLinkToClipboard(props.token) - : createInviteMutation.mutate({ teamId: props.teamId }) - } + onClick={() => { + createInviteMutation.mutate({ teamId: props.teamId, token: props.token }); + }} className={classNames("gap-2", props.token && "opacity-50")} data-testid="copy-invite-link-button"> diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 7ef45e69c2..a5d8a1b124 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -214,16 +214,17 @@ export async function getTeamWithMembers(args: { // also returns team export async function isTeamAdmin(userId: number, teamId: number) { - return ( - (await prisma.membership.findFirst({ - where: { - userId, - teamId, - accepted: true, - OR: [{ role: "ADMIN" }, { role: "OWNER" }], - }, - })) || false - ); + const team = await prisma.membership.findFirst({ + where: { + userId, + teamId, + accepted: true, + OR: [{ role: "ADMIN" }, { role: "OWNER" }], + }, + include: { team: true }, + }); + if (!team) return false; + return team; } export async function isTeamOwner(userId: number, teamId: number) { diff --git a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts index febefa541b..380882ab55 100644 --- a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts @@ -1,10 +1,13 @@ import { randomBytes } from "crypto"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@calcom/trpc/server"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import { getMembersHandler } from "../organizations/getMembers.handler"; import type { TCreateInviteInputSchema } from "./createInvite.schema"; type CreateInviteOptions = { @@ -16,8 +19,26 @@ type CreateInviteOptions = { export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) => { const { teamId } = input; + const membership = await isTeamAdmin(ctx.user.id, teamId); - if (!(await isTeamAdmin(ctx.user.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (!membership || !membership?.team) throw new TRPCError({ code: "UNAUTHORIZED" }); + const teamMetadata = teamMetadataSchema.parse(membership.team.metadata); + const isOrg = !!(membership.team?.parentId === null && teamMetadata?.isOrganization); + const orgMembers = await getMembersHandler({ + ctx, + input: { teamIdToExclude: teamId, distinctUser: true }, + }); + + if (input.token) { + const existingToken = await prisma.verificationToken.findFirst({ + where: { token: input.token, identifier: `invite-link-for-teamId-${teamId}`, teamId }, + }); + if (!existingToken) throw new TRPCError({ code: "NOT_FOUND" }); + return { + token: existingToken.token, + inviteLink: await getInviteLink(existingToken.token, isOrg, orgMembers?.length), + }; + } const token = randomBytes(32).toString("hex"); await prisma.verificationToken.create({ @@ -28,5 +49,13 @@ export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) = teamId, }, }); - return token; + + return { token, inviteLink: await getInviteLink(token, isOrg, orgMembers?.length) }; }; + +async function getInviteLink(token = "", isOrg = false, orgMembers = 0) { + const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`; + const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; + if (isOrg || orgMembers > 0) return orgInviteLink; + return teamInviteLink; +} diff --git a/packages/trpc/server/routers/viewer/teams/createInvite.schema.ts b/packages/trpc/server/routers/viewer/teams/createInvite.schema.ts index 532013c4bf..a0a980622c 100644 --- a/packages/trpc/server/routers/viewer/teams/createInvite.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/createInvite.schema.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const ZCreateInviteInputSchema = z.object({ teamId: z.number(), + token: z.string().optional(), }); export type TCreateInviteInputSchema = z.infer;