feat: new org onboarding wizard (#13139)

* added layouts for moving teams step

* added admin section for org creation step

* extended org creation to also move existing teams

* wip

* wip

* wip

* further changes

* Add checkout for org in onboarding

* Fix ts errors

* Self review feedback

* Self review addressed

* Fix unit tests

* Fix ts error

* Seans feedback addressed

* feat: fix correct accounts pending

* fix: unit tests for new invite member permissions

* tests: org admin onboarding tests for existing user

* tests: Inital user self serve flow

* chore: update teamAndUserFixture to create X amount of teams

* chore: add testId to card actionButton

* test: add test-Id to continue or checkout button

* tests: add tests for migrating existing teams

* feat: match new designs

* fix: isAdminCheck

* chore: fix pricing copy

* fix: flacky tests

* Fix tests?

* More test fixes

* Check all checkboxes

* Fix type error

* Fix failing test and typescript issues

* Fix unpaid org allowing auto-add users

* Add self-serve flag

* Skip tests

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Peer Richelsen
2024-04-03 18:53:02 +05:30
committed by GitHub
co-authored by Alex van Andel Hariom sean-brydon Joe Au-Yeung Joe Au-Yeung Omar López
parent c3b1de498d
commit b3f4cfab32
49 changed files with 1813 additions and 755 deletions
+3 -1
View File
@@ -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,
@@ -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 });
@@ -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 (
<WizardLayout
currentStep={5}
maxSteps={5}
isOptionalCallback={() => {
router.push(`/event-types`);
}}>
<WizardLayout currentStep={5} maxSteps={5}>
{page}
</WizardLayout>
);
@@ -41,12 +31,7 @@ AddNewTeamsPage.PageWrapper = PageWrapper;
export const WrapperAddNewTeamsPage = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir
currentStep={5}
maxSteps={5}
isOptionalCallback={() => {
redirect(`/event-types`);
}}>
<WizardLayoutAppDir currentStep={5} maxSteps={5}>
{page}
</WizardLayoutAppDir>
);
@@ -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 (
<>
<Meta title={t("set_a_password")} description={t("set_a_password_description")} />
<SetPasswordForm />
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={2} maxSteps={5}>
{page}
</WizardLayout>
);
};
export const WrappedSetPasswordPage = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir currentStep={2} maxSteps={5}>
{page}
</WizardLayoutAppDir>
);
};
SetPasswordPage.getLayout = LayoutWrapper;
SetPasswordPage.PageWrapper = PageWrapper;
export default SetPasswordPage;
@@ -15,7 +15,7 @@ const CreateNewOrganizationPage = ({ querySlug }: inferSSRProps<typeof getServer
return (
<LicenseRequired>
<Meta title={t("set_up_your_organization")} description={t("organizations_description")} />
<CreateANewOrganizationForm slug={querySlug} />
<CreateANewOrganizationForm />
</LicenseRequired>
);
};
+106 -94
View File
@@ -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)
),
},
},
});
}
}
}
}
+2
View File
@@ -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");
}
@@ -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);
});
});
});
@@ -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();
@@ -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 <strong>{{teamName}}</strong> 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 <strong>{{teamName}}</strong> 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 <strong>{{teamName}}</strong> 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&apos;t worry, all previous links will still work and redirect appropriately.<br /><br />Please note: All of your personal event types will be moved into the <strong>{teamName}</strong> organization, which may also include potential personal link. <br /><br />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 <a href={`https://${prevLink}`}>{prevLinkWithoutProtocol}</a> to <a href={`https://${newLink}`}>{newLinkWithoutProtocol}</a> but don&apos;t worry, all previous links still work and redirect appropriately. <br /><br />Please note: All of your personal event types have been moved into the <strong>{teamName}</strong> organisation, which may also include potential personal link.<br /><br />Please log in and make sure you have no private events on your new organisational account.<br /><br />For personal events we recommend creating a new account with a personal email address.<br /><br />Enjoy your new clean link: <a href={`https://${newLink}`}>{newLinkWithoutProtocol}</a>",
"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",
+6
View File
@@ -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));
};
@@ -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<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const { prevLink, newLink, orgName: teamName } = props;
const prevLinkWithoutProtocol = props.prevLink?.replace(/https?:\/\//, "");
const newLinkWithoutProtocol = props.newLink?.replace(/https?:\/\//, "");
return (
<V2BaseEmailHtml subject={props.language(`email_organization_created|subject`)}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>{props.language(`You have created ${props.orgName} organization.`)}</>
</p>
<img
style={{
borderRadius: "16px",
height: "270px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
src={`${WEBAPP_URL}/emails/calendar-email-hero.png`}
alt=""
/>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language(`You have been added as an owner of the organization.`, {
invitedBy: props.from.toString(),
appName: APP_NAME,
})}
</>
</p>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "48px",
lineHeightStep: "24px",
}}>
<Trans i18nKey="email|existing_user_added_link_changed">
Your link has been changed from <a href={`https://${prevLink}`}>{prevLinkWithoutProtocol}</a> to{" "}
<a href={`https://${newLink}`}>{newLinkWithoutProtocol}</a> but don&apos;t worry, all previous links
still work and redirect appropriately.
<br />
<br />
Please note: All of your personal event types have been moved into the <strong>
{teamName}
</strong>{" "}
organisation, which may also include potential personal link.
<br />
<br />
Please log in and make sure you have no private events on your new organisational account.
<br />
<br />
For personal events we recommend creating a new account with a personal email address.
<br />
<br />
Enjoy your new clean link: <a href={`https://${newLink}`}>{newLinkWithoutProtocol}</a>
</Trans>
</p>
<div className="">
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language("email_no_user_signoff", {
appName: APP_NAME,
})}
</>
</p>
</div>
<div style={{ borderTop: "1px solid #E1E1E1", marginTop: "32px", paddingTop: "32px" }}>
<p style={{ fontWeight: 400, margin: 0 }}>
<>
{props.language("have_any_questions")}{" "}
<a href="mailto:support@cal.com" style={{ color: "#3E3E3E" }} target="_blank" rel="noreferrer">
<>{props.language("contact")}</>
</a>{" "}
{props.language("our_support_team")}
</>
</p>
</div>
</V2BaseEmailHtml>
);
};
+157 -72
View File
@@ -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<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const typeOfInvite = getTypeOfInvite(props);
const heading = getHeading();
const content = getContent();
return (
<V2BaseEmailHtml
subject={props.language(`user_invited_you${props.parentTeamName ? "_to_subteam" : ""}`, {
user: props.from,
team: props.teamName,
appName: APP_NAME,
parentTeamName: props.parentTeamName,
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}>
<V2BaseEmailHtml subject={getSubject(props)}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>
{props.language(
`email_no_user_invite_heading_${props.isOrg ? "org" : props.parentTeamName ? "subteam" : "team"}`,
{
appName: APP_NAME,
parentTeamName: props.parentTeamName,
}
)}
</>
<>{heading}</>
</p>
<img
style={{
@@ -62,17 +56,7 @@ export const TeamInviteEmail = (
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{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}</>
</p>
<div style={{ display: "flex", justifyContent: "center" }}>
<CallToAction
@@ -90,50 +74,8 @@ export const TeamInviteEmail = (
marginBottom: "32px",
marginTop: "48px",
lineHeightStep: "24px",
}}>
{/* <>
{props.language("email_no_user_invite_steps_intro", {
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
</> */}
</p>
{/*
{!props.isCalcomMember && (
<div style={{ display: "flex", flexDirection: "column", gap: "32px" }}>
<EmailStep
translationString={props.language("email_no_user_step_one")}
iconsrc={
IS_PRODUCTION
? `${WEBAPP_URL}/emails/choose-username@2x.png`
: "http://localhost:3000/emails/choose-username@2x.png"
}
/>
<EmailStep
translationString={props.language("email_no_user_step_two")}
iconsrc={
IS_PRODUCTION
? `${WEBAPP_URL}/emails/calendar@2x.png`
: "http://localhost:3000/emails/calendar@2x.png"
}
/>
<EmailStep
translationString={props.language("email_no_user_step_three")}
iconsrc={
IS_PRODUCTION
? `${WEBAPP_URL}/emails/clock@2x.png`
: "http://localhost:3000/emails/clock@2x.png"
}
/>
<EmailStep
translationString={props.language("email_no_user_step_four", { teamName: props.teamName })}
iconsrc={
IS_PRODUCTION
? `${WEBAPP_URL}/emails/user-check@2x.png`
: "http://localhost:3000/emails/user-check@2x.png"
}
/>
</div>
)} */}
}}
/>
<div className="">
<p
@@ -165,4 +107,147 @@ export const TeamInviteEmail = (
</div>
</V2BaseEmailHtml>
);
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" ? (
<>
<Trans i18nKey="email_team_invite|content|added_to_org">
{invitedBy} has added you to the <strong>{teamName}</strong> organization.
</Trans>{" "}
<Trans
i18nKey="email_team_invite|content_addition|existing_user_added"
values={{ prevLink: props.prevLink, newLink: props.newLink, teamName: props.teamName }}>
Your link has been changed from{" "}
<a href={`https://${prevLink}`}>{prevLinkWithoutProtocol}</a> to{" "}
<a href={`https://${newLink}`}>{newLinkWithoutProtocol}</a> but don&apos;t worry, all
previous links still work and redirect appropriately.
<br />
<br />
Please note: All of your personal event types have been moved into the{" "}
<strong>{teamName}</strong> organisation, which may also include potential personal link.
<br />
<br />
Please log in and make sure you have no private events on your new organisational account.
<br />
<br />
For personal events we recommend creating a new account with a personal email address.
<br />
<br />
Enjoy your new clean link: <a href={`https://${newLink}`}>{newLinkWithoutProtocol}</a>
</Trans>
</>
) : (
<>
<Trans i18nKey="email_team_invite|content|invited_to_org">
{invitedBy} has invited you to join the <strong>{teamName}</strong> organization.
</Trans>{" "}
<Trans
i18nKey="existing_user_added_link_will_change"
values={{ prevLink: props.prevLink, newLink: props.newLink, teamName: props.teamName }}>
On accepting the invite, your link will change to your organization domain but don&apos;t
worry, all previous links will still work and redirect appropriately.
<br />
<br />
Please note: All of your personal event types will be moved into the{" "}
<strong>{teamName}</strong> organisation, which may also include potential personal link.
<br />
<br />
For personal events we recommend creating a new account with a personal email address.
</Trans>
</>
)}
</>
);
}
return (
<>
{autoJoinType === "added" ? (
<Trans i18nKey="email_team_invite|content|added_to_org">
{invitedBy} has added you to the <strong>{teamName}</strong> organization.
</Trans>
) : (
<Trans i18nKey="email_team_invite|content|invited_to_org">
{invitedBy} has invited you to join the <strong>{teamName}</strong> organization.
</Trans>
)}{" "}
<Trans>
{appName} is the event-juggling scheduler that enables you and your team to schedule meetings
without the email tennis.
</Trans>
</>
);
}
if (typeOfInvite === "TO_SUBTEAM") {
return (
<>
(
{autoJoinType === "added" ? (
<Trans i18nKey="email_team_invite|content|added_to_subteam">
{invitedBy} has added you to the team <strong>{teamName}</strong> in their organization{" "}
<strong>{parentTeamName}</strong>
</Trans>
) : (
<Trans i18nKey="email_team_invite|content|invited_to_subteam">
{invitedBy} has added you to the team <strong>{teamName}</strong> in their organization{" "}
<strong>{parentTeamName}</strong>
</Trans>
)}
)
<Trans>
{appName} is the event-juggling scheduler that enables you and your team to schedule meetings
without the email tennis.
</Trans>
</>
);
}
// 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);
}
};
+1
View File
@@ -33,3 +33,4 @@ export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
@@ -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<Record<string, unknown>> {
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: "",
};
}
}
+42 -13
View File
@@ -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: "",
};
@@ -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;
}
@@ -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,
});
}),
@@ -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
@@ -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,
});
@@ -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 <AddNewTeamsFormChild org={orgWithRequestedSlug} teams={regularTeams} />;
};
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 (
<>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Form form={form} handleSubmit={handleFormSubmit}>
{moveTeams.length ? (
<>
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">
Move existing teams
</label>
<ul className="mb-8 space-y-4">
{moveTeams.map((team, index) => {
return (
<li key={team.id}>
<Controller
name={`moveTeams.${index}.shouldMove`}
render={({ field: { value, onChange } }) => (
<CheckboxField
defaultValue={value}
checked={value}
onChange={onChange}
description={teams.find((t) => t.id === team.id)?.name ?? ""}
/>
)}
/>
{moveTeams[index].shouldMove ? (
<TextField
placeholder="New Slug"
defaultValue={teams.find((t) => t.id === team.id)?.slug ?? ""}
{...register(`moveTeams.${index}.newSlug`)}
className="mt-2"
label=""
/>
) : null}
</li>
);
})}
</ul>
</>
) : null}
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">Add New Teams</label>
{fields.map((field, index) => (
<div className={classNames("relative", index > 0 ? "mb-2" : "")} key={field.id}>
<TextField
key={field.id}
{...register(`teams.${index}.name`)}
label=""
onChange={(e) => 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")}
</Button>
</form>
</Form>
</>
);
};
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}`, "");
};
@@ -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 <CreateANewOrganizationFormChild session={session} />;
};
const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(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 (
<>
<Form
form={newOrganizationFormMethods}
className="space-y-5"
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isPending) {
@@ -98,17 +99,15 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => {
createOrganizationMutation.mutate(v);
}
}}>
<div className="mb-5">
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<Controller
name="adminEmail"
name="orgOwnerEmail"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_admin_email"),
}}
@@ -117,23 +116,18 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => {
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="adminEmail"
name="orgOwnerEmail"
disabled={!isAdmin && !isImpersonated}
label={t("admin_email")}
defaultValue={value}
onChange={(e) => {
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 }) => {
)}
/>
</div>
<div className="mb-5">
<div>
<Controller
name="name"
control={newOrganizationFormMethods.control}
@@ -170,7 +164,7 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => {
/>
</div>
<div className="mb-5">
<div>
<Controller
name="slug"
control={newOrganizationFormMethods.control}
@@ -196,7 +190,82 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => {
/>
</div>
<input hidden {...newOrganizationFormMethods.register("adminUsername")} />
{(isAdmin || isImpersonated) && (
<>
<section className="grid grid-cols-2 gap-2">
<div className="w-full">
<Controller
name="seats"
control={newOrganizationFormMethods.control}
render={({ field: { value, onChange } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="30"
name="seats"
type="number"
label="Seats (optional)"
min={MINIMUM_NUMBER_OF_ORG_SEATS}
defaultValue={value || MINIMUM_NUMBER_OF_ORG_SEATS}
onChange={(e) => {
onChange(+e.target.value);
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div className="w-full">
<Controller
name="pricePerSeat"
control={newOrganizationFormMethods.control}
render={({ field: { value, onChange } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="30"
name="pricePerSeat"
type="number"
addOnSuffix="$"
label="Price per seat (optional)"
defaultValue={value}
onChange={(e) => {
onChange(+e.target.value);
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
</section>
</>
)}
{/* This radio group does nothing - its just for visuall purposes */}
{!isAdmin && (
<>
<div className="bg-subtle space-y-5 rounded-lg p-5">
<h3 className="font-cal text-default text-lg font-semibold leading-4">
Upgrade to Organizations
</h3>
<RadioArea.Group className={classNames("mt-1 flex flex-col gap-4")} value="ORGANIZATION">
<RadioArea.Item
className={classNames("bg-default w-full text-sm opacity-70")}
value="TEAMS"
disabled>
<strong className="mb-1 block">{t("teams")}</strong>
<p>{t("your_current_plan")}</p>
</RadioArea.Item>
<RadioArea.Item className={classNames("bg-default w-full text-sm")} value="ORGANIZATION">
<strong className="mb-1 block">{t("organization")}</strong>
<p>{t("organization_price_per_user_month")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
</>
)}
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
@@ -212,17 +281,18 @@ export const CreateANewOrganizationForm = ({ slug }: { slug?: string }) => {
</Button>
</div>
</Form>
<VerifyCodeDialog
isOpenDialog={showVerifyCode}
setIsOpenDialog={setShowVerifyCode}
email={watchAdminEmail}
verifyCodeWithSessionNotRequired={verifyCode.verifyCodeWithSessionNotRequired}
verifyCodeWithSessionRequired={verifyCode.verifyCodeWithSessionRequired}
error={verifyCode.error}
resetErrors={verifyCode.resetErrors}
isPending={verifyCode.isPending}
setIsPending={verifyCode.setIsPending}
/>
</>
);
};
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);
}
@@ -31,6 +31,7 @@ export function OrgUpgradeBanner({ data }: OrgUpgradeBannerProps) {
variant="warning"
actions={
<button
data-testid="upgrade_org_banner_button"
className="border-b border-b-black"
onClick={() => {
publishOrgMutation.mutate();
@@ -1,111 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Form, PasswordField } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
const querySchema = z.object({
id: z.string(),
});
const formSchema = z.object({
password: z.string().superRefine((data, ctx) => {
const isStrict = true;
const result = isPasswordValid(data, true, isStrict);
Object.keys(result).map((key: string) => {
if (!result[key as keyof typeof result]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: key,
});
}
});
}),
});
export const SetPasswordForm = () => {
const { t } = useLocale();
const router = useRouter();
const routerQuery = useRouterQuery();
const { id: orgId } = querySchema.parse(routerQuery);
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const setPasswordFormMethods = useForm<{
password: string;
}>({
resolver: zodResolver(formSchema),
});
const setPasswordMutation = trpc.viewer.organizations.setPassword.useMutation({
onSuccess: (data) => {
if (data.update) {
router.push(`/settings/organizations/${orgId}/about`);
}
},
onError: (err) => {
setServerErrorMessage(err.message);
},
});
return (
<>
<Form
form={setPasswordFormMethods}
handleSubmit={(v) => {
if (!setPasswordMutation.isPending) {
setServerErrorMessage(null);
setPasswordMutation.mutate({ newPassword: v.password });
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
</div>
<div className="mb-5">
<Controller
name="password"
control={setPasswordFormMethods.control}
render={({ field: { onBlur, onChange, value } }) => (
<PasswordField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
setPasswordFormMethods.setValue("password", e.target.value);
await setPasswordFormMethods.trigger("password");
}}
hintErrors={["caplow", "admin_min", "num"]}
name="password"
autoComplete="off"
/>
)}
/>
</div>
<div className="flex">
<Button
disabled={setPasswordFormMethods.formState.isSubmitting || setPasswordMutation.isPending}
color="primary"
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};
@@ -1,4 +1,3 @@
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
export { AboutOrganizationForm } from "./AboutOrganizationForm";
export { SetPasswordForm } from "./SetPasswordForm";
export { AddNewTeamsForm } from "./AddNewTeamsForm";
@@ -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,
@@ -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 (
<ul className="bg-default divide-subtle border-subtle mb-2 divide-y overflow-hidden rounded-md border">
{ORG_SELF_SERVE_ENABLED &&
!props.pending &&
!isUserAlreadyInAnOrganization &&
props.teams.length > 2 &&
props.teams.map(
(team, i) =>
team.role !== "MEMBER" &&
i === 0 && (
<div className="bg-subtle p-4">
<div className="grid-col-1 grid gap-2 md:grid-cols-3">
<Card
icon={<Building className="h-5 w-5 text-red-700" />}
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",
}}
/>
<Card
icon={<Paintbrush className="h-5 w-5 text-orange-700" />}
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"),
}}
/>
<Card
icon={<LineChart className="h-5 w-5 text-green-700" />}
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"),
}}
/>
</div>
</div>
)
)}
{props.teams.map((team) => (
<TeamListItem
key={team?.id as number}
+59 -8
View File
@@ -2,11 +2,15 @@ import { z } from "zod";
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { MINIMUM_NUMBER_OF_ORG_SEATS, WEBAPP_URL } from "@calcom/lib/constants";
import { ORGANIZATION_MIN_SEATS } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
const log = logger.getSubLogger({ prefix: ["teams/lib/payments"] });
const teamPaymentMetadataSchema = z.object({
paymentId: z.string(),
subscriptionId: z.string(),
@@ -59,8 +63,9 @@ export 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: {
teamName,
@@ -74,18 +79,21 @@ export const generateTeamCheckoutSession = async ({
/**
* Used to generate a checkout session when creating a new org (parent team) or backwards compatibility for old teams
*/
export const purchaseTeamSubscription = async (input: {
export const purchaseTeamOrOrgSubscription = async (input: {
teamId: number;
seats: number;
userId: number;
isOrg?: boolean;
pricePerSeat: number | null;
}) => {
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) => {
+7 -1
View File
@@ -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";
@@ -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;
};
@@ -11,7 +11,6 @@ const log = logger.getSubLogger({ prefix: ["repository/membership"] });
type IMembership = {
teamId: number;
userId: number;
profileId: number | null;
accepted: boolean;
role: MembershipRole;
};
@@ -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 };
}
}
+2
View File
@@ -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(),
@@ -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);
}),
@@ -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<string> => {
};
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;
@@ -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),
});
@@ -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<TrpcSessionUser>;
@@ -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}`,
},
});
}
@@ -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<typeof ZCreateTeamsSchema>;
@@ -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",
@@ -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,
});
}
@@ -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<string, ReturnType<typeof getOrgConnectionInfo>>);
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<ReturnType<typeof getUsersToInvite>>;
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,
});
}
}
@@ -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", () => {
@@ -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<UserWithMembership[]> {
}) {
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<string, ReturnType<typeof getOrgConnectionInfo>>;
}) => {
const usersToAutoJoin = [];
@@ -539,7 +560,7 @@ export const sendEmails = async (emailPromises: Promise<void>[]) => {
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,
});
}
});
@@ -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({
@@ -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;
+13 -5
View File
@@ -108,6 +108,7 @@ export interface BaseCardProps extends CVACardType {
href?: string;
child: ReactNode;
onClick?: (event: React.MouseEvent<HTMLElement, 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">
<div className="absolute inset-0 bg-black bg-opacity-50 transition-opacity group-hover:bg-opacity-40" />
<svg
@@ -202,13 +204,18 @@ export function Card({
)}
{/* TODO: this should be CardActions https://mui.com/material-ui/api/card-actions/ */}
<div>
{variant === "basic" && (
<Button color="secondary" href={actionButton?.href} className="mt-10" EndIcon={ArrowRight}>
{variant === "basic" && actionButton && (
<div>
<Button
color="secondary"
href={actionButton?.href}
className="mt-10"
EndIcon={ArrowRight}
data-testId={actionButton["data-testId"]}>
{actionButton?.child}
</Button>
)}
</div>
</div>
)}
{variant === "SidebarCard" && (
<div className="mt-2 flex items-center justify-between">
@@ -226,6 +233,7 @@ export function Card({
<button
className="text-default hover:text-emphasis p-0 text-xs font-normal"
color="minimal"
data-testId={actionButton?.["data-testId"]}
onClick={actionButton?.onClick}>
{actionButton?.child}
</button>
@@ -52,9 +52,7 @@ export function HintsOrErrors<T extends FieldValues = FieldValues>({
<li
key={key}
data-testid="hint-error"
className={
error !== undefined ? (submitted ? "bg-yellow-200 text-red-700" : "") : "text-green-600"
}>
className={error !== undefined ? (submitted ? "text-red-500" : "") : "text-green-600"}>
{error !== undefined ? (
submitted ? (
<X size="12" strokeWidth="3" className="-ml-1 inline-block ltr:mr-2 rtl:ml-2" />
@@ -16,14 +16,14 @@ const RadioArea = ({ children, className, classNames: innerClassNames, ...props
return (
<div
className={classNames(
"border-subtle [&:has(input:checked)]:border-emphasis relative flex items-start rounded-md border",
"border-subtle [&:has(input:checked)]:border-emphasis relative flex items-start rounded-md border ",
className
)}>
<RadioGroupPrimitive.Item
id={id}
{...props}
className={classNames(
"hover:bg-subtle border-default focus:ring-emphasis absolute left-3 top-[0.9rem] mt-0.5 h-4 w-4 flex-shrink-0 rounded-full border focus:ring-2",
"hover:bg-subtle disabled:hover:bg-default border-default focus:ring-emphasis absolute left-3 top-[0.9rem] mt-0.5 h-4 w-4 flex-shrink-0 rounded-full border focus:ring-2 disabled:cursor-not-allowed",
props.disabled && "opacity-60"
)}>
<RadioGroupPrimitive.Indicator
+1
View File
@@ -312,6 +312,7 @@
"NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID",
"NEXT_PUBLIC_HOSTED_CAL_FEATURES",
"NEXT_PUBLIC_MINUTES_TO_BOOK",
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
"NEXT_PUBLIC_SENDER_ID",
"NEXT_PUBLIC_SENDGRID_SENDER_NAME",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",