diff --git a/.env.example b/.env.example index d1023ca7c0..e125757fd9 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # - SHARED # - NEXTAUTH # - E-MAIL SETTINGS +# - ORGANIZATIONS # - LICENSE (DEPRECATED) ************************************************************************************ # https://github.com/calcom/cal.com/blob/main/LICENSE @@ -32,6 +33,8 @@ PRISMA_GENERATE_DATAPROXY= # *********************************************************************************************************** # - SHARED ************************************************************************************************** +# Set this to http://app.cal.local:3000 if you want to enable organizations, and +# check variable ORGANIZATIONS_ENABLED at the bottom of this file NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000' # Change to 'http://localhost:3001' if running the website simultaneously NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000' @@ -183,3 +186,17 @@ CSP_POLICY= EDGE_CONFIG= NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes + +# - ORGANIZATIONS ******************************************************************************************* +# Enable Organizations non-prod domain setup, works in combination with organizations feature flag +# This is mainly needed locally, because for orgs to work a full domain name needs to point +# to the app, i.e. app.cal.local instead of using localhost, which is very disruptive +# +# This variable should only be set to 1 or true if you are in a non-prod environment and you want to +# use organizations +ORGANIZATIONS_ENABLED= + +# Vercel Config to create subdomains for organizations +PROJECT_ID_VERCEL= +TEAM_ID_VERCEL= +AUTH_BEARER_TOKEN_VERCEL= diff --git a/apps/api/pages/api/availability/_get.ts b/apps/api/pages/api/availability/_get.ts index f641d22c18..9d7d470a96 100644 --- a/apps/api/pages/api/availability/_get.ts +++ b/apps/api/pages/api/availability/_get.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { z } from "zod"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; import { availabilityUserSelect } from "@calcom/prisma"; @@ -119,8 +120,10 @@ const availabilitySchema = z async function handler(req: NextApiRequest) { const { prisma, isAdmin, userId: reqUserId } = req; const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? ""); if (!teamId) return getUserAvailability({ + orgSlug: isValidOrgDomain ? currentOrgDomain : undefined, username, dateFrom, dateTo, diff --git a/apps/web/components/booking/BookingDescription.tsx b/apps/web/components/booking/BookingDescription.tsx index b561b5ff6d..fcdcb825d9 100644 --- a/apps/web/components/booking/BookingDescription.tsx +++ b/apps/web/components/booking/BookingDescription.tsx @@ -83,7 +83,9 @@ const BookingDescription: FC = (props) => { size="sm" truncateAfter={3} /> -

{profile.name}

+

+ {eventType.team?.parent?.name} {profile.name} +

{eventType.title}

