diff --git a/apps/web/pages/404.tsx b/apps/web/pages/404.tsx index fc23e64fd1..12cb57ac7d 100644 --- a/apps/web/pages/404.tsx +++ b/apps/web/pages/404.tsx @@ -3,7 +3,10 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; -import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { + getOrgDomainConfigFromHostname, + subdomainSuffix, +} from "@calcom/features/ee/organizations/lib/orgDomains"; import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HeadSeo } from "@calcom/ui"; @@ -50,7 +53,10 @@ export default function Custom404() { const [url, setUrl] = useState(`${WEBSITE_URL}/signup`); useEffect(() => { - const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host); + const { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({ + hostname: window.location.host, + }); + const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? []; if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) { const splitPath = routerUsername.split("/"); diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 325120f476..5a4f46eed0 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -275,10 +275,7 @@ export type UserPageProps = { export const getServerSideProps: GetServerSideProps = async (context) => { const ssr = await ssrInit(context); - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( - context.req.headers.host ?? "", - context.params?.orgSlug - ); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug); const usernameList = getUsernameList(context.query.user as string); const isOrgContext = isValidOrgDomain && currentOrgDomain; const dataFetchStart = Date.now(); diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index f3a82730fc..b9b7293353 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -72,10 +72,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( - context.req.headers.host ?? "", - context.params?.orgSlug - ); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug); const users = await prisma.user.findMany({ where: { @@ -148,10 +145,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { const { user: usernames, type: slug } = paramsSchema.parse(context.params); const username = usernames[0]; const { rescheduleUid, bookingUid, duration: queryDuration } = context.query; - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( - context.req.headers.host ?? "", - context.params?.orgSlug - ); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug); const isOrgContext = currentOrgDomain && isValidOrgDomain; diff --git a/apps/web/pages/api/logo.ts b/apps/web/pages/api/logo.ts index d55bbef417..3c0f0a49a7 100644 --- a/apps/web/pages/api/logo.ts +++ b/apps/web/pages/api/logo.ts @@ -154,7 +154,7 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { query } = req; const parsedQuery = logoApiSchema.parse(query); - const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? ""); + const { isValidOrgDomain } = orgDomainConfig(req); const hostname = req?.headers["host"]; if (!hostname) throw new Error("No hostname"); diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index 6f6cabeaf7..da86db7bac 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -29,7 +29,7 @@ const querySchema = z async function getIdentityData(req: NextApiRequest) { const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query); - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? ""); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req); const org = isValidOrgDomain ? currentOrgDomain : null; diff --git a/apps/web/pages/api/username.ts b/apps/web/pages/api/username.ts index b78eab4c56..ea34666f7f 100644 --- a/apps/web/pages/api/username.ts +++ b/apps/web/pages/api/username.ts @@ -9,7 +9,7 @@ type Response = { }; export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { - const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? ""); + const { currentOrgDomain } = orgDomainConfig(req); const result = await checkUsername(req.body.username, currentOrgDomain); return res.status(200).json(result); } diff --git a/apps/web/pages/auth/sso/[provider].tsx b/apps/web/pages/auth/sso/[provider].tsx index f83c5e455b..ee9aa08d27 100644 --- a/apps/web/pages/auth/sso/[provider].tsx +++ b/apps/web/pages/auth/sso/[provider].tsx @@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const session = await getServerSession({ req, res }); const ssr = await ssrInit(context); - const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { currentOrgDomain } = orgDomainConfig(context.req); if (session) { // Validating if username is Premium, while this is true an email its required for stripe user confirmation diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 0b715ca5b9..58cae2a8af 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -61,7 +61,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { const session = await getServerSession(context); const { link, slug } = paramsSchema.parse(context.params); const { rescheduleUid, duration: queryDuration } = context.query; - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req); const org = isValidOrgDomain ? currentOrgDomain : null; const { ssrInit } = await import("@server/lib/ssr"); diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index b8ff44daa9..88fca5f179 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -269,10 +269,7 @@ function TeamPage({ export const getServerSideProps = async (context: GetServerSidePropsContext) => { const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug; - const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig( - context.req.headers.host ?? "", - context.params?.orgSlug - ); + const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug); const isOrgContext = isValidOrgDomain && currentOrgDomain; // Provided by Rewrite from next.config.js diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 771d0df8ac..55bbabb7c6 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -74,10 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const { rescheduleUid, duration: queryDuration } = context.query; const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig( - context.req.headers.host ?? "", - context.params?.orgSlug - ); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug); const isOrgContext = currentOrgDomain && isValidOrgDomain; if (!isOrgContext) { diff --git a/apps/web/playwright/fixtures/orgs.ts b/apps/web/playwright/fixtures/orgs.ts new file mode 100644 index 0000000000..265fcd73a1 --- /dev/null +++ b/apps/web/playwright/fixtures/orgs.ts @@ -0,0 +1,56 @@ +import type { Page } from "@playwright/test"; +import type { Team } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; + +const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`; + +// creates a user fixture instance and stores the collection +export const createOrgsFixture = (page: Page) => { + const store = { orgs: [], page } as { orgs: Team[]; page: typeof page }; + return { + create: async (opts: { name: string; slug?: string; requestedSlug?: string }) => { + const org = await createOrgInDb({ + name: opts.name, + slug: opts.slug || getRandomSlug(), + requestedSlug: opts.requestedSlug, + }); + store.orgs.push(org); + return org; + }, + get: () => store.orgs, + deleteAll: async () => { + await prisma.team.deleteMany({ where: { id: { in: store.orgs.map((org) => org.id) } } }); + store.orgs = []; + }, + delete: async (id: number) => { + await prisma.team.delete({ where: { id } }); + store.orgs = store.orgs.filter((b) => b.id !== id); + }, + }; +}; + +async function createOrgInDb({ + name, + slug, + requestedSlug, +}: { + name: string; + slug: string | null; + requestedSlug?: string; +}) { + return await prisma.team.create({ + data: { + name: name, + slug: slug, + metadata: { + isOrganization: true, + ...(requestedSlug + ? { + requestedSlug, + } + : null), + }, + }, + }); +} diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 7e8a8b1db2..c568105edf 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -9,6 +9,7 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail import { WEBAPP_URL } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils"; import { TimeZoneEnum } from "./types"; @@ -78,11 +79,13 @@ const createTeamAndAddUser = async ( isUnpublished, isOrg, hasSubteam, + organizationId, }: { user: { id: number; username: string | null; role?: MembershipRole }; isUnpublished?: boolean; isOrg?: boolean; hasSubteam?: true; + organizationId?: number | null; }, workerInfo: WorkerInfo ) => { @@ -101,6 +104,7 @@ const createTeamAndAddUser = async ( data.children = { connect: [{ id: team.id }] }; } data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined; + data.parent = organizationId ? { connect: { id: organizationId } } : undefined; const team = await prisma.team.create({ data, }); @@ -114,6 +118,7 @@ const createTeamAndAddUser = async ( accepted: true, }, }); + return team; }; @@ -282,6 +287,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn isUnpublished: scenario.isUnpublished, isOrg: scenario.isOrg, hasSubteam: scenario.hasSubteam, + organizationId: opts?.organizationId, }, workerInfo ); @@ -399,11 +405,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { logout: async () => { await page.goto("/auth/logout"); }, - getTeam: async () => { - return prisma.membership.findFirstOrThrow({ + getFirstTeam: async () => { + const memberships = await prisma.membership.findMany({ where: { userId: user.id }, include: { team: true }, }); + + const membership = memberships + .map((membership) => { + return { + ...membership, + team: { + ...membership.team, + metadata: teamMetadataSchema.parse(membership.team.metadata), + }, + }; + }) + .find((membership) => !membership.team?.metadata?.isOrganization); + if (!membership) { + throw new Error("No team found for user"); + } + return membership; }, getOrg: async () => { return prisma.membership.findFirstOrThrow({ @@ -453,16 +475,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & { _bookings?: PrismaType.BookingCreateInput[]; }; -type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email"; +type CustomUserOptsKeys = + | "username" + | "password" + | "completedOnboarding" + | "locale" + | "name" + | "email" + | "organizationId"; type CustomUserOpts = Partial> & { timeZone?: TimeZoneEnum; eventTypes?: SupportedTestEventTypes[]; // ignores adding the worker-index after username useExactUsername?: boolean; + roleInOrganization?: MembershipRole; }; // creates the actual user in the db. -const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => { +const createUser = ( + workerInfo: WorkerInfo, + opts?: CustomUserOpts | null +): PrismaType.UserUncheckedCreateInput => { // build a unique name for our user const uname = opts?.useExactUsername && opts?.username @@ -478,6 +511,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism completedOnboarding: opts?.completedOnboarding ?? true, timeZone: opts?.timeZone ?? TimeZoneEnum.UK, locale: opts?.locale ?? "en", + ...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }), schedules: opts?.completedOnboarding ?? true ? { @@ -493,6 +527,42 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism } : undefined, }; + + function getOrganizationRelatedProps({ + organizationId, + role, + }: { + organizationId: number | null | undefined; + role: MembershipRole | undefined; + }) { + if (!organizationId) { + return null; + } + if (!role) { + throw new Error("Missing role for user in organization"); + } + return { + organizationId: organizationId || null, + ...(organizationId + ? { + teams: { + // Create membership + create: [ + { + team: { + connect: { + id: organizationId, + }, + }, + accepted: true, + role: MembershipRole.ADMIN, + }, + ], + }, + } + : null), + }; + } }; async function confirmPendingPayment(page: Page) { diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 61d315a754..2e54268db3 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -9,6 +9,7 @@ import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; import { createEmbedsFixture } from "../fixtures/embeds"; +import { createOrgsFixture } from "../fixtures/orgs"; import { createPaymentsFixture } from "../fixtures/payments"; import { createBookingPageFixture } from "../fixtures/regularBookings"; import { createRoutingFormsFixture } from "../fixtures/routingForms"; @@ -17,6 +18,7 @@ import { createUsersFixture } from "../fixtures/users"; export interface Fixtures { page: Page; + orgs: ReturnType; users: ReturnType; bookings: ReturnType; payments: ReturnType; @@ -48,6 +50,10 @@ declare global { * @see https://playwright.dev/docs/test-fixtures */ export const test = base.extend({ + orgs: async ({ page }, use) => { + const orgsFixture = createOrgsFixture(page); + await use(orgsFixture); + }, users: async ({ page, context, emails }, use, workerInfo) => { const usersFixture = createUsersFixture(page, emails, workerInfo); await use(usersFixture); diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index 90731f8c1d..6f2013f2a1 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -1,16 +1,16 @@ +import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { prisma } from "@calcom/prisma"; -import { SchedulingType } from "@calcom/prisma/enums"; +import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { test } from "./lib/fixtures"; import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils"; test.describe.configure({ mode: "parallel" }); -test.afterEach(({ users }) => users.deleteAll()); - -test.describe("Teams", () => { +test.describe("Teams - NonOrg", () => { + test.afterEach(({ users }) => users.deleteAll()); test("Can create teams via Wizard", async ({ page, users }) => { const user = await users.create(); const inviteeEmail = `${user.username}+invitee@example.com`; @@ -64,6 +64,7 @@ test.describe("Teams", () => { // await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible(); }); }); + test("Can create a booking for Collective EventType", async ({ page, users }) => { const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ @@ -78,7 +79,7 @@ test.describe("Teams", () => { teammates: teamMatesObj, schedulingType: SchedulingType.COLLECTIVE, }); - const { team } = await owner.getTeam(); + const { team } = await owner.getFirstTeam(); const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); await page.goto(`/team/${team.slug}/${teamEventSlug}`); @@ -99,6 +100,7 @@ test.describe("Teams", () => { // TODO: Assert whether the user received an email }); + test("Can create a booking for Round Robin EventType", async ({ page, users }) => { const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ @@ -113,7 +115,7 @@ test.describe("Teams", () => { schedulingType: SchedulingType.ROUND_ROBIN, }); - const { team } = await owner.getTeam(); + const { team } = await owner.getFirstTeam(); const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); await page.goto(`/team/${team.slug}/${teamEventSlug}`); @@ -135,6 +137,7 @@ test.describe("Teams", () => { expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true); // TODO: Assert whether the user received an email }); + test("Non admin team members cannot create team in org", async ({ page, users }) => { const teamMateName = "teammate-1"; @@ -169,6 +172,7 @@ test.describe("Teams", () => { await prisma.team.delete({ where: { id: org.teamId } }); } }); + test("Can create team with same name as user", async ({ page, users }) => { // Name to be used for both user and team const uniqueName = "test-unique-name"; @@ -210,6 +214,7 @@ test.describe("Teams", () => { await prisma.team.delete({ where: { id: team?.id } }); }); }); + test("Can create a private team", async ({ page, users }) => { const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ @@ -226,7 +231,7 @@ test.describe("Teams", () => { }); await owner.apiLogin(); - const { team } = await owner.getTeam(); + const { team } = await owner.getFirstTeam(); // Mark team as private await page.goto(`/settings/teams/${team.id}/members`); @@ -247,3 +252,180 @@ test.describe("Teams", () => { todo("Reschedule a Collective EventType booking"); todo("Reschedule a Round Robin EventType booking"); }); + +test.describe("Teams - Org", () => { + test.afterEach(({ orgs, users }) => { + orgs.deleteAll(); + users.deleteAll(); + }); + + test("Can create teams via Wizard", async ({ page, users, orgs }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const user = await users.create({ + organizationId: org.id, + roleInOrganization: MembershipRole.ADMIN, + }); + const inviteeEmail = `${user.username}+invitee@example.com`; + await user.apiLogin(); + await page.goto("/teams"); + + await test.step("Can create team", async () => { + // Click text=Create Team + await page.locator("text=Create a new Team").click(); + await page.waitForURL((url) => url.pathname === "/settings/teams/new"); + // Fill input[name="name"] + await page.locator('input[name="name"]').fill(`${user.username}'s Team`); + // Click text=Continue + await page.locator("text=Continue").click(); + await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i); + await page.waitForSelector('[data-testid="pending-member-list"]'); + expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1); + }); + + await test.step("Can add members", async () => { + // Click [data-testid="new-member-button"] + await page.locator('[data-testid="new-member-button"]').click(); + // Fill [placeholder="email\@example\.com"] + await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail); + // Click [data-testid="invite-new-member-button"] + await page.locator('[data-testid="invite-new-member-button"]').click(); + await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible(); + expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2); + }); + + await test.step("Can remove members", async () => { + expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2); + + const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last(); + await lastRemoveMemberButton.click(); + await page.waitForLoadState("networkidle"); + expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1); + + // Cleanup here since this user is created without our fixtures. + await prisma.user.delete({ where: { email: inviteeEmail } }); + }); + + await test.step("Can finish team creation", async () => { + await page.locator("text=Finish").click(); + await page.waitForURL("/settings/teams"); + }); + + await test.step("Can disband team", async () => { + await page.locator('[data-testid="team-list-item-link"]').click(); + await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); + await page.locator("text=Disband Team").click(); + await page.locator("text=Yes, disband team").click(); + await page.waitForURL("/teams"); + expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0); + }); + }); + + test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const teamMatesObj = [ + { name: "teammate-1" }, + { name: "teammate-2" }, + { name: "teammate-3" }, + { name: "teammate-4" }, + ]; + + const owner = await users.create( + { + username: "pro-user", + name: "pro-user", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }, + { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.COLLECTIVE, + } + ); + const { team } = await owner.getFirstTeam(); + const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); + + await page.goto(`/team/${team.slug}/${teamEventSlug}`); + + await expect(page.locator('[data-testid="404-page"]')).toBeVisible(); + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await page.goto(`/team/${team.slug}/${teamEventSlug}`); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + // The title of the booking + const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`; + await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle); + // The booker should be in the attendee list + await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); + + // All the teammates should be in the booking + for (const teammate of teamMatesObj) { + await expect(page.getByText(teammate.name, { exact: true })).toBeVisible(); + } + } + ); + + // TODO: Assert whether the user received an email + }); + + test("Can create a booking for Round Robin EventType", async ({ page, users }) => { + const ownerObj = { username: "pro-user", name: "pro-user" }; + const teamMatesObj = [ + { name: "teammate-1" }, + { name: "teammate-2" }, + { name: "teammate-3" }, + { name: "teammate-4" }, + ]; + const owner = await users.create(ownerObj, { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + const { team } = await owner.getFirstTeam(); + const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); + + await page.goto(`/team/${team.slug}/${teamEventSlug}`); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + // The person who booked the meeting should be in the attendee list + await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); + + // The title of the booking + const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`; + await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle); + + // Since all the users have the same leastRecentlyBooked value + // Anyone of the teammates could be the Host of the booking. + const chosenUser = await page.getByTestId("booking-host-name").textContent(); + expect(chosenUser).not.toBeNull(); + expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true); + // TODO: Assert whether the user received an email + }); +}); + +async function doOnOrgDomain( + { orgSlug, page }: { orgSlug: string | null; page: Page }, + callback: ({ page }: { page: Page }) => Promise +) { + if (!orgSlug) { + throw new Error("orgSlug is not available"); + } + page.setExtraHTTPHeaders({ + "x-cal-force-slug": orgSlug, + }); + await callback({ page }); +} diff --git a/apps/web/playwright/unpublished.e2e.ts b/apps/web/playwright/unpublished.e2e.ts index fc5862ca7e..5f5a66b9eb 100644 --- a/apps/web/playwright/unpublished.e2e.ts +++ b/apps/web/playwright/unpublished.e2e.ts @@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => { test.describe("Unpublished", () => { test("Regular team profile", async ({ page, users }) => { const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true }); - const { team } = await owner.getTeam(); + const { team } = await owner.getFirstTeam(); const { requestedSlug } = team.metadata as { requestedSlug: string }; await page.goto(`/team/${requestedSlug}`); expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1); @@ -33,7 +33,7 @@ test.describe("Unpublished", () => { isUnpublished: true, schedulingType: SchedulingType.COLLECTIVE, }); - const { team } = await owner.getTeam(); + const { team } = await owner.getFirstTeam(); const { requestedSlug } = team.metadata as { requestedSlug: string }; const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); await page.goto(`/team/${requestedSlug}/${teamEventSlug}`); diff --git a/packages/app-store/routing-forms/pages/router/[...appPages].tsx b/packages/app-store/routing-forms/pages/router/[...appPages].tsx index 8a96d67d5f..c97c10a935 100644 --- a/packages/app-store/routing-forms/pages/router/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/router/[...appPages].tsx @@ -54,7 +54,7 @@ export const getServerSideProps = async function getServerSideProps( } // eslint-disable-next-line @typescript-eslint/no-unused-vars const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data; - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req); const form = await prisma.app_RoutingForms_Form.findFirst({ where: { 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 18b400a72d..e740da2f83 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -248,7 +248,7 @@ export const getServerSideProps = async function getServerSideProps( notFound: true, }; } - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req); const isEmbed = params.appPages[1] === "embed"; diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 8c55dd5929..ee864c507f 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -1,16 +1,33 @@ import type { Prisma } from "@prisma/client"; +import type { IncomingMessage } from "http"; import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import slugify from "@calcom/lib/slugify"; +const log = logger.getSubLogger({ + prefix: ["orgDomains.ts"], +}); /** * return the org slug * @param hostname */ -export function getOrgSlug(hostname: string) { +export function getOrgSlug(hostname: string, forcedSlug?: string) { + if (forcedSlug) { + if (process.env.NEXT_PUBLIC_IS_E2E) { + log.debug("Using provided forcedSlug in E2E", { + forcedSlug, + }); + return forcedSlug; + } + log.debug("Ignoring forcedSlug in non-test mode", { + forcedSlug, + }); + } + if (!hostname.includes(".")) { - // A no-dot domain can never be org domain. It automatically handles localhost + log.warn('Org support not enabled for hostname without "."', { hostname }); + // A no-dot domain can never be org domain. It automatically considers localhost to be non-org domain return null; } // Find which hostname is being currently used @@ -19,24 +36,45 @@ export function getOrgSlug(hostname: string) { const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`; return testHostname.endsWith(`.${ahn}`); }); - logger.debug(`getOrgSlug: ${hostname} ${currentHostname}`, { - ALLOWED_HOSTNAMES, - WEBAPP_URL, - currentHostname, - hostname, - }); - if (currentHostname) { - // Define which is the current domain/subdomain - const slug = hostname.replace(`.${currentHostname}` ?? "", ""); - return slug.indexOf(".") === -1 ? slug : null; + + if (!currentHostname) { + log.warn("Match of WEBAPP_URL with ALLOWED_HOSTNAME failed", { WEBAPP_URL, ALLOWED_HOSTNAMES }); + return null; } + // Define which is the current domain/subdomain + const slug = hostname.replace(`.${currentHostname}` ?? "", ""); + const hasNoDotInSlug = slug.indexOf(".") === -1; + if (hasNoDotInSlug) { + return slug; + } + log.warn("Derived slug ended up having dots, so not considering it an org domain", { slug }); return null; } -export function orgDomainConfig(hostname: string, fallback?: string | string[]) { - const currentOrgDomain = getOrgSlug(hostname); +export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: string | string[]) { + const forcedSlugHeader = req?.headers?.["x-cal-force-slug"]; + + const forcedSlug = forcedSlugHeader instanceof Array ? forcedSlugHeader[0] : forcedSlugHeader; + + const hostname = req?.headers?.host || ""; + return getOrgDomainConfigFromHostname({ + hostname, + fallback, + forcedSlug, + }); +} + +export function getOrgDomainConfigFromHostname({ + hostname, + fallback, + forcedSlug, +}: { + hostname: string; + fallback?: string | string[]; + forcedSlug?: string; +}) { + const currentOrgDomain = getOrgSlug(hostname, forcedSlug); const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain); - logger.debug(`orgDomainConfig: ${hostname} ${currentOrgDomain} ${isValidOrgDomain}`); if (isValidOrgDomain || !fallback) { return { currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null, @@ -100,6 +138,6 @@ export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) { } export function userOrgQuery(hostname: string, fallback?: string | string[]) { - const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(hostname, fallback); + const { currentOrgDomain, isValidOrgDomain } = getOrgDomainConfigFromHostname({ hostname, fallback }); return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null; } diff --git a/packages/features/ee/teams/components/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx index b67585d568..539b463e4e 100644 --- a/packages/features/ee/teams/components/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -183,6 +183,7 @@ export default function TeamListItem(props: Props) {
{!isInvitee ? ( diff --git a/packages/features/test/orgDomains.test.ts b/packages/features/test/orgDomains.test.ts index 414d54c2bf..c0d6b5d19c 100644 --- a/packages/features/test/orgDomains.test.ts +++ b/packages/features/test/orgDomains.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { orgDomainConfig, getOrgSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgSlug, getOrgDomainConfigFromHostname } from "@calcom/features/ee/organizations/lib/orgDomains"; import * as constants from "@calcom/lib/constants"; function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) { @@ -35,10 +35,10 @@ function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) { } describe("Org Domains Utils", () => { - describe("orgDomainConfig", () => { + describe("getOrgDomainConfigFromHostname", () => { it("should return a valid org domain", () => { setupEnvs(); - expect(orgDomainConfig("acme.cal.com")).toEqual({ + expect(getOrgDomainConfigFromHostname({ hostname: "acme.cal.com" })).toEqual({ currentOrgDomain: "acme", isValidOrgDomain: true, }); @@ -46,7 +46,7 @@ describe("Org Domains Utils", () => { it("should return a non valid org domain", () => { setupEnvs(); - expect(orgDomainConfig("app.cal.com")).toEqual({ + expect(getOrgDomainConfigFromHostname({ hostname: "app.cal.com" })).toEqual({ currentOrgDomain: null, isValidOrgDomain: false, }); @@ -54,7 +54,7 @@ describe("Org Domains Utils", () => { it("should return a non valid org domain for localhost", () => { setupEnvs(); - expect(orgDomainConfig("localhost:3000")).toEqual({ + expect(getOrgDomainConfigFromHostname({ hostname: "localhost:3000" })).toEqual({ currentOrgDomain: null, isValidOrgDomain: false, }); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 11f582757d..fbd63249b1 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -266,7 +266,7 @@ export function getRegularOrDynamicEventType( } export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { - const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? ""); + const orgDetails = orgDomainConfig(ctx?.req); if (process.env.INTEGRATION_TEST_MODE === "true") { logger.settings.minLevel = 2; }