From 65df5ac0e4086422bc0b8416f16ba991a10e785d Mon Sep 17 00:00:00 2001
From: Hariom Balhara
Date: Tue, 9 Apr 2024 15:03:39 +0530
Subject: [PATCH] fix: Allow unpublished org to serve redirected links plus
other org onboarding fixes (#14337)
* fix: Allow unpublished org to serve redirected links
* fix: Fix team event link for unpublished org
* fixes
* Rename variable
* Add minimum teams requirement for upgrade
* Fixes
* PR Feedback addressed
---
.../events/controllers/events.controller.ts | 5 +-
.../d/[link]/[slug]/getServerSideProps.tsx | 15 ++++
apps/web/lib/getTemporaryOrgRedirect.test.ts | 24 +++---
apps/web/lib/getTemporaryOrgRedirect.ts | 2 +-
.../[orgSlug]/[user]/getServerSideProps.tsx | 14 +++-
.../team/[slug]/[type]/getServerSideProps.tsx | 1 +
.../lib/team/[slug]/getServerSideProps.tsx | 73 ++++++++++++++----
.../users-public-view.getServerSideProps.tsx | 5 +-
.../modules/users/views/users-public-view.tsx | 3 +-
...rs-type-public-view.getServerSideProps.tsx | 2 +
.../instant-meeting/team/[slug]/[type].tsx | 1 +
apps/web/pages/team/[slug].tsx | 13 ++--
.../pages/routing-link/getServerSideProps.ts | 5 +-
.../templates/OrganizationCreationEmail.tsx | 8 +-
.../emails/src/templates/TeamInviteEmail.tsx | 10 +--
packages/features/bookings/Booker/Booker.tsx | 9 ++-
packages/features/bookings/Booker/types.ts | 1 +
.../features/eventtypes/lib/getPublicEvent.ts | 15 ++--
packages/lib/constants.ts | 1 +
.../lib/event-types/getEventTypesByViewer.ts | 3 +-
packages/lib/server/queries/teams/index.ts | 2 +
packages/lib/server/repository/membership.ts | 1 +
.../atoms/booker/BookerPlatformWrapper.tsx | 2 +-
.../routers/publicViewer/event.handler.ts | 3 +-
.../routers/publicViewer/event.schema.ts | 5 ++
.../viewer/organizations/create.handler.ts | 42 ++++++++--
.../organizations/createTeams.handler.ts | 18 +++--
.../inviteMember/inviteMember.handler.ts | 4 +-
.../inviteMember/inviteMemberUtils.test.ts | 77 +------------------
.../viewer/teams/inviteMember/utils.ts | 13 +---
.../unpublished-entity/UnpublishedEntity.tsx | 11 +++
31 files changed, 230 insertions(+), 158 deletions(-)
diff --git a/apps/api/v2/src/modules/events/controllers/events.controller.ts b/apps/api/v2/src/modules/events/controllers/events.controller.ts
index b1c0c2567d..67abb7a3aa 100644
--- a/apps/api/v2/src/modules/events/controllers/events.controller.ts
+++ b/apps/api/v2/src/modules/events/controllers/events.controller.ts
@@ -22,7 +22,10 @@ export class EventsController {
queryParams.eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
- this.prismaReadService.prisma as unknown as PrismaClient
+ this.prismaReadService.prisma as unknown as PrismaClient,
+ // We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
+ // If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
+ true
);
return {
data: event,
diff --git a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx
index 12ab695f03..436f39f415 100644
--- a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx
+++ b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx
@@ -8,7 +8,9 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
+import { RedirectType } from "@calcom/prisma/enums";
+import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
@@ -57,6 +59,19 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return notFound;
}
+ if (!org) {
+ const redirect = await getTemporaryOrgRedirect({
+ slugs: [username],
+ redirectType: RedirectType.User,
+ eventTypeSlug: slug,
+ currentQuery: context.query,
+ });
+
+ if (redirect) {
+ return redirect;
+ }
+ }
+
const [user] = await UserRepository.findUsersByUsername({
usernameList: [username],
orgSlug: org,
diff --git a/apps/web/lib/getTemporaryOrgRedirect.test.ts b/apps/web/lib/getTemporaryOrgRedirect.test.ts
index f2705a02bc..d851115321 100644
--- a/apps/web/lib/getTemporaryOrgRedirect.test.ts
+++ b/apps/web/lib/getTemporaryOrgRedirect.test.ts
@@ -63,7 +63,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/30min",
+ destination: "https://calcom.cal.com/30min?orgRedirection=true",
},
});
});
@@ -83,7 +83,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/30min?abc=1",
+ destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
},
});
});
@@ -103,7 +103,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com?abc=1",
+ destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
},
});
});
@@ -127,7 +127,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com?abc=1",
+ destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
},
});
});
@@ -151,7 +151,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/30min?abc=1",
+ destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
},
});
});
@@ -173,7 +173,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/30min",
+ destination: "https://calcom.cal.com/30min?orgRedirection=true",
},
});
});
@@ -200,7 +200,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/first-in-org1+second-in-org1",
+ destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
},
});
});
@@ -227,7 +227,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/first-in-org1+second-in-org1",
+ destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
},
});
@@ -241,7 +241,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect1).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/second-in-org1+first-in-org1",
+ destination: "https://calcom.cal.com/second-in-org1+first-in-org1?orgRedirection=true",
},
});
});
@@ -270,7 +270,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/first-in-org1+second-in-org1?abc=1",
+ destination: "https://calcom.cal.com/first-in-org1+second-in-org1?abc=1&orgRedirection=true",
},
});
});
@@ -297,7 +297,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min",
+ destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?orgRedirection=true",
},
});
});
@@ -326,7 +326,7 @@ describe("getTemporaryOrgRedirect", () => {
expect(redirect).toEqual({
redirect: {
permanent: false,
- destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?abc=1",
+ destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?abc=1&orgRedirection=true",
},
});
});
diff --git a/apps/web/lib/getTemporaryOrgRedirect.ts b/apps/web/lib/getTemporaryOrgRedirect.ts
index d9e2c690d0..f2d443f17a 100644
--- a/apps/web/lib/getTemporaryOrgRedirect.ts
+++ b/apps/web/lib/getTemporaryOrgRedirect.ts
@@ -45,7 +45,7 @@ export const getTemporaryOrgRedirect = async ({
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
const newOrigin = new URL(redirects[0].toUrl).origin;
- const query = currentQueryString ? `?${currentQueryString}` : "";
+ const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
const newSlugs = slugs.map((slug) => {
const redirect = redirects.find((redirect) => redirect.from === slug);
diff --git a/apps/web/lib/org/[orgSlug]/[user]/getServerSideProps.tsx b/apps/web/lib/org/[orgSlug]/[user]/getServerSideProps.tsx
index 4a729042bb..5045da2167 100644
--- a/apps/web/lib/org/[orgSlug]/[user]/getServerSideProps.tsx
+++ b/apps/web/lib/org/[orgSlug]/[user]/getServerSideProps.tsx
@@ -19,7 +19,17 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
},
});
if (team) {
- return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } });
+ return GSSTeamPage({
+ ...ctx,
+ query: { slug: ctx.query.user, orgRedirection: ctx.query.orgRedirection },
+ });
}
- return GSSUserPage({ ...ctx, query: { user: ctx.query.user, redirect: ctx.query.redirect } });
+ return GSSUserPage({
+ ...ctx,
+ query: {
+ user: ctx.query.user,
+ redirect: ctx.query.redirect,
+ orgRedirection: ctx.query.orgRedirection,
+ },
+ });
};
diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx
index 9679f29899..f5263dfa45 100644
--- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx
+++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx
@@ -72,6 +72,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventSlug: meetingSlug,
isTeamEvent: true,
org,
+ fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData) {
diff --git a/apps/web/lib/team/[slug]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/getServerSideProps.tsx
index 4559bd31cd..18d6df4a5b 100644
--- a/apps/web/lib/team/[slug]/getServerSideProps.tsx
+++ b/apps/web/lib/team/[slug]/getServerSideProps.tsx
@@ -9,6 +9,7 @@ import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
+import type { Team } from "@calcom/prisma/client";
import { RedirectType } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -47,6 +48,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
});
const team = await getTeamWithMembers({
+ // It only finds those teams that have slug set. So, if only requestedSlug is set, it won't get that team
slug: slugify(slug ?? ""),
orgSlug: currentOrgDomain,
isTeamView: true,
@@ -78,17 +80,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return { notFound: true } as const;
}
- if (!team || (team.parent && !team.parent.slug)) {
+ if (!team) {
+ // Because we are fetching by requestedSlug being set, it can either be an organization or a regular team. But it can't be a sub-team i.e.
const unpublishedTeam = await prisma.team.findFirst({
where: {
- ...(team?.parent
- ? { id: team.parent.id }
- : {
- metadata: {
- path: ["requestedSlug"],
- equals: slug,
- },
- }),
+ metadata: {
+ path: ["requestedSlug"],
+ equals: slug,
+ },
},
include: {
parent: {
@@ -98,17 +97,22 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
name: true,
isPrivate: true,
isOrganization: true,
+ metadata: true,
},
},
},
});
if (!unpublishedTeam) return { notFound: true } as const;
-
+ const teamParent = unpublishedTeam.parent ? getTeamWithoutMetadata(unpublishedTeam.parent) : null;
return {
props: {
- isUnpublished: true,
- team: { ...unpublishedTeam, createdAt: null },
+ considerUnpublished: true,
+ team: {
+ ...unpublishedTeam,
+ parent: teamParent,
+ createdAt: null,
+ },
trpcState: ssr.dehydrate(),
},
} as const;
@@ -150,7 +154,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const markdownStrippedBio = stripMarkdown(team?.bio || "");
- const { inviteToken: _inviteToken, ...serializableTeam } = team;
+ const serializableTeam = getSerializableTeam(team);
+
+ // For a team or Organization we check if it's unpublished
+ // For a subteam, we check if the parent org is unpublished. A subteam can't be unpublished in itself
+ const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
+ const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
+
+ const considerUnpublished = isUnpublished && !isARedirectFromNonOrgLink;
+
+ if (considerUnpublished) {
+ return {
+ props: {
+ considerUnpublished: true,
+ team: { ...serializableTeam },
+ trpcState: ssr.dehydrate(),
+ },
+ } as const;
+ }
return {
props: {
@@ -169,3 +190,29 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
} as const;
};
+
+/**
+ * Removes sensitive data from team and ensures that the object is serialiable by Next.js
+ */
+function getSerializableTeam(team: NonNullable>>) {
+ const { inviteToken: _inviteToken, ...serializableTeam } = team;
+
+ const teamParent = team.parent ? getTeamWithoutMetadata(team.parent) : null;
+
+ return {
+ ...serializableTeam,
+ parent: teamParent,
+ };
+}
+
+/**
+ * Removes metadata from team and just adds requestedSlug
+ */
+function getTeamWithoutMetadata>(team: T) {
+ const { metadata, ...rest } = team;
+ const teamMetadata = teamMetadataSchema.parse(metadata);
+ return {
+ ...rest,
+ requestedSlug: teamMetadata?.requestedSlug,
+ };
+}
diff --git a/apps/web/modules/users/views/users-public-view.getServerSideProps.tsx b/apps/web/modules/users/views/users-public-view.getServerSideProps.tsx
index 9a628ac051..ffb01c7441 100644
--- a/apps/web/modules/users/views/users-public-view.getServerSideProps.tsx
+++ b/apps/web/modules/users/views/users-public-view.getServerSideProps.tsx
@@ -47,7 +47,7 @@ export type UserPageProps = {
markdownStrippedBio: string;
safeBio: string;
entity: {
- isUnpublished?: boolean;
+ considerUnpublished: boolean;
orgSlug?: string | null;
name?: string | null;
};
@@ -74,6 +74,7 @@ export const getServerSideProps: GetServerSideProps = async (cont
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const usernameList = getUsernameList(context.query.user as string);
+ const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
let outOfOffice = false;
@@ -206,7 +207,7 @@ export const getServerSideProps: GetServerSideProps = async (cont
profile: user.profile,
})),
entity: {
- isUnpublished: org?.slug === null,
+ considerUnpublished: !isARedirectFromNonOrgLink && org?.slug === null,
orgSlug: currentOrgDomain,
name: org?.name ?? null,
},
diff --git a/apps/web/modules/users/views/users-public-view.tsx b/apps/web/modules/users/views/users-public-view.tsx
index 5b19d37fe6..fd2a7679f6 100644
--- a/apps/web/modules/users/views/users-public-view.tsx
+++ b/apps/web/modules/users/views/users-public-view.tsx
@@ -53,8 +53,7 @@ export function UserPage(props: InferGetServerSidePropsType
diff --git a/apps/web/modules/users/views/users-type-public-view.getServerSideProps.tsx b/apps/web/modules/users/views/users-type-public-view.getServerSideProps.tsx
index e634495801..4834f8902d 100644
--- a/apps/web/modules/users/views/users-type-public-view.getServerSideProps.tsx
+++ b/apps/web/modules/users/views/users-type-public-view.getServerSideProps.tsx
@@ -65,6 +65,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
username: usernames.join("+"),
eventSlug: slug,
org,
+ fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData) {
@@ -158,6 +159,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
username,
eventSlug: slug,
org,
+ fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData) {
diff --git a/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx b/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx
index 748cc40cf0..85bff0b1da 100644
--- a/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx
+++ b/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx
@@ -92,6 +92,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventSlug: meetingSlug,
isTeamEvent: true,
org,
+ fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData || !org) {
diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx
index 8435e5fe38..48a1574687 100644
--- a/apps/web/pages/team/[slug].tsx
+++ b/apps/web/pages/team/[slug].tsx
@@ -33,7 +33,7 @@ export { getServerSideProps };
export type PageProps = inferSSRProps;
function TeamPage({
team,
- isUnpublished,
+ considerUnpublished,
markdownStrippedBio,
isValidOrgDomain,
currentOrgDomain,
@@ -58,13 +58,16 @@ function TeamPage({
);
}, [telemetry, pathname]);
- if (isUnpublished) {
- const slug = team.slug || metadata?.requestedSlug;
+ if (considerUnpublished) {
+ const teamSlug = team.slug || metadata?.requestedSlug;
+ const parentSlug = team.parent?.slug || team.parent?.requestedSlug;
+ // Show unpublished state for parent Organization itself, if the team is a subteam(team.parent is NOT NULL)
+ const slugPropertyName = team.parent || team.isOrganization ? "orgSlug" : "teamSlug";
return (
);
diff --git a/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts b/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts
index ff810c0a1f..eb5a80b47a 100644
--- a/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts
+++ b/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts
@@ -15,7 +15,7 @@ export async function isAuthorizedToViewTheForm({
metadata: Prisma.JsonValue;
movedToProfileId: number | null;
profile: {
- organization: { slug: string | null } | null;
+ organization: { slug: string | null; requestedSlug: string | null } | null;
};
id: number;
};
@@ -25,11 +25,12 @@ export async function isAuthorizedToViewTheForm({
...user,
metadata: userMetadata.parse(user.metadata),
};
+ const orgSlug = formUser.profile.organization?.slug ?? formUser.profile.organization?.requestedSlug ?? null;
if (!currentOrgDomain) {
// If not on org domain, let's allow serving any form belong to any organization so that even if the form owner is migrate to an organization, old links for the form keep working
return true;
- } else if (currentOrgDomain !== formUser.profile.organization?.slug) {
+ } else if (currentOrgDomain !== orgSlug) {
// If on org domain,
// We don't serve the form that is of another org
// We don't serve the form that doesn't belong to any org
diff --git a/packages/emails/src/templates/OrganizationCreationEmail.tsx b/packages/emails/src/templates/OrganizationCreationEmail.tsx
index a78f24f31b..9743d3f69a 100644
--- a/packages/emails/src/templates/OrganizationCreationEmail.tsx
+++ b/packages/emails/src/templates/OrganizationCreationEmail.tsx
@@ -51,9 +51,9 @@ export const OrganizationCreationEmail = (
lineHeightStep: "24px",
}}>
- Your link has been changed from {prevLinkWithoutProtocol} to{" "}
- {newLinkWithoutProtocol} but don't worry, all previous links
- still work and redirect appropriately.
+ 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
@@ -68,7 +68,7 @@ export const OrganizationCreationEmail = (
For personal events we recommend creating a new account with a personal email address.
- Enjoy your new clean link: {newLinkWithoutProtocol}
+ Enjoy your new clean link: {newLinkWithoutProtocol}
diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx
index b339e852e5..0503a50167 100644
--- a/packages/emails/src/templates/TeamInviteEmail.tsx
+++ b/packages/emails/src/templates/TeamInviteEmail.tsx
@@ -163,10 +163,9 @@ export const TeamInviteEmail = (
- Your link has been changed from{" "}
- {prevLinkWithoutProtocol} to{" "}
- {newLinkWithoutProtocol} but don't worry, all
- previous links still work and redirect appropriately.
+ 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{" "}
@@ -179,7 +178,8 @@ export const TeamInviteEmail = (
For personal events we recommend creating a new account with a personal email address.
- Enjoy your new clean link: {newLinkWithoutProtocol}
+ Enjoy your new clean link:{" "}
+ {newLinkWithoutProtocol}
>
) : (
diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx
index b69f11f83e..c7d6dd26dd 100644
--- a/packages/features/bookings/Booker/Booker.tsx
+++ b/packages/features/bookings/Booker/Booker.tsx
@@ -224,7 +224,14 @@ const BookerComponent = ({
isPlatform,
]);
- if (entity.isUnpublished) {
+ /**
+ * Unpublished organization handling - Below
+ * - Reschedule links in email are of the organization event for an unpublished org, so we need to allow rescheduling unpublished event
+ * - Ideally, we should allow rescheduling only for the event that has an old link(non-org link) but that's a bit complex and we are fine showing all reschedule links on unpublished organization
+ */
+ const considerUnpublished = entity.considerUnpublished && !rescheduleUid;
+
+ if (considerUnpublished) {
return ;
}
diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts
index 6586847a4c..80e74437d3 100644
--- a/packages/features/bookings/Booker/types.ts
+++ b/packages/features/bookings/Booker/types.ts
@@ -22,6 +22,7 @@ export interface BookerProps {
* Whether is a team or org, we gather basic info from both
*/
entity: {
+ considerUnpublished: boolean;
isUnpublished?: boolean;
orgSlug?: string | null;
teamSlug?: string | null;
diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts
index 05b138ad67..ef2df1a194 100644
--- a/packages/features/eventtypes/lib/getPublicEvent.ts
+++ b/packages/features/eventtypes/lib/getPublicEvent.ts
@@ -119,12 +119,14 @@ const publicEventSelect = Prisma.validator()({
assignAllTeamMembers: true,
});
+// TODO: Convert it to accept a single parameter with structured data
export const getPublicEvent = async (
username: string,
eventSlug: string,
isTeamEvent: boolean | undefined,
org: string | null,
- prisma: PrismaClient
+ prisma: PrismaClient,
+ fromRedirectOfNonOrgLink: boolean
) => {
const usernameList = getUsernameList(username);
const orgQuery = org ? getSlugOrRequestedSlug(org) : null;
@@ -186,7 +188,7 @@ export const getPublicEvent = async (
),
},
entity: {
- isUnpublished: unPublishedOrgUser !== undefined,
+ considerUnpublished: !fromRedirectOfNonOrgLink && unPublishedOrgUser !== undefined,
orgSlug: org,
name: unPublishedOrgUser?.profile?.organization?.name ?? null,
},
@@ -289,10 +291,11 @@ export const getPublicEvent = async (
profile: getProfileFromEvent(eventWithUserProfiles),
users,
entity: {
- isUnpublished:
- eventWithUserProfiles.team?.slug === null ||
- eventWithUserProfiles.owner?.profile?.organization?.slug === null ||
- eventWithUserProfiles.team?.parent?.slug === null,
+ considerUnpublished:
+ !fromRedirectOfNonOrgLink &&
+ (eventWithUserProfiles.team?.slug === null ||
+ eventWithUserProfiles.owner?.profile?.organization?.slug === null ||
+ eventWithUserProfiles.team?.parent?.slug === null),
orgSlug: org,
teamSlug: (eventWithUserProfiles.team?.slug || teamMetadata?.requestedSlug) ?? null,
name:
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index e52a052123..0c6a62eba2 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -149,3 +149,4 @@ export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt(
export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY;
export const MINIMUM_NUMBER_OF_ORG_SEATS = 30;
export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1";
+export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE = 2;
diff --git a/packages/lib/event-types/getEventTypesByViewer.ts b/packages/lib/event-types/getEventTypesByViewer.ts
index 9d41edace5..2d087bc6a5 100644
--- a/packages/lib/event-types/getEventTypesByViewer.ts
+++ b/packages/lib/event-types/getEventTypesByViewer.ts
@@ -245,10 +245,11 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
}
const eventTypes = await Promise.all(team.eventTypes.map(mapEventType));
+ const teamParentMetadata = team.parent ? teamMetadataSchema.parse(team.parent.metadata) : null;
return {
teamId: team.id,
parentId: team.parentId,
- bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null),
+ bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? teamParentMetadata?.requestedSlug ?? null),
membershipRole:
orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts
index e3866b4c7c..e2732d7f92 100644
--- a/packages/lib/server/queries/teams/index.ts
+++ b/packages/lib/server/queries/teams/index.ts
@@ -101,8 +101,10 @@ export async function getTeamWithMembers(args: {
name: true,
isPrivate: true,
isOrganization: true,
+ metadata: true,
},
},
+ parentId: true,
children: {
select: {
name: true,
diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts
index 0e5258de9d..f054d254da 100644
--- a/packages/lib/server/repository/membership.ts
+++ b/packages/lib/server/repository/membership.ts
@@ -30,6 +30,7 @@ const teamParentSelect = Prisma.validator()({
slug: true,
logoUrl: true,
parentId: true,
+ metadata: true,
});
const userSelect = Prisma.validator()({
diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx
index 1bd3cdb8e9..52c5d6cfbb 100644
--- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx
+++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx
@@ -234,7 +234,7 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) =>
username={props.username}
entity={
event?.data?.entity ?? {
- isUnpublished: undefined,
+ considerUnpublished: false,
orgSlug: undefined,
teamSlug: undefined,
name: undefined,
diff --git a/packages/trpc/server/routers/publicViewer/event.handler.ts b/packages/trpc/server/routers/publicViewer/event.handler.ts
index 1552d085c6..39d1fab1f0 100644
--- a/packages/trpc/server/routers/publicViewer/event.handler.ts
+++ b/packages/trpc/server/routers/publicViewer/event.handler.ts
@@ -14,7 +14,8 @@ export const eventHandler = async ({ ctx, input }: EventHandlerOptions) => {
input.eventSlug,
input.isTeamEvent,
input.org,
- ctx.prisma
+ ctx.prisma,
+ input.fromRedirectOfNonOrgLink
);
return event;
};
diff --git a/packages/trpc/server/routers/publicViewer/event.schema.ts b/packages/trpc/server/routers/publicViewer/event.schema.ts
index 6436d1642a..d10a5965cc 100644
--- a/packages/trpc/server/routers/publicViewer/event.schema.ts
+++ b/packages/trpc/server/routers/publicViewer/event.schema.ts
@@ -5,6 +5,11 @@ export const ZEventInputSchema = z.object({
eventSlug: z.string(),
isTeamEvent: z.boolean().optional(),
org: z.string().nullable(),
+ /**
+ * Informs that the event request has been sent from a page that was reached by a redirect from non-org link(i.e. app.cal.com/username redirected to acme.cal.com/username)
+ * Based on this decision like whether to allow unpublished organization's event to be served or not can be made.
+ */
+ fromRedirectOfNonOrgLink: z.boolean().optional().default(false),
});
export type TEventInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
index d52b576330..556d1ed108 100644
--- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
@@ -3,7 +3,12 @@ import { lookup } from "dns";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { sendAdminOrganizationNotification, sendOrganizationCreationEmail } from "@calcom/emails";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
-import { RESERVED_SUBDOMAINS, WEBAPP_URL, ORG_SELF_SERVE_ENABLED } from "@calcom/lib/constants";
+import {
+ RESERVED_SUBDOMAINS,
+ WEBAPP_URL,
+ ORG_SELF_SERVE_ENABLED,
+ ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE,
+} from "@calcom/lib/constants";
import { createDomain } from "@calcom/lib/domainManager/organization";
import { getTranslation } from "@calcom/lib/server/i18n";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
@@ -34,11 +39,38 @@ const getIPAddress = async (url: string): Promise => {
export const createHandler = async ({ input, ctx }: CreateOptions) => {
const { slug, name, orgOwnerEmail, seats, pricePerSeat, isPlatform } = input;
- const IS_USER_ADMIN = ctx.user.role === UserPermissionRole.ADMIN;
+ const loggedInUser = await prisma.user.findUnique({
+ where: {
+ id: ctx.user.id,
+ },
+ select: {
+ id: true,
+ role: true,
+ teams: {
+ select: {
+ team: {
+ select: {
+ slug: true,
+ },
+ },
+ },
+ },
+ },
+ });
+ if (!loggedInUser) throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized." });
+
+ const IS_USER_ADMIN = loggedInUser.role === UserPermissionRole.ADMIN;
+
if (!ORG_SELF_SERVE_ENABLED && !IS_USER_ADMIN) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can create organizations" });
}
+ const publishedTeams = loggedInUser.teams.filter((team) => !!team.team.slug);
+
+ if (!IS_USER_ADMIN && publishedTeams.length < ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "You need to have atleast two published teams." });
+ }
+
const orgOwner = await prisma.user.findUnique({
where: {
email: orgOwnerEmail,
@@ -66,7 +98,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
}
// 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;
+ const isLoggedInUserOrgOwner = orgOwner.id === loggedInUser.id;
if (ctx.user.profile.organizationId && isLoggedInUserOrgOwner) {
throw new TRPCError({ code: "FORBIDDEN", message: "User is part of an organization already" });
}
@@ -100,7 +132,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
}
const autoAcceptEmail = orgOwnerEmail.split("@")[1];
- const nonOrgUsernameForOwner = ctx.user.profile.username || "";
+ const nonOrgUsernameForOwner = orgOwner.username || "";
const { organization, ownerProfile } = await OrganizationRepository.createWithOwner({
orgData: {
name,
@@ -129,7 +161,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
ownerOldUsername: nonOrgUsernameForOwner,
orgDomain: getOrgFullOrigin(slug, { protocol: false }),
orgName: organization.name,
- prevLink: `${getOrgFullOrigin("", { protocol: true })}/${ctx.user.username}`,
+ prevLink: `${getOrgFullOrigin("", { protocol: true })}/${nonOrgUsernameForOwner}`,
newLink: `${getOrgFullOrigin(slug, { protocol: true })}/${ownerProfile.username}`,
});
diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
index b1437c4b9c..c236a5851b 100644
--- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
@@ -91,14 +91,16 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) =>
);
await Promise.all(
- moveTeams.map(async ({ id: teamId, newSlug }) => {
- await moveTeam({
- teamId,
- newSlug,
- org: organization,
- ctx,
- });
- })
+ moveTeams
+ .filter((team) => team.shouldMove)
+ .map(async ({ id: teamId, newSlug }) => {
+ await moveTeam({
+ teamId,
+ newSlug,
+ org: organization,
+ ctx,
+ });
+ })
);
if (duplicatedSlugs.length === teamNames.length) {
diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
index affa48263b..17d3d563e2 100644
--- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
@@ -60,16 +60,14 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
isOrg,
});
- const { autoAcceptEmailDomain, orgVerified, orgConfigured, orgPublished } = getIsOrgVerified(isOrg, team);
+ const { autoAcceptEmailDomain, orgVerified } = 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,
diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts
index d29053126d..c76814ed9f 100644
--- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts
+++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts
@@ -171,60 +171,10 @@ 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 orgConfigured is true", () => {
- const result = getOrgConnectionInfo({
- orgAutoAcceptDomain,
- orgConfigured: true,
- orgPublished: true,
- orgVerified: true,
- usersEmail,
- team: {
- ...mockedRegularTeam,
- parentId: 2,
- },
- isOrg: false,
- });
- 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 ", () => {
+ it("should return autoAccept:false when orgVerified is false even if usersEmail domain matches orgAutoAcceptDomain", () => {
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,
@@ -238,9 +188,7 @@ describe("Invite Member Utils", () => {
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,
+ orgVerified: true,
usersEmail: "user@other.com",
team: {
...mockedRegularTeam,
@@ -251,25 +199,10 @@ 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 orgConfigured is false", () => {
+ it("should return orgId and autoAccept as true if team has no parent and isOrg is true and usersEmail domain matches orgAutoAcceptDomain", () => {
const result = getOrgConnectionInfo({
orgAutoAcceptDomain,
- orgConfigured: false,
- orgVerified: false,
- orgPublished: true,
- usersEmail,
- team: { ...mockedRegularTeam },
- isOrg: false,
- });
- 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 orgConfigured is true", () => {
- const result = getOrgConnectionInfo({
- orgAutoAcceptDomain,
- orgConfigured: true,
orgVerified: true,
- orgPublished: true,
usersEmail,
team: { ...mockedRegularTeam, parentId: null },
isOrg: true,
@@ -281,8 +214,6 @@ describe("Invite Member Utils", () => {
const result = getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified: false,
- orgConfigured: false,
- orgPublished: true,
usersEmail: "user@other.com",
team: { ...mockedRegularTeam, parentId: null },
isOrg: true,
@@ -293,9 +224,7 @@ 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,
diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
index 9b0da90cb5..1bd5bd093d 100644
--- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
+++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts
@@ -186,17 +186,13 @@ export async function getUsersToInvite({
export function getOrgConnectionInfo({
orgAutoAcceptDomain,
- orgConfigured,
orgVerified,
- orgPublished,
isOrg,
usersEmail,
team,
}: {
orgAutoAcceptDomain?: string | null;
- orgConfigured: boolean;
orgVerified: boolean;
- orgPublished: boolean;
usersEmail: string;
team: TeamWithParent;
isOrg: boolean;
@@ -207,11 +203,10 @@ export function getOrgConnectionInfo({
if (team.parentId || isOrg) {
orgId = team.parentId || team.id;
if (usersEmail.split("@")[1] == orgAutoAcceptDomain) {
- // 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;
+ // We discourage self-served organizations from being able to auto-accept feature by having a barrier of a fixed number of paying teams in the account for creating the organization
+ // We can't put restriction of a published organization here because when we move teams during the onboarding of the organization, it isn't published at the moment and we really need those members to be auto-added
+ // Further, sensitive operations like member editing and impersonating are disabled by default, unless reviewed by the ADMIN team
+ autoAccept = orgVerified;
} else {
orgId = undefined;
autoAccept = false;
diff --git a/packages/ui/components/unpublished-entity/UnpublishedEntity.tsx b/packages/ui/components/unpublished-entity/UnpublishedEntity.tsx
index 09789431d6..4ae53c3541 100644
--- a/packages/ui/components/unpublished-entity/UnpublishedEntity.tsx
+++ b/packages/ui/components/unpublished-entity/UnpublishedEntity.tsx
@@ -2,8 +2,19 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen, Avatar } from "@calcom/ui";
export type UnpublishedEntityProps = {
+ /**
+ * If it is passed, don't pass orgSlug
+ * It conveys two things - Slug for the team and that it is not an organization
+ */
teamSlug?: string | null;
+ /**
+ * If it is passed, don't pass teamSlug.
+ * It conveys two things - Slug for the team and that it is an organization infact
+ */
orgSlug?: string | null;
+ /**
+ * Team or Organization name
+ */
name?: string | null;
};