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:
co-authored by
Alex van Andel
Hariom
sean-brydon
Joe Au-Yeung
Joe Au-Yeung
Omar López
parent
c3b1de498d
commit
b3f4cfab32
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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'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",
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -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'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'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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user