diff --git a/apps/api/v1/pages/api/teams/_post.ts b/apps/api/v1/pages/api/teams/_post.ts index e2f9a1d43f..8fcc00fe22 100644 --- a/apps/api/v1/pages/api/teams/_post.ts +++ b/apps/api/v1/pages/api/teams/_post.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer"; import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; @@ -197,8 +198,9 @@ const generateTeamCheckoutSession = async ({ customer_update: { address: "auto", }, + // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode automatic_tax: { - enabled: true, + enabled: IS_PRODUCTION, }, metadata: { pendingPaymentTeamId, diff --git a/apps/web/app/future/settings/organizations/[id]/set-password/page.tsx b/apps/web/app/future/settings/organizations/[id]/set-password/page.tsx deleted file mode 100644 index 3ffb2a3a50..0000000000 --- a/apps/web/app/future/settings/organizations/[id]/set-password/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import LegacyPage, { WrappedSetPasswordPage } from "@pages/settings/organizations/[id]/set-password"; -import { _generateMetadata } from "app/_utils"; -import { WithLayout } from "app/layoutHOC"; - -export const generateMetadata = async () => - await _generateMetadata( - (t) => t("set_a_password"), - (t) => t("set_a_password_description") - ); - -export default WithLayout({ Page: LegacyPage, getLayout: WrappedSetPasswordPage }); diff --git a/apps/web/pages/settings/organizations/[id]/add-teams.tsx b/apps/web/pages/settings/organizations/[id]/add-teams.tsx index 450614e07a..9f3e21221c 100644 --- a/apps/web/pages/settings/organizations/[id]/add-teams.tsx +++ b/apps/web/pages/settings/organizations/[id]/add-teams.tsx @@ -1,7 +1,5 @@ "use client"; -import { redirect, useRouter } from "next/navigation"; - import { AddNewTeamsForm } from "@calcom/features/ee/organizations/components"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Meta, WizardLayout } from "@calcom/ui"; @@ -22,16 +20,8 @@ const AddNewTeamsPage = () => { }; AddNewTeamsPage.getLayout = (page: React.ReactElement) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const router = useRouter(); - return ( - { - router.push(`/event-types`); - }}> + {page} ); @@ -41,12 +31,7 @@ AddNewTeamsPage.PageWrapper = PageWrapper; export const WrapperAddNewTeamsPage = (page: React.ReactElement) => { return ( - { - redirect(`/event-types`); - }}> + {page} ); diff --git a/apps/web/pages/settings/organizations/[id]/set-password.tsx b/apps/web/pages/settings/organizations/[id]/set-password.tsx deleted file mode 100644 index 42f068c048..0000000000 --- a/apps/web/pages/settings/organizations/[id]/set-password.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { SetPasswordForm } from "@calcom/features/ee/organizations/components"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Meta, WizardLayout, WizardLayoutAppDir } from "@calcom/ui"; - -import PageWrapper from "@components/PageWrapper"; - -export { getServerSideProps } from "@calcom/features/ee/organizations/pages/organization"; - -const SetPasswordPage = () => { - const { t } = useLocale(); - return ( - <> - - - - ); -}; -const LayoutWrapper = (page: React.ReactElement) => { - return ( - - {page} - - ); -}; - -export const WrappedSetPasswordPage = (page: React.ReactElement) => { - return ( - - {page} - - ); -}; - -SetPasswordPage.getLayout = LayoutWrapper; -SetPasswordPage.PageWrapper = PageWrapper; - -export default SetPasswordPage; diff --git a/apps/web/pages/settings/organizations/new/index.tsx b/apps/web/pages/settings/organizations/new/index.tsx index 54f420d241..28dd02043b 100644 --- a/apps/web/pages/settings/organizations/new/index.tsx +++ b/apps/web/pages/settings/organizations/new/index.tsx @@ -15,7 +15,7 @@ const CreateNewOrganizationPage = ({ querySlug }: inferSSRProps - + ); }; diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index faa18a20ed..e9b368c58f 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -107,17 +107,22 @@ const createTeamAndAddUser = async ( isOrgVerified, hasSubteam, organizationId, + isDnsSetup, + index, }: { user: { id: number; email: string; username: string | null; role?: MembershipRole }; isUnpublished?: boolean; isOrg?: boolean; isOrgVerified?: boolean; + isDnsSetup?: boolean; hasSubteam?: true; organizationId?: number | null; + index?: number; }, workerInfo: WorkerInfo ) => { - const slug = `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}`; + const slugIndex = index ? `-count-${index}` : ""; + const slug = `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`; const data: PrismaType.TeamCreateInput = { name: `user-id-${user.id}'s ${isOrg ? "Org" : "Team"}`, isOrganization: isOrg, @@ -128,9 +133,9 @@ const createTeamAndAddUser = async ( if (isOrg) { data.organizationSettings = { create: { - isOrganizationVerified: !!isOrgVerified, orgAutoAcceptEmail: user.email.split("@")[1], - isOrganizationConfigured: false, + isOrganizationVerified: !!isOrgVerified, + isOrganizationConfigured: isDnsSetup, }, }; } @@ -211,6 +216,7 @@ export const createUsersFixture = ( scenario: { seedRoutingForms?: boolean; hasTeam?: true; + numberOfTeams?: number; teamRole?: MembershipRole; teammates?: CustomUserOpts[]; schedulingType?: SchedulingType; @@ -219,6 +225,7 @@ export const createUsersFixture = ( teamEventLength?: number; isOrg?: boolean; isOrgVerified?: boolean; + isDnsSetup?: boolean; hasSubteam?: true; isUnpublished?: true; } = {} @@ -382,103 +389,108 @@ export const createUsersFixture = ( include: userIncludes, }); if (scenario.hasTeam) { - const team = await createTeamAndAddUser( - { - user: { - id: user.id, - email: user.email, - username: user.username, - role: scenario.teamRole || "OWNER", + const numberOfTeams = scenario.numberOfTeams || 1; + for (let i = 0; i < numberOfTeams; i++) { + const team = await createTeamAndAddUser( + { + user: { + id: user.id, + email: user.email, + username: user.username, + role: scenario.teamRole || "OWNER", + }, + isUnpublished: scenario.isUnpublished, + isOrg: scenario.isOrg, + isOrgVerified: scenario.isOrgVerified, + isDnsSetup: scenario.isDnsSetup, + hasSubteam: scenario.hasSubteam, + organizationId: opts?.organizationId, }, - isUnpublished: scenario.isUnpublished, - isOrg: scenario.isOrg, - isOrgVerified: scenario.isOrgVerified, - hasSubteam: scenario.hasSubteam, - organizationId: opts?.organizationId, - }, - workerInfo - ); - store.teams.push(team); - const teamEvent = await createTeamEventType(user, team, scenario); - if (scenario.teammates) { - // Create Teammate users - const teamMates = []; - for (const teammateObj of scenario.teammates) { - const teamUser = await prisma.user.create({ - data: createUser(workerInfo, teammateObj), - }); + workerInfo + ); + store.teams.push(team); + const teamEvent = await createTeamEventType(user, team, scenario); + if (scenario.teammates) { + // Create Teammate users + const teamMates = []; + for (const teammateObj of scenario.teammates) { + const teamUser = await prisma.user.create({ + data: createUser(workerInfo, teammateObj), + }); - // Add teammates to the team - await prisma.membership.create({ - data: { - teamId: team.id, - userId: teamUser.id, - role: MembershipRole.MEMBER, - accepted: true, - }, - }); - - // Add teammate to the host list of team event - await prisma.host.create({ - data: { - userId: teamUser.id, - eventTypeId: teamEvent.id, - isFixed: scenario.schedulingType === SchedulingType.COLLECTIVE ? true : false, - }, - }); - - const teammateFixture = createUserFixture( - await prisma.user.findUniqueOrThrow({ - where: { id: teamUser.id }, - include: userIncludes, - }), - store.page - ); - teamMates.push(teamUser); - store.users.push(teammateFixture); - } - // Add Teammates to OrgUsers - if (scenario.isOrg) { - const orgProfilesCreate = teamMates - .map((teamUser) => ({ - user: { - connect: { - id: teamUser.id, - }, + // Add teammates to the team + await prisma.membership.create({ + data: { + teamId: team.id, + userId: teamUser.id, + role: MembershipRole.MEMBER, + accepted: true, }, - uid: v4(), - username: teamUser.username || teamUser.email.split("@")[0], - })) - .concat([ - { - user: { connect: { id: user.id } }, - uid: v4(), - username: user.username || user.email.split("@")[0], + }); + + // Add teammate to the host list of team event + await prisma.host.create({ + data: { + userId: teamUser.id, + eventTypeId: teamEvent.id, + isFixed: scenario.schedulingType === SchedulingType.COLLECTIVE ? true : false, }, - ]); + }); - const existingProfiles = await prisma.profile.findMany({ - where: { - userId: _user.id, - }, - }); - - await prisma.team.update({ - where: { - id: team.id, - }, - data: { - orgProfiles: _user.profiles.length - ? { - connect: _user.profiles.map((profile) => ({ id: profile.id })), - } - : { - create: orgProfilesCreate.filter( - (profile) => !existingProfiles.map((p) => p.userId).includes(profile.user.connect.id) - ), + const teammateFixture = createUserFixture( + await prisma.user.findUniqueOrThrow({ + where: { id: teamUser.id }, + include: userIncludes, + }), + store.page + ); + teamMates.push(teamUser); + store.users.push(teammateFixture); + } + // Add Teammates to OrgUsers + if (scenario.isOrg) { + const orgProfilesCreate = teamMates + .map((teamUser) => ({ + user: { + connect: { + id: teamUser.id, }, - }, - }); + }, + uid: v4(), + username: teamUser.username || teamUser.email.split("@")[0], + })) + .concat([ + { + user: { connect: { id: user.id } }, + uid: v4(), + username: user.username || user.email.split("@")[0], + }, + ]); + + const existingProfiles = await prisma.profile.findMany({ + where: { + userId: _user.id, + }, + }); + + await prisma.team.update({ + where: { + id: team.id, + }, + data: { + orgProfiles: _user.profiles.length + ? { + connect: _user.profiles.map((profile) => ({ id: profile.id })), + } + : { + create: orgProfilesCreate.filter( + (profile) => + !existingProfiles.map((p) => p.userId).includes(profile.user.connect.id) + ), + }, + }, + }); + } } } } diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 4c2440aa48..01720d5947 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -347,6 +347,8 @@ export async function fillStripeTestCheckout(page: Page) { await page.fill("[name=cardExpiry]", "12/30"); await page.fill("[name=cardCvc]", "111"); await page.fill("[name=billingName]", "Stripe Stripeson"); + await page.selectOption("[name=billingCountry]", "US"); + await page.fill("[name=billingPostalCode]", "12345"); await page.click(".SubmitButton--complete-Shimmer"); } diff --git a/apps/web/playwright/organization/organization-creation.e2e.ts b/apps/web/playwright/organization/organization-creation.e2e.ts index 4f03e60b15..7427a67090 100644 --- a/apps/web/playwright/organization/organization-creation.e2e.ts +++ b/apps/web/playwright/organization/organization-creation.e2e.ts @@ -2,9 +2,10 @@ import { expect } from "@playwright/test"; import path from "path"; import { uuid } from "short-uuid"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; + import { test } from "../lib/fixtures"; -import { generateTotpCode } from "../lib/testUtils"; -import { expectInvitationEmailToBeReceived } from "./expects"; +import { fillStripeTestCheckout } from "../lib/testUtils"; test.afterAll(({ users, orgs }) => { users.deleteAll(); @@ -19,22 +20,26 @@ function capitalize(text: string) { } test.describe("Organization", () => { - test("Admin should be able to create an organization and complete onboarding", async ({ - page, - users, - emails, - }) => { - const orgOwner = await users.create({ + test("Admin should be able to create an org for a target user", async ({ page, users, emails }) => { + const appLevelAdmin = await users.create({ role: "ADMIN", }); - const instanceAdmin = await users.create({ - username: `admin-${uuid()}`, - email: users.trackEmail({ username: "admin", domain: "example.com" }), + await appLevelAdmin.apiLogin(); + const stringUUID = uuid(); + + const orgOwnerUsername = `owner-${stringUUID}`; + + const targetOrgEmail = users.trackEmail({ + username: orgOwnerUsername, + domain: `example.com`, + }); + const orgOwnerUser = await users.create({ + username: orgOwnerUsername, + email: targetOrgEmail, role: "ADMIN", }); - const orgDomain = `${orgOwner.username}-org`; - const orgName = capitalize(`${orgOwner.username}-org`); - await orgOwner.apiLogin(); + + const orgName = capitalize(`${orgOwnerUsername}`); await page.goto("/settings/organizations/new"); await page.waitForLoadState("networkidle"); @@ -44,49 +49,17 @@ test.describe("Organization", () => { await expect(page.locator(".text-red-700")).toHaveCount(3); // Happy path - const adminEmail = users.trackEmail({ username: "john", domain: `${orgDomain}.com` }); - await page.locator("input[name=adminEmail]").fill(adminEmail); - expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName); - expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain); + await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail); + // Since we are admin fill in this infomation instead of deriving it + await page.locator("input[name=name]").fill(orgName); + await page.locator("input[name=slug]").fill(orgOwnerUsername); + + // Fill in seat infomation + await page.locator("input[name=seats]").fill("30"); + await page.locator("input[name=pricePerSeat]").fill("30"); + await page.locator("button[type=submit]").click(); await page.waitForLoadState("networkidle"); - - // Check admin email about code verification - await expectInvitationEmailToBeReceived( - page, - emails, - adminEmail, - "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`)); - // Check admin email about DNS pending action - await expectInvitationEmailToBeReceived( - page, - emails, - instanceAdmin.email, - "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 () => { @@ -110,20 +83,20 @@ test.describe("Organization", () => { await page.waitForSelector('[data-testid="pending-member-list"]'); expect(await page.getByTestId("pending-member-item").count()).toBe(1); - const adminEmail = users.trackEmail({ username: "rick", domain: `${orgDomain}.com` }); + const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` }); //can add members await page.getByTestId("new-member-button").click(); await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail); await page.getByTestId("invite-new-member-button").click(); await expect(page.locator(`li:has-text("${adminEmail}")`)).toBeVisible(); - // Check if invited admin received the invitation email - await expectInvitationEmailToBeReceived( - page, - emails, - adminEmail, - `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com` - ); + // TODO: Check if invited admin received the invitation email + // await expectInvitationEmailToBeReceived( + // page, + // emails, + // adminEmail, + // `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com` + // ); await expect(page.getByTestId("pending-member-item")).toHaveCount(2); // can remove members @@ -138,24 +111,318 @@ test.describe("Organization", () => { }); 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.getByTestId("continue_or_checkout").click(); await page.waitForURL("/event-types"); }); + + await test.step("Login as org owner and pay", async () => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled"); + + await orgOwnerUser.apiLogin(); + await page.goto("/event-types"); + const upgradeButton = await page.getByTestId("upgrade_org_banner_button"); + + await expect(upgradeButton).toBeVisible(); + await upgradeButton.click(); + // Check that stripe checkout is present + const expectedUrl = "https://checkout.stripe.com"; + + await page.waitForURL((url) => url.href.startsWith(expectedUrl)); + const url = page.url(); + + // Check that the URL matches the expected URL + expect(url).toContain(expectedUrl); + + await fillStripeTestCheckout(page); + await page.waitForLoadState("networkidle"); + + const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button"); + + await expect(upgradeButtonHidden).toBeHidden(); + }); + }); + + test("User can create and upgrade a org", async ({ page, users, emails }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled"); + const stringUUID = uuid(); + + const orgOwnerUsername = `owner-${stringUUID}`; + + const targetOrgEmail = users.trackEmail({ + username: orgOwnerUsername, + domain: `example.com`, + }); + const orgOwnerUser = await users.create({ + username: orgOwnerUsername, + email: targetOrgEmail, + }); + + await orgOwnerUser.apiLogin(); + const orgName = capitalize(`${orgOwnerUsername}`); + await page.goto("/settings/organizations/new"); + await page.waitForLoadState("networkidle"); + + await test.step("Basic info", async () => { + // These values are infered due to an existing user being signed + expect(await page.locator("input[name=name]").inputValue()).toBe("Example"); + expect(await page.locator("input[name=slug]").inputValue()).toBe("example"); + + await page.locator("input[name=name]").fill(orgName); + await page.locator("input[name=slug]").fill(orgOwnerUsername); + + await page.locator("button[type=submit]").click(); + await page.waitForLoadState("networkidle"); + }); + + 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-members"); + }); + + await test.step("On-board administrators", async () => { + await page.waitForSelector('[data-testid="pending-member-list"]'); + expect(await page.getByTestId("pending-member-item").count()).toBe(1); + + const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` }); + + //can add members + await page.getByTestId("new-member-button").click(); + await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail); + await page.getByTestId("invite-new-member-button").click(); + await expect(page.locator(`li:has-text("${adminEmail}")`)).toBeVisible(); + // TODO: Check if invited admin received the invitation email + // await expectInvitationEmailToBeReceived( + // page, + // emails, + // adminEmail, + // `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com` + // ); + await expect(page.getByTestId("pending-member-item")).toHaveCount(2); + + // can remove members + await expect(page.getByTestId("pending-member-item")).toHaveCount(2); + const lastRemoveMemberButton = page.getByTestId("remove-member-button").last(); + await lastRemoveMemberButton.click(); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("pending-member-item")).toHaveCount(1); + await page.getByTestId("publish-button").click(); + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/add-teams"); + }); + + await test.step("Create teams", async () => { + // Filling one team + await page.locator('input[name="teams.0.name"]').fill("Marketing"); + + // Adding another team + await page.locator('button:text("Add a team")').click(); + await page.locator('input[name="teams.1.name"]').fill("Sales"); + + // Finishing the creation wizard + await page.getByTestId("continue_or_checkout").click(); + }); + + await test.step("Login as org owner and pay", async () => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled"); + await orgOwnerUser.apiLogin(); + await page.goto("/event-types"); + const upgradeButton = await page.getByTestId("upgrade_org_banner_button"); + + await expect(upgradeButton).toBeVisible(); + await upgradeButton.click(); + // Check that stripe checkout is present + const expectedUrl = "https://checkout.stripe.com"; + + await page.waitForURL((url) => url.href.startsWith(expectedUrl)); + const url = page.url(); + + // Check that the URL matches the expected URL + expect(url).toContain(expectedUrl); + + await fillStripeTestCheckout(page); + await page.waitForLoadState("networkidle"); + + const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button"); + + await expect(upgradeButtonHidden).toBeHidden(); + }); + }); + + test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({ + page, + users, + }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled"); + const numberOfTeams = 3; + const stringUUID = uuid(); + + const orgOwnerUsername = `owner-${stringUUID}`; + + const targetOrgEmail = users.trackEmail({ + username: orgOwnerUsername, + domain: `example.com`, + }); + const orgOwnerUser = await users.create( + { + username: orgOwnerUsername, + email: targetOrgEmail, + }, + { hasTeam: true, numberOfTeams } + ); + + await orgOwnerUser.apiLogin(); + + await page.goto("/teams"); + + await test.step("Has org self serve banner", async () => { + // These values are infered due to an existing user being signed + const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button"); + await expect(selfServeButtonLocator).toBeVisible(); + + await selfServeButtonLocator.click(); + await page.waitForURL("/settings/organizations/new"); + }); + + await test.step("Basic info", async () => { + // These values are infered due to an existing user being signed + const slugLocator = await page.locator("input[name=slug]"); + expect(await page.locator("input[name=name]").inputValue()).toBe("Example"); + expect(await slugLocator.inputValue()).toBe("example"); + + await slugLocator.fill(`example-${stringUUID}`); + + await page.locator("button[type=submit]").click(); + await page.waitForLoadState("networkidle"); + }); + + 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-members"); + }); + + await test.step("On-board administrators", async () => { + await page.waitForSelector('[data-testid="pending-member-list"]'); + expect(await page.getByTestId("pending-member-item").count()).toBe(1); + + const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` }); + + //can add members + await page.getByTestId("new-member-button").click(); + await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail); + await page.getByTestId("invite-new-member-button").click(); + await expect(page.locator(`li:has-text("${adminEmail}")`)).toBeVisible(); + // TODO: Check if invited admin received the invitation email + // await expectInvitationEmailToBeReceived( + // page, + // emails, + // adminEmail, + // `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com` + // ); + await expect(page.getByTestId("pending-member-item")).toHaveCount(2); + + // can remove members + await expect(page.getByTestId("pending-member-item")).toHaveCount(2); + const lastRemoveMemberButton = page.getByTestId("remove-member-button").last(); + await lastRemoveMemberButton.click(); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("pending-member-item")).toHaveCount(1); + await page.getByTestId("publish-button").click(); + // Waiting to be in next step URL + await page.waitForURL("/settings/organizations/*/add-teams"); + }); + + await test.step("Move existing teams to org", async () => { + // No easy way to get all team checkboxes so we fill all checkboxes on the page in + const foundCheckboxes = page.locator('input[type="checkbox"]'); + const count = await foundCheckboxes.count(); + for (let i = 0; i < count; i++) { + const checkbox = foundCheckboxes.nth(i); + await checkbox.click(); + } + }); + + await test.step("Create teams", async () => { + // Filling one team + await page.locator('input[name="teams.0.name"]').fill("Marketing"); + + // Adding another team + await page.locator('button:text("Add a team")').click(); + await page.locator('input[name="teams.1.name"]').fill("Sales"); + + // Finishing the creation wizard + await page.getByTestId("continue_or_checkout").click(); + }); + + await test.step("Login as org owner and pay", async () => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled"); + await orgOwnerUser.apiLogin(); + await page.goto("/event-types"); + const upgradeButton = await page.getByTestId("upgrade_org_banner_button"); + + await expect(upgradeButton).toBeVisible(); + await upgradeButton.click(); + // Check that stripe checkout is present + const expectedUrl = "https://checkout.stripe.com"; + + await page.waitForURL((url) => url.href.startsWith(expectedUrl)); + const url = page.url(); + + // Check that the URL matches the expected URL + expect(url).toContain(expectedUrl); + + await fillStripeTestCheckout(page); + await page.waitForLoadState("networkidle"); + + const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button"); + + await expect(upgradeButtonHidden).toBeHidden(); + }); + + await test.step("Ensure correctnumberOfTeams are migrated", async () => { + // eslint-disable-next-line playwright/no-skipped-test + await page.goto("/teams"); + await page.waitForLoadState("networkidle"); + const teamListItems = await page.getByTestId("team-list-item-link").all(); + + // Number of teams migrated + the two created in the create teams step + expect(teamListItems.length).toBe(numberOfTeams + 2); + }); }); }); diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 80e275b9c3..250cbf40f3 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -195,9 +195,14 @@ test.describe("Organization", () => { }); }); - test.describe("Email matching orgAutoAcceptEmail and a Verified Organization", () => { + test.describe("Email matching orgAutoAcceptEmail and a Verified Organization with DNS Setup Done", () => { test("nonexisting user is invited to Org", async ({ browser, page, users, emails }) => { - const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true }); + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + isOrgVerified: true, + isDnsSetup: true, + }); const { team: org } = await orgOwner.getOrgMembership(); await orgOwner.apiLogin(); await page.goto("/settings/organizations/members"); @@ -263,7 +268,12 @@ test.describe("Organization", () => { // Such a user has user.username changed directly in addition to having the new username in the profile.username test("existing user migrated to an organization", async ({ users, page, emails }) => { - const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true }); + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + isOrgVerified: true, + isDnsSetup: true, + }); const { team: org } = await orgOwner.getOrgMembership(); await orgOwner.apiLogin(); const { existingUser } = await test.step("Invite an existing user to an organization", async () => { @@ -312,6 +322,7 @@ test.describe("Organization", () => { isOrg: true, hasSubteam: true, isOrgVerified: true, + isDnsSetup: true, }); const { team: org } = await orgOwner.getOrgMembership(); const { team } = await orgOwner.getFirstTeamMembership(); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 35f8ba2684..938665e17c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2316,6 +2316,29 @@ "field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect", "field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})", "account_already_linked": "Account is already linked", + + "email_team_invite|subject|added_to_org": "{{user}} added you to the organization {{team}} on {{appName}}", + "email_team_invite|subject|invited_to_org": "{{user}} invited you to join the organization {{team}} on {{appName}}", + "email_team_invite|subject|added_to_subteam": "{{user}} added you to the team {{team}} of organization {{parentTeamName}} on {{appName}}", + "email_team_invite|subject|invited_to_subteam": "{{user}} invited you to join the team {{team}} of organization {{parentTeamName}} on {{appName}}", + "email_team_invite|subject|invited_to_regular_team": "{{user}} invited you to join the team {{team}} on {{appName}}", + + "email_team_invite|heading|added_to_org": "You’ve been added to a {{appName}} organization", + "email_team_invite|heading|invited_to_org": "You’ve been invited to a {{appName}} organization", + "email_team_invite|heading|added_to_subteam": "You’ve been added to a team of {{parentTeamName}} organization", + "email_team_invite|heading|invited_to_subteam": "You’ve been invited to a team of {{parentTeamName}} organization", + "email_team_invite|heading|invited_to_regular_team": "You’ve been invited to join a {{appName}} team", + + "email_team_invite|content|added_to_org": "{{invitedBy}} has added you to the {{teamName}} organization.", + "email_team_invite|content|invited_to_org": "{{invitedBy}} has invited you to join the {{teamName}} organization.", + "email_team_invite|content|added_to_subteam": "{{invitedBy}} has added you to the team {{teamName}} in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", + "email_team_invite|content|invited_to_subteam": "{{invitedBy}} has invited you to join the team {{teamName}} in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", + "email_team_invite|content|invited_to_regular_team": "{{invitedBy}} has invited you to join their team {{teamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", + "email|existing_user_added_link_will_change":"On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.

