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