Merge branch 'main' into teste2e-phoneQuestionColletive
This commit is contained in:
commit
c49afdc45b
|
@ -37,7 +37,7 @@ type SignupProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
|
||||||
|
|
||||||
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
|
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
|
||||||
const [emailUser, emailDomain] = email.split("@");
|
const [emailUser, emailDomain = ""] = email.split("@");
|
||||||
const username =
|
const username =
|
||||||
emailDomain === autoAcceptEmailDomain
|
emailDomain === autoAcceptEmailDomain
|
||||||
? slugify(emailUser)
|
? slugify(emailUser)
|
||||||
|
@ -143,7 +143,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
|
||||||
methods.clearErrors("apiError");
|
methods.clearErrors("apiError");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) {
|
if (!methods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||||
methods.setValue(
|
methods.setValue(
|
||||||
"username",
|
"username",
|
||||||
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
|
getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail)
|
||||||
|
|
|
@ -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<string>((resolve, reject) => {
|
||||||
|
setInterval(() => {
|
||||||
|
if (!window.E2E_CLIPBOARD_VALUE) return;
|
||||||
|
resolve(window.E2E_CLIPBOARD_VALUE);
|
||||||
|
}, 500);
|
||||||
|
setTimeout(() => reject(new Error("Timeout")), 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -86,12 +86,14 @@ const createTeamAndAddUser = async (
|
||||||
user,
|
user,
|
||||||
isUnpublished,
|
isUnpublished,
|
||||||
isOrg,
|
isOrg,
|
||||||
|
isOrgVerified,
|
||||||
hasSubteam,
|
hasSubteam,
|
||||||
organizationId,
|
organizationId,
|
||||||
}: {
|
}: {
|
||||||
user: { id: number; username: string | null; role?: MembershipRole };
|
user: { id: number; email: string; username: string | null; role?: MembershipRole };
|
||||||
isUnpublished?: boolean;
|
isUnpublished?: boolean;
|
||||||
isOrg?: boolean;
|
isOrg?: boolean;
|
||||||
|
isOrgVerified?: boolean;
|
||||||
hasSubteam?: true;
|
hasSubteam?: true;
|
||||||
organizationId?: number | null;
|
organizationId?: number | null;
|
||||||
},
|
},
|
||||||
|
@ -103,7 +105,14 @@ const createTeamAndAddUser = async (
|
||||||
};
|
};
|
||||||
data.metadata = {
|
data.metadata = {
|
||||||
...(isUnpublished ? { requestedSlug: slug } : {}),
|
...(isUnpublished ? { requestedSlug: slug } : {}),
|
||||||
...(isOrg ? { isOrganization: true } : {}),
|
...(isOrg
|
||||||
|
? {
|
||||||
|
isOrganization: true,
|
||||||
|
isOrganizationVerified: !!isOrgVerified,
|
||||||
|
orgAutoAcceptEmail: user.email.split("@")[1],
|
||||||
|
isOrganizationConfigured: false,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
data.slug = !isUnpublished ? slug : undefined;
|
data.slug = !isUnpublished ? slug : undefined;
|
||||||
if (isOrg && hasSubteam) {
|
if (isOrg && hasSubteam) {
|
||||||
|
@ -145,6 +154,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||||
teamEventSlug?: string;
|
teamEventSlug?: string;
|
||||||
teamEventLength?: number;
|
teamEventLength?: number;
|
||||||
isOrg?: boolean;
|
isOrg?: boolean;
|
||||||
|
isOrgVerified?: boolean;
|
||||||
hasSubteam?: true;
|
hasSubteam?: true;
|
||||||
isUnpublished?: true;
|
isUnpublished?: true;
|
||||||
} = {}
|
} = {}
|
||||||
|
@ -292,9 +302,10 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||||
if (scenario.hasTeam) {
|
if (scenario.hasTeam) {
|
||||||
const team = await createTeamAndAddUser(
|
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,
|
isUnpublished: scenario.isUnpublished,
|
||||||
isOrg: scenario.isOrg,
|
isOrg: scenario.isOrg,
|
||||||
|
isOrgVerified: scenario.isOrgVerified,
|
||||||
hasSubteam: scenario.hasSubteam,
|
hasSubteam: scenario.hasSubteam,
|
||||||
organizationId: opts?.organizationId,
|
organizationId: opts?.organizationId,
|
||||||
},
|
},
|
||||||
|
@ -410,6 +421,9 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||||
routingForms: user.routingForms,
|
routingForms: user.routingForms,
|
||||||
self,
|
self,
|
||||||
apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page),
|
apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page),
|
||||||
|
/**
|
||||||
|
* @deprecated use apiLogin instead
|
||||||
|
*/
|
||||||
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await page.goto("/auth/logout");
|
await page.goto("/auth/logout");
|
||||||
|
|
|
@ -4,10 +4,12 @@ import type { API } from "mailhog";
|
||||||
import mailhog from "mailhog";
|
import mailhog from "mailhog";
|
||||||
|
|
||||||
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
|
import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||||
import { createBookingsFixture } from "../fixtures/bookings";
|
import { createBookingsFixture } from "../fixtures/bookings";
|
||||||
|
import { createClipboardFixture } from "../fixtures/clipboard";
|
||||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||||
import { createOrgsFixture } from "../fixtures/orgs";
|
import { createOrgsFixture } from "../fixtures/orgs";
|
||||||
import { createPaymentsFixture } from "../fixtures/payments";
|
import { createPaymentsFixture } from "../fixtures/payments";
|
||||||
|
@ -28,6 +30,7 @@ export interface Fixtures {
|
||||||
emails?: API;
|
emails?: API;
|
||||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||||
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
||||||
|
clipboard: ReturnType<typeof createClipboardFixture>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -85,6 +88,8 @@ export const test = base.extend<Fixtures>({
|
||||||
const mailhogAPI = mailhog();
|
const mailhogAPI = mailhog();
|
||||||
await use(mailhogAPI);
|
await use(mailhogAPI);
|
||||||
} else {
|
} 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);
|
await use(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -92,4 +97,8 @@ export const test = base.extend<Fixtures>({
|
||||||
const bookingPage = createBookingPageFixture(page);
|
const bookingPage = createBookingPageFixture(page);
|
||||||
await use(bookingPage);
|
await use(bookingPage);
|
||||||
},
|
},
|
||||||
|
clipboard: async ({ page }, use) => {
|
||||||
|
const clipboard = createClipboardFixture(page);
|
||||||
|
await use(clipboard);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import type { Frame, Page } from "@playwright/test";
|
import type { Frame, Page } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { noop } from "lodash";
|
import { noop } from "lodash";
|
||||||
import type { API, Messages } from "mailhog";
|
import type { API, Messages } from "mailhog";
|
||||||
|
import { totp } from "otplib";
|
||||||
|
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
@ -278,3 +280,12 @@ export async function createUserWithSeatedEventAndAttendees(
|
||||||
});
|
});
|
||||||
return { user, eventType, booking };
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1098,6 +1098,7 @@
|
||||||
"developer_documentation": "Documentation pour développeurs",
|
"developer_documentation": "Documentation pour développeurs",
|
||||||
"get_in_touch": "Contactez-nous",
|
"get_in_touch": "Contactez-nous",
|
||||||
"contact_support": "Contacter l'assistance",
|
"contact_support": "Contacter l'assistance",
|
||||||
|
"premium_support": "Assistance Premium",
|
||||||
"community_support": "Aide communautaire",
|
"community_support": "Aide communautaire",
|
||||||
"feedback": "Commentaires",
|
"feedback": "Commentaires",
|
||||||
"submitted_feedback": "Merci pour vos commentaires !",
|
"submitted_feedback": "Merci pour vos commentaires !",
|
||||||
|
|
|
@ -82,6 +82,7 @@
|
||||||
"@playwright/test": "^1.31.2",
|
"@playwright/test": "^1.31.2",
|
||||||
"@snaplet/copycat": "^0.3.0",
|
"@snaplet/copycat": "^0.3.0",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@types/jsdom": "^21.1.3",
|
||||||
"@types/jsonwebtoken": "^9.0.3",
|
"@types/jsonwebtoken": "^9.0.3",
|
||||||
"c8": "^7.13.0",
|
"c8": "^7.13.0",
|
||||||
"checkly": "latest",
|
"checkly": "latest",
|
||||||
|
|
|
@ -72,7 +72,10 @@ export class PaymentService implements IAbstractPaymentService {
|
||||||
amount: payment.amount,
|
amount: payment.amount,
|
||||||
externalId: invoice.paymentRequest,
|
externalId: invoice.paymentRequest,
|
||||||
currency: payment.currency,
|
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,
|
fee: 0,
|
||||||
refunded: false,
|
refunded: false,
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -84,7 +87,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||||
}
|
}
|
||||||
return paymentData;
|
return paymentData;
|
||||||
} catch (error) {
|
} 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);
|
throw new Error(ErrorCode.PaymentCreationFailure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { google } from "googleapis";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
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 getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||||
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||||
|
@ -14,14 +16,13 @@ const scopes = [
|
||||||
let client_id = "";
|
let client_id = "";
|
||||||
let client_secret = "";
|
let client_secret = "";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") {
|
|
||||||
// Get token from Google Calendar API
|
// Get token from Google Calendar API
|
||||||
const appKeys = await getAppKeysFromSlug("google-calendar");
|
const appKeys = await getAppKeysFromSlug("google-calendar");
|
||||||
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
|
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
|
||||||
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
|
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_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." });
|
||||||
if (!client_secret) return res.status(400).json({ message: "Google client_secret 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 redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`;
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
|
|
||||||
|
@ -37,5 +38,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({ url: authUrl });
|
res.status(200).json({ url: authUrl });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default defaultHandler({
|
||||||
|
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
|
||||||
|
});
|
||||||
|
|
|
@ -3,6 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL_FOR_OAUTH, CAL_URL } from "@calcom/lib/constants";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
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 prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||||
|
@ -12,24 +14,23 @@ import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||||
let client_id = "";
|
let client_id = "";
|
||||||
let client_secret = "";
|
let client_secret = "";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { code } = req.query;
|
const { code } = req.query;
|
||||||
const state = decodeOAuthState(req);
|
const state = decodeOAuthState(req);
|
||||||
|
|
||||||
if (typeof code !== "string") {
|
if (typeof code !== "string") {
|
||||||
res.status(400).json({ message: "`code` must be a string" });
|
throw new HttpError({ statusCode: 400, message: "`code` must be a string" });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.session?.user?.id) {
|
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");
|
const appKeys = await getAppKeysFromSlug("google-calendar");
|
||||||
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
|
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
|
||||||
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
|
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_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." });
|
||||||
if (!client_secret) return res.status(400).json({ message: "Google client_secret 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 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" })
|
getInstalledAppPath({ variant: "calendar", slug: "google-calendar" })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default defaultHandler({
|
||||||
|
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
|
||||||
|
});
|
||||||
|
|
|
@ -11,24 +11,6 @@ const MembersView = () => {
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
<Meta title={t("organization_members")} description={t("organization_description")} />
|
<Meta title={t("organization_members")} description={t("organization_description")} />
|
||||||
<div>
|
<div>
|
||||||
{/* {team && (
|
|
||||||
<>
|
|
||||||
{isInviteOpen && (
|
|
||||||
<TeamInviteList
|
|
||||||
teams={[
|
|
||||||
{
|
|
||||||
id: team.id,
|
|
||||||
accepted: team.membership.accepted || false,
|
|
||||||
logo: team.logo,
|
|
||||||
name: team.name,
|
|
||||||
slug: team.slug,
|
|
||||||
role: team.membership.role,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)} */}
|
|
||||||
<UserListTable />
|
<UserListTable />
|
||||||
</div>
|
</div>
|
||||||
</LicenseRequired>
|
</LicenseRequired>
|
||||||
|
|
|
@ -25,6 +25,7 @@ import {
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { Link } from "@calcom/ui/components/icon";
|
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 type { PendingMember } from "../lib/types";
|
||||||
import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
|
import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
|
||||||
|
@ -92,8 +93,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
|
|
||||||
const inviteLink =
|
const inviteLink =
|
||||||
isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink;
|
isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink;
|
||||||
|
try {
|
||||||
await navigator.clipboard.writeText(inviteLink);
|
await navigator.clipboard.writeText(inviteLink);
|
||||||
showToast(t("invite_link_copied"), "success");
|
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(() => {
|
const options: MembershipRoleOption[] = useMemo(() => {
|
||||||
|
|
|
@ -5,7 +5,6 @@ import type { Dayjs } from "@calcom/dayjs";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
|
||||||
import type { WorkingHours } from "@calcom/types/schedule";
|
import type { WorkingHours } from "@calcom/types/schedule";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
@ -210,19 +209,12 @@ const DateOverrideInputDialog = ({
|
||||||
onChange: (newValue: TimeRange[]) => void;
|
onChange: (newValue: TimeRange[]) => void;
|
||||||
value?: TimeRange[];
|
value?: TimeRange[];
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
|
||||||
const [open, setOpen] = useState(false);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent enableOverflow={enableOverflow} size="md" className="p-0">
|
<DialogContent enableOverflow={true} size="md" className="p-0">
|
||||||
<DateOverrideForm
|
<DateOverrideForm
|
||||||
excludedDates={excludedDates}
|
excludedDates={excludedDates}
|
||||||
{...passThroughProps}
|
{...passThroughProps}
|
||||||
|
|
|
@ -204,12 +204,15 @@ export function UserListTable() {
|
||||||
id: "teams",
|
id: "teams",
|
||||||
header: "Teams",
|
header: "Teams",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { teams, accepted } = row.original;
|
const { teams, accepted, email } = row.original;
|
||||||
// TODO: Implement click to filter
|
// TODO: Implement click to filter
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-wrap items-center gap-2">
|
<div className="flex h-full flex-wrap items-center gap-2">
|
||||||
{accepted ? null : (
|
{accepted ? null : (
|
||||||
<Badge variant="red" className="text-xs">
|
<Badge
|
||||||
|
variant="red"
|
||||||
|
className="text-xs"
|
||||||
|
data-testid={`email-${email.replace("@", "")}-pending`}>
|
||||||
Pending
|
Pending
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -7,12 +7,7 @@ import { sendAdminOrganizationNotification } from "@calcom/emails";
|
||||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||||
import {
|
import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
IS_TEAM_BILLING_ENABLED,
|
|
||||||
RESERVED_SUBDOMAINS,
|
|
||||||
IS_PRODUCTION,
|
|
||||||
WEBAPP_URL,
|
|
||||||
} from "@calcom/lib/constants";
|
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
@ -175,7 +170,6 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
||||||
|
|
||||||
return { user: { ...createOwnerOrg, password } };
|
return { user: { ...createOwnerOrg, password } };
|
||||||
} else {
|
} else {
|
||||||
if (!IS_PRODUCTION) return { checked: true };
|
|
||||||
const language = await getTranslation(input.language ?? "en", "common");
|
const language = await getTranslation(input.language ?? "en", "common");
|
||||||
|
|
||||||
const secret = createHash("md5")
|
const secret = createHash("md5")
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { createHash } from "crypto";
|
||||||
|
|
||||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
import { totpRawCheck } from "@calcom/lib/totp";
|
import { totpRawCheck } from "@calcom/lib/totp";
|
||||||
import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
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 (!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({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: email,
|
identifier: email,
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -13331,6 +13331,17 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/jsforce@npm:^1.11.0":
|
||||||
version: 1.11.0
|
version: 1.11.0
|
||||||
resolution: "@types/jsforce@npm:1.11.0"
|
resolution: "@types/jsforce@npm:1.11.0"
|
||||||
|
@ -17197,6 +17208,7 @@ __metadata:
|
||||||
"@playwright/test": ^1.31.2
|
"@playwright/test": ^1.31.2
|
||||||
"@snaplet/copycat": ^0.3.0
|
"@snaplet/copycat": ^0.3.0
|
||||||
"@testing-library/jest-dom": ^5.16.5
|
"@testing-library/jest-dom": ^5.16.5
|
||||||
|
"@types/jsdom": ^21.1.3
|
||||||
"@types/jsonwebtoken": ^9.0.3
|
"@types/jsonwebtoken": ^9.0.3
|
||||||
c8: ^7.13.0
|
c8: ^7.13.0
|
||||||
checkly: latest
|
checkly: latest
|
||||||
|
|
Loading…
Reference in New Issue
Block a user