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
This commit is contained in:
1 parent
84fdd03603
commit
65df5ac0e4
31 files changed
+230
-158
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -72,6 +72,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getTeamWithMembers>>>) {
|
||||
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<T extends Pick<Team, "metadata">>(team: T) {
|
||||
const { metadata, ...rest } = team;
|
||||
const teamMetadata = teamMetadataSchema.parse(metadata);
|
||||
return {
|
||||
...rest,
|
||||
requestedSlug: teamMetadata?.requestedSlug,
|
||||
};
|
||||
}
|
||||
@@ -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<UserPageProps> = 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<UserPageProps> = async (cont
|
||||
profile: user.profile,
|
||||
})),
|
||||
entity: {
|
||||
isUnpublished: org?.slug === null,
|
||||
considerUnpublished: !isARedirectFromNonOrgLink && org?.slug === null,
|
||||
orgSlug: currentOrgDomain,
|
||||
name: org?.name ?? null,
|
||||
},
|
||||
|
||||
@@ -53,8 +53,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/[user]"));
|
||||
}
|
||||
}, [telemetry, router.asPath]); */
|
||||
|
||||
if (entity?.isUnpublished) {
|
||||
if (entity.considerUnpublished) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<UnpublishedEntity {...entity} />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -92,6 +92,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
|
||||
if (!eventData || !org) {
|
||||
|
||||
@@ -33,7 +33,7 @@ export { getServerSideProps };
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
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 (
|
||||
<div className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<UnpublishedEntity
|
||||
{...(team?.isOrganization || team.parentId ? { orgSlug: slug } : { teamSlug: slug })}
|
||||
name={teamName}
|
||||
{...{ [slugPropertyName]: team.parent ? parentSlug : teamSlug }}
|
||||
name={team.parent ? team.parent.name : team.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,9 +51,9 @@ export const OrganizationCreationEmail = (
|
||||
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.
|
||||
Your link has been changed from <a href={prevLink ?? ""}>{prevLinkWithoutProtocol}</a> to{" "}
|
||||
<a href={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>
|
||||
@@ -68,7 +68,7 @@ export const OrganizationCreationEmail = (
|
||||
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>
|
||||
Enjoy your new clean link: <a href={`${newLink}?orgRedirection=true`}>{newLinkWithoutProtocol}</a>
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -163,10 +163,9 @@ export const TeamInviteEmail = (
|
||||
<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.
|
||||
Your link has been changed from <a href={prevLink ?? ""}>{prevLinkWithoutProtocol}</a> to{" "}
|
||||
<a href={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{" "}
|
||||
@@ -179,7 +178,8 @@ export const TeamInviteEmail = (
|
||||
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>
|
||||
Enjoy your new clean link:{" "}
|
||||
<a href={`${newLink}?orgRedirection=true`}>{newLinkWithoutProtocol}</a>
|
||||
</Trans>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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 <UnpublishedEntity {...entity} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -119,12 +119,14 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -101,8 +101,10 @@ export async function getTeamWithMembers(args: {
|
||||
name: true,
|
||||
isPrivate: true,
|
||||
isOrganization: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
parentId: true,
|
||||
children: {
|
||||
select: {
|
||||
name: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ const teamParentSelect = Prisma.validator<Prisma.TeamSelect>()({
|
||||
slug: true,
|
||||
logoUrl: true,
|
||||
parentId: true,
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<typeof ZEventInputSchema>;
|
||||
@@ -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<string> => {
|
||||
|
||||
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}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user