diff --git a/apps/web/components/getting-started/steps-views/UserProfile.tsx b/apps/web/components/getting-started/steps-views/UserProfile.tsx index e7d9041e34..2a769137fa 100644 --- a/apps/web/components/getting-started/steps-views/UserProfile.tsx +++ b/apps/web/components/getting-started/steps-views/UserProfile.tsx @@ -8,21 +8,15 @@ import { md } from "@calcom/lib/markdownIt"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import turndown from "@calcom/lib/turndownService"; import { trpc } from "@calcom/trpc/react"; -import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui"; -import { Avatar } from "@calcom/ui"; +import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui"; import { ArrowRight } from "@calcom/ui/components/icon"; -import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]"; - type FormData = { bio: string; }; -interface IUserProfileProps { - user: IOnboardingPageProps["user"]; -} -const UserProfile = (props: IUserProfileProps) => { - const { user } = props; +const UserProfile = () => { + const [user] = trpc.viewer.me.useSuspenseQuery(); const { t } = useLocale(); const avatarRef = useRef(null); const { setValue, handleSubmit, getValues } = useForm({ diff --git a/apps/web/components/getting-started/steps-views/UserSettings.tsx b/apps/web/components/getting-started/steps-views/UserSettings.tsx index 5a353325e5..e39fb83340 100644 --- a/apps/web/components/getting-started/steps-views/UserSettings.tsx +++ b/apps/web/components/getting-started/steps-views/UserSettings.tsx @@ -13,15 +13,13 @@ import { ArrowRight } from "@calcom/ui/components/icon"; import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; -import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]"; - interface IUserSettingsProps { - user: IOnboardingPageProps["user"]; nextStep: () => void; } const UserSettings = (props: IUserSettingsProps) => { - const { user, nextStep } = props; + const { nextStep } = props; + const [user] = trpc.viewer.me.useSuspenseQuery(); const { t } = useLocale(); const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess()); const telemetry = useTelemetry(); @@ -69,7 +67,7 @@ const UserSettings = (props: IUserSettingsProps) => {
{/* Username textfield */} - + {/* Full name textfield */}
diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx index 27e3b0f6f9..ece7d20f8d 100644 --- a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -8,14 +8,12 @@ import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/ut import { fetchUsername } from "@calcom/lib/fetchUsername"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { User } from "@calcom/prisma/client"; import type { TRPCClientErrorLike } from "@calcom/trpc/client"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import { Button, Dialog, DialogClose, DialogContent, Input, Label } from "@calcom/ui"; -import { Star as StarSolid } from "@calcom/ui/components/icon"; -import { Check, Edit2, ExternalLink } from "@calcom/ui/components/icon"; +import { Check, Edit2, ExternalLink, Star as StarSolid } from "@calcom/ui/components/icon"; export enum UsernameChangeStatusEnum { UPGRADE = "UPGRADE", @@ -29,7 +27,6 @@ interface ICustomUsernameProps { setInputUsernameValue: (value: string) => void; onSuccessMutation?: () => void; onErrorMutation?: (error: TRPCClientErrorLike) => void; - user: Pick; readonly?: boolean; } @@ -57,8 +54,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { onSuccessMutation, onErrorMutation, readonly: disabled, - user, } = props; + const [user] = trpc.viewer.me.useSuspenseQuery(); const [usernameIsAvailable, setUsernameIsAvailable] = useState(false); const [markAsError, setMarkAsError] = useState(false); const router = useRouter(); diff --git a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx index 6ac4201eba..8dea096373 100644 --- a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx @@ -21,7 +21,7 @@ interface ICustomUsernameProps { onErrorMutation?: (error: TRPCClientErrorLike) => void; } -const UsernameTextfield = (props: ICustomUsernameProps) => { +const UsernameTextfield = (props: ICustomUsernameProps & Partial>) => { const { t } = useLocale(); const { currentUsername, @@ -31,6 +31,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => { usernameRef, onSuccessMutation, onErrorMutation, + ...rest } = props; const [usernameIsAvailable, setUsernameIsAvailable] = useState(false); const [markAsError, setMarkAsError] = useState(false); @@ -116,9 +117,6 @@ const UsernameTextfield = (props: ICustomUsernameProps) => { ref={usernameRef} name="username" value={inputUsernameValue} - addOnLeading={ - <>{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/ - } autoComplete="none" autoCapitalize="none" autoCorrect="none" @@ -133,6 +131,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => { setInputUsernameValue(event.target.value); }} data-testid="username-input" + {...rest} /> {currentUsername !== inputUsernameValue && (
diff --git a/apps/web/components/ui/UsernameAvailability/index.tsx b/apps/web/components/ui/UsernameAvailability/index.tsx index d2c1c9c5b1..886d9f957d 100644 --- a/apps/web/components/ui/UsernameAvailability/index.tsx +++ b/apps/web/components/ui/UsernameAvailability/index.tsx @@ -2,9 +2,11 @@ import { useRouter } from "next/router"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { IS_SELF_HOSTED } from "@calcom/lib/constants"; -import type { User } from "@calcom/prisma/client"; import type { TRPCClientErrorLike } from "@calcom/trpc/client"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import useRouterQuery from "@lib/hooks/useRouterQuery"; @@ -17,14 +19,24 @@ export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : Premium interface UsernameAvailabilityFieldProps { onSuccessMutation?: () => void; onErrorMutation?: (error: TRPCClientErrorLike) => void; - user: Pick; } + +function useUserNamePrefix(organization: RouterOutputs["viewer"]["me"]["organization"]): string { + return organization + ? organization.slug + ? `${organization.slug}.${subdomainSuffix()}` + : organization.metadata && organization.metadata.requestedSlug + ? `${organization.metadata.requestedSlug}.${subdomainSuffix()}` + : process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "") + : process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", ""); +} + export const UsernameAvailabilityField = ({ onSuccessMutation, onErrorMutation, - user, }: UsernameAvailabilityFieldProps) => { const router = useRouter(); + const [user] = trpc.viewer.me.useSuspenseQuery(); const [currentUsernameState, setCurrentUsernameState] = useState(user.username || ""); const { username: usernameFromQuery, setQuery: setUsernameFromQuery } = useRouterQuery("username"); const { username: currentUsername, setQuery: setCurrentUsername } = @@ -37,6 +49,8 @@ export const UsernameAvailabilityField = ({ }, }); + const usernamePrefix = useUserNamePrefix(user.organization); + return ( ); }} diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index b8c0fa29dd..9e5adf7d08 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -9,6 +9,8 @@ import type { NextRouter } from "next/router"; import { useRouter } from "next/router"; import type { ComponentProps, PropsWithChildren, ReactNode } from "react"; +import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider"; +import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks"; import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic"; import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic"; import { FeatureProvider } from "@calcom/features/flags/context/provider"; @@ -205,6 +207,11 @@ function FeatureFlagsProvider({ children }: { children: React.ReactNode }) { return {children}; } +function OrgBrandProvider({ children }: { children: React.ReactNode }) { + const orgBrand = useOrgBrandingValues(); + return {children}; +} + const AppProviders = (props: AppPropsWithChildren) => { const session = trpc.viewer.public.session.useQuery().data; // No need to have intercom on public pages - Good for Page Performance @@ -222,7 +229,9 @@ const AppProviders = (props: AppPropsWithChildren) => { isThemeSupported={props.Component.isThemeSupported} isBookingPage={props.Component.isBookingPage}> - {props.children} + + {props.children} + diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 5cb9c038be..283c15a531 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -3,6 +3,7 @@ import { collectEvents } from "next-collect/server"; import type { NextMiddleware } from "next/server"; import { NextResponse, userAgent } from "next/server"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import { isIpInBanlist } from "@calcom/lib/getIP"; import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry"; @@ -10,6 +11,15 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry const middleware: NextMiddleware = async (req) => { const url = req.nextUrl; const requestHeaders = new Headers(req.headers); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.get("host") ?? ""); + + // Make sure we are in the presence of an organization + if (isValidOrgDomain && url.pathname === "/") { + // In the presence of an organization, cover its profile page at "/" + // rewrites for org profile page using team profile page + url.pathname = `/org/${currentOrgDomain}`; + return NextResponse.rewrite(url); + } if (isIpInBanlist(req) && url.pathname !== "/api/nope") { // DDOS Prevention: Immediately end request with no response - Avoids a redirect as well initiated by NextAuth on invalid callback @@ -76,6 +86,26 @@ const middleware: NextMiddleware = async (req) => { requestHeaders.set("x-csp-enforce", "true"); } + if (isValidOrgDomain) { + // Match /:slug to determine if it corresponds to org subteam slug or org user slug + const slugs = /^\/([^/]+)(\/[^/]+)?$/.exec(url.pathname); + // In the presence of an organization, if not team profile, a user or team is being accessed + if (slugs) { + const [_, teamName, eventType] = slugs; + // Fetch the corresponding subteams for the entered organization + const getSubteams = await fetch(`${WEBAPP_URL}/api/organizations/${currentOrgDomain}/subteams`); + if (getSubteams.ok) { + const data = await getSubteams.json(); + // Treat entered slug as a team if found in the subteams fetched + if (data.slugs.includes(teamName)) { + // Rewriting towards /team/:slug to bring up the team profile within the org + url.pathname = `/team/${teamName}${eventType ?? ""}`; + return NextResponse.rewrite(url); + } + } + } + } + return NextResponse.next({ request: { headers: requestHeaders, diff --git a/apps/web/next.config.js b/apps/web/next.config.js index f3122d66ed..46a1424de0 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -184,6 +184,10 @@ const nextConfig = { }, async rewrites() { return [ + { + source: "/org/:slug", + destination: "/team/:slug", + }, { source: "/:user/avatar.png", destination: "/api/user/avatar?username=:user", diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index ebd7ce56a0..eef4a6c058 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -10,6 +10,7 @@ import { useEmbedStyles, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -255,6 +256,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); const crypto = await import("crypto"); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); const usernameList = getUsernameList(context.query.user as string); const dataFetchStart = Date.now(); @@ -263,6 +265,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => username: { in: usernameList, }, + organization: isValidOrgDomain + ? { + slug: currentOrgDomain, + } + : null, }, select: { id: true, @@ -272,6 +279,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => bio: true, brandColor: true, darkBrandColor: true, + organizationId: true, theme: true, away: true, verified: true, @@ -284,7 +292,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => avatar: `${WEBAPP_URL}/${user.username}/avatar.png`, })); - if (!users.length) { + if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) { return { notFound: true, } as { diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index f0347f9d35..b72d5460e2 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -1,7 +1,8 @@ -import type { GetStaticPaths, GetStaticPropsContext } from "next"; +import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; import type { LocationObject } from "@calcom/app-store/locations"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -15,7 +16,9 @@ import type { EmbedProps } from "@lib/withEmbedSsr"; import PageWrapper from "@components/PageWrapper"; import AvailabilityPage from "@components/booking/pages/AvailabilityPage"; -export type AvailabilityPageProps = inferSSRProps & EmbedProps; +import { ssrInit } from "@server/lib/ssr"; + +export type AvailabilityPageProps = inferSSRProps & EmbedProps; export default function Type(props: AvailabilityPageProps) { const { t } = useLocale(); @@ -50,6 +53,21 @@ export default function Type(props: AvailabilityPageProps) {
+ ) : !props.isValidOrgDomain && props.organizationContext ? ( +
+
+
+
+
+

+ {" " + t("unavailable")} +

+

{t("user_belongs_organization")}

+
+
+
+
+
) : ( ); @@ -59,21 +77,25 @@ Type.isBookingPage = true; Type.PageWrapper = PageWrapper; const paramsSchema = z.object({ type: z.string(), user: z.string() }); -async function getUserPageProps(context: GetStaticPropsContext) { +async function getUserPageProps(context: GetServerSidePropsContext) { // load server side dependencies const prisma = await import("@calcom/prisma").then((mod) => mod.default); const { privacyFilteredLocations } = await import("@calcom/app-store/locations"); const { parseRecurringEvent } = await import("@calcom/lib/isRecurringEvent"); const { EventTypeMetaDataSchema, teamMetadataSchema } = await import("@calcom/prisma/zod-utils"); - const { ssgInit } = await import("@server/lib/ssg"); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); + const ssr = await ssrInit(context); + const { type: slug, user: username } = paramsSchema.parse(context.query); - const { type: slug, user: username } = paramsSchema.parse(context.params); - const ssg = await ssgInit(context); - - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: { /** TODO: We should standarize this */ username: username.toLowerCase().replace(/( |%20)/g, "+"), + organization: isValidOrgDomain + ? { + slug: currentOrgDomain, + } + : null, }, select: { id: true, @@ -87,6 +109,7 @@ async function getUserPageProps(context: GetStaticPropsContext) { brandColor: true, darkBrandColor: true, metadata: true, + organizationId: true, eventTypes: { where: { // Many-to-many relationship causes inclusion of the team events - cool - @@ -108,6 +131,17 @@ async function getUserPageProps(context: GetStaticPropsContext) { schedulingType: true, metadata: true, seatsPerTimeSlot: true, + team: { + select: { + logo: true, + parent: { + select: { + logo: true, + name: true, + }, + }, + }, + }, }, orderBy: [ { @@ -179,16 +213,17 @@ async function getUserPageProps(context: GetStaticPropsContext) { }, // Dynamic group has no theme preference right now. It uses system theme. themeBasis: user.username, + organizationContext: user?.organizationId !== null, away: user?.away, isDynamic: false, - trpcState: ssg.dehydrate(), + trpcState: ssr.dehydrate(), + isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""), isBrandingHidden: isBrandingHidden(user.hideBranding, hasActiveTeam || hasPremiumUserName), }, - revalidate: 10, // seconds }; } -async function getDynamicGroupPageProps(context: GetStaticPropsContext) { +async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { // load server side dependencies const { getDefaultEvent, getGroupName, getUsernameList } = await import("@calcom/lib/defaultEvents"); const { privacyFilteredLocations } = await import("@calcom/app-store/locations"); @@ -197,11 +232,11 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) { const { EventTypeMetaDataSchema, userMetadata: userMetadataSchema } = await import( "@calcom/prisma/zod-utils" ); - const { ssgInit } = await import("@server/lib/ssg"); + const ssr = await ssrInit(context); + const { getAppFromSlug } = await import("@calcom/app-store/utils"); - const ssg = await ssgInit(context); - const { type: typeParam, user: userParam } = paramsSchema.parse(context.params); + const { type: typeParam, user: userParam } = paramsSchema.parse(context.query); const usernameList = getUsernameList(userParam); const length = parseInt(typeParam); const eventType = getDefaultEvent("" + length); @@ -230,6 +265,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) { defaultScheduleId: true, allowDynamicBooking: true, metadata: true, + organizationId: true, away: true, schedules: { select: { @@ -313,15 +349,16 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) { themeBasis: null, isDynamic: true, away: false, - trpcState: ssg.dehydrate(), + organizationContext: !users.some((user) => user.organizationId === null), + trpcState: ssr.dehydrate(), + isValidOrgDomain: orgDomainConfig(context.req.headers.host ?? ""), isBrandingHidden: false, // I think we should always show branding for dynamic groups - saves us checking every single user }, - revalidate: 10, // seconds }; } -export const getStaticProps = async (context: GetStaticPropsContext) => { - const { user: userParam } = paramsSchema.parse(context.params); +export async function getServerSideProps(context: GetServerSidePropsContext) { + const { user: userParam } = paramsSchema.parse(context.query); // dynamic groups are not generated at build time, but otherwise are probably cached until infinity. const isDynamicGroup = userParam.includes("+"); if (isDynamicGroup) { @@ -329,8 +366,4 @@ export const getStaticProps = async (context: GetStaticPropsContext) => { } else { return await getUserPageProps(context); } -}; - -export const getStaticPaths: GetStaticPaths = async () => { - return { paths: [], fallback: "blocking" }; -}; +} diff --git a/apps/web/pages/[user]/[type]/embed.tsx b/apps/web/pages/[user]/[type]/embed.tsx index 31d34e03f4..b9384e607e 100644 --- a/apps/web/pages/[user]/[type]/embed.tsx +++ b/apps/web/pages/[user]/[type]/embed.tsx @@ -1,20 +1,18 @@ -import type { GetStaticPropsContext } from "next"; +import type { GetServerSidePropsContext } from "next"; -import { getStaticProps as _getStaticProps } from "../[type]"; - -export { getStaticPaths } from "../[type]"; +import { getServerSideProps as _getServerSideProps } from "../[type]"; export { default } from "../[type]"; -export const getStaticProps = async (context: GetStaticPropsContext) => { - const staticResponse = await _getStaticProps(context); - if (staticResponse.notFound) { - return staticResponse; +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const ssrResponse = await _getServerSideProps(context); + if (ssrResponse.notFound) { + return ssrResponse; } return { - ...staticResponse, + ...ssrResponse, props: { - ...staticResponse.props, + ...ssrResponse.props, isEmbed: true, }, }; diff --git a/apps/web/pages/[user]/calendar-cache/[month].tsx b/apps/web/pages/[user]/calendar-cache/[month].tsx index 1bbd543c3d..6e4dbaa34b 100644 --- a/apps/web/pages/[user]/calendar-cache/[month].tsx +++ b/apps/web/pages/[user]/calendar-cache/[month].tsx @@ -16,7 +16,7 @@ export const getStaticProps: GetStaticProps< { user: string } > = async (context) => { const { user: username, month } = paramsSchema.parse(context.params); - const userWithCredentials = await prisma.user.findUnique({ + const userWithCredentials = await prisma.user.findFirst({ where: { username, }, diff --git a/apps/web/pages/api/logo.ts b/apps/web/pages/api/logo.ts index 72dac999d7..b3e7d859a9 100644 --- a/apps/web/pages/api/logo.ts +++ b/apps/web/pages/api/logo.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { ANDROID_CHROME_ICON_192, ANDROID_CHROME_ICON_256, @@ -104,7 +105,7 @@ function isValidLogoType(type: string): type is LogoType { return type in logoDefinitions; } -async function getTeamLogos(subdomain: string) { +async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) { try { if ( // if not cal.com @@ -118,9 +119,15 @@ async function getTeamLogos(subdomain: string) { } // load from DB const { default: prisma } = await import("@calcom/prisma"); - const team = await prisma.team.findUnique({ + const team = await prisma.team.findFirst({ where: { slug: subdomain, + ...(isValidOrgDomain && { + metadata: { + path: ["isOrganization"], + equals: true, + }, + }), }, select: { appLogo: true, @@ -147,6 +154,7 @@ async function getTeamLogos(subdomain: string) { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { query } = req; const parsedQuery = logoApiSchema.parse(query); + const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? ""); const hostname = req?.headers["host"]; if (!hostname) throw new Error("No hostname"); @@ -154,7 +162,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!domains) throw new Error("No domains"); const [subdomain] = domains; - const teamLogos = await getTeamLogos(subdomain); + const teamLogos = await getTeamLogos(subdomain, isValidOrgDomain); // Resolve all icon types to team logos, falling back to Cal.com defaults. const type: LogoType = parsedQuery?.type && isValidLogoType(parsedQuery.type) ? parsedQuery.type : "logo"; diff --git a/apps/web/pages/api/organizations/[org]/subteams.ts b/apps/web/pages/api/organizations/[org]/subteams.ts new file mode 100644 index 0000000000..92a698ada6 --- /dev/null +++ b/apps/web/pages/api/organizations/[org]/subteams.ts @@ -0,0 +1 @@ +export { default } from "@calcom/features/ee/organizations/api/subteams"; diff --git a/apps/web/pages/api/trpc/organizations/[trpc].ts b/apps/web/pages/api/trpc/organizations/[trpc].ts new file mode 100644 index 0000000000..83520cde33 --- /dev/null +++ b/apps/web/pages/api/trpc/organizations/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router"; + +export default createNextApiHandler(viewerOrganizationsRouter); diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index 27b171c2da..321264ffaf 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -2,6 +2,7 @@ import crypto from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import prisma from "@calcom/prisma"; @@ -16,10 +17,18 @@ const querySchema = z async function getIdentityData(req: NextApiRequest) { const { username, teamname } = querySchema.parse(req.query); + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? ""); if (username) { - const user = await prisma.user.findUnique({ - where: { username }, + const user = await prisma.user.findFirst({ + where: { + username, + organization: isValidOrgDomain + ? { + slug: currentOrgDomain, + } + : null, + }, select: { avatar: true, email: true }, }); return { @@ -29,8 +38,15 @@ async function getIdentityData(req: NextApiRequest) { }; } if (teamname) { - const team = await prisma.team.findUnique({ - where: { slug: teamname }, + const team = await prisma.team.findFirst({ + where: { + slug: teamname, + parent: isValidOrgDomain + ? { + slug: currentOrgDomain, + } + : null, + }, select: { logo: true }, }); return { diff --git a/apps/web/pages/api/username.ts b/apps/web/pages/api/username.ts index 9251aada56..b78eab4c56 100644 --- a/apps/web/pages/api/username.ts +++ b/apps/web/pages/api/username.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { checkUsername } from "@calcom/lib/server/checkUsername"; type Response = { @@ -8,6 +9,7 @@ type Response = { }; export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { - const result = await checkUsername(req.body.username); + const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? ""); + 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 a5d0c0341e..accb5fe890 100644 --- a/apps/web/pages/auth/sso/[provider].tsx +++ b/apps/web/pages/auth/sso/[provider].tsx @@ -5,6 +5,7 @@ import { useEffect } from "react"; import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { hostedCal, @@ -68,11 +69,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const session = await getServerSession({ req, res }); const ssr = await ssrInit(context); + const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); if (session) { // Validating if username is Premium, while this is true an email its required for stripe user confirmation if (usernameParam && session.user.email) { - const availability = await checkUsername(usernameParam); + const availability = await checkUsername(usernameParam, currentOrgDomain); if (availability.available && availability.premium) { const stripePremiumUrl = await getStripePremiumUsernameUrl({ userEmail: session.user.email, diff --git a/apps/web/pages/bookings/[status].tsx b/apps/web/pages/bookings/[status].tsx index 72b0777d8e..ddc86e65ed 100644 --- a/apps/web/pages/bookings/[status].tsx +++ b/apps/web/pages/bookings/[status].tsx @@ -22,7 +22,7 @@ import SkeletonLoader from "@components/booking/SkeletonLoader"; import { ssgInit } from "@server/lib/ssg"; -type BookingListingStatus = z.infer["status"]; +type BookingListingStatus = z.infer>["status"]; type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0]; type RecurringInfo = { @@ -34,7 +34,7 @@ type RecurringInfo = { const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const; -const descriptionByStatus: Record = { +const descriptionByStatus: Record, string> = { upcoming: "upcoming_bookings", recurring: "recurring_bookings", past: "past_bookings", diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 32e72156e2..32666d43d4 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -7,10 +7,13 @@ import type { FC } from "react"; import { useEffect, useState, memo } from "react"; import { z } from "zod"; +import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks"; +import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog"; import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog"; +import { OrganizationEventTypeFilter } from "@calcom/features/eventtypes/components/OrganizationEventTypeFilter"; import Shell from "@calcom/features/shell/Shell"; import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -44,6 +47,8 @@ import { HeadSeo, Skeleton, Label, + VerticalDivider, + Alert, } from "@calcom/ui"; import { ArrowDown, @@ -59,9 +64,11 @@ import { Trash, Upload, Users, + User as UserIcon, } from "@calcom/ui/components/icon"; import { withQuery } from "@lib/QueryCell"; +import useMeQuery from "@lib/hooks/useMeQuery"; import { EmbedButton, EmbedDialog } from "@components/Embed"; import PageWrapper from "@components/PageWrapper"; @@ -74,6 +81,7 @@ interface EventTypeListHeadingProps { profile: EventTypeGroupProfile; membershipCount: number; teamId?: number | null; + orgSlug?: string; } type EventTypeGroup = EventTypeGroups[number]; @@ -194,6 +202,7 @@ const MemoizedItem = memo(Item); export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => { const { t } = useLocale(); const router = useRouter(); + const orgBranding = useOrgBrandingValues(); const [parent] = useAutoAnimate(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0); @@ -362,7 +371,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
    {types.map((type, index) => { const embedLink = `${group.profile.slug}/${type.slug}`; - const calLink = `${CAL_URL}/${embedLink}`; + const calLink = `${ + orgBranding ? `${new URL(CAL_URL).protocol}//${orgBranding.slug}.${subdomainSuffix()}` : CAL_URL + }/${embedLink}`; const isManagedEventType = type.schedulingType === SchedulingType.MANAGED; const isChildrenManagedEventType = type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED; @@ -687,6 +698,7 @@ const EventTypeListHeading = ({ profile, membershipCount, teamId, + orgSlug, }: EventTypeListHeadingProps): JSX.Element => { const { t } = useLocale(); const router = useRouter(); @@ -727,7 +739,9 @@ const EventTypeListHeading = ({ )} {profile?.slug && ( - {`${CAL_URL?.replace("https://", "")}/${profile.slug}`} + {orgSlug + ? `${orgSlug}.${subdomainSuffix()}/${profile.slug}` + : `${CAL_URL?.replace("https://", "")}/${profile.slug}`} )}
@@ -782,6 +796,43 @@ const CTA = () => { ); }; +const Actions = () => { + return ( +
+ + +
+ ); +}; + +const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => { + const { t } = useLocale(); + const orgBranding = useOrgBrandingValues(); + + return ( + + + + + } + /> + ); +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any); @@ -790,12 +841,24 @@ const EventTypesPage = () => { const router = useRouter(); const { open } = useIntercom(); const { query } = router; + const { data: user } = useMeQuery(); const isMobile = useMediaQuery("(max-width: 768px)"); + const [showProfileBanner, setShowProfileBanner] = useState(false); + const orgBranding = useOrgBrandingValues(); + + function closeBanner() { + setShowProfileBanner(false); + document.cookie = `calcom-profile-banner=1;max-age=${60 * 60 * 24 * 90}`; // 3 months + showToast(t("we_wont_show_again"), "success"); + } useEffect(() => { if (query?.openIntercom && query?.openIntercom === "true") { open(); } + setShowProfileBanner( + !!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -810,6 +873,8 @@ const EventTypesPage = () => { heading={t("event_types_page_title")} hideHeadingOnMobile subtitle={t("event_types_page_subtitle")} + afterHeading={showProfileBanner && } + beforeCTAactions={} CTA={}> } @@ -826,6 +891,7 @@ const EventTypesPage = () => { profile={group.profile} membershipCount={group.metadata.membershipCount} teamId={group.teamId} + orgSlug={orgBranding?.slug} /> ; +import { ssrInit } from "@server/lib/ssr"; const INITIAL_STEP = "user-settings"; const steps = [ @@ -44,9 +45,9 @@ const stepRouteSchema = z.object({ }); // TODO: Refactor how steps work to be contained in one array/object. Currently we have steps,initalsteps,headers etc. These can all be in one place -const OnboardingPage = (props: IOnboardingPageProps) => { +const OnboardingPage = () => { const router = useRouter(); - const { user } = props; + const [user] = trpc.viewer.me.useSuspenseQuery(); const { t } = useLocale(); const result = stepRouteSchema.safeParse(router.query); @@ -139,17 +140,20 @@ const OnboardingPage = (props: IOnboardingPageProps) => { - {currentStep === "user-settings" && goToIndex(1)} />} + }> + {currentStep === "user-settings" && goToIndex(1)} />} + {currentStep === "connected-calendar" && goToIndex(2)} />} - {currentStep === "connected-calendar" && goToIndex(2)} />} + {currentStep === "connected-video" && goToIndex(3)} />} - {currentStep === "connected-video" && goToIndex(3)} />} - - {currentStep === "setup-availability" && ( - goToIndex(4)} defaultScheduleId={user.defaultScheduleId} /> - )} - - {currentStep === "user-profile" && } + {currentStep === "setup-availability" && ( + goToIndex(4)} + defaultScheduleId={user.defaultScheduleId} + /> + )} + {currentStep === "user-profile" && } + {headers[currentStepIndex]?.skipText && ( @@ -176,34 +180,21 @@ const OnboardingPage = (props: IOnboardingPageProps) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const { req, res } = context; - const crypto = await import("crypto"); const session = await getServerSession({ req, res }); if (!session?.user?.id) { return { redirect: { permanent: false, destination: "/auth/login" } }; } + const ssr = await ssrInit(context); + + await ssr.viewer.me.prefetch(); + const user = await prisma.user.findUnique({ where: { id: session.user.id, }, select: { - id: true, - username: true, - name: true, - email: true, - bio: true, - avatar: true, - timeZone: true, - weekStart: true, - hideBranding: true, - theme: true, - brandColor: true, - darkBrandColor: true, - metadata: true, - timeFormat: true, - allowDynamicBooking: true, - defaultScheduleId: true, completedOnboarding: true, teams: { select: { @@ -231,10 +222,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { props: { ...(await serverSideTranslations(context.locale ?? "", ["common"])), - user: { - ...user, - emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), - }, + trpcState: ssr.dehydrate(), hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false, }, }; diff --git a/apps/web/pages/new-booker/[user]/[type].tsx b/apps/web/pages/new-booker/[user]/[type].tsx index 4bc452ddc3..705d9114f2 100644 --- a/apps/web/pages/new-booker/[user]/[type].tsx +++ b/apps/web/pages/new-booker/[user]/[type].tsx @@ -5,6 +5,7 @@ import { Booker } from "@calcom/atoms"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { classNames } from "@calcom/lib"; import { getUsernameList } from "@calcom/lib/defaultEvents"; import prisma from "@calcom/prisma"; @@ -94,12 +95,18 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { async function getUserPageProps(context: GetServerSidePropsContext) { const { user: username, type: slug } = paramsSchema.parse(context.params); const { rescheduleUid } = context.query; + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: { username, + organization: isValidOrgDomain + ? { + slug: currentOrgDomain, + } + : null, }, select: { away: true, diff --git a/apps/web/pages/new-booker/d/[link]/[slug].tsx b/apps/web/pages/new-booker/d/[link]/[slug].tsx index f61d876526..187847c781 100644 --- a/apps/web/pages/new-booker/d/[link]/[slug].tsx +++ b/apps/web/pages/new-booker/d/[link]/[slug].tsx @@ -5,6 +5,7 @@ import { Booker } from "@calcom/atoms"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getBookingByUidOrRescheduleUid } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import prisma from "@calcom/prisma"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -38,6 +39,7 @@ Type.PageWrapper = PageWrapper; async function getUserPageProps(context: GetServerSidePropsContext) { const { link, slug } = paramsSchema.parse(context.params); const { rescheduleUid } = context.query; + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); @@ -68,9 +70,14 @@ async function getUserPageProps(context: GetServerSidePropsContext) { }; } - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: { username, + organization: isValidOrgDomain + ? { + slug: currentOrgDomain, + } + : null, }, select: { away: true, diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index a9dde1a8c8..a890dea73a 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -215,7 +215,6 @@ const ProfileView = () => { extraField={
{ showToast(t("settings_updated_successfully"), "success"); await utils.viewer.me.invalidate(); diff --git a/apps/web/pages/settings/organizations/[id]/about.tsx b/apps/web/pages/settings/organizations/[id]/about.tsx new file mode 100644 index 0000000000..9fcd1041c4 --- /dev/null +++ b/apps/web/pages/settings/organizations/[id]/about.tsx @@ -0,0 +1,31 @@ +import { useRouter } from "next/router"; + +import { AboutOrganizationForm } from "@calcom/features/ee/organizations/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout, Meta } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +const AboutOrganizationPage = () => { + const { t } = useLocale(); + const router = useRouter(); + if (!router.isReady) return null; + return ( + <> + + + + ); +}; +const LayoutWrapper = (page: React.ReactElement) => { + return ( + + {page} + + ); +}; + +AboutOrganizationPage.getLayout = LayoutWrapper; +AboutOrganizationPage.PageWrapper = PageWrapper; + +export default AboutOrganizationPage; diff --git a/apps/web/pages/settings/organizations/[id]/add-teams.tsx b/apps/web/pages/settings/organizations/[id]/add-teams.tsx new file mode 100644 index 0000000000..ae670d2384 --- /dev/null +++ b/apps/web/pages/settings/organizations/[id]/add-teams.tsx @@ -0,0 +1,37 @@ +import type { NextRouter } from "next/router"; +import { useRouter } from "next/router"; + +import { AddNewTeamsForm } from "@calcom/features/ee/organizations/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout, Meta } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +const AddNewTeamsPage = () => { + const { t } = useLocale(); + const router = useRouter(); + if (!router.isReady) return null; + return ( + <> + + + + ); +}; + +AddNewTeamsPage.getLayout = (page: React.ReactElement, router: NextRouter) => ( + <> + { + router.push(`/event-types`); + }}> + {page} + + +); + +AddNewTeamsPage.PageWrapper = PageWrapper; + +export default AddNewTeamsPage; diff --git a/apps/web/pages/settings/organizations/[id]/onboard-admins.tsx b/apps/web/pages/settings/organizations/[id]/onboard-admins.tsx new file mode 100644 index 0000000000..9fd7774017 --- /dev/null +++ b/apps/web/pages/settings/organizations/[id]/onboard-admins.tsx @@ -0,0 +1,38 @@ +import type { NextRouter } from "next/router"; +import { useRouter } from "next/router"; + +import { AddNewOrgAdminsForm } from "@calcom/features/ee/organizations/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout, Meta } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +const OnboardTeamMembersPage = () => { + const { t } = useLocale(); + const router = useRouter(); + if (!router.isReady) return null; + return ( + <> + + + + ); +}; + +OnboardTeamMembersPage.getLayout = (page: React.ReactElement, router: NextRouter) => ( + { + router.push(`/settings/organizations/${router.query.id}/add-teams`); + }}> + {page} + +); + +OnboardTeamMembersPage.PageWrapper = PageWrapper; + +export default OnboardTeamMembersPage; diff --git a/apps/web/pages/settings/organizations/[id]/set-password.tsx b/apps/web/pages/settings/organizations/[id]/set-password.tsx new file mode 100644 index 0000000000..86b0bff1f0 --- /dev/null +++ b/apps/web/pages/settings/organizations/[id]/set-password.tsx @@ -0,0 +1,31 @@ +import { useRouter } from "next/router"; + +import { SetPasswordForm } from "@calcom/features/ee/organizations/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout, Meta } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +const SetPasswordPage = () => { + const { t } = useLocale(); + const router = useRouter(); + if (!router.isReady) return null; + return ( + <> + + + + ); +}; +const LayoutWrapper = (page: React.ReactElement) => { + return ( + + {page} + + ); +}; + +SetPasswordPage.getLayout = LayoutWrapper; +SetPasswordPage.PageWrapper = PageWrapper; + +export default SetPasswordPage; diff --git a/apps/web/pages/settings/organizations/new/index.tsx b/apps/web/pages/settings/organizations/new/index.tsx new file mode 100644 index 0000000000..132c1f85da --- /dev/null +++ b/apps/web/pages/settings/organizations/new/index.tsx @@ -0,0 +1,27 @@ +import { CreateANewOrganizationForm } from "@calcom/features/ee/organizations/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout, Meta } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +const CreateNewOrganizationPage = () => { + const { t } = useLocale(); + return ( + <> + + + + ); +}; +const LayoutWrapper = (page: React.ReactElement) => { + return ( + + {page} + + ); +}; + +CreateNewOrganizationPage.getLayout = LayoutWrapper; +CreateNewOrganizationPage.PageWrapper = PageWrapper; + +export default CreateNewOrganizationPage; diff --git a/apps/web/pages/settings/teams/[id]/onboard-members.tsx b/apps/web/pages/settings/teams/[id]/onboard-members.tsx index 92a300ba4f..4ff3fb9d77 100644 --- a/apps/web/pages/settings/teams/[id]/onboard-members.tsx +++ b/apps/web/pages/settings/teams/[id]/onboard-members.tsx @@ -2,9 +2,9 @@ import Head from "next/head"; import AddNewTeamMembers from "@calcom/features/ee/teams/components/AddNewTeamMembers"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; -import WizardLayout from "@components/layouts/WizardLayout"; const OnboardTeamMembersPage = () => { const { t } = useLocale(); diff --git a/apps/web/pages/settings/teams/new/index.tsx b/apps/web/pages/settings/teams/new/index.tsx index 604dd287c5..d3442f1696 100644 --- a/apps/web/pages/settings/teams/new/index.tsx +++ b/apps/web/pages/settings/teams/new/index.tsx @@ -2,9 +2,9 @@ import Head from "next/head"; import { CreateANewTeamForm } from "@calcom/features/ee/teams/components"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WizardLayout } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; -import WizardLayout from "@components/layouts/WizardLayout"; const CreateNewTeamPage = () => { const { t } = useLocale(); diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 61270c1116..1d5dadf1ca 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -5,8 +5,9 @@ import { useRouter } from "next/router"; import { useEffect } from "react"; import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription"; -import { CAL_URL } from "@calcom/lib/constants"; +import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; @@ -15,6 +16,7 @@ import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; import { stripMarkdown } from "@calcom/lib/stripMarkdown"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { Avatar, AvatarGroup, Button, EmptyScreen, HeadSeo } from "@calcom/ui"; import { ArrowRight } from "@calcom/ui/components/icon"; @@ -27,7 +29,7 @@ import Team from "@components/team/screens/Team"; import { ssrInit } from "@server/lib/ssr"; export type TeamPageProps = inferSSRProps; -function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) { +function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: TeamPageProps) { useTheme(team.theme); const showMembers = useToggleQuery("members"); const { t } = useLocale(); @@ -36,6 +38,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) { const router = useRouter(); const teamName = team.name || "Nameless Team"; const isBioEmpty = !team.bio || !team.bio.replace("


", "").length; + const metadata = teamMetadataSchema.parse(team.metadata); useEffect(() => { telemetry.event( @@ -49,8 +52,12 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
} - headline={t("team_is_unpublished", { team: teamName })} - description={t("team_is_unpublished_description")} + headline={t("team_is_unpublished", { + team: teamName, + })} + description={t("team_is_unpublished_description", { + entity: metadata?.isOrganization ? t("organization").toLowerCase() : t("team").toLowerCase(), + })} />
); @@ -71,7 +78,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
{ @@ -106,6 +113,53 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) { ); + const SubTeams = () => + team.children.length ? ( +
    + {team.children.map((ch, i) => ( +
  • + +
    + +
    + {ch.name} + + {t("number_member", { count: ch.members.length })} + +
    +
    + ({ + alt: member.name || "", + image: `${WEBAPP_URL}/${member.username}/avatar.png`, + title: member.name || "", + }))} + /> + +
  • + ))} +
+ ) : ( +
+
+
+

+ {" " + t("no_teams_yet")} +

+

{t("no_teams_yet_description")}

+
+
+
+ ); + return ( <>
- -

{teamName}

+
+ +
+

+ {team.parent && `${team.parent.name} `} + {teamName} +

{!isBioEmpty && ( <>
)}
- {(showMembers.isOn || !team.eventTypes.length) && } - {!showMembers.isOn && team.eventTypes.length > 0 && ( -
- + {metadata?.isOrganization ? ( + + ) : ( + <> + {(showMembers.isOn || !team.eventTypes.length) && } + {!showMembers.isOn && team.eventTypes.length > 0 && ( +
+ - {!team.hideBookATeamMember && ( -
-
- + {!team.hideBookATeamMember && ( +
+
+ - + +
+ )}
)} -
+ )}
@@ -175,8 +244,19 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug; + const { isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); const team = await getTeamWithMembers(undefined, slug); + const metadata = teamMetadataSchema.parse(team?.metadata ?? {}); + + // Taking care of sub-teams and orgs + if ( + (isValidOrgDomain && team?.parent && !!metadata?.isOrganization) || + (!isValidOrgDomain && team?.parent) || + (!isValidOrgDomain && !!metadata?.isOrganization) + ) { + return { notFound: true } as const; + } if (!team) { const unpublishedTeam = await prisma.team.findFirst({ @@ -193,7 +273,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { props: { isUnpublished: true, - team: unpublishedTeam, + team: { ...unpublishedTeam, createdAt: null }, trpcState: ssr.dehydrate(), }, } as const; @@ -222,6 +302,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => themeBasis: team.slug, trpcState: ssr.dehydrate(), markdownStrippedBio, + isValidOrgDomain, }, } as const; }; diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index d06e7776b7..9859a5e919 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -76,7 +76,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, }, }, - title: true, availability: true, description: true, @@ -132,6 +131,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, }, }, + parent: { + select: { + logo: true, + name: true, + }, + }, }, }, }, diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index 53ed06d0ca..d40be2a3f2 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -86,6 +86,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { theme: true, brandColor: true, darkBrandColor: true, + parent: { + select: { + logo: true, + name: true, + }, + }, }, }, users: { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b8f4566e5e..7909b256e0 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -117,7 +117,7 @@ "team_info": "Team Info", "request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.", "you_have_been_invited": "You have been invited to join the team {{teamName}}", - "user_invited_you": "{{user}} invited you to join the team {{team}} on {{appName}}", + "user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}", "hidden_team_member_title": "You are hidden in this team", "hidden_team_member_message": "Your seat is not paid for, either Upgrade to PRO or let the team owner know they can pay for your seat.", "hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.", @@ -238,6 +238,7 @@ "done": "Done", "all_done": "All done!", "all_apps": "All", + "yours":"Yours", "available_apps": "Available Apps", "check_email_reset_password": "Check your email. We sent you a link to reset your password.", "finish": "Finish", @@ -540,6 +541,8 @@ "team_description": "A few sentences about your team. This will appear on your team's url page.", "members": "Members", "member": "Member", + "number_member_one": "{{count}} member", + "number_member_other": "{{count}} members", "owner": "Owner", "admin": "Admin", "administrator_user": "Administrator user", @@ -680,6 +683,7 @@ "create_team_to_get_started": "Create a team to get started", "teams": "Teams", "team": "Team", + "organization": "Organization", "team_billing": "Team Billing", "team_billing_description": "Manage billing for your team", "upgrade_to_flexible_pro_title": "We've changed billing for teams", @@ -1611,10 +1615,10 @@ "delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.", "organizer_timezone": "Organizer timezone", "email_user_cta": "View Invitation", - "email_no_user_invite_heading": "You’ve been invited to join a team on {{appName}}", + "email_no_user_invite_heading": "You’ve been invited to join a {{appName}} {{entity}}", "email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email_user_invite_subheading": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your team in no time.", + "email_user_invite_subheading": "{{invitedBy}} has invited you to join their {{entity}} `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your {{entity}} to schedule meetings without the email tennis.", + "email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your {{entity}} in no time.", "email_no_user_step_one": "Choose your username", "email_no_user_step_two": "Connect your calendar account", "email_no_user_step_three": "Set your Availability", @@ -1657,7 +1661,7 @@ "show_on_booking_page":"Show on booking page", "get_started_zapier_templates": "Get started with Zapier templates", "team_is_unpublished": "{{team}} is unpublished", - "team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.", + "team_is_unpublished_description": "This {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them publish it.", "team_member": "Team member", "a_routing_form": "A Routing Form", "form_description_placeholder": "Form Description", @@ -1814,7 +1818,6 @@ "book_my_cal": "Book my Cal", "invite_as":"Invite as", "form_updated_successfully":"Form updated successfully.", - "email_not_cal_member_cta": "Join your team", "disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees", "disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.", "disable_host_confirmation_emails": "Disable default confirmation emails for host", @@ -1825,7 +1828,46 @@ "google_workspace_admin_tooltip":"You must be a Workspace Admin to use this feature", "first_event_type_webhook_description": "Create your first webhook for this event type", "create_for": "Create for", + "setup_organization": "Setup an Organization", + "organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.", + "organization_banner_title": "Manage organizations with multiple teams", + "set_up_your_organization": "Set up your organization", + "organizations_description": "Organizations are shared environments where teams can create shared event types, apps, workflows and more.", + "organization_url_taken": "This URL is already taken", + "must_enter_organization_name": "Must enter an organization name", + "must_enter_organization_admin_email": "Must enter your organization email address", + "admin_email": "Your organization email address", + "admin_username": "Administrator's username", + "organization_name": "Organization name", + "organization_url": "Organization URL", + "organization_verify_header" :"Verify your organization email", + "organization_verify_email_body":"Please use the code below to verify your email address to continue setting up your organization.", "additional_url_parameters": "Additional URL parameters", + "about_your_organization": "About your organization", + "about_your_organization_description": "Organizations are shared environments where you can create multiple teams with shared members, event types, apps, workflows and more.", + "create_your_teams": "Create your teams", + "create_your_teams_description": "Start scheduling together by adding your team members to your organisation", + "invite_organization_admins": "Invite your organization admins", + "invite_organization_admins_description": "These admins will have access to all teams in your organization. You can add team admins and members later.", + "set_a_password": "Set a password", + "set_a_password_description": "This will create a new user account with your organization email and this password.", + "organization_logo": "Organization Logo", + "organization_about_description": "A few sentences about your organization. This will appear on your organization public profile page.", + "ill_do_this_later": "I'll do this later", + "verify_your_email": "Verify your email", + "enter_digit_code": "Enter the 6 digit code we sent to {{email}}", + "verify_email_organization": "Verify your email to create an organization", + "code_provided_invalid": "The code provided is not valid, try again", + "email_already_used": "Email already being used", + "duplicated_slugs_warning": "The following teams couldn't be created due to duplicated slugs: {{slugs}}", + "team_names_empty": "Team names can't be empty", + "team_names_repeated": "Team names can't be repeated", + "user_belongs_organization": "User belongs to an organization", + "no_teams_yet": "This organization has no teams yet", + "no_teams_yet_description": "if you are an administrator, be sure to create teams to be shown here.", + "set_up": "Set up", + "set_up_your_profile": "Set up your profile", + "set_up_your_profile_description": "Let people know who you are within {{orgName}}, and when they engage with your public link.", "sender_id_info": "Name or number shown as the sender of an SMS (some countries do not allow alphanumeric sender IDs)", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 16d94b5573..2189e0acbb 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -27,6 +27,7 @@ const availabilitySchema = z beforeEventBuffer: z.number().optional(), duration: z.number().optional(), withSource: z.boolean().optional(), + orgSlug: z.string().optional(), }) .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); @@ -67,8 +68,8 @@ const getEventType = async (id: number) => { type EventType = Awaited>; -const getUser = (where: Prisma.UserWhereUniqueInput) => - prisma.user.findUnique({ +const getUser = (where: Prisma.UserWhereInput) => + prisma.user.findFirst({ where, select: { ...availabilityUserSelect, @@ -112,6 +113,7 @@ export async function getUserAvailability( afterEventBuffer?: number; beforeEventBuffer?: number; duration?: number; + orgSlug?: string; }, initialData?: { user?: User; @@ -119,15 +121,25 @@ export async function getUserAvailability( currentSeats?: CurrentSeats; } ) { - const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } = - availabilitySchema.parse(query); + const { + username, + userId, + dateFrom, + dateTo, + eventTypeId, + afterEventBuffer, + beforeEventBuffer, + duration, + orgSlug, + } = availabilitySchema.parse(query); if (!dateFrom.isValid() || !dateTo.isValid()) { throw new HttpError({ statusCode: 400, message: "Invalid time range given." }); } - const where: Prisma.UserWhereUniqueInput = {}; + const where: Prisma.UserWhereInput = {}; if (username) where.username = username; + if (orgSlug) where.organization = { slug: orgSlug }; if (userId) where.id = userId; const user = initialData?.user || (await getUser(where)); diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 1652e97861..fe8f35927f 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -25,6 +25,8 @@ import FeedbackEmail from "./templates/feedback-email"; import type { PasswordReset } from "./templates/forgot-password-email"; import ForgotPasswordEmail from "./templates/forgot-password-email"; import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email"; +import type { OrganizationEmailVerify } from "./templates/organization-email-verification"; +import OrganizationEmailVerification from "./templates/organization-email-verification"; import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email"; import OrganizerCancelledEmail from "./templates/organizer-cancelled-email"; import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email"; @@ -354,3 +356,7 @@ export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, dow } await Promise.all(emailsToSend); }; + +export const sendOrganizationEmailVerification = async (sendOrgInput: OrganizationEmailVerify) => { + await sendEmail(() => new OrganizationEmailVerification(sendOrgInput)); +}; diff --git a/packages/emails/src/templates/OrganizationAccountVerifyEmail.tsx b/packages/emails/src/templates/OrganizationAccountVerifyEmail.tsx new file mode 100644 index 0000000000..aa7ef5d989 --- /dev/null +++ b/packages/emails/src/templates/OrganizationAccountVerifyEmail.tsx @@ -0,0 +1,65 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME, SUPPORT_MAIL_ADDRESS, COMPANY_NAME } from "@calcom/lib/constants"; + +import { BaseEmailHtml } from "../components"; + +export type OrganizationEmailVerify = { + language: TFunction; + user: { + email: string; + }; + code: string; +}; + +export const OrganisationAccountVerifyEmail = ( + props: OrganizationEmailVerify & Partial> +) => { + return ( + +

+ <>{props.language("organization_verify_header")} +

+

+ <>{props.language("hi_user_name", { name: props.user.email })}! +

+

+ <>{props.language("organization_verify_email_body")} +

+ +
+
+ + {props.code} + +
+
+ +
+

+ <> + {props.language("happy_scheduling")}
+ + <>{props.language("the_calcom_team", { companyName: COMPANY_NAME })} + + +

+
+
+ ); +}; diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx index d744b5c005..87474c51eb 100644 --- a/packages/emails/src/templates/TeamInviteEmail.tsx +++ b/packages/emails/src/templates/TeamInviteEmail.tsx @@ -11,6 +11,7 @@ type TeamInvite = { teamName: string; joinLink: string; isCalcomMember: boolean; + isOrg: boolean; }; export const TeamInviteEmail = ( @@ -22,9 +23,15 @@ export const TeamInviteEmail = ( user: props.from, team: props.teamName, appName: APP_NAME, + entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(), })}>

- <>{props.language("email_no_user_invite_heading", { appName: APP_NAME })} + <> + {props.language("email_no_user_invite_heading", { + appName: APP_NAME, + entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(), + })} +

@@ -72,7 +80,11 @@ export const TeamInviteEmail = ( marginTop: "48px", lineHeightStep: "24px", }}> - <>{props.language("email_no_user_invite_steps_intro")} + <> + {props.language("email_no_user_invite_steps_intro", { + entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(), + })} +

{!props.isCalcomMember && ( @@ -121,7 +133,11 @@ export const TeamInviteEmail = ( marginTop: "32px", lineHeightStep: "24px", }}> - <>{props.language("email_no_user_signoff", { appName: APP_NAME })} + <> + {props.language("email_no_user_signoff", { + appName: APP_NAME, + })} +

diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index e6411eb17b..91f64b4ce9 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -26,3 +26,4 @@ export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail"; export { VerifyAccountEmail } from "./VerifyAccountEmail"; export * from "@calcom/app-store/routing-forms/emails/components"; export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail"; +export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail"; diff --git a/packages/emails/templates/organization-email-verification.ts b/packages/emails/templates/organization-email-verification.ts new file mode 100644 index 0000000000..cfbc591df6 --- /dev/null +++ b/packages/emails/templates/organization-email-verification.ts @@ -0,0 +1,38 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME } from "@calcom/lib/constants"; + +import renderEmail from "../src/renderEmail"; +import BaseEmail from "./_base-email"; + +export type OrganizationEmailVerify = { + language: TFunction; + user: { + email: string; + }; + code: string; +}; + +export default class OrganizationEmailVerification extends BaseEmail { + orgVerifyInput: OrganizationEmailVerify; + + constructor(orgVerifyInput: OrganizationEmailVerify) { + super(); + this.name = "SEND_ORG_ACCOUNT_VERIFY_EMAIL"; + this.orgVerifyInput = orgVerifyInput; + } + + protected getNodeMailerPayload(): Record { + return { + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + to: this.orgVerifyInput.user.email, + subject: this.orgVerifyInput.language("verify_email_organization"), + html: renderEmail("OrganisationAccountVerifyEmail", this.orgVerifyInput), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return `Code: ${this.orgVerifyInput.code}`; + } +} diff --git a/packages/emails/templates/team-invite-email.ts b/packages/emails/templates/team-invite-email.ts index be70399e71..d583f7dc96 100644 --- a/packages/emails/templates/team-invite-email.ts +++ b/packages/emails/templates/team-invite-email.ts @@ -1,4 +1,4 @@ -import { TFunction } from "next-i18next"; +import type { TFunction } from "next-i18next"; import { APP_NAME } from "@calcom/lib/constants"; @@ -12,6 +12,7 @@ export type TeamInvite = { teamName: string; joinLink: string; isCalcomMember: boolean; + isOrg: boolean; }; export default class TeamInviteEmail extends BaseEmail { @@ -31,6 +32,9 @@ export default class TeamInviteEmail extends BaseEmail { user: this.teamInviteEvent.from, team: this.teamInviteEvent.teamName, appName: APP_NAME, + entity: this.teamInviteEvent + .language(this.teamInviteEvent.isOrg ? "organization" : "team") + .toLowerCase(), }), html: renderEmail("TeamInviteEmail", this.teamInviteEvent), text: "", diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index 5368484be3..97128b1ebe 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -72,6 +72,7 @@ export async function getServerSession(options: { image: `${CAL_URL}/${user.username}/avatar.png`, impersonatedByUID: token.impersonatedByUID ?? undefined, belongsToActiveTeam: token.belongsToActiveTeam, + organizationId: token.organizationId, }, }; diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index b5e27ee8f8..e9d0e0a62f 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -82,6 +82,7 @@ const providers: Provider[] = [ metadata: true, identityProvider: true, password: true, + organizationId: true, twoFactorEnabled: true, twoFactorSecret: true, teams: { @@ -172,6 +173,7 @@ const providers: Provider[] = [ name: user.name, role: validateRole(user.role), belongsToActiveTeam: hasActiveTeams, + organizationId: user.organizationId, }; }, }), @@ -353,6 +355,7 @@ export const AUTH_OPTIONS: AuthOptions = { username: true, name: true, email: true, + organizationId: true, role: true, teams: { include: { @@ -397,6 +400,7 @@ export const AUTH_OPTIONS: AuthOptions = { role: user.role, impersonatedByUID: user?.impersonatedByUID, belongsToActiveTeam: user?.belongsToActiveTeam, + organizationId: user?.organizationId, }; } @@ -434,6 +438,7 @@ export const AUTH_OPTIONS: AuthOptions = { role: existingUser.role, impersonatedByUID: token.impersonatedByUID as number, belongsToActiveTeam: token?.belongsToActiveTeam as boolean, + organizationId: token?.organizationId, }; } @@ -452,6 +457,7 @@ export const AUTH_OPTIONS: AuthOptions = { role: token.role as UserPermissionRole, impersonatedByUID: token.impersonatedByUID as number, belongsToActiveTeam: token?.belongsToActiveTeam as boolean, + organizationId: token?.organizationId, }, }; return calendsoSession; @@ -605,7 +611,12 @@ export const AUTH_OPTIONS: AuthOptions = { !existingUserWithEmail.username ) { await prisma.user.update({ - where: { email: existingUserWithEmail.email }, + where: { + email_username: { + email: existingUserWithEmail.email, + username: existingUserWithEmail.username!, + }, + }, data: { // update the email to the IdP email email: user.email, diff --git a/packages/features/bookings/lib/useFilterQuery.tsx b/packages/features/bookings/lib/useFilterQuery.tsx index 457c86e51f..344d741f23 100644 --- a/packages/features/bookings/lib/useFilterQuery.tsx +++ b/packages/features/bookings/lib/useFilterQuery.tsx @@ -6,7 +6,7 @@ import { queryNumberArray, useTypedQuery } from "@calcom/lib/hooks/useTypedQuery export const filterQuerySchema = z.object({ teamIds: queryNumberArray.optional(), userIds: queryNumberArray.optional(), - status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]), + status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]).optional(), eventTypeIds: queryNumberArray.optional(), }); diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts index 3e49c8698b..9b90bb78d7 100644 --- a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts +++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts @@ -10,7 +10,7 @@ const teamIdschema = z.object({ }); const auditAndReturnNextUser = async ( - impersonatedUser: Pick, + impersonatedUser: Pick, impersonatedByUID: number, hasTeam?: boolean ) => { @@ -38,6 +38,7 @@ const auditAndReturnNextUser = async ( role: impersonatedUser.role, impersonatedByUID, belongsToActiveTeam: hasTeam, + organizationId: impersonatedUser.organizationId, }; return obj; @@ -79,6 +80,7 @@ const ImpersonationProvider = CredentialsProvider({ role: true, name: true, email: true, + organizationId: true, disableImpersonation: true, teams: { where: { diff --git a/packages/features/ee/organizations/api/subteams.ts b/packages/features/ee/organizations/api/subteams.ts new file mode 100644 index 0000000000..352b922bb4 --- /dev/null +++ b/packages/features/ee/organizations/api/subteams.ts @@ -0,0 +1,36 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +const querySchema = z.object({ + org: z.string({ required_error: "org slug is required" }), +}); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const parsedQuery = querySchema.safeParse(req.query); + + if (!parsedQuery.success) throw new HttpError({ statusCode: 400, message: parsedQuery.error.message }); + + const { + data: { org: slug }, + } = parsedQuery; + if (!slug) return res.status(400).json({ message: "Org is needed" }); + + const org = await prisma.team.findFirst({ where: { slug }, select: { children: true, metadata: true } }); + + if (!org) return res.status(400).json({ message: "Org doesn't exist" }); + + const metadata = teamMetadataSchema.parse(org?.metadata); + + if (!metadata?.isOrganization) return res.status(400).json({ message: "Team is not an org" }); + + return res.status(200).json({ slugs: org.children.map((ch) => ch.slug) }); +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/features/ee/organizations/components/AboutOrganizationForm.tsx b/packages/features/ee/organizations/components/AboutOrganizationForm.tsx new file mode 100644 index 0000000000..1ba3142edc --- /dev/null +++ b/packages/features/ee/organizations/components/AboutOrganizationForm.tsx @@ -0,0 +1,122 @@ +import { useRouter } from "next/router"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import z from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar, Button, Form, ImageUploader, Alert, Label, TextAreaField } from "@calcom/ui"; +import { ArrowRight, Plus } from "@calcom/ui/components/icon"; + +const querySchema = z.object({ + id: z.string(), +}); + +export const AboutOrganizationForm = () => { + const { t } = useLocale(); + const router = useRouter(); + const { id: orgId } = querySchema.parse(router.query); + const [serverErrorMessage, setServerErrorMessage] = useState(null); + const [image, setImage] = useState(""); + + const aboutOrganizationFormMethods = useForm<{ + logo: string; + bio: string; + }>(); + + const updateOrganizationMutation = trpc.viewer.organizations.update.useMutation({ + onSuccess: (data) => { + if (data.update) { + router.push(`/settings/organizations/${orgId}/onboard-admins`); + } + }, + onError: (err) => { + setServerErrorMessage(err.message); + }, + }); + + return ( + <> + { + if (!updateOrganizationMutation.isLoading) { + setServerErrorMessage(null); + updateOrganizationMutation.mutate({ ...v, orgId }); + } + }}> + {serverErrorMessage && ( +
+ +
+ )} + +
+ ( + <> + +
+ } + asChild + className="items-center" + size="lg" + /> +
+ { + setImage(newAvatar); + aboutOrganizationFormMethods.setValue("logo", newAvatar); + }} + imageSrc={image} + /> +
+
+ + )} + /> +
+ +
+ ( + <> + { + aboutOrganizationFormMethods.setValue("bio", e?.target.value); + }} + /> +

{t("organization_about_description")}

+ + )} + /> +
+ +
+ +
+ + + ); +}; diff --git a/packages/features/ee/organizations/components/AddNewOrgAdminsForm.tsx b/packages/features/ee/organizations/components/AddNewOrgAdminsForm.tsx new file mode 100644 index 0000000000..39baf857fd --- /dev/null +++ b/packages/features/ee/organizations/components/AddNewOrgAdminsForm.tsx @@ -0,0 +1,99 @@ +import { ArrowRight } from "lucide-react"; +import { useRouter } from "next/router"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { Button, showToast, TextAreaField, Form } from "@calcom/ui"; + +const querySchema = z.object({ + id: z.string().transform((val) => parseInt(val)), +}); + +export const AddNewOrgAdminsForm = () => { + const { t, i18n } = useLocale(); + const router = useRouter(); + const { id: orgId } = querySchema.parse(router.query); + const newAdminsFormMethods = useForm<{ + emails: string[]; + }>(); + const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({ + async onSuccess(data) { + if (data.sendEmailInvitation) { + if (Array.isArray(data.usernameOrEmail)) { + showToast( + t("email_invite_team_bulk", { + userCount: data.usernameOrEmail.length, + }), + "success" + ); + } else { + showToast( + t("email_invite_team", { + email: data.usernameOrEmail, + }), + "success" + ); + } + } + router.push(`/settings/organizations/${orgId}/add-teams`); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + return ( +
{ + inviteMemberMutation.mutate({ + teamId: orgId, + language: i18n.language, + role: MembershipRole.ADMIN, + usernameOrEmail: values.emails, + sendEmailInvitation: true, + isOrg: true, + }); + }}> +
+ ( + <> + { + const emails = e.target.value.split(",").map((email) => email.trim().toLocaleLowerCase()); + + return onChange(emails); + }} + /> + {error && {error.message}} + + )} + /> + +
+
+ ); +}; diff --git a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx new file mode 100644 index 0000000000..e258228a04 --- /dev/null +++ b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx @@ -0,0 +1,107 @@ +import { ArrowRight } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { z } from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button, showToast, TextField } from "@calcom/ui"; +import { Plus, X } from "@calcom/ui/components/icon"; + +const querySchema = z.object({ + id: z.string().transform((val) => parseInt(val)), +}); + +export const AddNewTeamsForm = () => { + const { t } = useLocale(); + const router = useRouter(); + const { id: orgId } = querySchema.parse(router.query); + const [counter, setCounter] = useState(1); + + const [inputValues, setInputValues] = useState([""]); + + const handleCounterIncrease = () => { + setCounter((prevCounter) => prevCounter + 1); + setInputValues((prevInputValues) => [...prevInputValues, ""]); + }; + + const handleInputChange = (index: number, value: string) => { + const newInputValues = [...inputValues]; + newInputValues[index] = value; + setInputValues(newInputValues); + }; + + const handleRemoveInput = (index: number) => { + const newInputValues = [...inputValues]; + newInputValues.splice(index, 1); + setInputValues(newInputValues); + setCounter((prevCounter) => prevCounter - 1); + }; + + const createTeamsMutation = trpc.viewer.organizations.createTeams.useMutation({ + async onSuccess(data) { + if (data.duplicatedSlugs.length) { + showToast(t("duplicated_slugs_warning", { slugs: data.duplicatedSlugs.join(", ") }), "warning"); + setTimeout(() => { + router.push(`/event-types`); + }, 3000); + } else { + router.push(`/event-types`); + } + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + return ( + <> + {Array.from({ length: counter }, (_, index) => ( +
+ handleInputChange(index, e.target.value)} + addOnClassname="bg-transparent p-0 border-l-0" + addOnSuffix={ + index > 0 && ( + + ) + } + /> +
+ ))} + + + + ); +}; diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx new file mode 100644 index 0000000000..34a3418f18 --- /dev/null +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -0,0 +1,318 @@ +import { signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import type { Dispatch, SetStateAction } from "react"; +import { useState } from "react"; +import useDigitInput from "react-digit-input"; +import { Controller, useForm } from "react-hook-form"; + +import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import slugify from "@calcom/lib/slugify"; +import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; +import { trpc } from "@calcom/trpc/react"; +import { + Button, + Form, + TextField, + Alert, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + Label, + Input, +} from "@calcom/ui"; +import { ArrowRight, Info } from "@calcom/ui/components/icon"; + +function extractDomainFromEmail(email: string) { + let out = ""; + try { + const match = email.match(/^(?:.*?:\/\/)?.*?(?[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/); + out = (match && match.groups?.root) ?? ""; + } catch (ignore) {} + return out.split(".")[0]; +} + +export const VerifyCodeDialog = ({ + isOpenDialog, + setIsOpenDialog, + email, + onSuccess, +}: { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + email: string; + onSuccess: (isVerified: boolean) => void; +}) => { + const { t } = useLocale(); + // Not using the mutation isLoading flag because after verifying we submit the underlying org creation form + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [value, onChange] = useState(""); + + const digits = useDigitInput({ + acceptedCharacters: /^[0-9]$/, + length: 6, + value, + onChange, + }); + + const verifyCodeMutation = trpc.viewer.organizations.verifyCode.useMutation({ + onSuccess: (data) => { + setIsLoading(false); + onSuccess(data); + }, + onError: (err) => { + setIsLoading(false); + if (err.message === "invalid_code") { + setError(t("code_provided_invalid")); + } + }, + }); + + const digitClassName = "h-12 w-12 !text-xl text-center"; + + return ( + { + onChange(""); + setError(""); + setIsOpenDialog(open); + }}> + +
+
+ + +
+ + + + + + +
+ {error && ( +
+
+ +
+

{error}

+
+ )} + + + + +
+
+
+
+ ); +}; + +export const CreateANewOrganizationForm = () => { + const { t, i18n } = useLocale(); + const router = useRouter(); + const telemetry = useTelemetry(); + const [serverErrorMessage, setServerErrorMessage] = useState(null); + const [showVerifyCode, setShowVerifyCode] = useState(false); + + const newOrganizationFormMethods = useForm<{ + name: string; + slug: string; + adminEmail: string; + adminUsername: string; + }>(); + const watchAdminEmail = newOrganizationFormMethods.watch("adminEmail"); + + const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({ + onSuccess: async (data) => { + if (data.checked) { + setShowVerifyCode(true); + } else if (data.user) { + telemetry.event(telemetryEventTypes.org_created); + await signIn("credentials", { + redirect: false, + callbackUrl: "/", + email: data.user.email, + password: data.user.password, + }); + router.push(`/settings/organizations/${data.user.organizationId}/set-password`); + } + }, + onError: (err) => { + if (err.message === "admin_email_taken") { + newOrganizationFormMethods.setError("adminEmail", { + type: "custom", + message: t("email_already_used"), + }); + } else if (err.message === "organization_url_taken") { + newOrganizationFormMethods.setError("slug", { type: "custom", message: t("organization_url_taken") }); + } else { + setServerErrorMessage(err.message); + } + }, + }); + + return ( + <> +
{ + if (!createOrganizationMutation.isLoading) { + setServerErrorMessage(null); + createOrganizationMutation.mutate(v); + } + }}> +
+ {serverErrorMessage && ( +
+ +
+ )} + + ( +
+ { + const domain = extractDomainFromEmail(e?.target.value); + newOrganizationFormMethods.setValue("adminEmail", e?.target.value); + newOrganizationFormMethods.setValue("adminUsername", e?.target.value.split("@")[0]); + newOrganizationFormMethods.setValue("slug", domain); + newOrganizationFormMethods.setValue( + "name", + domain.charAt(0).toUpperCase() + domain.slice(1) + ); + }} + autoComplete="off" + /> +
+ )} + /> +
+
+ ( + <> + { + newOrganizationFormMethods.setValue("name", e?.target.value); + if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) { + newOrganizationFormMethods.setValue("slug", slugify(e?.target.value)); + } + }} + autoComplete="off" + /> + + )} + /> +
+ +
+ ( + { + newOrganizationFormMethods.setValue("slug", slugify(e?.target.value), { + shouldTouch: true, + }); + newOrganizationFormMethods.clearErrors("slug"); + }} + /> + )} + /> +
+ + + +
+ +
+
+ { + if (isVerified) { + createOrganizationMutation.mutate({ + ...newOrganizationFormMethods.getValues(), + language: i18n.language, + check: false, + }); + } + }} + /> + + ); +}; diff --git a/packages/features/ee/organizations/components/SetPasswordForm.tsx b/packages/features/ee/organizations/components/SetPasswordForm.tsx new file mode 100644 index 0000000000..29f721f67a --- /dev/null +++ b/packages/features/ee/organizations/components/SetPasswordForm.tsx @@ -0,0 +1,109 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button, Form, Alert, PasswordField } from "@calcom/ui"; +import { ArrowRight } from "@calcom/ui/components/icon"; + +const querySchema = z.object({ + id: z.string(), +}); + +const formSchema = z.object({ + password: z.string().superRefine((data, ctx) => { + const isStrict = true; + const result = isPasswordValid(data, true, isStrict); + Object.keys(result).map((key: string) => { + if (!result[key as keyof typeof result]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: key, + }); + } + }); + }), +}); + +export const SetPasswordForm = () => { + const { t } = useLocale(); + const router = useRouter(); + const { id: orgId } = querySchema.parse(router.query); + + const [serverErrorMessage, setServerErrorMessage] = useState(null); + + const setPasswordFormMethods = useForm<{ + password: string; + }>({ + resolver: zodResolver(formSchema), + }); + + const setPasswordMutation = trpc.viewer.organizations.setPassword.useMutation({ + onSuccess: (data) => { + if (data.update) { + router.push(`/settings/organizations/${orgId}/about`); + } + }, + onError: (err) => { + setServerErrorMessage(err.message); + }, + }); + + return ( + <> +
{ + if (!setPasswordMutation.isLoading) { + setServerErrorMessage(null); + setPasswordMutation.mutate({ newPassword: v.password }); + } + }}> +
+ {serverErrorMessage && ( +
+ +
+ )} +
+ +
+ ( + { + onChange(e.target.value); + setPasswordFormMethods.setValue("password", e.target.value); + await setPasswordFormMethods.trigger("password"); + }} + hintErrors={["caplow", "admin_min", "num"]} + name="password" + autoComplete="off" + /> + )} + /> +
+ +
+ +
+
+ + ); +}; diff --git a/packages/features/ee/organizations/components/index.ts b/packages/features/ee/organizations/components/index.ts new file mode 100644 index 0000000000..ec1e41fc7f --- /dev/null +++ b/packages/features/ee/organizations/components/index.ts @@ -0,0 +1,5 @@ +export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm"; +export { AboutOrganizationForm } from "./AboutOrganizationForm"; +export { SetPasswordForm } from "./SetPasswordForm"; +export { AddNewOrgAdminsForm } from "./AddNewOrgAdminsForm"; +export { AddNewTeamsForm } from "./AddNewTeamsForm"; diff --git a/packages/features/ee/organizations/context/provider.ts b/packages/features/ee/organizations/context/provider.ts new file mode 100644 index 0000000000..1162186d6e --- /dev/null +++ b/packages/features/ee/organizations/context/provider.ts @@ -0,0 +1,66 @@ +import { createContext, useContext, createElement } from "react"; +import type z from "zod"; + +import type { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +/** + * Organization branding + * + * Entries consist of the different properties that constitues a brand for an organization. + */ +export type OrganizationBranding = + | ({ + logo?: string | null | undefined; + name?: string; + slug?: string; + } & z.infer) + | null + | undefined; + +/** + * Allows you to access the flags from context + */ +const OrganizationBrandingContext = createContext(null); + +/** + * Accesses the branding for an organization from context. + * + * You need to render a further up to be able to use + * this component. + */ +export function useOrgBranding() { + const orgBrandingContext = useContext(OrganizationBrandingContext); + if (orgBrandingContext === null) + throw new Error("Error: useOrganizationBranding was used outside of OrgBrandingProvider."); + return orgBrandingContext as OrganizationBranding; +} + +/** + * If you want to be able to access the flags from context using `useOrganizationBranding()`, + * you can render the OrgBrandingProvider at the top of your Next.js pages, like so: + * + * ```ts + * import { useOrgBrandingValues } from "@calcom/features/flags/hooks/useFlag" + * import { OrgBrandingProvider, useOrgBranding } from @calcom/features/flags/context/provider" + * + * export default function YourPage () { + * const orgBrand = useOrgBrandingValues() + * + * return ( + * + * + * + * ) + * } + * ``` + * + * You can then call `useOrgBrandingValues()` to access your `OrgBranding` from within + * `YourOwnComponent` or further down. + * + */ +export function OrgBrandingProvider(props: { + value: F; + children: React.ReactNode; +}) { + return createElement(OrganizationBrandingContext.Provider, { value: props.value }, props.children); +} diff --git a/packages/features/ee/organizations/hooks/index.ts b/packages/features/ee/organizations/hooks/index.ts new file mode 100644 index 0000000000..bbaac3a8f5 --- /dev/null +++ b/packages/features/ee/organizations/hooks/index.ts @@ -0,0 +1,5 @@ +import { trpc } from "@calcom/trpc/react"; + +export function useOrgBrandingValues() { + return trpc.viewer.organizations.getBrand.useQuery().data; +} diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts new file mode 100644 index 0000000000..489b08d41e --- /dev/null +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -0,0 +1,45 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; + +// Define which hostnames are expected for the app +export const appHostnames = [ + "cal.com", + "cal.dev", + "cal-staging.com", + "cal.community", + "cal.local:3000", + // ⬇️ Prevents 404 error for normal localhost development, makes it backwards compatible + "localhost:3000", +]; + +/** + * return the org slug + * @param hostname + */ +export function getOrgDomain(hostname: string) { + // Find which hostname is being currently used + const currentHostname = appHostnames.find((ahn) => { + const url = new URL(WEBAPP_URL); + const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`; + return testHostname.endsWith(`.${ahn}`); + }); + if (currentHostname) { + // Define which is the current domain/subdomain + const slug = hostname.replace(`.${currentHostname}` ?? "", ""); + return slug.indexOf(".") === -1 ? slug : null; + } + return null; +} + +export function orgDomainConfig(hostname: string) { + const currentOrgDomain = getOrgDomain(hostname); + return { + currentOrgDomain, + isValidOrgDomain: + currentOrgDomain !== null && currentOrgDomain !== "app" && !appHostnames.includes(currentOrgDomain), + }; +} + +export function subdomainSuffix() { + const urlSplit = WEBAPP_URL.replace("https://", "")?.replace("http://", "").split("."); + return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join("."); +} diff --git a/packages/features/ee/organizations/lib/types.ts b/packages/features/ee/organizations/lib/types.ts new file mode 100644 index 0000000000..8316998e34 --- /dev/null +++ b/packages/features/ee/organizations/lib/types.ts @@ -0,0 +1,6 @@ +export interface NewOrganizationFormValues { + name: string; + slug: string; + logo: string; + adminEmail: string; +} diff --git a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx index 3acebbe40f..13d1f9be63 100644 --- a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx @@ -6,6 +6,8 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks"; +import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { useFlagMap } from "@calcom/features/flags/context/provider"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -81,6 +83,7 @@ export default function CreateEventTypeDialog({ const { t } = useLocale(); const router = useRouter(); const [firstRender, setFirstRender] = useState(true); + const orgBranding = useOrgBrandingValues(); const { data: { teamId, eventPage: pageSlug }, @@ -136,6 +139,9 @@ export default function CreateEventTypeDialog({ }); const flags = useFlagMap(); + const urlPrefix = orgBranding + ? `${orgBranding.slug}.${subdomainSuffix()}` + : process.env.NEXT_PUBLIC_WEBSITE_URL; return ( - {process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined && - process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? ( + {urlPrefix && urlPrefix.length >= 21 ? (
/{!isManagedEventType ? pageSlug : t("username_placeholder")}/} {...register("slug")} @@ -205,8 +210,7 @@ export default function CreateEventTypeDialog({ required addOnLeading={ <> - {process.env.NEXT_PUBLIC_WEBSITE_URL}/ - {!isManagedEventType ? pageSlug : t("username_placeholder")}/ + {urlPrefix}/{!isManagedEventType ? pageSlug : t("username_placeholder")}/ } {...register("slug")} diff --git a/packages/features/eventtypes/components/OrganizationEventTypeFilter.tsx b/packages/features/eventtypes/components/OrganizationEventTypeFilter.tsx new file mode 100644 index 0000000000..7a8a6b6c27 --- /dev/null +++ b/packages/features/eventtypes/components/OrganizationEventTypeFilter.tsx @@ -0,0 +1,118 @@ +import { useSession } from "next-auth/react"; +import type { ReactNode, InputHTMLAttributes } from "react"; +import { useState, forwardRef, Fragment } from "react"; + +import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import { AnimatedPopover, Avatar } from "@calcom/ui"; +import { Layers, User } from "@calcom/ui/components/icon"; + +export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"]; +export type IEventTypeFilter = IEventTypesFilters[0]; + +export const OrganizationEventTypeFilter = () => { + const { t } = useLocale(); + const session = useSession(); + const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery(); + const [dropdownTitle, setDropdownTitle] = useState(t("all_apps")); + + const { data: teams, status } = trpc.viewer.teams.list.useQuery(); + const isNotEmpty = !!teams?.length; + + return status === "success" ? ( + + + } + checked={dropdownTitle === t("all_apps")} + onChange={(e) => { + removeAllQueryParams(); + setDropdownTitle(t("all_apps")); + // TODO: What to do when all event types is unchecked + }} + label={t("all_apps")} + /> + + + } + checked={query.userIds?.includes(session.data?.user.id || 0)} + onChange={(e) => { + setDropdownTitle(t("yours")); + if (e.target.checked) { + pushItemToKey("userIds", session.data?.user.id || 0); + } else if (!e.target.checked) { + removeItemByKeyAndValue("userIds", session.data?.user.id || 0); + } + }} + label={t("yours")} + /> + + + {isNotEmpty && ( + +
TEAMS
+ {teams?.map((team) => ( + + + } + checked={query.teamIds?.includes(team.id)} + onChange={(e) => { + setDropdownTitle(team.name); + if (e.target.checked) { + pushItemToKey("teamIds", team.id); + } else if (!e.target.checked) { + removeItemByKeyAndValue("teamIds", team.id); + } + }} + /> + + ))} +
+ )} +
+ ) : null; +}; + +type Props = InputHTMLAttributes & { + label: string; + icon: ReactNode; +}; + +const CheckboxField = forwardRef(({ label, icon, ...rest }, ref) => { + return ( + + ); +}); + +const CheckboxFieldContainer = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +CheckboxField.displayName = "CheckboxField"; diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 589de2e2b5..2a7ecf0280 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -9,6 +9,7 @@ export type AppFlags = { webhooks: boolean; workflows: boolean; "managed-event-types": boolean; + organizations: boolean; "email-verification": boolean; "booker-layouts": boolean; "google-workspace-directory": boolean; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 7798b6702e..6786ce9038 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -2,7 +2,9 @@ import { trpc } from "@calcom/trpc/react"; export function useFlags() { const query = trpc.viewer.features.map.useQuery(undefined, { - initialData: process.env.NEXT_PUBLIC_IS_E2E ? { "managed-event-types": true, teams: true } : {}, + initialData: process.env.NEXT_PUBLIC_IS_E2E + ? { "managed-event-types": true, organizations: true, teams: true } + : {}, }); return query.data; } diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 8ae5284629..dcece219db 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -12,6 +12,7 @@ import dayjs from "@calcom/dayjs"; import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge"; import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner"; +import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks"; import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem"; import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components"; import { useFlagMap } from "@calcom/features/flags/context/provider"; @@ -31,6 +32,7 @@ import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import type { SVGComponent } from "@calcom/types/SVGComponent"; import { + Avatar, Button, Credits, Dropdown, @@ -90,8 +92,14 @@ export const ONBOARDING_NEXT_REDIRECT = { }, } as const; -export const shouldShowOnboarding = (user: Pick) => { - return !user.completedOnboarding && dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT); +export const shouldShowOnboarding = ( + user: Pick +) => { + return ( + !user.completedOnboarding && + !user.organizationId && + dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT) + ); }; function useRedirectToLoginIfUnauthenticated(isPublic = false) { @@ -228,6 +236,8 @@ type LayoutProps = { withoutSeo?: boolean; // Gives the ability to include actions to the right of the heading actions?: JSX.Element; + beforeCTAactions?: JSX.Element; + afterHeading?: ReactNode; smallHeading?: boolean; hideHeadingOnMobile?: boolean; }; @@ -281,6 +291,7 @@ function UserDropdown({ small }: { small?: boolean }) { const { t } = useLocale(); const { data: user } = useMeQuery(); const { data: avatar } = useAvatarQuery(); + const orgBranding = useOrgBrandingValues(); useEffect(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -348,8 +359,8 @@ function UserDropdown({ small }: { small?: boolean }) { {user.username ? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" - ? `cal.com/${user.username}` - : `/${user.username}` + ? `${orgBranding && orgBranding.slug}cal.com/${user.username}` + : `${orgBranding && orgBranding.slug}/${user.username}` : "No public page"} @@ -789,6 +800,7 @@ function SideBarContainer({ bannersHeight }: SideBarContainerProps) { } function SideBar({ bannersHeight }: SideBarProps) { + const orgBranding = useOrgBrandingValues(); return (