From 00553e897bca9b8a63b6854a3121f775a94b2564 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:09:50 +0200 Subject: [PATCH 1/5] fix: alby payment could not be created (#12460) * fix: alby payment could not be created * fixup! fix: alby payment could not be created * fixup! fixup! fix: alby payment could not be created --- packages/app-store/alby/lib/PaymentService.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app-store/alby/lib/PaymentService.ts b/packages/app-store/alby/lib/PaymentService.ts index 9974f1aa25..71e9c3e851 100644 --- a/packages/app-store/alby/lib/PaymentService.ts +++ b/packages/app-store/alby/lib/PaymentService.ts @@ -72,7 +72,10 @@ export class PaymentService implements IAbstractPaymentService { amount: payment.amount, externalId: invoice.paymentRequest, currency: payment.currency, - data: Object.assign({}, { invoice }) as unknown as Prisma.InputJsonValue, + data: Object.assign( + {}, + { invoice: { ...invoice, isPaid: await invoice.isPaid() } } + ) as unknown as Prisma.InputJsonValue, fee: 0, refunded: false, success: false, @@ -84,7 +87,7 @@ export class PaymentService implements IAbstractPaymentService { } return paymentData; } catch (error) { - log.error("Alby: Payment could not be created", bookingId); + log.error("Alby: Payment could not be created", bookingId, JSON.stringify(error)); throw new Error(ErrorCode.PaymentCreationFailure); } } From 404bc0e4d6bb4066712ba21ebf7775b927117360 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 21 Nov 2023 12:13:50 +0000 Subject: [PATCH 2/5] New Crowdin translations by Github Action --- apps/web/public/static/locales/fr/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 49dc72ca82..0bf2d447f7 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -1098,6 +1098,7 @@ "developer_documentation": "Documentation pour développeurs", "get_in_touch": "Contactez-nous", "contact_support": "Contacter l'assistance", + "premium_support": "Assistance Premium", "community_support": "Aide communautaire", "feedback": "Commentaires", "submitted_feedback": "Merci pour vos commentaires !", From 85237c49851584f62eb56707bbe5358e1d88d5e2 Mon Sep 17 00:00:00 2001 From: Ujjwal Goyal <35370133+ujjwalgoyal19@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:26:59 +0530 Subject: [PATCH 3/5] fix: Date overrides UI bug depending on screen size (#12423) * Update DateOverrideInputDialog.tsx fix: Date overrides UI bug depending on screen size (calcom#12406) * chore: remove comment --------- Co-authored-by: madhurgoyal19 <35370133+madhurgoyal19@users.noreply.github.com> Co-authored-by: Udit Takkar --- .../schedules/components/DateOverrideInputDialog.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/features/schedules/components/DateOverrideInputDialog.tsx b/packages/features/schedules/components/DateOverrideInputDialog.tsx index 48612ff4ce..a628e81b0c 100644 --- a/packages/features/schedules/components/DateOverrideInputDialog.tsx +++ b/packages/features/schedules/components/DateOverrideInputDialog.tsx @@ -5,7 +5,6 @@ import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { yyyymmdd } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import type { WorkingHours } from "@calcom/types/schedule"; import { Dialog, @@ -210,19 +209,12 @@ const DateOverrideInputDialog = ({ onChange: (newValue: TimeRange[]) => void; value?: TimeRange[]; }) => { - const isMobile = useMediaQuery("(max-width: 768px)"); const [open, setOpen] = useState(false); - { - /* enableOverflow is used to allow overflow when there are too many overrides to show on mobile. - ref:- https://github.com/calcom/cal.com/pull/6215 - */ - } - const enableOverflow = isMobile; return ( {Trigger} - + Date: Tue, 21 Nov 2023 17:14:25 +0200 Subject: [PATCH 4/5] fix: better errors for googlecalendar integration (#12403) --- packages/app-store/googlecalendar/api/add.ts | 48 ++++++++++--------- .../app-store/googlecalendar/api/callback.ts | 17 ++++--- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/packages/app-store/googlecalendar/api/add.ts b/packages/app-store/googlecalendar/api/add.ts index 3a32c968fa..7ed6fcf02d 100644 --- a/packages/app-store/googlecalendar/api/add.ts +++ b/packages/app-store/googlecalendar/api/add.ts @@ -2,6 +2,8 @@ import { google } from "googleapis"; import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; @@ -14,28 +16,30 @@ const scopes = [ let client_id = ""; let client_secret = ""; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "GET") { - // Get token from Google Calendar API - const appKeys = await getAppKeysFromSlug("google-calendar"); - if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; - if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) return res.status(400).json({ message: "Google client_id missing." }); - if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." }); - const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + // Get token from Google Calendar API + const appKeys = await getAppKeysFromSlug("google-calendar"); + if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; + if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; + if (!client_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." }); + if (!client_secret) throw new HttpError({ statusCode: 400, message: "Google client_secret missing." }); + const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); - const authUrl = oAuth2Client.generateAuthUrl({ - access_type: "offline", - scope: scopes, - // A refresh token is only returned the first time the user - // consents to providing access. For illustration purposes, - // setting the prompt to 'consent' will force this consent - // every time, forcing a refresh_token to be returned. - prompt: "consent", - state: encodeOAuthState(req), - }); + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: scopes, + // A refresh token is only returned the first time the user + // consents to providing access. For illustration purposes, + // setting the prompt to 'consent' will force this consent + // every time, forcing a refresh_token to be returned. + prompt: "consent", + state: encodeOAuthState(req), + }); - res.status(200).json({ url: authUrl }); - } + res.status(200).json({ url: authUrl }); } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index 2b3d2d90b0..3577e9b092 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -3,6 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; @@ -12,24 +14,23 @@ import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; let client_id = ""; let client_secret = ""; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function getHandler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; const state = decodeOAuthState(req); if (typeof code !== "string") { - res.status(400).json({ message: "`code` must be a string" }); - return; + throw new HttpError({ statusCode: 400, message: "`code` must be a string" }); } if (!req.session?.user?.id) { - return res.status(401).json({ message: "You must be logged in to do this" }); + throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" }); } const appKeys = await getAppKeysFromSlug("google-calendar"); if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) return res.status(400).json({ message: "Google client_id missing." }); - if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." }); + if (!client_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." }); + if (!client_secret) throw new HttpError({ statusCode: 400, message: "Google client_secret missing." }); const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; @@ -107,3 +108,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) getInstalledAppPath({ variant: "calendar", slug: "google-calendar" }) ); } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); From 48dde246e92fd2ed94c84bf39389078ab9ec639a Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 21 Nov 2023 22:33:01 +0530 Subject: [PATCH 5/5] test: Add more orgs tests (#12241) --- apps/web/pages/signup.tsx | 4 +- apps/web/playwright/fixtures/clipboard.ts | 34 +++++ apps/web/playwright/fixtures/users.ts | 20 ++- apps/web/playwright/lib/fixtures.ts | 9 ++ apps/web/playwright/lib/testUtils.ts | 11 ++ apps/web/playwright/organization/expects.ts | 28 ++++ .../organization/organization-creation.e2e.ts | 143 ++++++++++++++++++ .../organization-invitation.e2e.ts | 119 +++++++++++++++ package.json | 1 + .../organizations/pages/settings/members.tsx | 18 --- .../components/MemberInvitationModal.tsx | 12 +- .../components/UserTable/UserListTable.tsx | 7 +- .../viewer/organizations/create.handler.ts | 8 +- .../organizations/verifyCode.handler.ts | 6 +- yarn.lock | 12 ++ 15 files changed, 397 insertions(+), 35 deletions(-) create mode 100644 apps/web/playwright/fixtures/clipboard.ts create mode 100644 apps/web/playwright/organization/expects.ts create mode 100644 apps/web/playwright/organization/organization-creation.e2e.ts create mode 100644 apps/web/playwright/organization/organization-invitation.e2e.ts diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 0b0cb0e5a8..041dcd943d 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) @@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA methods.clearErrors("apiError"); } - if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) { + if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) { methods.setValue( "username", getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) diff --git a/apps/web/playwright/fixtures/clipboard.ts b/apps/web/playwright/fixtures/clipboard.ts new file mode 100644 index 0000000000..47cc92d95c --- /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 0f07d18507..ae5fbfbec2 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -86,12 +86,14 @@ const createTeamAndAddUser = async ( user, isUnpublished, isOrg, + isOrgVerified, hasSubteam, organizationId, }: { - user: { id: number; username: string | null; role?: MembershipRole }; + user: { id: number; email: string; username: string | null; role?: MembershipRole }; isUnpublished?: boolean; isOrg?: boolean; + isOrgVerified?: boolean; hasSubteam?: true; organizationId?: number | null; }, @@ -103,7 +105,14 @@ const createTeamAndAddUser = async ( }; data.metadata = { ...(isUnpublished ? { requestedSlug: slug } : {}), - ...(isOrg ? { isOrganization: true } : {}), + ...(isOrg + ? { + isOrganization: true, + isOrganizationVerified: !!isOrgVerified, + orgAutoAcceptEmail: user.email.split("@")[1], + isOrganizationConfigured: false, + } + : {}), }; data.slug = !isUnpublished ? slug : undefined; if (isOrg && hasSubteam) { @@ -145,6 +154,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn teamEventSlug?: string; teamEventLength?: number; isOrg?: boolean; + isOrgVerified?: boolean; hasSubteam?: true; isUnpublished?: true; } = {} @@ -292,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn if (scenario.hasTeam) { const team = await createTeamAndAddUser( { - user: { id: user.id, username: user.username, role: "OWNER" }, + user: { id: user.id, email: user.email, username: user.username, role: "OWNER" }, isUnpublished: scenario.isUnpublished, isOrg: scenario.isOrg, + isOrgVerified: scenario.isOrgVerified, hasSubteam: scenario.hasSubteam, organizationId: opts?.organizationId, }, @@ -410,6 +421,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 2e54268db3..cf66ebb2f9 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -4,10 +4,12 @@ import type { API } from "mailhog"; import mailhog from "mailhog"; import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; 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 +30,7 @@ export interface Fixtures { emails?: API; routingForms: ReturnType; bookingPage: ReturnType; + clipboard: ReturnType; } declare global { @@ -85,6 +88,8 @@ export const test = base.extend({ const mailhogAPI = mailhog(); await use(mailhogAPI); } else { + //FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it + logger.warn("Mailhog is not enabled - Skipping Emails verification"); await use(undefined); } }, @@ -92,4 +97,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 b9cf3850d6..7038b656b1 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -1,11 +1,13 @@ import type { Frame, Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { createHash } from "crypto"; import EventEmitter from "events"; import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; // eslint-disable-next-line no-restricted-imports import { noop } from "lodash"; import type { API, Messages } from "mailhog"; +import { totp } from "otplib"; import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees( }); return { user, eventType, booking }; } + +export function generateTotpCode(email: string) { + const secret = createHash("md5") + .update(email + process.env.CALENDSO_ENCRYPTION_KEY) + .digest("hex"); + + totp.options = { step: 90 }; + return totp.generate(secret); +} diff --git a/apps/web/playwright/organization/expects.ts b/apps/web/playwright/organization/expects.ts new file mode 100644 index 0000000000..e5ba1a0e83 --- /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(5000); + 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/organization-creation.e2e.ts b/apps/web/playwright/organization/organization-creation.e2e.ts new file mode 100644 index 0000000000..19b3477026 --- /dev/null +++ b/apps/web/playwright/organization/organization-creation.e2e.ts @@ -0,0 +1,143 @@ +import { expect } from "@playwright/test"; +import path from "path"; + +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("should be able to create an organization and complete onboarding", async ({ + page, + users, + emails, + }) => { + const orgOwner = await users.create(); + const orgDomain = `${orgOwner.username}-org`; + const orgName = capitalize(`${orgOwner.username}-org`); + await orgOwner.apiLogin(); + await page.goto("/settings/organizations/new"); + await page.waitForLoadState("networkidle"); + + await test.step("Basic info", async () => { + // Check required fields + await page.locator("button[type=submit]").click(); + await expect(page.locator(".text-red-700")).toHaveCount(3); + + // Happy path + await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`); + expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName); + expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain); + await page.locator("button[type=submit]").click(); + await page.waitForLoadState("networkidle"); + + // Check admin email about code verification + await expectInvitationEmailToBeReceived( + page, + emails, + `john@${orgOwner.username}-org.com`, + "Verify your email to create an organization" + ); + + await test.step("Verification", async () => { + // Code verification + await expect(page.locator("#modal-title")).toBeVisible(); + await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`)); + await page.locator("button:text('Verify')").click(); + + // Check admin email about DNS pending action + await expectInvitationEmailToBeReceived( + page, + emails, + "admin@example.com", + "New organization created: pending action" + ); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/set-password"); + }); + }); + + await test.step("Admin password", async () => { + // Check required fields + await page.locator("button[type=submit]").click(); + await expect(page.locator(".text-red-700")).toHaveCount(3); // 3 password hints + + // Happy path + await page.locator("input[name='password']").fill("ADMIN_user2023$"); + await page.locator("button[type=submit]").click(); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/about"); + }); + + await test.step("About the organization", async () => { + // Choosing an avatar + await page.locator('button:text("Upload")').click(); + 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 page.locator('button:text("Save")').click(); + + // About text + await page.locator('textarea[name="about"]').fill("This is a testing org"); + await page.locator("button[type=submit]").click(); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/onboard-admins"); + }); + + await test.step("On-board administrators", async () => { + // Required field + await page.locator("button[type=submit]").click(); + + // Happy path + await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`); + await page.locator("button[type=submit]").click(); + + // Check if invited admin received the invitation email + await expectInvitationEmailToBeReceived( + page, + emails, + `rick@${orgDomain}.com`, + `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com` + ); + + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/add-teams"); + }); + + await test.step("Create teams", async () => { + // Initial state + await expect(page.locator('input[name="teams.0.name"]')).toHaveCount(1); + await expect(page.locator('button:text("Continue")')).toBeDisabled(); + + // Filling one team + await page.locator('input[name="teams.0.name"]').fill("Marketing"); + await expect(page.locator('button:text("Continue")')).toBeEnabled(); + + // Adding another team + await page.locator('button:text("Add a team")').click(); + await expect(page.locator('button:text("Continue")')).toBeDisabled(); + await expect(page.locator('input[name="teams.1.name"]')).toHaveCount(1); + await page.locator('input[name="teams.1.name"]').fill("Sales"); + await expect(page.locator('button:text("Continue")')).toBeEnabled(); + + // Finishing the creation wizard + await page.locator('button:text("Continue")').click(); + await page.waitForURL("/event-types"); + }); + }); +}); diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts new file mode 100644 index 0000000000..6561a01e55 --- /dev/null +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -0,0 +1,119 @@ +import { expect } from "@playwright/test"; + +import { test } from "../lib/fixtures"; +import { expectInvitationEmailToBeReceived } from "./expects"; + +test.describe.configure({ mode: "parallel" }); + +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, clipboard }) => { + const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); + const { team: org } = await orgOwner.getOrg(); + await orgOwner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (external user)", async () => { + const invitedUserEmail = `rick@domain-${Date.now()}.com`; + await page.locator('button:text("Add")').click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator('button:text("Send invite")').click(); + await page.waitForLoadState("networkidle"); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`, + "signup?token" + ); + + // Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!inviteLink) return null; + + // Follow invite link in new window + const context = await browser.newContext(); + const newPage = await context.newPage(); + newPage.goto(inviteLink); + await newPage.waitForLoadState("networkidle"); + + // Check required fields + await newPage.locator("button[type=submit]").click(); + await expect(newPage.locator(".text-red-700")).toHaveCount(3); // 3 password hints + await newPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await newPage.locator("button[type=submit]").click(); + await newPage.waitForURL("/getting-started?from=signup"); + await context.close(); + await newPage.close(); + + // Check newly invited member is not pending anymore + await page.bringToFront(); + await page.goto("/settings/organizations/members"); + page.locator(`[data-testid="login-form"]`); + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + + await test.step("To the organization by invite link", async () => { + // 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"); + + // Follow invite link in new window + const context = await browser.newContext(); + const inviteLinkPage = await context.newPage(); + await inviteLinkPage.goto(inviteLink); + await inviteLinkPage.waitForLoadState("networkidle"); + + // Check required fields + await inviteLinkPage.locator("button[type=submit]").click(); + await expect(inviteLinkPage.locator(".text-red-700")).toHaveCount(4); // email + 3 password hints + + // Happy path + 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.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (internal user)", async () => { + const invitedUserEmail = `rick@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(); + await page.waitForLoadState("networkidle"); + await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com` + ); + + // Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + }); +}); diff --git a/package.json b/package.json index 4f74854f79..586870f28e 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@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", diff --git a/packages/features/ee/organizations/pages/settings/members.tsx b/packages/features/ee/organizations/pages/settings/members.tsx index 36ba6b64c5..9f40c42a57 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 4aeffb573c..fc8fcf90fc 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,8 +93,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) const inviteLink = isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink; - await navigator.clipboard.writeText(inviteLink); - showToast(t("invite_link_copied"), "success"); + 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); + } }; const options: MembershipRoleOption[] = useMemo(() => { diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 21abc1bc9a..a2b9ecb3d6 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -204,12 +204,15 @@ export function UserListTable() { id: "teams", header: "Teams", cell: ({ row }) => { - const { teams, accepted } = row.original; + const { teams, accepted, email } = row.original; // TODO: Implement click to filter return (
{accepted ? null : ( - + Pending )} diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 1e0f9a2e03..1b5509afb5 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -7,12 +7,7 @@ import { sendAdminOrganizationNotification } from "@calcom/emails"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; -import { - IS_TEAM_BILLING_ENABLED, - RESERVED_SUBDOMAINS, - IS_PRODUCTION, - WEBAPP_URL, -} from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; @@ -175,7 +170,6 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { return { user: { ...createOwnerOrg, password } }; } else { - if (!IS_PRODUCTION) 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 885bb3b6ac..17bfa84be5 100644 --- a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts @@ -2,6 +2,7 @@ import { createHash } from "crypto"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import { totpRawCheck } from "@calcom/lib/totp"; import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -21,7 +22,10 @@ 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) { + logger.warn(`Skipping code verification in dev/E2E environment`); + return true; + } await checkRateLimitAndThrowError({ rateLimitingType: "core", identifier: email, diff --git a/yarn.lock b/yarn.lock index 4e231a4e0d..01cf0964ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13331,6 +13331,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" @@ -17197,6 +17208,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