Please note: All of your personal event types will be moved into the {teamName} organization, which may also include potential personal link.

For personal events we recommend creating a new account with a personal email address.", + "email|existing_user_added_link_changed":"Your link has been changed from {prevLinkWithoutProtocol} to {newLinkWithoutProtocol} but don't worry, all previous links still work and redirect appropriately.

Please note: All of your personal event types have been moved into the {teamName} organisation, which may also include potential personal link.

Please log in and make sure you have no private events on your new organisational account.

For personal events we recommend creating a new account with a personal email address.

Enjoy your new clean link: {newLinkWithoutProtocol}", + "email_organization_created|subject": "Your organization has been created", + "your_current_plan":"Your current plan", + "organization_price_per_user_month":"$37 per user per month (30 seats minimum)", "privacy_organization_description": "Manage privacy settings for your organization", "privacy": "Privacy", "team_will_be_under_org": "New teams will be under your organization", diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index dd3b765e42..aca5ad30b5 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -38,6 +38,8 @@ import MonthlyDigestEmail from "./templates/monthly-digest-email"; import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email"; import type { OrgAutoInvite } from "./templates/org-auto-join-invite"; import OrgAutoJoinEmail from "./templates/org-auto-join-invite"; +import type { OrganizationCreation } from "./templates/organization-creation-email"; +import OrganizationCreationEmail from "./templates/organization-creation-email"; import type { OrganizationEmailVerify } from "./templates/organization-email-verification"; import OrganizationEmailVerification from "./templates/organization-email-verification"; import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email"; @@ -327,6 +329,10 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => { await sendEmail(() => new TeamInviteEmail(teamInviteEvent)); }; +export const sendOrganizationCreationEmail = async (organizationCreationEvent: OrganizationCreation) => { + await sendEmail(() => new OrganizationCreationEmail(organizationCreationEvent)); +}; + export const sendOrganizationAutoJoinEmail = async (orgInviteEvent: OrgAutoInvite) => { await sendEmail(() => new OrgAutoJoinEmail(orgInviteEvent)); }; diff --git a/packages/emails/src/templates/OrganizationCreationEmail.tsx b/packages/emails/src/templates/OrganizationCreationEmail.tsx new file mode 100644 index 0000000000..a78f24f31b --- /dev/null +++ b/packages/emails/src/templates/OrganizationCreationEmail.tsx @@ -0,0 +1,105 @@ +import { Trans } from "next-i18next"; + +import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; + +import type { OrganizationCreation } from "../../templates/organization-creation-email"; +import { V2BaseEmailHtml } from "../components"; + +export const OrganizationCreationEmail = ( + props: OrganizationCreation & Partial> +) => { + const { prevLink, newLink, orgName: teamName } = props; + const prevLinkWithoutProtocol = props.prevLink?.replace(/https?:\/\//, ""); + const newLinkWithoutProtocol = props.newLink?.replace(/https?:\/\//, ""); + return ( + +

