Merge branch 'main' into teste2e-bookCollective

This commit is contained in:
Keith Williams 2023-11-28 09:45:01 -03:00 committed by GitHub
commit 1a74403f25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 75 additions and 86 deletions

View File

@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { MembershipRole } from "@calcom/prisma/enums";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
@ -89,7 +90,9 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
if (req.isAdmin) return true;
if (eventType?.teamId) {
req.query.teamId = String(eventType.teamId);
await canAccessTeamEventOrThrow(req, "MEMBER");
await canAccessTeamEventOrThrow(req, {
in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
});
}
if (eventType?.userId === req.userId) return true; // is owner.
throw new HttpError({ statusCode: 403, message: "Forbidden" });

View File

@ -22,7 +22,11 @@ export async function checkPermissions(
role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER
) {
const { userId, prisma, isAdmin } = req;
const { teamId } = schemaQueryTeamId.parse(req.query);
const { teamId } = schemaQueryTeamId.parse({
teamId: req.query.teamId,
version: req.query.version,
apiKey: req.query.apiKey,
});
const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } };
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } };

View File

@ -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<string>((resolve, reject) => {
setInterval(() => {
if (!window.E2E_CLIPBOARD_VALUE) return;
resolve(window.E2E_CLIPBOARD_VALUE);
}, 500);
setTimeout(() => reject(new Error("Timeout")), 1000);
});
});
}

View File

@ -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<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
clipboard: ReturnType<typeof createClipboardFixture>;
}
declare global {
@ -97,8 +95,4 @@ export const test = base.extend<Fixtures>({
const bookingPage = createBookingPageFixture(page);
await use(bookingPage);
},
clipboard: async ({ page }, use) => {
const clipboard = createClipboardFixture(page);
await use(clipboard);
},
});

View File

@ -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,

View File

@ -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);

View File

@ -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();

View File

@ -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">
<Link className="text-default h-4 w-4" aria-hidden="true" />

View File

@ -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) {

View File

@ -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;
}

View File

@ -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<typeof ZCreateInviteInputSchema>;