From 4fe75771b8842b6c96f1099d216dda1b4036fbb5 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 5 Feb 2024 21:21:30 +0530 Subject: [PATCH] fix: Broken routing form public links(old links) on migrating the owner user to an organization (#13416) * Fix routing-form access restrictions * Update packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * Fix routing form access issue for users who are never migrated * Improve comment * wip * Fix event-type listing not working * Automatic reload of the app when being added to a different organization --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Peer Richelsen --- .../enrichFormWithMigrationData.ts | 57 ++++++++++++ .../routing-forms/getEventTypeRedirectUrl.ts | 88 ++++++++++++++++++ .../pages/router/getServerSideProps.ts | 64 ++++++++++++- .../pages/routing-link/[...appPages].tsx | 12 ++- .../pages/routing-link/getServerSideProps.ts | 70 +++++++++++++- .../ee/teams/components/TeamListItem.tsx | 10 +- packages/lib/server/repository/eventType.ts | 93 ++++++++++++------- packages/lib/server/repository/profile.ts | 3 +- packages/lib/server/repository/user.ts | 35 ++++++- packages/prisma/zod-utils.ts | 8 ++ 10 files changed, 392 insertions(+), 48 deletions(-) create mode 100644 packages/app-store/routing-forms/enrichFormWithMigrationData.ts create mode 100644 packages/app-store/routing-forms/getEventTypeRedirectUrl.ts diff --git a/packages/app-store/routing-forms/enrichFormWithMigrationData.ts b/packages/app-store/routing-forms/enrichFormWithMigrationData.ts new file mode 100644 index 0000000000..f5deb5f875 --- /dev/null +++ b/packages/app-store/routing-forms/enrichFormWithMigrationData.ts @@ -0,0 +1,57 @@ +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { CAL_URL } from "@calcom/lib/constants"; +import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; + +export const enrichFormWithMigrationData = < + T extends { + user: { + movedToProfileId: number | null; + metadata?: unknown; + username: string | null; + nonProfileUsername: string | null; + profile: { + organization: { + slug: string | null; + } | null; + }; + }; + team: { + parent: { + slug: string | null; + } | null; + metadata?: unknown; + } | null; + } +>( + form: T +) => { + const parsedUserMetadata = userMetadata.parse(form.user.metadata ?? null); + const parsedTeamMetadata = teamMetadataSchema.parse(form.team?.metadata ?? null); + const formOwnerOrgSlug = form.user.profile.organization?.slug ?? null; + const nonOrgUsername = parsedUserMetadata?.migratedToOrgFrom?.username ?? form.user.nonProfileUsername; + const nonOrgTeamslug = parsedTeamMetadata?.migratedToOrgFrom?.teamSlug ?? null; + + return { + ...form, + user: { + ...form.user, + metadata: parsedUserMetadata, + }, + team: { + ...form.team, + metadata: teamMetadataSchema.parse(form.team?.metadata ?? null), + }, + userOrigin: formOwnerOrgSlug + ? getOrgFullOrigin(formOwnerOrgSlug, { + protocol: true, + }) + : CAL_URL, + teamOrigin: form.team?.parent?.slug + ? getOrgFullOrigin(form.team.parent.slug, { + protocol: true, + }) + : CAL_URL, + nonOrgUsername, + nonOrgTeamslug, + }; +}; diff --git a/packages/app-store/routing-forms/getEventTypeRedirectUrl.ts b/packages/app-store/routing-forms/getEventTypeRedirectUrl.ts new file mode 100644 index 0000000000..a1d342d20d --- /dev/null +++ b/packages/app-store/routing-forms/getEventTypeRedirectUrl.ts @@ -0,0 +1,88 @@ +import { CAL_URL } from "@calcom/lib/constants"; + +function getUserAndEventTypeSlug(eventTypeRedirectUrl: string) { + if (eventTypeRedirectUrl.startsWith("/")) { + eventTypeRedirectUrl = eventTypeRedirectUrl.slice(1); + } + const parts = eventTypeRedirectUrl.split("/"); + const isTeamSlug = parts[0] == "team" ? true : false; + const teamSlug = isTeamSlug ? parts[1] : null; + const eventTypeSlug = teamSlug ? parts[2] : parts[1]; + const username = isTeamSlug ? null : parts[0]; + return { teamSlug, eventTypeSlug, username }; +} + +/** + * Handles the following cases + * 1. A team form where the team isn't a sub-team + * 1.1 A team form where team isn't a sub-team and the user is migrated. i.e. User has been migrated but not the team + * 1.2 A team form where team isn't a sub-team and the user is not migrated i.e. Both user and team are not migrated + * 2. A team form where the team is a sub-team + * 1.1 A team form where the team is a sub-team and the user is migrated i.e. Both user and team are migrated + * 1.2 A team form where the team is a sub-team and the user is not migrated i.e. Team has been migrated but not the user + * 3. A user form where the user is migrated + * 3.1 A user form where the user is migrated and the team is migrated i.e. Both user and team are migrated + * 3.2 A user form where the user is migrated and the team is not migrated i.e. User has been migrated but not the team + * 4. A user form where the user is not migrated + * 4.1 A user form where the user is not migrated and the team is migrated i.e. Team has been migrated but not the user + * 4.2 A user form where the user is not migrated and the team is not migrated i.e. Both user and team are not migrated + */ +export function getAbsoluteEventTypeRedirectUrl({ + eventTypeRedirectUrl, + form, + allURLSearchParams, +}: { + eventTypeRedirectUrl: string; + form: { + team: { + // parentId is set if the team is a sub-team + parentId: number | null; + } | null; + /** + * Set only if user is migrated + */ + nonOrgUsername: string | null; + /** + * Set only if team is migrated + */ + nonOrgTeamslug: string | null; + /** + * The origin for the user + */ + userOrigin: string; + /** + * The origin for the team the form belongs to + */ + teamOrigin: string; + }; + allURLSearchParams: URLSearchParams; +}) { + // It could be using the old(before migration) username/team-slug or it could be using the new one(after migration) + // If it's using the old one, it would work by redirection as long as we use CAL_URL(which is non-org domain) + // But if it's using the new one, it has to use the org domain. + // The format is /user/abc or /team/team1/abc + const { username: usernameInRedirectUrl, teamSlug: teamSlugInRedirectUrl } = + getUserAndEventTypeSlug(eventTypeRedirectUrl); + + if (!teamSlugInRedirectUrl && !usernameInRedirectUrl) { + throw new Error("eventTypeRedirectUrl must have username or teamSlug"); + } + + if (teamSlugInRedirectUrl && form.nonOrgTeamslug) { + const isEventTypeRedirectToOldTeamSlug = teamSlugInRedirectUrl === form.nonOrgTeamslug; + if (isEventTypeRedirectToOldTeamSlug) { + return `${CAL_URL}/${eventTypeRedirectUrl}?${allURLSearchParams}`; + } + } + + if (usernameInRedirectUrl && form.nonOrgUsername) { + const isEventTypeRedirectToOldUser = usernameInRedirectUrl === form.nonOrgUsername; + if (isEventTypeRedirectToOldUser) { + return `${CAL_URL}/${eventTypeRedirectUrl}?${allURLSearchParams}`; + } + } + + const origin = teamSlugInRedirectUrl ? form.teamOrigin : form.userOrigin; + + return `${origin}/${eventTypeRedirectUrl}?${allURLSearchParams}`; +} diff --git a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts index b678844934..a11dc45266 100644 --- a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts @@ -6,11 +6,15 @@ import logger from "@calcom/lib/logger"; import { TRPCError } from "@calcom/trpc/server"; import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps"; +import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData"; +import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl"; import getFieldIdentifier from "../../lib/getFieldIdentifier"; import { getSerializableForm } from "../../lib/getSerializableForm"; import { processRoute } from "../../lib/processRoute"; +import { substituteVariables } from "../../lib/substituteVariables"; import transformResponse from "../../lib/transformResponse"; import type { Response } from "../../types/types"; +import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps"; const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] }); @@ -33,7 +37,6 @@ export const getServerSideProps = async function getServerSideProps( notFound: true, }; } - const { ProfileRepository } = await import("@calcom/lib/server/repository/profile"); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data; const { currentOrgDomain } = orgDomainConfig(context.req); @@ -41,8 +44,32 @@ export const getServerSideProps = async function getServerSideProps( const form = await prisma.app_RoutingForms_Form.findFirst({ where: { id: formId, + }, + include: { user: { - ...ProfileRepository._getPrismaWhereForProfilesOfOrg({ orgSlug: currentOrgDomain }), + select: { + id: true, + username: true, + movedToProfileId: true, + metadata: true, + organization: { + select: { + slug: true, + }, + }, + }, + }, + team: { + select: { + parentId: true, + parent: { + select: { + slug: true, + }, + }, + slug: true, + metadata: true, + }, }, }, }); @@ -52,10 +79,28 @@ export const getServerSideProps = async function getServerSideProps( notFound: true, }; } - const serializableForm = await getSerializableForm({ form }); + + const { UserRepository } = await import("@calcom/lib/server/repository/user"); + const formWithUserProfile = { + ...form, + user: await UserRepository.enrichUserWithItsProfile({ user: form.user }), + }; + + if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) { + return { + notFound: true, + }; + } + + const serializableForm = await getSerializableForm({ + form: enrichFormWithMigrationData(formWithUserProfile), + }); const response: Response = {}; - serializableForm.fields?.forEach((field) => { + if (!serializableForm.fields) { + throw new Error("Form has no fields"); + } + serializableForm.fields.forEach((field) => { const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || ""; response[field.id] = { @@ -102,9 +147,18 @@ export const getServerSideProps = async function getServerSideProps( }, }; } else if (decidedAction.type === "eventTypeRedirectUrl") { + const eventTypeUrlWithResolvedVariables = substituteVariables( + decidedAction.value, + response, + serializableForm.fields + ); return { redirect: { - destination: `/${decidedAction.value}?${stringify(context.query)}`, + destination: getAbsoluteEventTypeRedirectUrl({ + eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables, + form: serializableForm, + allURLSearchParams: new URLSearchParams(stringify(context.query)), + }), permanent: false, }, }; diff --git a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx index 73b5bd5f3f..727d2dc0b5 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -18,6 +18,7 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import { Button, showToast, useCalcomTheme } from "@calcom/ui"; import FormInputFields from "../../components/FormInputFields"; +import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl"; import getFieldIdentifier from "../../lib/getFieldIdentifier"; import { processRoute } from "../../lib/processRoute"; import { substituteVariables } from "../../lib/substituteVariables"; @@ -102,9 +103,14 @@ function RoutingForm({ form, profile, ...restProps }: Props) { if (decidedAction.type === "customPageMessage") { setCustomPageMessage(decidedAction.value); } else if (decidedAction.type === "eventTypeRedirectUrl") { - const eventTypeUrlWithVariables = substituteVariables(decidedAction.value, response, fields); - - await router.push(`/${eventTypeUrlWithVariables}?${allURLSearchParams}`); + const eventTypeUrlWithResolvedVariables = substituteVariables(decidedAction.value, response, fields); + router.push( + getAbsoluteEventTypeRedirectUrl({ + form, + eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables, + allURLSearchParams, + }) + ); } else if (decidedAction.type === "externalRedirectUrl") { window.parent.location.href = `${decidedAction.value}?${allURLSearchParams}`; } 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 5a309e1790..ff810c0a1f 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts @@ -1,8 +1,43 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import type { Prisma } from "@calcom/prisma/client"; +import { userMetadata } from "@calcom/prisma/zod-utils"; import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps"; +import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData"; import { getSerializableForm } from "../../lib/getSerializableForm"; +export async function isAuthorizedToViewTheForm({ + user, + currentOrgDomain, +}: { + user: { + username: string | null; + metadata: Prisma.JsonValue; + movedToProfileId: number | null; + profile: { + organization: { slug: string | null } | null; + }; + id: number; + }; + currentOrgDomain: string | null; +}) { + const formUser = { + ...user, + metadata: userMetadata.parse(user.metadata), + }; + + 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) { + // 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 + return false; + } + return true; +} + export const getServerSideProps = async function getServerSideProps( context: AppGetServerSidePropsContext, prisma: AppPrisma @@ -22,22 +57,36 @@ export const getServerSideProps = async function getServerSideProps( const { currentOrgDomain } = orgDomainConfig(context.req); const isEmbed = params.appPages[1] === "embed"; - const { ProfileRepository } = await import("@calcom/lib/server/repository/profile"); const form = await prisma.app_RoutingForms_Form.findFirst({ where: { id: formId, - user: { - ...ProfileRepository._getPrismaWhereForProfilesOfOrg({ orgSlug: currentOrgDomain }), - }, }, include: { user: { select: { + id: true, + movedToProfileId: true, + organization: { + select: { + slug: true, + }, + }, username: true, theme: true, brandColor: true, darkBrandColor: true, + metadata: true, + }, + }, + team: { + select: { + slug: true, + parent: { + select: { slug: true }, + }, + parentId: true, + metadata: true, }, }, }, @@ -49,6 +98,17 @@ export const getServerSideProps = async function getServerSideProps( }; } + const { UserRepository } = await import("@calcom/lib/server/repository/user"); + const formWithUserProfile = { + ...form, + user: await UserRepository.enrichUserWithItsProfile({ user: form.user }), + }; + + if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) { + return { + notFound: true, + }; + } return { props: { isEmbed, @@ -58,7 +118,7 @@ export const getServerSideProps = async function getServerSideProps( brandColor: form.user.brandColor, darkBrandColor: form.user.darkBrandColor, }, - form: await getSerializableForm({ form }), + form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }), }, }; }; diff --git a/packages/features/ee/teams/components/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx index 85cecde109..e66c8cadd6 100644 --- a/packages/features/ee/teams/components/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -58,6 +58,7 @@ export default function TeamListItem(props: Props) { const searchParams = useCompatSearchParams(); const { t, i18n } = useLocale(); const utils = trpc.useContext(); + const user = trpc.viewer.me.useQuery().data; const team = props.team; const showDialog = searchParams?.get("inviteModal") === "true"; @@ -68,12 +69,19 @@ export default function TeamListItem(props: Props) { const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation(); const acceptOrLeaveMutation = trpc.viewer.teams.acceptOrLeave.useMutation({ - onSuccess: () => { + onSuccess: (_data, variables) => { showToast(t("success"), "success"); utils.viewer.teams.get.invalidate(); utils.viewer.teams.list.invalidate(); utils.viewer.teams.hasTeamPlan.invalidate(); utils.viewer.teams.listInvites.invalidate(); + const userOrganizationId = user?.profile?.organization?.id; + const isSubTeamOfDifferentOrg = team.parentId ? team.parentId != userOrganizationId : false; + const isDifferentOrg = isOrganization({ team }) && team.id !== userOrganizationId; + // If the user team being accepted is a sub-team of different organization or the different organization itself then page must be reloaded to let the session change reflect reliably everywhere. + if (variables.accept && (isSubTeamOfDifferentOrg || isDifferentOrg)) { + window.location.reload(); + } }, }); diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 1faa8eb0bc..8a561ff05a 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -87,48 +87,79 @@ export class EventTypeRepository { ) { if (!upId) return []; const lookupTarget = ProfileRepository.getLookupTarget(upId); - const eventTypeWhere = { - ...(lookupTarget.type === LookupTarget.User - ? { - userId: lookupTarget.id, - } - : { - profileId: lookupTarget.id, - }), - ...where, + const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id; + const include = { + // TODO: As required by getByViewHandler - Make it configurable + team: { + include: { + eventTypes: true, + }, + }, + hashedLink: true, + users: true, + children: { + include: { + users: true, + }, + }, + hosts: { + include: { + user: true, + }, + }, }; + log.debug( "findAllByUpId", safeStringify({ upId, orderBy, argumentWhere: where, - where: eventTypeWhere, }) ); - return await prisma.eventType.findMany({ - where: eventTypeWhere, - include: { - // TODO: As required by getByViewHandler - Make it configurable - team: { - include: { - eventTypes: true, - }, + + if (!profileId) { + // Lookup is by userId + return await prisma.eventType.findMany({ + where: { + userId: lookupTarget.id, + ...where, }, - hashedLink: true, - users: true, - children: { - include: { - users: true, - }, + include, + orderBy, + }); + } + + const profile = await ProfileRepository.findById(profileId); + if (profile?.movedFromUser) { + // Because the user has been moved to this profile, we need to get all user events except those that belong to some other profile + // This is because those event-types that are created after moving to profile would have profileId but existing event-types would have profileId set to null + return await prisma.eventType.findMany({ + where: { + OR: [ + // Existing events + { + userId: profile.movedFromUser.id, + profileId: null, + }, + // New events + { + profileId, + }, + ], + ...where, }, - hosts: { - include: { - user: true, - }, + include, + orderBy, + }); + } else { + return await prisma.eventType.findMany({ + where: { + profileId, }, - }, - orderBy, - }); + include, + orderBy, + }); + } } } diff --git a/packages/lib/server/repository/profile.ts b/packages/lib/server/repository/profile.ts index 7baea0881f..2387ba56ba 100644 --- a/packages/lib/server/repository/profile.ts +++ b/packages/lib/server/repository/profile.ts @@ -276,7 +276,7 @@ export class ProfileRepository { static async findByUpId(upId: string) { const lookupTarget = ProfileRepository.getLookupTarget(upId); - log.debug("findById", safeStringify({ upId, lookupTarget })); + log.debug("findByUpId", safeStringify({ upId, lookupTarget })); if (lookupTarget.type === LookupTarget.User) { const user = await UserRepository.findById({ id: lookupTarget.id }); if (!user) { @@ -314,6 +314,7 @@ export class ProfileRepository { }, include: { user: true, + movedFromUser: true, organization: { include: { members: true, diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 706778d0fb..aaf3255376 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -1,5 +1,6 @@ import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; import prisma from "@calcom/prisma"; +import type { User as UserType, Prisma } from "@calcom/prisma/client"; import type { UpId, UserProfile } from "@calcom/types/UserProfile"; import { isOrganization } from "../../entityPermissionUtils"; @@ -7,7 +8,6 @@ import logger from "../../logger"; import { safeStringify } from "../../safeStringify"; import { ProfileRepository } from "./profile"; import { getParsedTeam } from "./teamUtils"; -import type { User as UserType, Prisma } from ".prisma/client"; const log = logger.getSubLogger({ prefix: ["[repository/user]"] }); @@ -202,6 +202,30 @@ export class UserRepository { return user.profiles.some((profile) => profile.organizationId === organizationId); } + static async findIfAMemberOfSomeOrganization({ user }: { user: { id: number } }) { + return !!( + await ProfileRepository.findManyForUser({ + id: user.id, + }) + ).length; + } + + static isMigratedToOrganization({ + user, + }: { + user: { + metadata?: { + migratedToOrgFrom?: unknown; + } | null; + }; + }) { + return !!user.metadata?.migratedToOrgFrom; + } + + static async isMovedToAProfile({ user }: { user: Pick }) { + return !!user.movedToProfileId; + } + static async enrichUserWithTheProfile({ user, upId, @@ -233,13 +257,19 @@ export class UserRepository { user, }: { user: T; - }): Promise { + }): Promise< + T & { + nonProfileUsername: string | null; + profile: UserProfile; + } + > { const profiles = await ProfileRepository.findManyForUser({ id: user.id }); if (profiles.length) { const profile = profiles[0]; return { ...user, username: profile.username, + nonProfileUsername: user.username, profile, }; } @@ -247,6 +277,7 @@ export class UserRepository { // If no organization profile exists, use the personal profile so that the returned user is normalized to have a profile always return { ...user, + nonProfileUsername: user.username, profile: ProfileRepository.buildPersonalProfileFromUser({ user }), }; } diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 1a2e0c5caf..671118e222 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -329,6 +329,14 @@ export const userMetadata = z }) .optional(), defaultBookerLayouts: bookerLayouts.optional(), + migratedToOrgFrom: z + .object({ + username: z.string().or(z.null()).optional(), + lastMigrationTime: z.string().optional(), + reverted: z.boolean().optional(), + revertTime: z.string().optional(), + }) + .optional(), }) .nullable();