+ <>{props.language(`You have created ${props.orgName} organization.`)} +

+ +

+ <> + {props.language(`You have been added as an owner of the organization.`, { + invitedBy: props.from.toString(), + appName: APP_NAME, + })} + +

+

+ + Your link has been changed from {prevLinkWithoutProtocol} to{" "} + {newLinkWithoutProtocol} but don't worry, all previous links + still work and redirect appropriately. +
+
+ Please note: All of your personal event types have been moved into the + {teamName} + {" "} + organisation, which may also include potential personal link. +
+
+ Please log in and make sure you have no private events on your new organisational account. +
+
+ For personal events we recommend creating a new account with a personal email address. +
+
+ Enjoy your new clean link: {newLinkWithoutProtocol} +
+

+ +
+

+ <> + {props.language("email_no_user_signoff", { + appName: APP_NAME, + })} + +

+
+ +
+

+ <> + {props.language("have_any_questions")}{" "} + + <>{props.language("contact")} + {" "} + {props.language("our_support_team")} + +

+
+
+ ); +}; diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx index d18e4716e2..b53a4a0448 100644 --- a/packages/emails/src/templates/TeamInviteEmail.tsx +++ b/packages/emails/src/templates/TeamInviteEmail.tsx @@ -1,7 +1,9 @@ import type { TFunction } from "next-i18next"; +import { Trans } from "next-i18next"; import { APP_NAME, WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants"; +import { getSubject, getTypeOfInvite } from "../../templates/team-invite-email"; import { V2BaseEmailHtml, CallToAction } from "../components"; type TeamInvite = { @@ -14,30 +16,22 @@ type TeamInvite = { isAutoJoin: boolean; isOrg: boolean; parentTeamName: string | undefined; + isExistingUserMovedToOrg: boolean; + prevLink: string | null; + newLink: string | null; }; export const TeamInviteEmail = ( props: TeamInvite & Partial> ) => { + const typeOfInvite = getTypeOfInvite(props); + + const heading = getHeading(); + const content = getContent(); return ( - +

- <> - {props.language( - `email_no_user_invite_heading_${props.isOrg ? "org" : props.parentTeamName ? "subteam" : "team"}`, - { - appName: APP_NAME, - parentTeamName: props.parentTeamName, - } - )} - + <>{heading}

- <> - {props.language( - `email_user_invite_subheading_${props.isOrg ? "org" : props.parentTeamName ? "subteam" : "team"}`, - { - invitedBy: props.from.toString(), - appName: APP_NAME, - teamName: props.teamName, - parentTeamName: props.parentTeamName, - } - )} - + <>{content}

- {/* <> - {props.language("email_no_user_invite_steps_intro", { - entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(), - })} - */} -

- {/* - {!props.isCalcomMember && ( -
- - - - -
- )} */} + }} + />

); + + function getHeading() { + const autoJoinType = props.isAutoJoin ? "added" : "invited"; + const variables = { + appName: APP_NAME, + parentTeamName: props.parentTeamName, + }; + + if (typeOfInvite === "TO_ORG") { + return props.language(`email_team_invite|heading|${autoJoinType}_to_org`, variables); + } + + if (typeOfInvite === "TO_SUBTEAM") { + return props.language(`email_team_invite|heading|${autoJoinType}_to_subteam`, variables); + } + + return props.language(`email_team_invite|heading|invited_to_regular_team`, variables); + } + + function getContent() { + const autoJoinType = props.isAutoJoin ? "added" : "invited"; + + const variables = { + invitedBy: props.from.toString(), + appName: APP_NAME, + teamName: props.teamName, + parentTeamName: props.parentTeamName, + prevLink: props.prevLink, + newLink: props.newLink, + orgName: props.parentTeamName ?? props.isOrg ? props.teamName : "", + prevLinkWithoutProtocol: props.prevLink?.replace(/https?:\/\//, ""), + newLinkWithoutProtocol: props.newLink?.replace(/https?:\/\//, ""), + }; + + const { + prevLink, + newLink, + teamName, + invitedBy, + appName, + parentTeamName, + prevLinkWithoutProtocol, + newLinkWithoutProtocol, + } = variables; + if (typeOfInvite === "TO_ORG") { + if (props.isExistingUserMovedToOrg) { + return ( + <> + {autoJoinType == "added" ? ( + <> + + {invitedBy} has added you to the {teamName} organization. + {" "} + + Your link has been changed from{" "} + {prevLinkWithoutProtocol} to{" "} + {newLinkWithoutProtocol} but don't worry, all + previous links still work and redirect appropriately. +
+
+ Please note: All of your personal event types have been moved into the{" "} + {teamName} organisation, which may also include potential personal link. +
+
+ Please log in and make sure you have no private events on your new organisational account. +
+
+ For personal events we recommend creating a new account with a personal email address. +
+
+ Enjoy your new clean link: {newLinkWithoutProtocol} +
+ + ) : ( + <> + + {invitedBy} has invited you to join the {teamName} organization. + {" "} + + On accepting the invite, your link will change to your organization domain but don't + worry, all previous links will still work and redirect appropriately. +
+
+ Please note: All of your personal event types will be moved into the{" "} + {teamName} organisation, which may also include potential personal link. +
+
+ For personal events we recommend creating a new account with a personal email address. +
+ + )} + + ); + } + return ( + <> + {autoJoinType === "added" ? ( + + {invitedBy} has added you to the {teamName} organization. + + ) : ( + + {invitedBy} has invited you to join the {teamName} organization. + + )}{" "} + + {appName} is the event-juggling scheduler that enables you and your team to schedule meetings + without the email tennis. + + + ); + } + + if (typeOfInvite === "TO_SUBTEAM") { + return ( + <> + ( + {autoJoinType === "added" ? ( + + {invitedBy} has added you to the team {teamName} in their organization{" "} + {parentTeamName} + + ) : ( + + {invitedBy} has added you to the team {teamName} in their organization{" "} + {parentTeamName} + + )} + ) + + {appName} is the event-juggling scheduler that enables you and your team to schedule meetings + without the email tennis. + + + ); + } + // Regular team doesn't support auto-join. So, they have to be invited always + return props.language(`email_team_invite|content|invited_to_regular_team`, variables); + } }; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 070f8e66dd..01f539375d 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -33,3 +33,4 @@ export { MonthlyDigestEmail } from "./MonthlyDigestEmail"; export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail"; export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification"; export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail"; +export { OrganizationCreationEmail } from "./OrganizationCreationEmail"; diff --git a/packages/emails/templates/organization-creation-email.ts b/packages/emails/templates/organization-creation-email.ts new file mode 100644 index 0000000000..8fffc25132 --- /dev/null +++ b/packages/emails/templates/organization-creation-email.ts @@ -0,0 +1,38 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME } from "@calcom/lib/constants"; + +import { renderEmail } from "../"; +import BaseEmail from "./_base-email"; + +export type OrganizationCreation = { + language: TFunction; + from: string; + to: string; + ownerNewUsername: string; + ownerOldUsername: string; + orgDomain: string; + orgName: string; + prevLink: string; + newLink: string; +}; + +export default class OrganizationCreationEmail extends BaseEmail { + organizationCreationEvent: OrganizationCreation; + + constructor(organizationCreationEvent: OrganizationCreation) { + super(); + this.name = "SEND_ORGANIZATION_CREATION_EMAIL"; + this.organizationCreationEvent = organizationCreationEvent; + } + + protected async getNodeMailerPayload(): Promise> { + return { + to: this.organizationCreationEvent.to, + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + subject: this.organizationCreationEvent.language(`email_organization_created|subject`), + html: await renderEmail("OrganizationCreationEmail", this.organizationCreationEvent), + text: "", + }; + } +} diff --git a/packages/emails/templates/team-invite-email.ts b/packages/emails/templates/team-invite-email.ts index a7fb7c83c5..339e2e38c7 100644 --- a/packages/emails/templates/team-invite-email.ts +++ b/packages/emails/templates/team-invite-email.ts @@ -18,11 +18,51 @@ export type TeamInvite = { isAutoJoin: boolean; isOrg: boolean; parentTeamName: string | undefined; + isExistingUserMovedToOrg: boolean; + prevLink: string | null; + newLink: string | null; +}; + +export function getTypeOfInvite(teamInviteEvent: TeamInvite) { + if (teamInviteEvent.isOrg) { + return "TO_ORG"; + } + + if (teamInviteEvent.parentTeamName) { + return "TO_SUBTEAM"; + } + + if (teamInviteEvent.isAutoJoin) { + throw new Error("Auto-join is not supported for regular teams"); + } + + return "TO_REGULAR_TEAM"; +} + +export const getSubject = (teamInviteEvent: TeamInvite) => { + const typeOfInvite = getTypeOfInvite(teamInviteEvent); + const type = teamInviteEvent.isAutoJoin ? "added" : "invited"; + const variables = { + user: teamInviteEvent.from, + team: teamInviteEvent.teamName, + appName: APP_NAME, + parentTeamName: teamInviteEvent.parentTeamName, + entity: teamInviteEvent.language(teamInviteEvent.isOrg ? "organization" : "team").toLowerCase(), + }; + + if (typeOfInvite === "TO_ORG") { + return teamInviteEvent.language(`email_team_invite|subject|${type}_to_org`, variables); + } + + if (typeOfInvite === "TO_SUBTEAM") { + return teamInviteEvent.language(`email_team_invite|subject|${type}_to_subteam`, variables); + } + + return teamInviteEvent.language(`email_team_invite|subject|${type}_to_regular_team`, variables); }; export default class TeamInviteEmail extends BaseEmail { teamInviteEvent: TeamInvite; - constructor(teamInviteEvent: TeamInvite) { super(); this.name = "SEND_TEAM_INVITE_EMAIL"; @@ -33,18 +73,7 @@ export default class TeamInviteEmail extends BaseEmail { return { to: this.teamInviteEvent.to, from: `${APP_NAME} <${this.getMailerOptions().from}>`, - subject: this.teamInviteEvent.language( - `user_invited_you${this.teamInviteEvent.parentTeamName ? "_to_subteam" : ""}`, - { - user: this.teamInviteEvent.from, - team: this.teamInviteEvent.teamName, - appName: APP_NAME, - parentTeamName: this.teamInviteEvent.parentTeamName, - entity: this.teamInviteEvent - .language(this.teamInviteEvent.isOrg ? "organization" : "team") - .toLowerCase(), - } - ), + subject: getSubject(this.teamInviteEvent), html: await renderEmail("TeamInviteEmail", this.teamInviteEvent), text: "", }; diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index e0b2141733..a2c2a9daf3 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -33,7 +33,6 @@ export async function getServerSession(options: { res?: NextApiResponse | GetServerSidePropsContext["res"]; authOptions?: AuthOptions; }) { - log.debug("Getting server session"); const { req, authOptions: { secret } = {} } = options; const token = await getToken({ @@ -41,6 +40,8 @@ export async function getServerSession(options: { secret, }); + log.debug("Getting server session", safeStringify({ token })); + if (!token || !token.email || !token.sub) { log.debug("Couldnt get token"); return null; @@ -49,6 +50,7 @@ export async function getServerSession(options: { const cachedSession = CACHE.get(JSON.stringify(token)); if (cachedSession) { + log.debug("Returning cached session", safeStringify(cachedSession)); return cachedSession; } diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index 586b2c2f8b..507363e451 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -161,17 +161,27 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb const translation = await getTranslation(user.locale || "en", "common"); return sendExistingUserTeamInviteEmails({ currentUserTeamName: group.team.name, - existingUsersWithMembersips: [user], + existingUsersWithMembersips: [ + { + ...user, + profile: null, + }, + ], language: translation, isOrg: false, teamId: group.teamId, isAutoJoin: true, currentUserParentTeamName: org.name, + orgSlug: null, }); }), ...newOrgMembers.map((user) => { return createAProfileForAnExistingUser({ - user, + user: { + id: user.id, + email: user.email, + currentUsername: user.username, + }, organizationId, }); }), diff --git a/packages/features/ee/dsync/lib/handleUserEvents.ts b/packages/features/ee/dsync/lib/handleUserEvents.ts index 2f16e24b3a..527ef4c324 100644 --- a/packages/features/ee/dsync/lib/handleUserEvents.ts +++ b/packages/features/ee/dsync/lib/handleUserEvents.ts @@ -49,12 +49,18 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe await sendExistingUserTeamInviteEmails({ currentUserName: user.username, currentUserTeamName: org.name, - existingUsersWithMembersips: [addedUser], + existingUsersWithMembersips: [ + { + ...addedUser, + profile: null, + }, + ], language: translation, isOrg: true, teamId: org.id, isAutoJoin: true, currentUserParentTeamName: org?.parent?.name, + orgSlug: org.slug, }); } else { // If data.active is false then remove the user from the org diff --git a/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts b/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts index 3eed36664d..452441ecd8 100644 --- a/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts +++ b/packages/features/ee/dsync/lib/users/inviteExistingUserToOrg.ts @@ -17,7 +17,11 @@ const inviteExistingUserToOrg = async ({ translation: TFunction; }) => { await createAProfileForAnExistingUser({ - user, + user: { + id: user.id, + email: user.email, + currentUsername: user.username, + }, organizationId: org.id, }); diff --git a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx index d9d096b9b5..13a3067d26 100644 --- a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx +++ b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx @@ -1,13 +1,16 @@ +import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { useFieldArray, useForm } from "react-hook-form"; +import { useFieldArray, useForm, Controller } from "react-hook-form"; import { z } from "zod"; import { classNames } from "@calcom/lib"; +import { IS_TEAM_BILLING_ENABLED_CLIENT } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; +import { UserPermissionRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; -import { Button, showToast, TextField } from "@calcom/ui"; +import { Button, showToast, TextField, CheckboxField, Form } from "@calcom/ui"; import { ArrowRight, Plus, X } from "@calcom/ui/components/icon"; const querySchema = z.object({ @@ -15,24 +18,60 @@ const querySchema = z.object({ }); const schema = z.object({ - teams: z - .array( - z.object({ - name: z.string().min(2, "org_team_name_min_2_chars").trim(), - }) - ) - .min(1, "At least one team is required"), + teams: z.array( + z.object({ + name: z.string().trim(), + }) + ), + moveTeams: z.array( + z.object({ + id: z.number(), + shouldMove: z.boolean(), + newSlug: z.string().optional(), + }) + ), }); export const AddNewTeamsForm = () => { + const { data: teams } = trpc.viewer.teams.list.useQuery(); + const routerQuery = useRouterQuery(); + + const { id: orgId } = querySchema.parse(routerQuery); + + const { data: org } = trpc.viewer.teams.get.useQuery({ teamId: orgId, isOrg: true }); + + if (!teams || !org) { + return null; + } + + const orgWithRequestedSlug = { + ...org, + requestedSlug: org.metadata.requestedSlug ?? null, + }; + + const regularTeams = teams.filter((team) => !team.parentId); + return ; +}; + +const AddNewTeamsFormChild = ({ + teams, + org, +}: { + org: { id: number; slug: string | null; requestedSlug: string | null }; + teams: { id: number; name: string; slug: string | null }[]; +}) => { const { t } = useLocale(); const router = useRouter(); - const routerQuery = useRouterQuery(); - const { id: orgId } = querySchema.parse(routerQuery); const [counter, setCounter] = useState(1); - - const { register, control, handleSubmit, formState, trigger, setValue, getValues } = useForm({ - defaultValues: { teams: [{ name: "" }] }, // Set initial values + const form = useForm({ + defaultValues: { + teams: [{ name: "" }], + moveTeams: teams.map((team) => ({ + id: team.id, + shouldMove: false, + newSlug: getSuggestedSlug({ teamSlug: team.slug, orgSlug: org.slug || org.requestedSlug }), + })), + }, // Set initial values resolver: async (data) => { try { const validatedData = await schema.parseAsync(data); @@ -50,11 +89,27 @@ export const AddNewTeamsForm = () => { } }, }); + const session = useSession(); + const isAdmin = + session.data?.user?.role === UserPermissionRole.ADMIN || + session.data?.user?.impersonatedBy?.role === UserPermissionRole.ADMIN; + + const allowWizardCompletionWithoutUpgrading = !IS_TEAM_BILLING_ENABLED_CLIENT || isAdmin; + const { register, control, watch, getValues } = form; const { fields, append, remove } = useFieldArray({ control, name: "teams", }); + const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({ + onSuccess(data) { + router.push(data.url); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + const handleCounterIncrease = () => { if (counter >= 0 && counter < 5) { setCounter((prevCounter) => prevCounter + 1); @@ -73,11 +128,18 @@ export const AddNewTeamsForm = () => { showToast(t("duplicated_slugs_warning", { slugs: data.duplicatedSlugs.join(", ") }), "warning"); // Server will return array of duplicated slugs, so we need to wait for user to read the warning // before pushing to next page - setTimeout(() => { - router.push(`/event-types`); - }, 3000); + setTimeout(() => handleSuccessRedirect, 3000); } else { - router.push(`/event-types`); + handleSuccessRedirect(); + } + + function handleSuccessRedirect() { + if (allowWizardCompletionWithoutUpgrading) { + router.push(`/event-types`); + return; + } + // mutate onSuccess will take care of routing to the correct place. + publishOrgMutation.mutate(); } }, onError: (error) => { @@ -85,29 +147,58 @@ export const AddNewTeamsForm = () => { }, }); - const handleInputChange = (index: number, event: any) => { - const { name, value } = event.target; - setValue(`teams.${index}.name`, value); - trigger(`teams.${index}.name`); - }; - const handleFormSubmit = () => { - if (formState.isValid) { - const fields = getValues("teams"); - createTeamsMutation.mutate({ orgId, teamNames: fields.map((field) => field.name) }); - } + const fields = getValues("teams"); + const moveTeams = getValues("moveTeams"); + createTeamsMutation.mutate({ orgId: org.id, moveTeams, teamNames: fields.map((field) => field.name) }); }; + const moveTeams = watch("moveTeams"); return ( <> -

