diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 07a10e63a0201e..70ee34c238c2cc 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -37,7 +37,7 @@ type SignupProps = inferSSRProps; const checkValidEmail = (email: string) => z.string().email().safeParse(email).success; const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { - const [emailUser, emailDomain] = email.split("@"); + const [emailUser, emailDomain = ""] = email.split("@"); const username = emailDomain === autoAcceptEmailDomain ? slugify(emailUser) @@ -57,10 +57,9 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA const telemetry = useTelemetry(); const { t, i18n } = useLocale(); const flags = useFlagMap(); - const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username; const methods = useForm({ mode: "onChange", - resolver: zodResolver(isOrgInviteByLink ? signupSchema.omit({ username: true }) : signupSchema), + resolver: zodResolver(signupSchema), defaultValues: prepopulateFormValues, }); const { @@ -75,10 +74,9 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA } }; + const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username; + const signUp: SubmitHandler = async (data) => { - if (data.username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) { - data = { ...data, username: getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) }; - } await fetch("/api/auth/signup", { body: JSON.stringify({ ...data, @@ -145,6 +143,12 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA methods.clearErrors("apiError"); } + if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) { + methods.setValue( + "username", + getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) + ); + } methods.handleSubmit(signUp)(event); }} className="bg-default space-y-6"> diff --git a/apps/web/playwright/fixtures/clipboard.ts b/apps/web/playwright/fixtures/clipboard.ts new file mode 100644 index 00000000000000..47cc92d95c98c2 --- /dev/null +++ b/apps/web/playwright/fixtures/clipboard.ts @@ -0,0 +1,34 @@ +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/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 5cf5d1ecb14583..10693c526442e6 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -412,6 +412,9 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { routingForms: user.routingForms, self, apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page), + /** + * @deprecated use apiLogin instead + */ login: async () => login({ ...(await self()), password: user.username }, store.page), logout: async () => { await page.goto("/auth/logout"); diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 2e54268db3cfb7..75e98b39fcd625 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -8,6 +8,7 @@ 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"; @@ -28,6 +29,7 @@ export interface Fixtures { emails?: API; routingForms: ReturnType; bookingPage: ReturnType; + clipboard: ReturnType; } declare global { @@ -92,4 +94,8 @@ 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 e6cda68861d54d..7038b656b1f1f1 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -4,7 +4,6 @@ import { createHash } from "crypto"; import EventEmitter from "events"; import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; -import { JSDOM } from "jsdom"; // eslint-disable-next-line no-restricted-imports import { noop } from "lodash"; import type { API, Messages } from "mailhog"; @@ -282,37 +281,6 @@ export async function createUserWithSeatedEventAndAttendees( return { user, eventType, booking }; } -export async function expectInvitationEmailToBeReceived( - page: Page, - emails: API | undefined, - userEmail: string, - subject: string, - returnLink?: string -) { - if (!emails) return null; - // We need to wait for the email to go through, otherwise it will fail - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(2000); - const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail }); - expect(receivedEmails?.total).toBe(1); - const [firstReceivedEmail] = (receivedEmails as Messages).items; - expect(firstReceivedEmail.subject).toBe(subject); - if (!returnLink) return; - const dom = new JSDOM(firstReceivedEmail.html); - const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`); - return anchor?.getAttribute("href"); -} - -export async function getInviteLinkFromConsole(page: Page): Promise { - return new Promise((resolve) => { - page.on("console", (msg) => { - if (msg.text().indexOf("signup?token") > -1) { - resolve(msg.text()); - } - }); - }); -} - export function generateTotpCode(email: string) { const secret = createHash("md5") .update(email + process.env.CALENDSO_ENCRYPTION_KEY) diff --git a/apps/web/playwright/organization/expects.ts b/apps/web/playwright/organization/expects.ts new file mode 100644 index 00000000000000..393868d07baa74 --- /dev/null +++ b/apps/web/playwright/organization/expects.ts @@ -0,0 +1,28 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { JSDOM } from "jsdom"; +// eslint-disable-next-line no-restricted-imports +import type { API, Messages } from "mailhog"; + +import { getEmailsReceivedByUser } from "../lib/testUtils"; + +export async function expectInvitationEmailToBeReceived( + page: Page, + emails: API | undefined, + userEmail: string, + subject: string, + returnLink?: string +) { + if (!emails) return null; + // We need to wait for the email to go through, otherwise it will fail + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(2000); + const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail }); + expect(receivedEmails?.total).toBe(1); + const [firstReceivedEmail] = (receivedEmails as Messages).items; + expect(firstReceivedEmail.subject).toBe(subject); + if (!returnLink) return; + const dom = new JSDOM(firstReceivedEmail.html); + const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`); + return anchor?.getAttribute("href"); +} diff --git a/apps/web/playwright/organization-creation.e2e.ts b/apps/web/playwright/organization/organization-creation.e2e.ts similarity index 88% rename from apps/web/playwright/organization-creation.e2e.ts rename to apps/web/playwright/organization/organization-creation.e2e.ts index 26ec611ef89e1a..19b3477026acda 100644 --- a/apps/web/playwright/organization-creation.e2e.ts +++ b/apps/web/playwright/organization/organization-creation.e2e.ts @@ -1,20 +1,32 @@ import { expect } from "@playwright/test"; import path from "path"; -import { test } from "./lib/fixtures"; -import { expectInvitationEmailToBeReceived, generateTotpCode } from "./lib/testUtils"; +import { test } from "../lib/fixtures"; +import { generateTotpCode } from "../lib/testUtils"; +import { expectInvitationEmailToBeReceived } from "./expects"; test.afterAll(({ users, emails }) => { users.deleteAll(); emails?.deleteAll(); }); +function capitalize(text: string) { + if (!text) { + return text; + } + return text.charAt(0).toUpperCase() + text.slice(1); +} + test.describe("Organization", () => { - test("Creation", async ({ page, users, emails }) => { + test("should be able to create an organization and complete onboarding", async ({ + page, + users, + emails, + }) => { const orgOwner = await users.create(); - const orgName = `${orgOwner.username}-org`.charAt(0).toUpperCase() + `${orgOwner.username}-org`.slice(1); const orgDomain = `${orgOwner.username}-org`; - await orgOwner.login(); + const orgName = capitalize(`${orgOwner.username}-org`); + await orgOwner.apiLogin(); await page.goto("/settings/organizations/new"); await page.waitForLoadState("networkidle"); @@ -76,7 +88,7 @@ test.describe("Organization", () => { const fileChooserPromise = page.waitForEvent("filechooser"); await page.getByText("Choose a file...").click(); const fileChooser = await fileChooserPromise; - await fileChooser.setFiles(path.join(__dirname, "../public/apple-touch-icon.png")); + await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png")); await page.locator('button:text("Save")').click(); // About text @@ -90,7 +102,6 @@ test.describe("Organization", () => { await test.step("On-board administrators", async () => { // Required field await page.locator("button[type=submit]").click(); - await page.locator('textarea[name="emails"]:invalid'); // Happy path await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`); diff --git a/apps/web/playwright/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts similarity index 80% rename from apps/web/playwright/organization-invitation.e2e.ts rename to apps/web/playwright/organization/organization-invitation.e2e.ts index 619853b100b43e..6561a01e5578fd 100644 --- a/apps/web/playwright/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -1,20 +1,21 @@ import { expect } from "@playwright/test"; -import { test } from "./lib/fixtures"; -import { getInviteLinkFromConsole, expectInvitationEmailToBeReceived } from "./lib/testUtils"; +import { test } from "../lib/fixtures"; +import { expectInvitationEmailToBeReceived } from "./expects"; test.describe.configure({ mode: "parallel" }); -test.afterAll(({ users, emails }) => { - users.deleteAll(); +test.afterEach(async ({ users, emails, clipboard }) => { + clipboard.reset(); + await users.deleteAll(); emails?.deleteAll(); }); test.describe("Organization", () => { - test("Invitation (non verified)", async ({ browser, page, users, emails }) => { + test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); const { team: org } = await orgOwner.getOrg(); - await orgOwner.login(); + await orgOwner.apiLogin(); await page.goto("/settings/organizations/members"); await page.waitForLoadState("networkidle"); @@ -68,31 +69,31 @@ 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 getInviteLinkFromConsole(page); + const inviteLink = await clipboard.get(); await page.waitForLoadState("networkidle"); // Follow invite link in new window const context = await browser.newContext(); - const newPage = await context.newPage(); - newPage.goto(inviteLink); - await newPage.waitForLoadState("networkidle"); + const inviteLinkPage = await context.newPage(); + await inviteLinkPage.goto(inviteLink); + await inviteLinkPage.waitForLoadState("networkidle"); // Check required fields - await newPage.locator("button[type=submit]").click(); - await expect(newPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints + await inviteLinkPage.locator("button[type=submit]").click(); + await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints // Happy path - await newPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`); - await newPage.locator("input[name=password]").fill(`P4ssw0rd!`); - await newPage.locator("button[type=submit]").click(); - await newPage.waitForURL("/getting-started"); + await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`); + await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await inviteLinkPage.locator("button[type=submit]").click(); + await inviteLinkPage.waitForURL("/getting-started"); }); }); test("Invitation (verified)", async ({ browser, page, users, emails }) => { const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true }); const { team: org } = await orgOwner.getOrg(); - await orgOwner.login(); + await orgOwner.apiLogin(); await page.goto("/settings/organizations/members"); await page.waitForLoadState("networkidle"); diff --git a/packages/features/ee/organizations/pages/settings/members.tsx b/packages/features/ee/organizations/pages/settings/members.tsx index 36ba6b64c5a571..9f40c42a573bbf 100644 --- a/packages/features/ee/organizations/pages/settings/members.tsx +++ b/packages/features/ee/organizations/pages/settings/members.tsx @@ -11,24 +11,6 @@ const MembersView = () => {
- {/* {team && ( - <> - {isInviteOpen && ( - - )} - - )} */}
diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index ddbfd494f84d71..fc8fcf90fc1538 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -25,6 +25,7 @@ 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"; @@ -92,15 +93,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) const inviteLink = isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink; - try { await navigator.clipboard.writeText(inviteLink); - console.log(inviteLink); + showToast(t("invite_link_copied"), "success"); } catch (e) { - console.log(inviteLink); + if (process.env.NEXT_PUBLIC_IS_E2E) { + (window as WindowWithClipboardValue).E2E_CLIPBOARD_VALUE = inviteLink; + } + console.error(e); } - - showToast(t("invite_link_copied"), "success"); }; const options: MembershipRoleOption[] = useMemo(() => { diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 7b7c22ce770e68..8078ad55caa4d1 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -175,7 +175,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { return { user: { ...createOwnerOrg, password } }; } else { - if (!IS_PRODUCTION && !process.env.NEXT_PUBLIC_IS_E2E) return { checked: true }; + if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) return { checked: true }; const language = await getTranslation(input.language ?? "en", "common"); const secret = createHash("md5") diff --git a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts index 885bb3b6ac9784..4d4354a0d6ce25 100644 --- a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts @@ -21,7 +21,7 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => { if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" }); - if (!IS_PRODUCTION) return true; + if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) return true; await checkRateLimitAndThrowError({ rateLimitingType: "core", identifier: email, diff --git a/yarn.lock b/yarn.lock index 85c71169df953d..88a1c0a64982d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13305,6 +13305,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^21.1.3": + version: 21.1.4 + resolution: "@types/jsdom@npm:21.1.4" + dependencies: + "@types/node": "*" + "@types/tough-cookie": "*" + parse5: ^7.0.0 + checksum: 915f619111dadd8d1bb7f12b6736c9d2e486911e1aed086de5fb003e7e40ae1e368da322dc04f2122ef47faf40ca75b9315ae2df3e8011f882dcf84660fb0d68 + languageName: node + linkType: hard + "@types/jsforce@npm:^1.11.0": version: 1.11.0 resolution: "@types/jsforce@npm:1.11.0" @@ -17171,6 +17182,7 @@ __metadata: "@playwright/test": ^1.31.2 "@snaplet/copycat": ^0.3.0 "@testing-library/jest-dom": ^5.16.5 + "@types/jsdom": ^21.1.3 "@types/jsonwebtoken": ^9.0.3 c8: ^7.13.0 checkly: latest