+ + {moveTeams.length ? ( + <> + +
    + {moveTeams.map((team, index) => { + return ( +
  • + ( + t.id === team.id)?.name ?? ""} + /> + )} + /> + {moveTeams[index].shouldMove ? ( + t.id === team.id)?.slug ?? ""} + {...register(`moveTeams.${index}.newSlug`)} + className="mt-2" + label="" + /> + ) : null} +
  • + ); + })} +
+ + ) : null} + {fields.map((field, index) => (
0 ? "mb-2" : "")} key={field.id}> handleInputChange(index, e)} addOnClassname="bg-transparent p-0 border-l-0" className={index > 0 ? "mb-2" : ""} placeholder={t(`org_team_names_example_${index + 1}`)} @@ -143,11 +234,21 @@ export const AddNewTeamsForm = () => { EndIcon={ArrowRight} color="primary" className="mt-6 w-full justify-center" - disabled={!formState.isValid || createTeamsMutation.isPending || createTeamsMutation.isSuccess} + data-testId="continue_or_checkout" + disabled={createTeamsMutation.isPending || createTeamsMutation.isSuccess} onClick={handleFormSubmit}> - {t("continue")} + {allowWizardCompletionWithoutUpgrading ? t("continue") : t("checkout")} - + ); }; + +const getSuggestedSlug = ({ teamSlug, orgSlug }: { teamSlug: string | null; orgSlug: string | null }) => { + // If there is no orgSlug, we can't suggest a slug + if (!teamSlug || !orgSlug) { + return teamSlug; + } + + return teamSlug.replace(`${orgSlug}-`, "").replace(`-${orgSlug}`, ""); +}; diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx index cde15cce2e..921145c42a 100644 --- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -1,16 +1,20 @@ +import { useSession } from "next-auth/react"; import { signIn } from "next-auth/react"; +import type { SessionContextValue } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; -import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode"; -import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; +import classNames from "@calcom/lib/classNames"; +import { MINIMUM_NUMBER_OF_ORG_SEATS } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import slugify from "@calcom/lib/slugify"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; +import { UserPermissionRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; -import { Button, Form, TextField, Alert } from "@calcom/ui"; +import type { Ensure } from "@calcom/types/utils"; +import { Button, Form, TextField, Alert, RadioGroup as RadioArea } from "@calcom/ui"; import { ArrowRight } from "@calcom/ui/components/icon"; function extractDomainFromEmail(email: string) { @@ -22,47 +26,55 @@ function extractDomainFromEmail(email: string) { return out.split(".")[0]; } -export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => { - const { t, i18n } = useLocale(); +export const CreateANewOrganizationForm = () => { + const session = useSession(); + if (!session.data) { + return null; + } + return ; +}; + +const CreateANewOrganizationFormChild = ({ session }: { session: Ensure }) => { + const { t } = useLocale(); const router = useRouter(); const telemetry = useTelemetry(); const [serverErrorMessage, setServerErrorMessage] = useState(null); - const [showVerifyCode, setShowVerifyCode] = useState(false); - + const isAdmin = session.data.user.role === UserPermissionRole.ADMIN; + const isImpersonated = session.data.user.impersonatedBy; + const defaultOrgOwnerEmail = session.data.user.email ?? ""; const newOrganizationFormMethods = useForm<{ name: string; + seats: number; + pricePerSeat: number; slug: string; - adminEmail: string; - adminUsername: string; + orgOwnerEmail: string; }>({ defaultValues: { - slug: `${slug ?? ""}`, + slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined, + orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined, + name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined, }, }); - const watchAdminEmail = newOrganizationFormMethods.watch("adminEmail"); const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({ onSuccess: async (data) => { - if (data.checked) { - setShowVerifyCode(true); - } else if (data.user) { - telemetry.event(telemetryEventTypes.org_created); - await signIn("credentials", { - redirect: false, - callbackUrl: "/", - email: data.user.email, - password: data.user.password, + telemetry.event(telemetryEventTypes.org_created); + // This is necessary so that server token has the updated upId + await session.update({ + upId: data.upId, + }); + if (isAdmin && data.userId !== session.data?.user.id) { + // Impersonate the user chosen as the organization owner(if the admin user isn't the owner himself), so that admin can now configure the organisation on his behalf. + // He won't need to have access to the org directly in this way. + signIn("impersonation-auth", { + username: data.email, + callbackUrl: `/settings/organizations/${data.organizationId}/about`, }); - router.push(`/settings/organizations/${data.user.organizationId}/set-password`); } + router.push(`/settings/organizations/${data.organizationId}/about`); }, onError: (err) => { - if (err.message === "admin_email_taken") { - newOrganizationFormMethods.setError("adminEmail", { - type: "custom", - message: t("email_already_used"), - }); - } else if (err.message === "organization_url_taken") { + if (err.message === "organization_url_taken") { newOrganizationFormMethods.setError("slug", { type: "custom", message: t("url_taken") }); } else if (err.message === "domain_taken_team" || err.message === "domain_taken_project") { newOrganizationFormMethods.setError("slug", { @@ -75,22 +87,11 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => { }, }); - const verifyCode = useVerifyCode({ - onSuccess: (isVerified) => { - if (isVerified) { - createOrganizationMutation.mutate({ - ...newOrganizationFormMethods.getValues(), - language: i18n.language, - check: false, - }); - } - }, - }); - return ( <>
{ if (!createOrganizationMutation.isPending) { @@ -98,17 +99,15 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => { createOrganizationMutation.mutate(v); } }}> -
+
{serverErrorMessage && (
)} - { { - const domain = extractDomainFromEmail(e?.target.value); - newOrganizationFormMethods.setValue("adminEmail", e?.target.value.trim()); - newOrganizationFormMethods.setValue( - "adminUsername", - e?.target.value.split("@")[0].trim() - ); + const email = e?.target.value; + const slug = deriveSlugFromEmail(email); + newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim()); if (newOrganizationFormMethods.getValues("slug") === "") { - newOrganizationFormMethods.setValue("slug", domain); + newOrganizationFormMethods.setValue("slug", slug); } - newOrganizationFormMethods.setValue( - "name", - domain.charAt(0).toUpperCase() + domain.slice(1) - ); + newOrganizationFormMethods.setValue("name", deriveOrgNameFromEmail(email)); }} autoComplete="off" /> @@ -141,7 +135,7 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => { )} />
-
+
{ />
-
+
{ />
- + {(isAdmin || isImpersonated) && ( + <> +
+
+ ( +
+ { + onChange(+e.target.value); + }} + autoComplete="off" + /> +
+ )} + /> +
+
+ ( +
+ { + onChange(+e.target.value); + }} + autoComplete="off" + /> +
+ )} + /> +
+
+ + )} + + {/* This radio group does nothing - its just for visuall purposes */} + {!isAdmin && ( + <> +
+

+ Upgrade to Organizations +

+ + + {t("teams")} +

{t("your_current_plan")}

+
+ + {t("organization")} +

{t("organization_price_per_user_month")}

+
+
+
+ + )}
- ); }; + +function deriveSlugFromEmail(email: string) { + const domain = extractDomainFromEmail(email); + + return domain; +} + +function deriveOrgNameFromEmail(email: string) { + const domain = extractDomainFromEmail(email); + + return domain.charAt(0).toUpperCase() + domain.slice(1); +} diff --git a/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx index 073ffe9c35..92b654f807 100644 --- a/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx +++ b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx @@ -31,6 +31,7 @@ export function OrgUpgradeBanner({ data }: OrgUpgradeBannerProps) { variant="warning" actions={ -
- - - ); -}; diff --git a/packages/features/ee/organizations/components/index.ts b/packages/features/ee/organizations/components/index.ts index 8474e319d9..32e312bf20 100644 --- a/packages/features/ee/organizations/components/index.ts +++ b/packages/features/ee/organizations/components/index.ts @@ -1,4 +1,3 @@ export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm"; export { AboutOrganizationForm } from "./AboutOrganizationForm"; -export { SetPasswordForm } from "./SetPasswordForm"; export { AddNewTeamsForm } from "./AddNewTeamsForm"; diff --git a/packages/features/ee/organizations/pages/organization.tsx b/packages/features/ee/organizations/pages/organization.tsx index 0699101f40..16a65e377e 100644 --- a/packages/features/ee/organizations/pages/organization.tsx +++ b/packages/features/ee/organizations/pages/organization.tsx @@ -16,7 +16,8 @@ export const getServerSideProps = async ({ req, res }: GetServerSidePropsContext // Check if logged in user has an organization assigned const session = await getServerSession({ req, res }); - if (!session?.user.org?.id) { + + if (!session?.user.profile.organizationId) { return { notFound: true, }; @@ -26,7 +27,7 @@ export const getServerSideProps = async ({ req, res }: GetServerSidePropsContext const membership = await prisma.membership.findFirst({ where: { userId: session?.user.id, - teamId: session?.user.org.id, + teamId: session?.user.profile.organizationId, }, select: { role: true, diff --git a/packages/features/ee/teams/components/TeamList.tsx b/packages/features/ee/teams/components/TeamList.tsx index b97da81d4f..88c82fb456 100644 --- a/packages/features/ee/teams/components/TeamList.tsx +++ b/packages/features/ee/teams/components/TeamList.tsx @@ -1,16 +1,20 @@ import { useState } from "react"; +import { ORG_SELF_SERVE_ENABLED } from "@calcom/lib/constants"; import { trackFormbricksAction } from "@calcom/lib/formbricks-client"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Card, showToast } from "@calcom/ui"; -import { UserPlus, Users, Edit } from "@calcom/ui/components/icon"; +import { UserPlus, Building, LineChart, Paintbrush, Users, Edit } from "@calcom/ui/components/icon"; import TeamListItem from "./TeamListItem"; interface Props { teams: RouterOutputs["viewer"]["teams"]["list"]; + /** + * True for teams that are pending invite acceptance + */ pending?: boolean; } @@ -18,6 +22,7 @@ export default function TeamList(props: Props) { const utils = trpc.useUtils(); const { t } = useLocale(); + const { data: user } = trpc.viewer.me.useQuery(); const [hideDropdown, setHideDropdown] = useState(false); @@ -44,8 +49,62 @@ export default function TeamList(props: Props) { deleteTeamMutation.mutate({ teamId }); } + if (!user) return null; + const isUserAlreadyInAnOrganization = user.profile.organization; return (
    + {ORG_SELF_SERVE_ENABLED && + !props.pending && + !isUserAlreadyInAnOrganization && + props.teams.length > 2 && + props.teams.map( + (team, i) => + team.role !== "MEMBER" && + i === 0 && ( +
    +
    + } + variant="basic" + title={t("You have a lot of teams")} + description={t( + "Consider consolidating your teams in an organisation, unify billing, admin tools and analytics." + )} + actionButton={{ + href: `/settings/organizations/new`, + child: t("set_up_your_organization"), + "data-testId": "setup_your_org_action_button", + }} + /> + } + variant="basic" + title={t("Get a clean subdomain")} + description={t( + "Right now, team member URLs are all over the place. Get a beautiful link and turn every email address into a scheduling link: anna@acme.com → acme.cal.com/anna" + )} + actionButton={{ + href: "https://www.youtube.com/watch?v=G0Jd2dp7064", + child: t("learn_more"), + }} + /> + } + variant="basic" + title={t("Admin tools and analytics")} + description={t( + "As an organization owner, you are in charge of every team account. You can make changes with admin-only tools and see organization wide analytics in one place." + )} + actionButton={{ + href: "https://i.cal.com/sales/enterprise", + child: t("learn_more"), + }} + /> +
    +
    + ) + )} + {props.teams.map((team) => ( { - const { teamId, seats, userId, isOrg } = input; + const { teamId, seats, userId, isOrg, pricePerSeat } = input; const { url } = await checkIfTeamPaymentRequired({ teamId }); if (url) return { url }; + // For orgs, enforce minimum of 30 seats - const quantity = isOrg ? (seats < 30 ? 30 : seats) : seats; + const quantity = isOrg ? Math.max(seats, MINIMUM_NUMBER_OF_ORG_SEATS) : seats; const customer = await getStripeCustomerIdFromUserId(userId); + const session = await stripe.checkout.sessions.create({ customer, mode: "subscription", @@ -94,16 +102,16 @@ export const purchaseTeamSubscription = async (input: { cancel_url: `${WEBAPP_URL}/settings/my-account/profile`, line_items: [ { - /** We only need to set the base price and we can upsell it directly on Stripe's checkout */ - price: isOrg ? process.env.STRIPE_ORG_MONTHLY_PRICE_ID : process.env.STRIPE_TEAM_MONTHLY_PRICE_ID, + price: await getPriceId(), quantity: quantity, }, ], customer_update: { address: "auto", }, + // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode automatic_tax: { - enabled: true, + enabled: IS_PRODUCTION, }, metadata: { teamId, @@ -115,6 +123,49 @@ export const purchaseTeamSubscription = async (input: { }, }); return { url: session.url }; + + /** + * Determines the priceId depending on if a custom price is required or not. + * If the organization has a custom price per seat, it will create a new price in stripe and return its ID. + */ + async function getPriceId() { + const fixedPriceId = isOrg + ? process.env.STRIPE_ORG_MONTHLY_PRICE_ID + : process.env.STRIPE_TEAM_MONTHLY_PRICE_ID; + + if (!fixedPriceId) { + throw new Error( + "You need to have STRIPE_ORG_MONTHLY_PRICE_ID and STRIPE_TEAM_MONTHLY_PRICE_ID env variables set" + ); + } + + log.debug("Getting price ID", safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat })); + + if (!pricePerSeat) { + return fixedPriceId; + } + + const priceObj = await stripe.prices.retrieve(fixedPriceId); + if (!priceObj) throw new Error(`No price found for ID ${fixedPriceId}`); + try { + const customPriceObj = await stripe.prices.create({ + nickname: `Custom price for ${isOrg ? "Organization" : "Team"} ID: ${teamId}`, + unit_amount: pricePerSeat * 100, // Stripe expects the amount in cents + // Use the same currency as in the fixed price to avoid hardcoding it. + currency: priceObj.currency, + recurring: { interval: "month" }, // Define your subscription interval + product: typeof priceObj.product === "string" ? priceObj.product : priceObj.product.id, + }); + return customPriceObj.id; + } catch (e) { + log.error( + `Error creating custom price for ${isOrg ? "Organization" : "Team"} ID: ${teamId}`, + safeStringify(e) + ); + + throw new Error("Error in creation of custom price"); + } + } }; const getTeamWithPaymentMetadata = async (teamId: number) => { diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index e689309efb..194230896b 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -91,8 +91,11 @@ export const IS_STRIPE_ENABLED = !!( process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && process.env.STRIPE_PRIVATE_KEY ); -/** Self hosted shouldn't checkout when creating teams unless required */ +/** This has correct value only server side. When you want to use client side, go for IS_TEAM_BILLING_ENABLED_CLIENT. I think we should use the _CLIENT one only everywhere so that it works reliably everywhere on client as well as server */ export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES; +export const IS_TEAM_BILLING_ENABLED_CLIENT = + !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES; + export const FULL_NAME_LENGTH_MAX_LIMIT = 50; export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5"; export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1"; @@ -142,3 +145,6 @@ export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt( process.env.NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD ?? "0", 0 ); + +export const MINIMUM_NUMBER_OF_ORG_SEATS = 30; +export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1"; diff --git a/packages/lib/createAProfileForAnExistingUser.ts b/packages/lib/createAProfileForAnExistingUser.ts index 6baa587b3f..8771f917a6 100644 --- a/packages/lib/createAProfileForAnExistingUser.ts +++ b/packages/lib/createAProfileForAnExistingUser.ts @@ -17,7 +17,7 @@ export const createAProfileForAnExistingUser = async ({ user: { email: string; id: number; - username: string | null; + currentUsername: string | null; }; organizationId: number; }) => { @@ -46,7 +46,7 @@ export const createAProfileForAnExistingUser = async ({ log.debug( "Created profile for user", - safeStringify({ userId: user.id, profileId: profile.id, usernameInOrg, username: user.username }) + safeStringify({ userId: user.id, profileId: profile.id, usernameInOrg, username: user.currentUsername }) ); const orgSlug = org.slug || org.requestedSlug; @@ -57,29 +57,31 @@ export const createAProfileForAnExistingUser = async ({ const orgUrl = getOrgFullOrigin(orgSlug); - if (user.username) { - log.debug(`Creating redirect for user ${user.username} to ${orgUrl}/${usernameInOrg}`); + if (user.currentUsername) { + log.debug(`Creating redirect for user ${user.currentUsername} to ${orgUrl}/${usernameInOrg}`); await prisma.tempOrgRedirect.upsert({ where: { from_type_fromOrgId: { - from: user.username, + from: user.currentUsername, type: RedirectType.User, fromOrgId: 0, }, }, update: { type: RedirectType.User, - from: user.username, + from: user.currentUsername, fromOrgId: 0, toUrl: `${orgUrl}/${usernameInOrg}`, }, create: { type: RedirectType.User, - from: user.username, + from: user.currentUsername, fromOrgId: 0, toUrl: `${orgUrl}/${usernameInOrg}`, }, }); + } else { + log.debug(`Skipping redirect setup as ${user.id} doesn't have a username`); } return profile; }; diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index dc1f94be3c..0e5258de9d 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -11,7 +11,6 @@ const log = logger.getSubLogger({ prefix: ["repository/membership"] }); type IMembership = { teamId: number; userId: number; - profileId: number | null; accepted: boolean; role: MembershipRole; }; diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts new file mode 100644 index 0000000000..73e4366b8c --- /dev/null +++ b/packages/lib/server/repository/organization.ts @@ -0,0 +1,68 @@ +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { createAProfileForAnExistingUser } from "../../createAProfileForAnExistingUser"; + +export class OrganizationRepository { + static async createWithOwner({ + orgData, + owner, + }: { + orgData: { + name: string; + slug: string; + isOrganizationConfigured: boolean; + autoAcceptEmail: string; + seats: number | null; + pricePerSeat: number | null; + }; + owner: { + id: number; + email: string; + nonOrgUsername: string; + }; + }) { + logger.debug("Creating organization with owner", safeStringify({ orgData, owner })); + const organization = await prisma.team.create({ + data: { + name: orgData.name, + isOrganization: true, + ...(!IS_TEAM_BILLING_ENABLED ? { slug: orgData.slug } : {}), + organizationSettings: { + create: { + isOrganizationVerified: true, + isOrganizationConfigured: orgData.isOrganizationConfigured, + orgAutoAcceptEmail: orgData.autoAcceptEmail, + }, + }, + metadata: { + ...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: orgData.slug } : {}), + orgSeats: orgData.seats, + orgPricePerSeat: orgData.pricePerSeat, + }, + }, + }); + + const ownerProfile = await createAProfileForAnExistingUser({ + user: { + id: owner.id, + email: owner.email, + currentUsername: owner.nonOrgUsername, + }, + organizationId: organization.id, + }); + + await prisma.membership.create({ + data: { + userId: owner.id, + role: MembershipRole.OWNER, + accepted: true, + teamId: organization.id, + }, + }); + return { organization, ownerProfile }; + } +} diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 902686d421..4886b5a468 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -364,6 +364,8 @@ export const teamMetadataSchema = z paymentId: z.string(), subscriptionId: z.string().nullable(), subscriptionItemId: z.string().nullable(), + orgSeats: z.number().nullable(), + orgPricePerSeat: z.number().nullable(), migratedToOrgFrom: z .object({ teamSlug: z.string().or(z.null()).optional(), diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 921b60e575..749ca4961d 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -29,7 +29,7 @@ const NAMESPACE = "organizations"; const namespaced = (s: string) => `${NAMESPACE}.${s}`; export const viewerOrganizationsRouter = router({ - create: authedAdminProcedure.input(ZCreateInputSchema).mutation(async (opts) => { + create: authedProcedure.input(ZCreateInputSchema).mutation(async (opts) => { const handler = await importHandler(namespaced("create"), () => import("./create.handler")); return handler(opts); }), diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index c9b81fbdcd..0a974b50b5 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -1,18 +1,15 @@ -import { createHash } from "crypto"; import { lookup } from "dns"; -import { totp } from "otplib"; -import { sendOrganizationEmailVerification } from "@calcom/emails"; -import { sendAdminOrganizationNotification } from "@calcom/emails"; -import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import { sendAdminOrganizationNotification, sendOrganizationCreationEmail } from "@calcom/emails"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; -import { IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; +import { RESERVED_SUBDOMAINS, WEBAPP_URL, ORG_SELF_SERVE_ENABLED } from "@calcom/lib/constants"; import { createDomain } from "@calcom/lib/domainManager/organization"; import { getTranslation } from "@calcom/lib/server/i18n"; -import { ProfileRepository } from "@calcom/lib/server/repository/profile"; -import slugify from "@calcom/lib/slugify"; +import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; -import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; +import { UserPermissionRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -36,11 +33,15 @@ const getIPAddress = async (url: string): Promise => { }; export const createHandler = async ({ input, ctx }: CreateOptions) => { - const { slug, name, adminEmail, adminUsername, check, isPlatform } = input; + const { slug, name, orgOwnerEmail, seats, pricePerSeat, isPlatform } = input; - const userCollisions = await prisma.user.findUnique({ + if (!ORG_SELF_SERVE_ENABLED && ctx.user.role !== UserPermissionRole.ADMIN) { + throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can create organizations" }); + } + + const orgOwner = await prisma.user.findUnique({ where: { - email: adminEmail, + email: orgOwnerEmail, }, }); @@ -58,22 +59,21 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { if (hasAnOrgWithSameSlug || RESERVED_SUBDOMAINS.includes(slug)) throw new TRPCError({ code: "BAD_REQUEST", message: "organization_url_taken" }); - if (userCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "admin_email_taken" }); - const password = createHash("md5") - .update(`${adminEmail}${process.env.CALENDSO_ENCRYPTION_KEY}`) - .digest("hex"); - const hashedPassword = await hashPassword(password); + if (!orgOwner) { + // Create a new user and invite them as the owner of the organization + throw new Error("Inviting a new user to be the owner of the organization is not supported yet"); + } - const emailDomain = adminEmail.split("@")[1]; + // If we are making the loggedIn user the owner of the organization and he is already a part of an organization, we don't allow it because multi-org is not supported yet + const isLoggedInUserOrgOwner = orgOwner.id === ctx.user.id; + if (ctx.user.profile.organizationId && isLoggedInUserOrgOwner) { + throw new TRPCError({ code: "FORBIDDEN", message: "User is part of an organization already" }); + } const t = await getTranslation(ctx.user.locale ?? "en", "common"); - const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); - if (check) { - await checkOrg(adminEmail, input.language); - return { checked: true }; - } + const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); const isOrganizationConfigured = isPlatform ? true : await createDomain(slug); @@ -88,7 +88,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { await sendAdminOrganizationNotification({ instanceAdmins, orgSlug: slug, - ownerEmail: adminEmail, + ownerEmail: orgOwnerEmail, webappIPAddress: await getIPAddress( WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "") ), @@ -99,90 +99,56 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { } } - const { user: createOwnerOrg, organization } = await prisma.$transaction(async (tx) => { - const organization = await tx.team.create({ - data: { - name, - isOrganization: true, - ...(!IS_TEAM_BILLING_ENABLED ? { slug } : {}), - organizationSettings: { - create: { - isOrganizationVerified: true, - isOrganizationConfigured, - orgAutoAcceptEmail: emailDomain, - }, - }, - metadata: { - ...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}), - }, - }, - }); + const autoAcceptEmail = orgOwnerEmail.split("@")[1]; + const nonOrgUsernameForOwner = ctx.user.profile.username || ""; + const { organization, ownerProfile } = await OrganizationRepository.createWithOwner({ + orgData: { + name, + slug, + isOrganizationConfigured, + autoAcceptEmail, + seats: seats ?? null, + pricePerSeat: pricePerSeat ?? null, + }, + owner: { + id: orgOwner.id, + email: orgOwnerEmail, + nonOrgUsername: nonOrgUsernameForOwner, + }, + }); - const user = await tx.user.create({ - data: { - username: slugify(adminUsername), - email: adminEmail, - emailVerified: new Date(), - password: { create: { hash: hashedPassword } }, - organizationId: organization.id, - // Default schedule - schedules: { - create: { - name: t("default_schedule_name"), - availability: { - createMany: { - data: availability.map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - })), - }, - }, - }, - }, - profiles: { - create: { - username: slugify(adminUsername), - organizationId: organization.id, - uid: ProfileRepository.generateProfileUid(), - }, - }, - }, - }); + const translation = await getTranslation(input.language ?? "en", "common"); - await tx.membership.create({ - data: { - userId: user.id, - role: MembershipRole.OWNER, - accepted: true, - teamId: organization.id, - }, - }); - return { user, organization }; + await sendOrganizationCreationEmail({ + language: translation, + from: ctx.user.name ?? `${organization.name}'s admin`, + to: orgOwnerEmail, + ownerNewUsername: ownerProfile.username, + ownerOldUsername: nonOrgUsernameForOwner, + orgDomain: getOrgFullOrigin(slug, { protocol: false }), + orgName: organization.name, + prevLink: `${getOrgFullOrigin("", { protocol: true })}/${ctx.user.username}`, + newLink: `${getOrgFullOrigin(slug, { protocol: true })}/${ownerProfile.username}`, }); if (!organization.id) throw Error("User not created"); + const user = await UserRepository.enrichUserWithItsProfile({ + user: { ...orgOwner, organizationId: organization.id }, + }); - return { user: { ...createOwnerOrg, organizationId: organization.id, password } }; + await prisma.availability.createMany({ + data: availability.map((schedule) => ({ + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + userId: user.id, + })), + }); + + return { userId: user.id, email: user.email, organizationId: user.organizationId, upId: user.profile.upId }; + + // Sync Services: Close.com + //closeComUpsertOrganizationUser(createTeam, ctx.user, MembershipRole.OWNER); }; -async function checkOrg(adminEmail: string, inputLanguage?: string) { - const language = await getTranslation(inputLanguage ?? "en", "common"); - - const secret = createHash("md5") - .update(adminEmail + process.env.CALENDSO_ENCRYPTION_KEY) - .digest("hex"); - - totp.options = { step: 900 }; - const code = totp.generate(secret); - - await sendOrganizationEmailVerification({ - user: { - email: adminEmail, - }, - code, - language, - }); -} - export default createHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/create.schema.ts b/packages/trpc/server/routers/viewer/organizations/create.schema.ts index 2fe68d6e3c..024b989ecd 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.schema.ts @@ -5,10 +5,10 @@ import slugify from "@calcom/lib/slugify"; export const ZCreateInputSchema = z.object({ name: z.string(), slug: z.string().transform((val) => slugify(val.trim())), - adminEmail: z.string().email(), - adminUsername: z.string(), - check: z.boolean().default(true), + orgOwnerEmail: z.string().email(), language: z.string().optional(), + seats: z.number().optional(), + pricePerSeat: z.number().optional(), isPlatform: z.boolean().default(false), }); diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index 68f1109338..b1437c4b9c 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -1,14 +1,19 @@ +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { UserRepository } from "@calcom/lib/server/repository/user"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole, RedirectType } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; +import inviteMemberHandler from "../teams/inviteMember/inviteMember.handler"; import type { TCreateTeamsSchema } from "./createTeams.schema"; +const log = logger.getSubLogger({ prefix: ["viewer/organizations/createTeams.handler"] }); type CreateTeamsOptions = { ctx: { user: NonNullable; @@ -24,7 +29,10 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => throw new NoUserError(); } - const { teamNames, orgId } = input; + const { orgId, moveTeams } = input; + + // Remove empty team names that could be there due to the default empty team name + const teamNames = input.teamNames.filter((name) => name.trim().length > 0); if (orgId !== user.organizationId) { throw new NotAuthorizedError(); @@ -52,7 +60,7 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => const organization = await prisma.team.findFirst({ where: { id: orgId }, - select: { slug: true, metadata: true }, + select: { slug: true, id: true, metadata: true }, }); if (!organization) throw new NoOrganizationError(); @@ -82,9 +90,21 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => teamNames.map((item) => slugify(item)).includes(slug) ); + await Promise.all( + moveTeams.map(async ({ id: teamId, newSlug }) => { + await moveTeam({ + teamId, + newSlug, + org: organization, + ctx, + }); + }) + ); + if (duplicatedSlugs.length === teamNames.length) { return { duplicatedSlugs }; } + await prisma.$transaction( teamNames.flatMap((name) => { if (!duplicatedSlugs.includes(slugify(name))) { @@ -138,3 +158,128 @@ class NoOrganizationSlugError extends TRPCError { } export default createTeamsHandler; + +async function moveTeam({ + teamId, + newSlug, + org, + ctx, +}: { + teamId: number; + newSlug?: string | null; + org: { + id: number; + slug: string | null; + }; + ctx: CreateTeamsOptions["ctx"]; +}) { + log.debug("Moving team", safeStringify({ teamId, newSlug, org })); + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + slug: true, + members: { + select: { + role: true, + userId: true, + user: { + select: { + email: true, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Team with id: ${teamId} not found`, + }); + } + + newSlug = newSlug ?? team.slug; + await prisma.team.update({ + where: { + id: teamId, + }, + data: { + slug: newSlug, + parentId: org.id, + }, + }); + + await Promise.all( + // TODO: Support different role for different members in usernameOrEmail list and then remove this map + team.members.map(async (membership) => { + // Invite team members to the new org. They are already members of the team. + await inviteMemberHandler({ + ctx, + input: { + teamId: org.id, + language: "en", + role: membership.role, + usernameOrEmail: membership.user.email, + isOrg: true, + }, + }); + }) + ); + + await addTeamRedirect({ + oldTeamSlug: team.slug, + teamSlug: newSlug, + orgSlug: org.slug, + }); +} + +async function addTeamRedirect({ + oldTeamSlug, + teamSlug, + orgSlug, +}: { + oldTeamSlug: string | null; + teamSlug: string | null; + orgSlug: string | null; +}) { + logger.info(`Adding redirect for team: ${oldTeamSlug} -> ${teamSlug}`); + if (!oldTeamSlug) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No oldSlug for team. Not adding the redirect", + }); + } + if (!teamSlug) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No slug for team. Not adding the redirect", + }); + } + if (!orgSlug) { + logger.warn(`No slug for org. Not adding the redirect`); + return; + } + const orgUrlPrefix = getOrgFullOrigin(orgSlug); + + await prisma.tempOrgRedirect.upsert({ + where: { + from_type_fromOrgId: { + type: RedirectType.Team, + from: oldTeamSlug, + fromOrgId: 0, + }, + }, + create: { + type: RedirectType.Team, + from: oldTeamSlug, + fromOrgId: 0, + toUrl: `${orgUrlPrefix}/${teamSlug}`, + }, + update: { + toUrl: `${orgUrlPrefix}/${teamSlug}`, + }, + }); +} diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.schema.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.schema.ts index 4d77e8d5f6..724a7cf4e2 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.schema.ts @@ -3,6 +3,13 @@ import { z } from "zod"; export const ZCreateTeamsSchema = z.object({ teamNames: z.string().array(), orgId: z.number(), + moveTeams: z.array( + z.object({ + id: z.number(), + newSlug: z.string().nullable(), + shouldMove: z.boolean(), + }) + ), }); export type TCreateTeamsSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index 39c7bd95ce..05f6b92fdb 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -1,5 +1,5 @@ import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; -import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; @@ -37,12 +37,14 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { // Since this is an ORG we need to make sure ORG members are scyned with the team. Every time a user is added to the TEAM, we need to add them to the ORG if (IS_TEAM_BILLING_ENABLED) { - const checkoutSession = await purchaseTeamSubscription({ + const checkoutSession = await purchaseTeamOrOrgSubscription({ teamId: prevTeam.id, - seats: prevTeam.members.length, + seats: Math.max(prevTeam.members.length, metadata.data?.orgSeats ?? 0), userId: ctx.user.id, isOrg: true, + pricePerSeat: metadata.data?.orgPricePerSeat ?? null, }); + if (!checkoutSession.url) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts index 963c53f5b6..b9663a8abc 100644 --- a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts @@ -49,7 +49,11 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) const needProfileUpdate = !!idOfOrganizationInContext; if (needProfileUpdate) { await createAProfileForAnExistingUser({ - user: ctx.user, + user: { + id: ctx.user.id, + email: ctx.user.email, + currentUsername: ctx.user.username, + }, organizationId: idOfOrganizationInContext, }); } diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 53a1e488e6..affa48263b 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -60,14 +60,16 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = isOrg, }); - const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(isOrg, team); + const { autoAcceptEmailDomain, orgVerified, orgConfigured, orgPublished } = getIsOrgVerified(isOrg, team); const usernameOrEmailsToInvite = await getUsernameOrEmailsToInvite(input.usernameOrEmail); const orgConnectInfoByUsernameOrEmail = usernameOrEmailsToInvite.reduce((acc, usernameOrEmail) => { return { ...acc, [usernameOrEmail]: getOrgConnectionInfo({ + orgConfigured, orgVerified, + orgPublished, orgAutoAcceptDomain: autoAcceptEmailDomain, usersEmail: usernameOrEmail, team, @@ -75,19 +77,24 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = }), }; }, {} as Record>); - const existingUsersWithMembersips = await getUsersToInvite({ + const existingUsersWithMemberships = await getUsersToInvite({ usernamesOrEmails: usernameOrEmailsToInvite, isInvitedToOrg: isOrg, team, }); - const existingUsersEmailsAndUsernames = existingUsersWithMembersips.reduce( + const existingUsersWithMembershipsThatNeedToBeInvited = existingUsersWithMemberships.filter( + (invitee) => invitee.canBeInvited + ); + + const existingUsersEmailsAndUsernames = existingUsersWithMemberships.reduce( (acc, user) => ({ emails: user.email ? [...acc.emails, user.email] : acc.emails, usernames: user.username ? [...acc.usernames, user.username] : acc.usernames, }), { emails: [], usernames: [] } as { emails: string[]; usernames: string[] } ); + const newUsersEmailsOrUsernames = usernameOrEmailsToInvite.filter( (usernameOrEmail) => !existingUsersEmailsAndUsernames.emails.includes(usernameOrEmail) && @@ -99,7 +106,8 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = safeStringify({ usernameOrEmailsToInvite, orgConnectInfoByUsernameOrEmail, - existingUsersWithMembersips, + existingUsersWithMembershipsThatNeedToBeInvited: existingUsersWithMembershipsThatNeedToBeInvited, + existingUsersWithMemberships, existingUsersEmailsAndUsernames, newUsersEmailsOrUsernames, }) @@ -127,13 +135,16 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = sendEmails(sendVerifEmailsPromises); } + const organization = ctx.user.profile.organization; + const orgSlug = organization ? organization.slug || organization.requestedSlug : null; // deal with existing users invited to join the team/org await handleExistingUsersInvites({ - existingUsersWithMembersips, + existingUsersWithMembersips: existingUsersWithMembershipsThatNeedToBeInvited, team, orgConnectInfoByUsernameOrEmail, input, inviter: ctx.user, + orgSlug, }); if (IS_TEAM_BILLING_ENABLED) { @@ -154,6 +165,7 @@ async function handleExistingUsersInvites({ orgConnectInfoByUsernameOrEmail, input, inviter, + orgSlug, }: { existingUsersWithMembersips: Awaited>; team: TeamWithParent; @@ -168,6 +180,7 @@ async function handleExistingUsersInvites({ inviter: { name: string | null; }; + orgSlug: string | null; }) { if (!existingUsersWithMembersips.length) { return; @@ -176,7 +189,12 @@ async function handleExistingUsersInvites({ const translation = await getTranslation(input.language ?? "en", "common"); if (!team.isOrganization) { const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ - existingUsersWithMembersips, + existingUsersWithMembersips: existingUsersWithMembersips.map((u) => { + return { + ...u, + profile: null, + }; + }), team, connectionInfoMap: orgConnectInfoByUsernameOrEmail, }); @@ -213,6 +231,7 @@ async function handleExistingUsersInvites({ teamId: team.id, isAutoJoin: true, currentUserParentTeamName: team?.parent?.name, + orgSlug, }); } @@ -233,6 +252,7 @@ async function handleExistingUsersInvites({ teamId: team.id, isAutoJoin: false, currentUserParentTeamName: team?.parent?.name, + orgSlug, }); } @@ -246,7 +266,11 @@ async function handleExistingUsersInvites({ .filter((u) => u.needToCreateProfile) .map((user) => createAProfileForAnExistingUser({ - user: user, + user: { + id: user.id, + email: user.email, + currentUsername: user.username, + }, organizationId: parsedOrg.id, }) ), @@ -261,33 +285,44 @@ async function handleExistingUsersInvites({ }) ); - const autoJoinUsers = existingUsersWithMembersips.filter( + const existingUsersWithMembershipsNew = await Promise.all( + existingUsersWithMembersips.map(async (user) => { + const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept; + let profile = null; + if (shouldAutoAccept) { + profile = await createAProfileForAnExistingUser({ + user: { + id: user.id, + email: user.email, + currentUsername: user.username, + }, + organizationId: organization.id, + }); + } + + await prisma.membership.create({ + data: { + userId: user.id, + teamId: team.id, + accepted: shouldAutoAccept, + role: input.role, + }, + }); + return { + ...user, + profile, + }; + }) + ); + + const autoJoinUsers = existingUsersWithMembershipsNew.filter( (user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept ); - const regularUsers = existingUsersWithMembersips.filter( + const regularUsers = existingUsersWithMembershipsNew.filter( (user) => !orgConnectInfoByUsernameOrEmail[user.email].autoAccept ); - for (const user of existingUsersWithMembersips) { - const shouldAutoAccept = orgConnectInfoByUsernameOrEmail[user.email].autoAccept; - if (shouldAutoAccept) { - await createAProfileForAnExistingUser({ - user: user, - organizationId: organization.id, - }); - } - - await prisma.membership.create({ - data: { - userId: user.id, - teamId: team.id, - accepted: shouldAutoAccept, - role: input.role, - }, - }); - } - // Send emails to user who auto-joined await sendExistingUserTeamInviteEmails({ currentUserName: inviter.name, @@ -298,6 +333,7 @@ async function handleExistingUsersInvites({ teamId: team.id, isAutoJoin: true, currentUserParentTeamName: team?.parent?.name, + orgSlug, }); // Send emails to user who need to accept invite @@ -310,6 +346,7 @@ async function handleExistingUsersInvites({ teamId: team.id, isAutoJoin: false, currentUserParentTeamName: team?.parent?.name, + orgSlug, }); } } diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index b0c76f2bb4..d29053126d 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -13,7 +13,7 @@ import { getUsernameOrEmailsToInvite, getIsOrgVerified, getOrgConnectionInfo, - validateInviteeEligibility, + canBeInvited, getAutoJoinStatus, checkInputEmailIsValid, } from "./utils"; @@ -88,6 +88,7 @@ const mockedRegularTeam: TeamWithParent = { logoUrl: "", isOrganization: false, calVideoLogo: "", + bannerUrl: "", }; const mockedSubTeam = { @@ -99,7 +100,10 @@ const mockUser: UserWithMembership = { id: 4, username: "pro", email: "pro@example.com", - password: "", + password: { + hash: "", + userId: 0, + }, completedOnboarding: true, identityProvider: "CAL", profiles: [], @@ -167,9 +171,11 @@ describe("Invite Member Utils", () => { const orgAutoAcceptDomain = "example.com"; const usersEmail = "user@example.com"; - it("should return orgId and autoAccept as true if team has parent and usersEmail domain matches orgAutoAcceptDomain and orgVerified is true", () => { + it("should return orgId and autoAccept as true if team has parent and usersEmail domain matches orgAutoAcceptDomain and orgConfigured is true", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, + orgConfigured: true, + orgPublished: true, orgVerified: true, usersEmail, team: { @@ -181,9 +187,60 @@ describe("Invite Member Utils", () => { expect(result).toEqual({ orgId: 2, autoAccept: true }); }); + it("should return autoAccept:false when orgConfigured is false even if usersEmail domain matches orgAutoAcceptDomain and orgVerified and orgPublished is true ", () => { + const result = getOrgConnectionInfo({ + orgAutoAcceptDomain, + orgConfigured: false, + orgPublished: true, + orgVerified: true, + usersEmail, + team: { + ...mockedRegularTeam, + parentId: 2, + }, + isOrg: false, + }); + expect(result).toEqual({ orgId: 2, autoAccept: false }); + }); + + it("should return autoAccept:false when orgVerified is false even if usersEmail domain matches orgAutoAcceptDomain and orgConfigured and orgPublished is true ", () => { + const result = getOrgConnectionInfo({ + orgAutoAcceptDomain, + orgVerified: false, + orgConfigured: true, + orgPublished: true, + usersEmail, + team: { + ...mockedRegularTeam, + parentId: 2, + }, + isOrg: false, + }); + expect(result).toEqual({ orgId: 2, autoAccept: false }); + }); + + it("should return autoAccept:false when orgPublished is false even if usersEmail domain matches orgAutoAcceptDomain and orgVerified and orgConfigured is true ", () => { + const result = getOrgConnectionInfo({ + orgAutoAcceptDomain, + orgPublished: false, + orgConfigured: true, + orgVerified: true, + usersEmail, + team: { + ...mockedRegularTeam, + parentId: 2, + }, + isOrg: false, + }); + expect(result).toEqual({ orgId: 2, autoAccept: false }); + }); + it("should return orgId and autoAccept as false if team has parent and usersEmail domain does not match orgAutoAcceptDomain", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, + orgConfigured: false, + orgVerified: false, + orgPublished: true, usersEmail: "user@other.com", team: { ...mockedRegularTeam, @@ -194,10 +251,12 @@ describe("Invite Member Utils", () => { expect(result).toEqual({ orgId: undefined, autoAccept: false }); }); - it("should return orgId and autoAccept as false if team has parent and usersEmail domain matches orgAutoAcceptDomain but orgVerified is false", () => { + it("should return orgId and autoAccept as false if team has parent and usersEmail domain matches orgAutoAcceptDomain but orgConfigured is false", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, + orgConfigured: false, orgVerified: false, + orgPublished: true, usersEmail, team: { ...mockedRegularTeam }, isOrg: false, @@ -205,10 +264,12 @@ describe("Invite Member Utils", () => { expect(result).toEqual({ orgId: undefined, autoAccept: false }); }); - it("should return orgId and autoAccept as true if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain and orgVerified is true", () => { + it("should return orgId and autoAccept as true if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain and orgConfigured is true", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, + orgConfigured: true, orgVerified: true, + orgPublished: true, usersEmail, team: { ...mockedRegularTeam, parentId: null }, isOrg: true, @@ -219,6 +280,9 @@ describe("Invite Member Utils", () => { it("should return orgId and autoAccept as false if team has no parent and isOrg is true and usersEmail domain does not match orgAutoAcceptDomain", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, + orgVerified: false, + orgConfigured: false, + orgPublished: true, usersEmail: "user@other.com", team: { ...mockedRegularTeam, parentId: null }, isOrg: true, @@ -229,7 +293,9 @@ describe("Invite Member Utils", () => { it("should return orgId and autoAccept as false if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain but orgVerified is false", () => { const result = getOrgConnectionInfo({ orgAutoAcceptDomain, + orgConfigured: false, orgVerified: false, + orgPublished: true, usersEmail, team: { ...mockedRegularTeam, parentId: null }, isOrg: true, @@ -247,12 +313,15 @@ describe("Invite Member Utils", () => { isOrganizationVerified: true, orgAutoAcceptEmail: "example.com", }, + slug: "abc", parent: null, }; const result = getIsOrgVerified(true, { ...mockedRegularTeam, ...team }); expect(result).toEqual({ isInOrgScope: true, orgVerified: true, + orgConfigured: false, + orgPublished: true, autoAcceptEmailDomain: "example.com", }); }); @@ -275,6 +344,8 @@ describe("Invite Member Utils", () => { expect(result).toEqual({ isInOrgScope: true, orgVerified: false, + orgConfigured: false, + orgPublished: false, autoAcceptEmailDomain: "example.com", }); }); @@ -291,7 +362,7 @@ describe("Invite Member Utils", () => { }); }); - describe("validateInviteeEligibility: Check if user can be invited to the team/org", () => { + describe("canBeInvited: Check if user can be invited to the team/org", () => { const invitee: UserWithMembership = { ...mockUser, id: 1, @@ -299,9 +370,8 @@ describe("Invite Member Utils", () => { email: "testuser@example.com", profiles: [], }; - const isOrg = false; - it("should not throw when inviting to an organization's team an existing org user", () => { + it("should return true when inviting to an organization's team an existing org user", () => { const inviteeWithOrg: UserWithMembership = { ...invitee, profiles: [getSampleProfile({ organizationId: 2 })], @@ -310,10 +380,10 @@ describe("Invite Member Utils", () => { ...mockedRegularTeam, parentId: 2, }; - expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg)).not.toThrow(); + expect(canBeInvited(inviteeWithOrg, teamWithOrg)).toBe(true); }); - it("should throw a TRPCError when inviting a user who is already a member of the team", () => { + it("should return false when inviting a user who is already a member of the team", () => { const inviteeWithOrg: UserWithMembership = { ...invitee, profiles: [getSampleProfile()], @@ -323,15 +393,15 @@ describe("Invite Member Utils", () => { ...mockedRegularTeam, id: 1, }; - expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg)).toThrow(TRPCError); + expect(canBeInvited(inviteeWithOrg, teamWithOrg)).toBe(false); }); - it("should not throw any error if the invitee already exists in Cal.com and is being invited to an organization", () => { - expect(() => validateInviteeEligibility(invitee, mockedRegularTeam)).not.toThrow(); + it("should return true if the invitee already exists in Cal.com and is being invited to an organization", () => { + expect(canBeInvited(invitee, mockedRegularTeam)).toBe(true); }); - it("should not throw an error if the invitee does not already belong to another organization and is not being invited to an organization", () => { - expect(() => validateInviteeEligibility(invitee, mockedRegularTeam)).not.toThrow(); + it("should return true if the invitee does not already belong to another organization and is not being invited to an organization", () => { + expect(canBeInvited(invitee, mockedRegularTeam)).toBe(true); }); }); describe("shouldAutoJoinIfInOrg", () => { diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index f344a48413..9b0da90cb5 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -1,6 +1,7 @@ import { randomBytes } from "crypto"; import type { TFunction } from "next-i18next"; +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { sendTeamInviteEmail } from "@calcom/emails"; import { ENABLE_PROFILE_SWITCHER, WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; @@ -99,13 +100,10 @@ export async function getUsernameOrEmailsToInvite(usernameOrEmail: string | stri return emailsToInvite; } -export function validateInviteeEligibility(invitee: UserWithMembership, team: TeamWithParent) { +export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent) { const alreadyInvited = invitee.teams?.find(({ teamId: membershipTeamId }) => team.id === membershipTeamId); if (alreadyInvited) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `${invitee.email} has already been invited.`, - }); + return false; } const orgMembership = invitee.teams?.find((membersip) => membersip.teamId === team.parentId); @@ -114,15 +112,12 @@ export function validateInviteeEligibility(invitee: UserWithMembership, team: Te team.parentId && UserRepository.isAMemberOfOrganization({ user: invitee, organizationId: team.parentId }) ) { - return; + return true; } // user invited to join a team inside an org, but has not accepted invite to org yet if (team.parentId && orgMembership && !orgMembership.accepted) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `User ${invitee.username} needs to accept the invitation to join your organization first.`, - }); + return false; } // user is invited to join a team in an organization where he isn't a member @@ -130,11 +125,9 @@ export function validateInviteeEligibility(invitee: UserWithMembership, team: Te !ENABLE_PROFILE_SWITCHER && invitee.profiles.find((profile) => profile.organizationId != team.parentId) ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `User ${invitee.username} is already a member of another organization.`, - }); + return false; } + return true; } export async function getUsersToInvite({ @@ -145,7 +138,7 @@ export async function getUsersToInvite({ usernamesOrEmails: string[]; isInvitedToOrg: boolean; team: TeamWithParent; -}): Promise { +}) { const memberships = []; if (isInvitedToOrg) { memberships.push({ teamId: team.id }); @@ -183,21 +176,27 @@ export async function getUsersToInvite({ }); // Check if the users found in the database can be invited to join the team/org - invitees.forEach((invitee) => { - validateInviteeEligibility(invitee, team); + return invitees.map((invitee) => { + return { + ...invitee, + canBeInvited: canBeInvited(invitee, team), + }; }); - return invitees; } export function getOrgConnectionInfo({ orgAutoAcceptDomain, + orgConfigured, orgVerified, + orgPublished, isOrg, usersEmail, team, }: { orgAutoAcceptDomain?: string | null; - orgVerified?: boolean | null; + orgConfigured: boolean; + orgVerified: boolean; + orgPublished: boolean; usersEmail: string; team: TeamWithParent; isOrg: boolean; @@ -208,7 +207,11 @@ export function getOrgConnectionInfo({ if (team.parentId || isOrg) { orgId = team.parentId || team.id; if (usersEmail.split("@")[1] == orgAutoAcceptDomain) { - autoAccept = orgVerified ?? true; + // If there is a failure in DNS setup(orgConfigured is false), we can't let the user auto-join. It could be due to that domain not being allowed to be created. + // If the org is not verified, we can't let the user auto-join. It could be due to the org not being verified. + // If the org is not published, we can't let the user auto-join. This is to prevent someone squatting a domain without paying. Because only after payment, the org is published. + // Also, this is to discourage someone creating an org like yahoo.cal.com and auto-adding any yahoo.com email user to it. + autoAccept = orgConfigured && orgVerified && orgPublished; } else { orgId = undefined; autoAccept = false; @@ -410,6 +413,10 @@ export async function sendSignupToOrganizationEmail({ isOrg: isOrg, parentTeamName: team?.parent?.name, isAutoJoin: false, + isExistingUserMovedToOrg: false, + // For a new user there is no prev and new links. + prevLink: null, + newLink: null, }); } @@ -429,19 +436,29 @@ export function getIsOrgVerified( return { isInOrgScope: true, orgVerified: team.organizationSettings.isOrganizationVerified, + orgConfigured: team.organizationSettings.isOrganizationConfigured, autoAcceptEmailDomain: team.organizationSettings.orgAutoAcceptEmail, + orgPublished: !!team.slug, }; } else if (parentSettings?.orgAutoAcceptEmail) { return { isInOrgScope: true, orgVerified: parentSettings.isOrganizationVerified, + orgConfigured: parentSettings.isOrganizationConfigured, autoAcceptEmailDomain: parentSettings.orgAutoAcceptEmail, + orgPublished: !!team.parent?.slug, }; } return { isInOrgScope: false, - } as { isInOrgScope: false; orgVerified: never; autoAcceptEmailDomain: never }; + } as { + isInOrgScope: false; + orgVerified: never; + autoAcceptEmailDomain: never; + orgConfigured: never; + orgPublished: never; + }; } export function getAutoJoinStatus({ @@ -506,7 +523,11 @@ export const groupUsersByJoinability = ({ connectionInfoMap, }: { team: TeamWithParent; - existingUsersWithMembersips: UserWithMembership[]; + existingUsersWithMembersips: (UserWithMembership & { + profile: { + username: string; + } | null; + })[]; connectionInfoMap: Record>; }) => { const usersToAutoJoin = []; @@ -539,7 +560,7 @@ export const sendEmails = async (emailPromises: Promise[]) => { const sentEmails = await Promise.allSettled(emailPromises); sentEmails.forEach((sentEmail) => { if (sentEmail.status === "rejected") { - logger.error("Could not send email to user"); + logger.error("Could not send email to user. Reason:", sentEmail.reason); } }); }; @@ -553,19 +574,24 @@ export const sendExistingUserTeamInviteEmails = async ({ isOrg, teamId, isAutoJoin, + orgSlug, }: { language: TFunction; isAutoJoin: boolean; - existingUsersWithMembersips: UserWithMembership[]; + existingUsersWithMembersips: (UserWithMembership & { + profile: { + username: string; + } | null; + })[]; currentUserTeamName?: string; currentUserParentTeamName: string | undefined; currentUserName?: string | null; isOrg: boolean; teamId: number; + orgSlug: string | null; }) => { const sendEmailsPromises = existingUsersWithMembersips.map(async (user) => { let sendTo = user.email; - if (!isEmail(user.email)) { sendTo = user.email; } @@ -617,6 +643,9 @@ export const sendExistingUserTeamInviteEmails = async ({ ...inviteTeamOptions, isOrg: isOrg, parentTeamName: currentUserParentTeamName, + isExistingUserMovedToOrg: true, + prevLink: `${getOrgFullOrigin("")}/${user.username || ""}`, + newLink: user.profile ? `${getOrgFullOrigin(orgSlug ?? "")}/${user.profile.username}` : null, }); } }); diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts index 4f543321e3..d5550ed5bf 100644 --- a/packages/trpc/server/routers/viewer/teams/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -2,7 +2,7 @@ import type { Prisma } from "@prisma/client"; import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; import { - purchaseTeamSubscription, + purchaseTeamOrOrgSubscription, updateQuantitySubscriptionFromStripe, } from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; @@ -43,10 +43,11 @@ const generateCheckoutSession = async ({ }) => { if (!IS_TEAM_BILLING_ENABLED) return; - const checkoutSession = await purchaseTeamSubscription({ + const checkoutSession = await purchaseTeamOrOrgSubscription({ teamId, seats, userId, + pricePerSeat: null, }); if (!checkoutSession.url) throw new TRPCError({ diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts index 855b5a5a11..c439c2da40 100644 --- a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -56,6 +56,10 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption ...inviteTeamOptions, isOrg: input.isOrg, parentTeamName: team?.parent?.name, + // We don't know at his moment if this user was an existing user or a new user as it is a resend. So, we assume it's a new user and we can avoid sending the prevLink and newLink. + isExistingUserMovedToOrg: false, + prevLink: null, + newLink: null, }); return input; diff --git a/packages/ui/components/card/Card.tsx b/packages/ui/components/card/Card.tsx index d918154ea6..2ad1d0c17f 100644 --- a/packages/ui/components/card/Card.tsx +++ b/packages/ui/components/card/Card.tsx @@ -108,6 +108,7 @@ export interface BaseCardProps extends CVACardType { href?: string; child: ReactNode; onClick?: (event: React.MouseEvent) => void; + "data-testId"?: string; }; learnMore?: { href: string; @@ -177,6 +178,7 @@ export function Card({ target="_blank" rel="noreferrer" href={mediaLink} + data-testId={actionButton?.["data-testId"]} className="group relative my-3 flex aspect-video items-center overflow-hidden rounded">
    - {variant === "basic" && ( -
    + - )} -
    +
    + )} {variant === "SidebarCard" && (
    @@ -226,6 +233,7 @@ export function Card({ diff --git a/packages/ui/components/form/inputs/HintOrErrors.tsx b/packages/ui/components/form/inputs/HintOrErrors.tsx index 3a32240e40..472323b23d 100644 --- a/packages/ui/components/form/inputs/HintOrErrors.tsx +++ b/packages/ui/components/form/inputs/HintOrErrors.tsx @@ -52,9 +52,7 @@ export function HintsOrErrors({
  • + className={error !== undefined ? (submitted ? "text-red-500" : "") : "text-green-600"}> {error !== undefined ? ( submitted ? ( diff --git a/packages/ui/form/radio-area/RadioAreaGroup.tsx b/packages/ui/form/radio-area/RadioAreaGroup.tsx index 2cf1e90014..ce54693074 100644 --- a/packages/ui/form/radio-area/RadioAreaGroup.tsx +++ b/packages/ui/form/radio-area/RadioAreaGroup.tsx @@ -16,14 +16,14 @@ const RadioArea = ({ children, className, classNames: innerClassNames, ...props return (