Compare commits

...

90 Commits

Author SHA1 Message Date
Leo Giovanetti b228e09d3a Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-14 18:08:19 -03:00
Leo Giovanetti a26e1f3c0c Cookie domain needs a dot 2023-06-14 17:37:03 -03:00
Leo Giovanetti e91cfd34fd Feedback 2023-06-14 16:44:10 -03:00
Leo Giovanetti 71102742c8 Feedback 2023-06-14 16:40:46 -03:00
zomars a540b45342 Removes the need for useEffect here 2023-06-14 12:20:55 -07:00
zomars ca74cf1d32 Merge branch 'main' into feat/organizations 2023-06-14 12:08:15 -07:00
Keith Williams f9e57766b0 chore: Cache node_modules (#9492)
* Adding check for cache hit

* Adding a separate install step first

* Put the restore cache steps back

* Revert the uses type for restoring cache

* Added step to restore nm cache

* Removed the cache-hit check

* Comments and naming

* Removed extra install command

* Updated the name of the linting step to be more clear
2023-06-14 12:08:12 -07:00
Leo Giovanetti b0883ec721 Feedback: SSR for user event-types to have org context 2023-06-14 16:00:43 -03:00
Leo Giovanetti 952dde9f54 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-14 12:18:34 -03:00
Leo Giovanetti 958dc24628 Merge branch 'main' into feat/organizations 2023-06-14 12:17:17 -03:00
Leo Giovanetti 48c039f615 Feedback 2023-06-14 12:11:38 -03:00
Leo Giovanetti f5c20da7e6 Feedback 2023-06-14 12:09:23 -03:00
Leo Giovanetti afd1163308 Merge branch 'feat/organizations' of github.com:calcom/cal.com into feat/organizations 2023-06-14 12:01:53 -03:00
Leo Giovanetti 5e780fd3d0 Feedback 2023-06-14 12:01:38 -03:00
zomars 63e3a2b789 Update useRouterQuery.ts 2023-06-13 13:24:44 -07:00
Leo Giovanetti 4b295a45d4 Host comes with port 2023-06-13 17:19:12 -03:00
Leo Giovanetti 77667200ab Merge branch 'feat/organizations' of github.com:calcom/cal.com into feat/organizations 2023-06-13 16:59:58 -03:00
Leo Giovanetti cd3fc45023 Improving orgDomains util 2023-06-13 16:58:47 -03:00
Efraín Rochín 201671f6ce fix issue getting org slug from domain 2023-06-13 12:24:02 -07:00
Leo Giovanetti 3d7fd92fdd Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-13 14:35:43 -03:00
Leo Giovanetti 4eec43dbb5 Merge branch 'main' into feat/organizations 2023-06-13 14:35:09 -03:00
Leo Giovanetti 95c94db732 NIT 2023-06-13 12:04:52 -03:00
Leo Giovanetti 66ab394be1 Reverting 2023-06-13 11:56:21 -03:00
Leo Giovanetti 65cdd2e8e7 Feedback 2023-06-13 11:55:19 -03:00
zomars cdf60dacfb Merge branch 'main' into feat/organizations 2023-06-12 17:03:03 -07:00
zomars 249129e9d2 Update orgDomains.ts 2023-06-12 16:56:25 -07:00
zomars 9e7fbefa51 Refactor and type fixes 2023-06-12 16:17:47 -07:00
Leo Giovanetti 09480a34d5
Merge branch 'main' into feat/organizations 2023-06-12 16:12:57 -03:00
Leo Giovanetti fda220bfd9 Type errors 2023-06-12 16:12:26 -03:00
Leo Giovanetti 2655e1f1c2
feat: Organization support for event types page (#9449)
* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Org branding provider used in shell sidebar

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Using org avatar (temp)

* Not showing org logo if not set

* User onboarding with org branding (slug)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Feedback

* Org public profile

* Public profiles for team event types

* Added setup profile alert

* Using org avatar on subteams avatar

* Processing orgs and children as profile options

* Reverting change not belonging to this PR

* Making sure we show the set up profile on org only

* Removing console.log

* Comparing memberships to choose the highest one

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-06-12 15:21:50 -03:00
Leo Giovanetti 9db02038e9 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-12 14:54:23 -03:00
Leo Giovanetti e91b908f2a Merge branch 'main' into feat/organizations 2023-06-12 14:51:23 -03:00
Leo Giovanetti dc1f1b5db9
feat: Organization branding in side menu (#9279)
* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Org branding provider used in shell sidebar

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Using org avatar (temp)

* Not showing org logo if not set

* User onboarding with org branding (slug)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Feedback

* Org public profile

* Public profiles for team event types

* Added setup profile alert

* Using org avatar on subteams avatar

* Making sure we show the set up profile on org only

* Profile username availability rely on org hook

* Update apps/web/pages/team/[slug].tsx

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Update apps/web/pages/team/[slug].tsx

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-06-12 13:46:56 -03:00
Leo Giovanetti 174cf3a3e4 Type check fixes 2023-06-12 13:01:17 -03:00
Leo Giovanetti 98c6709fbf Feedback 2023-06-12 12:30:55 -03:00
Leo Giovanetti db0a5b3f0b Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-12 10:57:02 -03:00
Leo Giovanetti 37caa2de44 Merge branch 'main' into feat/organizations 2023-06-12 10:56:37 -03:00
Leo Giovanetti 5ecb4ba26c Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-10 15:25:17 -03:00
Leo Giovanetti 3c3e834a44 Merge branch 'main' into feat/organizations 2023-06-10 15:23:41 -03:00
Leo Giovanetti 33f79c6260 Making sure we let localhost still work 2023-06-09 17:49:52 -03:00
Leo Giovanetti 26adf02697 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-09 16:48:02 -03:00
Leo Giovanetti 893b1f19a0 Merge branch 'main' into feat/organizations 2023-06-09 16:46:06 -03:00
Leo Giovanetti 357b759ac6 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-08 12:49:50 -03:00
Leo Giovanetti 476110d303 Merge branch 'main' into feat/organizations 2023-06-08 12:48:20 -03:00
Leo Giovanetti 5a9caa4cc6 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-07 18:41:57 -03:00
Leo Giovanetti e2b083bf84 Merge branch 'main' into feat/organizations 2023-06-07 18:40:17 -03:00
Leo Giovanetti 2fd46a8d67 Vercel subdomain creation in PROD only 2023-06-07 13:26:45 -03:00
Leo Giovanetti 6100e2ca60
feat: [CAL-1816] Organization subdomain support (#9345)
* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* Covering users and subteams, excluding non-org users

* Unpublished teams shows correctly

* Create subdomain in Vercel

* feedback

* Renaming Vercel env vars

* Vercel domain check before creation

* Supporting cal-staging.com

* Change to have vercel detect it

* vercel domain check data message error

* Remove check domain

* Making sure we check requestedSlug now

* Feedback and unneeded code

* Reverting unneeded changes

* Unneeded changes

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-06-07 13:16:59 -03:00
Leo Giovanetti 23d04b1147 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-07 10:51:59 -03:00
Leo Giovanetti b936e9c8d3 Merge branch 'main' into feat/organizations 2023-06-07 10:50:58 -03:00
Leo Giovanetti 60ba429c8a Merge branch 'main' into feat/organizations-banner 2023-06-07 10:49:55 -03:00
Leo Giovanetti 8032a86581 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-06 19:57:02 -03:00
Leo Giovanetti dbb4417836 Merge branch 'main' into feat/organizations 2023-06-06 19:55:54 -03:00
Leo Giovanetti ed87a76c15 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-06 19:55:03 -03:00
Leo Giovanetti 3bc71665b4
feat: Onboarding process to create an organization (#9184)
* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Making sure we check requestedSlug now

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-06-06 19:01:15 -03:00
Leo Giovanetti 5403141d55 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-06 13:11:57 -03:00
Leo Giovanetti 27b643ac67 Merge branch 'main' into feat/organizations 2023-06-06 13:10:52 -03:00
Leo Giovanetti ab9fb4e0e5 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-05 11:10:18 -03:00
Leo Giovanetti 852b81068c Missing changes to support orgs schema changes 2023-06-05 11:09:25 -03:00
Leo Giovanetti 5c5b936741 Merge branch 'main' into feat/organizations 2023-06-05 11:08:43 -03:00
Udit Takkar 20c7fee1a9
feat: organization event type filter (#9253)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-06-05 10:40:46 +01:00
Leo Giovanetti 39a65f79d2 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-02 17:07:15 -03:00
Leo Giovanetti acda675519 Merge branch 'main' into feat/organizations 2023-06-02 17:01:31 -03:00
Leo Giovanetti 0386639b60 Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-02 12:42:12 -03:00
Leo Giovanetti ac67e358e4 Merge branch 'main' into feat/organizations 2023-06-02 12:41:53 -03:00
Leo Giovanetti 82c693dfdd Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-01 15:22:41 -03:00
Leo Giovanetti 9c5320d1d7 Merge branch 'main' into feat/organizations 2023-06-01 15:21:41 -03:00
Leo Giovanetti 68b7b7fa9d Merge branch 'feat/organizations' into feat/organizations-banner 2023-06-01 12:41:22 -03:00
Leo Giovanetti 6e04e03808 Merge branch 'main' into feat/organizations 2023-06-01 12:40:42 -03:00
Leo Giovanetti 0efc026b6a Merge branch 'feat/organizations' into feat/organizations-banner 2023-05-31 14:44:22 -03:00
Leo Giovanetti 890a5c0def Merge branch 'main' into feat/organizations 2023-05-31 14:44:05 -03:00
Leo Giovanetti 985dee0308 Supporting having the orgId in the session cookie 2023-05-31 14:43:25 -03:00
Leo Giovanetti 14a7e58a6b Merge branch 'feat/organizations' into feat/organizations-banner 2023-05-31 11:28:09 -03:00
Leo Giovanetti 1e659f915b Covering null on unique clauses 2023-05-31 11:27:37 -03:00
Leo Giovanetti d636cf7b42 Merge branch 'feat/organizations' into feat/organizations-banner 2023-05-31 10:58:53 -03:00
Leo Giovanetti 6251f06509 Merge branch 'main' into feat/organizations 2023-05-31 10:58:12 -03:00
Leo Giovanetti c0bbae191a Letting duplicate slugs for teams to support orgs 2023-05-31 10:57:37 -03:00
Leo Giovanetti 3c0bc42b36 Merge branch 'feat/organizations' into feat/organizations-banner 2023-05-30 18:55:21 -03:00
Leo Giovanetti a2de700f86 Merge branch 'main' into feat/organizations 2023-05-30 18:51:07 -03:00
Joe Au-Yeung 1776e5d082
feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209)
* Change scopedMembers to orgMembers

* Change to orgUsers
2023-05-30 14:56:32 -03:00
Leo Giovanetti e35be50b92 banner onClose 2023-05-29 14:54:05 -03:00
Leo Giovanetti 403e4eae67 Merge branch 'feat/organizations' into feat/organizations-banner 2023-05-28 18:47:33 -03:00
Leo Giovanetti fd539ad76c Merge branch 'main' into feat/organizations 2023-05-28 18:44:45 -03:00
Leo Giovanetti 9e84108dd4 Removing dead code and img 2023-05-25 12:48:05 -03:00
Leo Giovanetti 68e03945d0 Desktop first banner, mobile pending 2023-05-25 12:36:19 -03:00
Leo Giovanetti 582079d32c Merge branch 'main' into feat/organizations 2023-05-25 12:30:15 -03:00
Leo Giovanetti c2951d3126 Merge branch 'main' into feat/organizations 2023-05-25 10:15:09 -03:00
Leo Giovanetti d58b5f0204 Merge branch 'main' into feat/organizations 2023-05-22 15:06:15 -03:00
Leo Giovanetti 65d6b7e9f3 Adding feature flag 2023-05-19 11:37:10 -03:00
Leo Giovanetti 93aad0a8f8 Initial commit 2023-05-19 10:30:10 -03:00
105 changed files with 3051 additions and 394 deletions

View File

@ -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=

View File

@ -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,

View File

@ -83,7 +83,9 @@ const BookingDescription: FC<Props> = (props) => {
size="sm"
truncateAfter={3}
/>
<h2 className="text-default mt-1 mb-2 break-words text-sm font-medium ">{profile.name}</h2>
<h2 className="text-default mt-1 mb-2 break-words text-sm font-medium ">
{eventType.team?.parent?.name} {profile.name}
</h2>
<h1 className="font-cal text-emphasis mb-6 break-words text-2xl font-semibold leading-none">
{eventType.title}
</h1>

View File

@ -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<HTMLInputElement>(null);
const { setValue, handleSubmit, getValues } = useForm<FormData>({

View File

@ -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) => {
<form onSubmit={onSubmit}>
<div className="space-y-6">
{/* Username textfield */}
<UsernameAvailabilityField user={user} />
<UsernameAvailabilityField />
{/* Full name textfield */}
<div className="w-full">

View File

@ -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<AppRouter>) => void;
user: Pick<User, "username" | "metadata">;
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();

View File

@ -21,7 +21,7 @@ interface ICustomUsernameProps {
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
}
const UsernameTextfield = (props: ICustomUsernameProps) => {
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
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 && (
<div className="absolute right-[2px] top-6 flex flex-row">

View File

@ -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<AppRouter>) => void;
user: Pick<User, "username" | "metadata">;
}
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 (
<Controller
control={formMethods.control}
@ -51,7 +65,7 @@ export const UsernameAvailabilityField = ({
setInputUsernameValue={onChange}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
user={user}
addOnLeading={usernamePrefix}
/>
);
}}

View File

@ -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 <FeatureProvider value={flags}>{children}</FeatureProvider>;
}
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
const orgBrand = useOrgBrandingValues();
return <OrgBrandingProvider value={orgBrand}>{children}</OrgBrandingProvider>;
}
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}>
<FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>

View File

@ -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,

View File

@ -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",

View File

@ -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 {

View File

@ -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<typeof getStaticProps> & EmbedProps;
import { ssrInit } from "@server/lib/ssr";
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type(props: AvailabilityPageProps) {
const { t } = useLocale();
@ -50,6 +53,21 @@ export default function Type(props: AvailabilityPageProps) {
</div>
</main>
</div>
) : !props.isValidOrgDomain && props.organizationContext ? (
<div className="dark:bg-darkgray-50 h-screen">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_belongs_organization")}</p>
</div>
</div>
</div>
</main>
</div>
) : (
<AvailabilityPage {...props} />
);
@ -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" };
};
}

View File

@ -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,
},

View File

@ -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";

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/organizations/api/subteams";

View File

@ -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);

View File

@ -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 {

View File

@ -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<Response>): Promise<void> {
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);
}

View File

@ -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,

View File

@ -22,7 +22,7 @@ import SkeletonLoader from "@components/booking/SkeletonLoader";
import { ssgInit } from "@server/lib/ssg";
type BookingListingStatus = z.infer<typeof filterQuerySchema>["status"];
type BookingListingStatus = z.infer<NonNullable<typeof filterQuerySchema>>["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<BookingListingStatus, string> = {
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
upcoming: "upcoming_bookings",
recurring: "recurring_bookings",
past: "past_bookings",

View File

@ -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,12 @@ import {
Trash,
Upload,
Users,
X,
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 +82,7 @@ interface EventTypeListHeadingProps {
profile: EventTypeGroupProfile;
membershipCount: number;
teamId?: number | null;
orgSlug?: string;
}
type EventTypeGroup = EventTypeGroups[number];
@ -194,6 +203,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<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
@ -362,7 +372,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
{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 +699,7 @@ const EventTypeListHeading = ({
profile,
membershipCount,
teamId,
orgSlug,
}: EventTypeListHeadingProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
@ -727,7 +740,9 @@ const EventTypeListHeading = ({
)}
{profile?.slug && (
<Link href={`${CAL_URL}/${profile.slug}`} className="text-subtle block text-xs">
{`${CAL_URL?.replace("https://", "")}/${profile.slug}`}
{orgSlug
? `${orgSlug}.${subdomainSuffix()}/${profile.slug}`
: `${CAL_URL?.replace("https://", "")}/${profile.slug}`}
</Link>
)}
</div>
@ -754,6 +769,41 @@ const CreateFirstEventTypeView = () => {
);
};
const SetupOrganizationBanner = ({ closeAction }: { closeAction: () => void }) => {
const { t } = useLocale();
return (
<div className="bg-inverted text-inverted relative mx-4 mt-4 h-56 max-w-full rounded-md bg-[url('/noise.svg')] md:mt-8 lg:mx-12">
<div className="h-full w-full rounded-md bg-[url('/grid.png')] bg-[length:60%_100%] bg-[100%_0rem] bg-no-repeat">
<div className="h-full gap-4 rounded-md bg-[url('/orgs_banner.png')] bg-[length:50%] bg-[120%_4.4rem] bg-no-repeat">
<Button
variant="icon"
StartIcon={X}
color="minimal"
className="hover:text-muted absolute top-0 right-0 text-white hover:bg-transparent"
onClick={closeAction}
/>
<div className="flex flex-col gap-2 px-8 pt-8 pb-8">
<h1 className="text-2xl font-bold">{t("organisation_banner_title")}</h1>
<p className="max-w-2xl">{t("organisation_banner_description")}</p>
</div>
<div className="flex flex-row gap-2 px-8">
<Button variant="button" color="secondary">
{t("setup_organisation")}
</Button>
<Button
variant="button"
color="minimal"
className="text-inverted hover:text-muted hover:bg-transparent">
{t("learn_more")}
</Button>
</div>
</div>
</div>
</div>
);
};
const CTA = () => {
const { t } = useLocale();
@ -782,6 +832,43 @@ const CTA = () => {
);
};
const Actions = () => {
return (
<div className="flex items-center">
<OrganizationEventTypeFilter />
<VerticalDivider />
</div>
);
};
const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
const { t } = useLocale();
const orgBranding = useOrgBrandingValues();
return (
<Alert
className="my-4"
severity="info"
title={t("set_up_your_profile")}
message={t("set_up_your_profile_description", { orgName: orgBranding?.name })}
CustomIcon={UserIcon}
actions={
<div className="flex gap-1">
<Button color="minimal" className="text-sky-700 hover:bg-sky-100" onClick={closeAction}>
{t("dismiss")}
</Button>
<Button
color="secondary"
className="border-sky-700 bg-sky-50 text-sky-700 hover:border-sky-900 hover:bg-sky-200"
href="/getting-started">
{t("set_up")}
</Button>
</div>
}
/>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any);
@ -790,12 +877,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
}, []);
@ -809,7 +908,10 @@ const EventTypesPage = () => {
withoutSeo
heading={t("event_types_page_title")}
hideHeadingOnMobile
TopNavContainer={showOrgsBanner && <SetupOrganizationBanner closeAction={closeBanner} />}
subtitle={t("event_types_page_subtitle")}
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
beforeCTAactions={<Actions />}
CTA={<CTA />}>
<WithQuery
customLoader={<SkeletonLoader />}
@ -826,6 +928,7 @@ const EventTypesPage = () => {
profile={group.profile}
membershipCount={group.metadata.membershipCount}
teamId={group.teamId}
orgSlug={orgBranding?.slug}
/>
<EventTypeList

View File

@ -3,15 +3,16 @@ import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Head from "next/head";
import { useRouter } from "next/router";
import type { CSSProperties } from "react";
import { Suspense } from "react";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { trpc } from "@calcom/trpc";
import { Button, StepCard, Steps } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import { Loader } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
@ -20,7 +21,7 @@ import { SetupAvailability } from "@components/getting-started/steps-views/Setup
import UserProfile from "@components/getting-started/steps-views/UserProfile";
import { UserSettings } from "@components/getting-started/steps-views/UserSettings";
export type IOnboardingPageProps = inferSSRProps<typeof getServerSideProps>;
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) => {
<Steps maxSteps={steps.length} currentStep={currentStepIndex + 1} navigateToStep={goToIndex} />
</div>
<StepCard>
{currentStep === "user-settings" && <UserSettings user={user} nextStep={() => goToIndex(1)} />}
<Suspense fallback={<Loader />}>
{currentStep === "user-settings" && <UserSettings nextStep={() => goToIndex(1)} />}
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
{currentStep === "connected-video" && <ConnectedVideoStep nextStep={() => goToIndex(3)} />}
{currentStep === "connected-video" && <ConnectedVideoStep nextStep={() => goToIndex(3)} />}
{currentStep === "setup-availability" && (
<SetupAvailability nextStep={() => goToIndex(4)} defaultScheduleId={user.defaultScheduleId} />
)}
{currentStep === "user-profile" && <UserProfile user={user} />}
{currentStep === "setup-availability" && (
<SetupAvailability
nextStep={() => goToIndex(4)}
defaultScheduleId={user.defaultScheduleId}
/>
)}
{currentStep === "user-profile" && <UserProfile />}
</Suspense>
</StepCard>
{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,
},
};

View File

@ -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,

View File

@ -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,

View File

@ -215,7 +215,6 @@ const ProfileView = () => {
extraField={
<div className="mt-8">
<UsernameAvailabilityField
user={user}
onSuccessMutation={async () => {
showToast(t("settings_updated_successfully"), "success");
await utils.viewer.me.invalidate();

View File

@ -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 (
<>
<Meta title={t("about_your_organization")} description={t("about_your_organization_description")} />
<AboutOrganizationForm />
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={3} maxSteps={5}>
{page}
</WizardLayout>
);
};
AboutOrganizationPage.getLayout = LayoutWrapper;
AboutOrganizationPage.PageWrapper = PageWrapper;
export default AboutOrganizationPage;

View File

@ -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 (
<>
<Meta title={t("create_your_teams")} description={t("create_your_teams_description")} />
<AddNewTeamsForm />
</>
);
};
AddNewTeamsPage.getLayout = (page: React.ReactElement, router: NextRouter) => (
<>
<WizardLayout
currentStep={5}
maxSteps={5}
isOptionalCallback={() => {
router.push(`/event-types`);
}}>
{page}
</WizardLayout>
</>
);
AddNewTeamsPage.PageWrapper = PageWrapper;
export default AddNewTeamsPage;

View File

@ -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 (
<>
<Meta
title={t("invite_organization_admins")}
description={t("invite_organization_admins_description")}
/>
<AddNewOrgAdminsForm />
</>
);
};
OnboardTeamMembersPage.getLayout = (page: React.ReactElement, router: NextRouter) => (
<WizardLayout
currentStep={4}
maxSteps={5}
isOptionalCallback={() => {
router.push(`/settings/organizations/${router.query.id}/add-teams`);
}}>
{page}
</WizardLayout>
);
OnboardTeamMembersPage.PageWrapper = PageWrapper;
export default OnboardTeamMembersPage;

View File

@ -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 (
<>
<Meta title={t("set_a_password")} description={t("set_a_password_description")} />
<SetPasswordForm />
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={2} maxSteps={5}>
{page}
</WizardLayout>
);
};
SetPasswordPage.getLayout = LayoutWrapper;
SetPasswordPage.PageWrapper = PageWrapper;
export default SetPasswordPage;

View File

@ -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 (
<>
<Meta title={t("set_up_your_organization")} description={t("organizations_description")} />
<CreateANewOrganizationForm />
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={1} maxSteps={5}>
{page}
</WizardLayout>
);
};
CreateNewOrganizationPage.getLayout = LayoutWrapper;
CreateNewOrganizationPage.PageWrapper = PageWrapper;
export default CreateNewOrganizationPage;

View File

@ -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();

View File

@ -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();

View File

@ -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<typeof getServerSideProps>;
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("<p><br></p>", "").length;
const metadata = teamMetadataSchema.parse(team.metadata);
useEffect(() => {
telemetry.event(
@ -49,8 +52,12 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
<div className="m-8 flex items-center justify-center">
<EmptyScreen
avatar={<Avatar alt={teamName} imageSrc={getPlaceholderAvatar(team.logo, team.name)} size="lg" />}
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(),
})}
/>
</div>
);
@ -71,7 +78,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
<div className="px-6 py-4 ">
<Link
href={{
pathname: `/team/${team.slug}/${type.slug}`,
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}/${type.slug}`,
query: queryParamsToForward,
}}
onClick={async () => {
@ -106,6 +113,53 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
</ul>
);
const SubTeams = () =>
team.children.length ? (
<ul className="divide-subtle border-subtle bg-default !static w-full divide-y rounded-md border">
{team.children.map((ch, i) => (
<li key={i} className="hover:bg-muted w-full">
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
<div className="flex items-center px-5 py-5">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(ch?.logo, ch?.name as string)}
alt="Team Logo"
className="inline-flex justify-center"
/>
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{ch.name}</span>
<span className="text-subtle block text-xs">
{t("number_member", { count: ch.members.length })}
</span>
</div>
</div>
<AvatarGroup
className="mr-6"
size="sm"
truncateAfter={4}
items={ch.members.map(({ user: member }) => ({
alt: member.name || "",
image: `${WEBAPP_URL}/${member.username}/avatar.png`,
title: member.name || "",
}))}
/>
</Link>
</li>
))}
</ul>
) : (
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="text-muted dark:text-inverted p-8 text-center">
<h2 className="font-cal dark:text-inverted text-emphasis600 mb-2 text-3xl">
{" " + t("no_teams_yet")}
</h2>
<p className="mx-auto max-w-md">{t("no_teams_yet_description")}</p>
</div>
</div>
</div>
);
return (
<>
<HeadSeo
@ -118,8 +172,17 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
/>
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pt-12 pb-12">
<div className="mx-auto mb-8 max-w-3xl text-center">
<Avatar alt={teamName} imageSrc={getPlaceholderAvatar(team.logo, team.name)} size="lg" />
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider">{teamName}</p>
<div className="relative">
<Avatar
alt={teamName}
imageSrc={getPlaceholderAvatar(team.parent ? team.parent.logo : team.logo, team.name)}
size="lg"
/>
</div>
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider">
{team.parent && `${team.parent.name} `}
{teamName}
</p>
{!isBioEmpty && (
<>
<div
@ -129,43 +192,49 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio }: TeamPageProps) {
</>
)}
</div>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes />
{metadata?.isOrganization ? (
<SubTeams />
) : (
<>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes />
{!team.hideBookATeamMember && (
<div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="border-subtle w-full border-t" />
</div>
<div className="relative flex justify-center">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
{t("or")}
</span>
</div>
</div>
{!team.hideBookATeamMember && (
<div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="border-subtle w-full border-t" />
</div>
<div className="relative flex justify-center">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
{t("or")}
</span>
</div>
</div>
<aside className="dark:text-inverted mt-8 flex justify-center text-center">
<Button
color="minimal"
EndIcon={ArrowRight}
className="dark:hover:bg-darkgray-200"
href={{
pathname: `/team/${team.slug}`,
query: {
members: "1",
...queryParamsToForward,
},
}}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
<aside className="dark:text-inverted mt-8 flex justify-center text-center">
<Button
color="minimal"
EndIcon={ArrowRight}
className="dark:hover:bg-darkgray-200"
href={{
pathname: `${isValidOrgDomain ? "" : "/team"}/${team.slug}`,
query: {
members: "1",
...queryParamsToForward,
},
}}
shallow={true}>
{t("book_a_team_member")}
</Button>
</aside>
</div>
)}
</div>
)}
</div>
</>
)}
</main>
</>
@ -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;
};

View File

@ -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,
},
},
},
},
},

View File

@ -86,6 +86,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: true,
brandColor: true,
darkBrandColor: true,
parent: {
select: {
logo: true,
name: true,
},
},
},
},
users: {

BIN
apps/web/public/grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -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": "Youve been invited to join a team on {{appName}}",
"email_no_user_invite_heading": "Youve 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": "Well walk you through a few short steps and youll 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": "Well walk you through a few short steps and youll 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,7 @@
"book_my_cal": "Book my Cal",
"invite_as":"Invite as",
"form_updated_successfully":"Form updated successfully.",
"email_not_cal_member_cta": "Join your team",
"email_not_cal_member_cta": "Join your {{entity}}",
"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 +1829,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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -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<ReturnType<typeof getEventType>>;
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));

View File

@ -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));
};

View File

@ -0,0 +1,65 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml } from "../components";
export type OrganizationEmailVerify = {
language: TFunction;
user: {
email: string;
};
code: string;
};
export const OrganisationAccountVerifyEmail = (
props: OrganizationEmailVerify & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("organization_verify_header", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("organization_verify_header")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.email })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("organization_verify_email_body")}</>
</p>
<div style={{ display: "flex" }}>
<div
style={{
borderRadius: "6px",
backgroundColor: "#101010",
padding: "6px 2px 6px 8px",
flexShrink: 1,
}}>
<b style={{ fontWeight: 400, lineHeight: "24px", color: "white", letterSpacing: "6px" }}>
{props.code}
</b>
</div>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")} <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team")}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@ -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(),
})}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>{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(),
})}
</>
</p>
<img
style={{
@ -54,6 +61,7 @@ export const TeamInviteEmail = (
invitedBy: props.from,
appName: APP_NAME,
teamName: props.teamName,
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
</>
</p>
@ -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(),
})}
</>
</p>
{!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,
})}
</>
</p>
</div>

View File

@ -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";

View File

@ -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<string, unknown> {
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 `<b>Code:</b> ${this.orgVerifyInput.code}`;
}
}

View File

@ -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: "",

View File

@ -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,
},
};

View File

@ -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,

View File

@ -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(),
});

View File

@ -10,7 +10,7 @@ const teamIdschema = z.object({
});
const auditAndReturnNextUser = async (
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role">,
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "organizationId">,
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: {

View File

@ -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) }),
});

View File

@ -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<string | null>(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 (
<>
<Form
form={aboutOrganizationFormMethods}
className="space-y-5"
handleSubmit={(v) => {
if (!updateOrganizationMutation.isLoading) {
setServerErrorMessage(null);
updateOrganizationMutation.mutate({ ...v, orgId });
}
}}>
{serverErrorMessage && (
<div>
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<div>
<Controller
control={aboutOrganizationFormMethods.control}
name="logo"
render={() => (
<>
<Label>{t("organization_logo")}</Label>
<div className="flex items-center">
<Avatar
alt=""
fallback={<Plus className="text-subtle h-6 w-6" />}
asChild
className="items-center"
size="lg"
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload")}
handleAvatarChange={(newAvatar: string) => {
setImage(newAvatar);
aboutOrganizationFormMethods.setValue("logo", newAvatar);
}}
imageSrc={image}
/>
</div>
</div>
</>
)}
/>
</div>
<div>
<Controller
control={aboutOrganizationFormMethods.control}
name="bio"
render={({ field: { value } }) => (
<>
<TextAreaField
name="about"
defaultValue={value}
onChange={(e) => {
aboutOrganizationFormMethods.setValue("bio", e?.target.value);
}}
/>
<p className="text-subtle text-sm">{t("organization_about_description")}</p>
</>
)}
/>
</div>
<div className="flex">
<Button
disabled={
aboutOrganizationFormMethods.formState.isSubmitting || updateOrganizationMutation.isLoading
}
color="primary"
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};

View File

@ -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 (
<Form
form={newAdminsFormMethods}
handleSubmit={(values) => {
inviteMemberMutation.mutate({
teamId: orgId,
language: i18n.language,
role: MembershipRole.ADMIN,
usernameOrEmail: values.emails,
sendEmailInvitation: true,
isOrg: true,
});
}}>
<div className="flex flex-col rounded-md">
<Controller
name="emails"
control={newAdminsFormMethods.control}
rules={{
required: t("enter_email_or_username"),
}}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
<TextAreaField
name="emails"
label="Invite via email"
rows={4}
autoCorrect="off"
placeholder="john@doe.com, alex@smith.com"
required
value={value}
onChange={(e) => {
const emails = e.target.value.split(",").map((email) => email.trim().toLocaleLowerCase());
return onChange(emails);
}}
/>
{error && <span className="text-sm text-red-800">{error.message}</span>}
</>
)}
/>
<Button
EndIcon={ArrowRight}
color="primary"
type="submit"
className="mt-6 w-full justify-center"
disabled={inviteMemberMutation.isLoading}>
Continue
</Button>
</div>
</Form>
);
};

View File

@ -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<string[]>([""]);
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) => (
<div className="relative" key={index}>
<TextField
key={index}
value={inputValues[index]}
onChange={(e) => handleInputChange(index, e.target.value)}
addOnClassname="bg-transparent p-0 border-l-0"
addOnSuffix={
index > 0 && (
<Button
color="minimal"
className="group/remove mx-2 px-0 hover:bg-transparent"
onClick={() => handleRemoveInput(index)}>
<X className="bg-subtle text group-hover/remove:text-inverted group-hover/remove:bg-inverted h-5 w-5 rounded-full p-1" />
</Button>
)
}
/>
</div>
))}
<Button
StartIcon={Plus}
color="secondary"
disabled={createTeamsMutation.isLoading}
onClick={handleCounterIncrease}>
{t("add_a_team")}
</Button>
<Button
EndIcon={ArrowRight}
color="primary"
className="mt-6 w-full justify-center"
disabled={createTeamsMutation.isLoading}
onClick={() => {
if (inputValues.includes("")) {
showToast(t("team_name_empty"), "error");
} else {
const duplicates = inputValues.filter((item, index) => inputValues.indexOf(item) !== index);
if (duplicates.length) {
showToast("team_names_repeated", "error");
} else {
createTeamsMutation.mutate({ orgId, teamNames: inputValues });
}
}
}}>
{t("continue")}
</Button>
</>
);
};

View File

@ -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(/^(?:.*?:\/\/)?.*?(?<root>[\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<SetStateAction<boolean>>;
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 (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
onChange("");
setError("");
setIsOpenDialog(open);
}}>
<DialogContent className="sm:max-w-md">
<div className="flex flex-row">
<div className="w-full">
<DialogHeader title={t("verify_your_email")} subtitle={t("enter_digit_code", { email })} />
<Label htmlFor="code">{t("code")}</Label>
<div className="flex flex-row justify-between">
<Input
className={digitClassName}
name="2fa1"
inputMode="decimal"
{...digits[0]}
autoFocus
autoComplete="one-time-code"
/>
<Input className={digitClassName} name="2fa2" inputMode="decimal" {...digits[1]} />
<Input className={digitClassName} name="2fa3" inputMode="decimal" {...digits[2]} />
<Input className={digitClassName} name="2fa4" inputMode="decimal" {...digits[3]} />
<Input className={digitClassName} name="2fa5" inputMode="decimal" {...digits[4]} />
<Input className={digitClassName} name="2fa6" inputMode="decimal" {...digits[5]} />
</div>
{error && (
<div className="mt-2 flex items-center gap-x-2 text-sm text-red-700">
<div>
<Info className="h-3 w-3" />
</div>
<p>{error}</p>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
disabled={isLoading}
onClick={() => {
setError("");
if (value === "") {
setError("The code is a required field");
} else {
setIsLoading(true);
verifyCodeMutation.mutate({
code: value,
email,
});
}
}}>
{t("verify")}
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export const CreateANewOrganizationForm = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(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 (
<>
<Form
form={newOrganizationFormMethods}
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isLoading) {
setServerErrorMessage(null);
createOrganizationMutation.mutate(v);
}
}}>
<div className="mb-5">
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<Controller
name="adminEmail"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_admin_email"),
}}
render={({ field: { value } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="adminEmail"
label={t("admin_email")}
defaultValue={value}
onChange={(e) => {
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"
/>
</div>
)}
/>
</div>
<div className="mb-5">
<Controller
name="name"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
placeholder="Acme"
name="name"
label={t("organization_name")}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("name", e?.target.value);
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
</>
)}
/>
</div>
<div className="mb-5">
<Controller
name="slug"
control={newOrganizationFormMethods.control}
rules={{
required: "Must enter organization slug",
}}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="slug"
label={t("organization_url")}
placeholder="acme"
addOnSuffix={`.${subdomainSuffix()}`}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value), {
shouldTouch: true,
});
newOrganizationFormMethods.clearErrors("slug");
}}
/>
)}
/>
</div>
<input hidden {...newOrganizationFormMethods.register("adminUsername")} />
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.isLoading
}
color="primary"
EndIcon={ArrowRight}
type="submit"
form="createOrg"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
<VerifyCodeDialog
isOpenDialog={showVerifyCode}
setIsOpenDialog={setShowVerifyCode}
email={watchAdminEmail}
onSuccess={(isVerified) => {
if (isVerified) {
createOrganizationMutation.mutate({
...newOrganizationFormMethods.getValues(),
language: i18n.language,
check: false,
});
}
}}
/>
</>
);
};

View File

@ -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<string | null>(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 (
<>
<Form
form={setPasswordFormMethods}
handleSubmit={(v) => {
if (!setPasswordMutation.isLoading) {
setServerErrorMessage(null);
setPasswordMutation.mutate({ newPassword: v.password });
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
</div>
<div className="mb-5">
<Controller
name="password"
control={setPasswordFormMethods.control}
render={({ field: { onBlur, onChange, value } }) => (
<PasswordField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
setPasswordFormMethods.setValue("password", e.target.value);
await setPasswordFormMethods.trigger("password");
}}
hintErrors={["caplow", "admin_min", "num"]}
name="password"
autoComplete="off"
/>
)}
/>
</div>
<div className="flex">
<Button
disabled={setPasswordFormMethods.formState.isSubmitting || setPasswordMutation.isLoading}
color="primary"
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};

View File

@ -0,0 +1,5 @@
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
export { AboutOrganizationForm } from "./AboutOrganizationForm";
export { SetPasswordForm } from "./SetPasswordForm";
export { AddNewOrgAdminsForm } from "./AddNewOrgAdminsForm";
export { AddNewTeamsForm } from "./AddNewTeamsForm";

View File

@ -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<typeof teamMetadataSchema>)
| null
| undefined;
/**
* Allows you to access the flags from context
*/
const OrganizationBrandingContext = createContext<OrganizationBranding | null>(null);
/**
* Accesses the branding for an organization from context.
*
* You need to render a <OrgBrandingProvider /> 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 (
* <OrgBrandingProvider value={orgBrand}>
* <YourOwnComponent />
* </OrgBrandingProvider>
* )
* }
* ```
*
* You can then call `useOrgBrandingValues()` to access your `OrgBranding` from within
* `YourOwnComponent` or further down.
*
*/
export function OrgBrandingProvider<F extends OrganizationBranding>(props: {
value: F;
children: React.ReactNode;
}) {
return createElement(OrganizationBrandingContext.Provider, { value: props.value }, props.children);
}

View File

@ -0,0 +1,5 @@
import { trpc } from "@calcom/trpc/react";
export function useOrgBrandingValues() {
return trpc.viewer.organizations.getBrand.useQuery().data;
}

View File

@ -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(".");
}

View File

@ -0,0 +1,6 @@
export interface NewOrganizationFormValues {
name: string;
slug: string;
logo: string;
adminEmail: string;
}

View File

@ -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 (
<Dialog
@ -181,11 +187,10 @@ export default function CreateEventTypeDialog({
}}
/>
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
{urlPrefix && urlPrefix.length >= 21 ? (
<div>
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
label={`${t("url")}: ${urlPrefix}`}
required
addOnLeading={<>/{!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")}

View File

@ -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<string>(t("all_apps"));
const { data: teams, status } = trpc.viewer.teams.list.useQuery();
const isNotEmpty = !!teams?.length;
return status === "success" ? (
<AnimatedPopover text={dropdownTitle} popoverTriggerClassNames="!mb-0">
<CheckboxFieldContainer>
<CheckboxField
id="all-eventtypes-checkbox"
icon={<Layers className="h-4 w-4" />}
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")}
/>
</CheckboxFieldContainer>
<CheckboxFieldContainer>
<CheckboxField
id="all-eventtypes-checkbox"
icon={<User className="h-4 w-4" />}
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")}
/>
</CheckboxFieldContainer>
{isNotEmpty && (
<Fragment>
<div className="text-subtle px-4 py-2.5 text-xs font-medium uppercase leading-none">TEAMS</div>
{teams?.map((team) => (
<CheckboxFieldContainer key={team.id}>
<CheckboxField
id={team.name}
label={team.name}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
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);
}
}}
/>
</CheckboxFieldContainer>
))}
</Fragment>
)}
</AnimatedPopover>
) : null;
};
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: string;
icon: ReactNode;
};
const CheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
return (
<label className="flex w-full items-center justify-between">
<div className="flex items-center">
<div className="text-default flex h-6 w-6 items-center justify-center ltr:mr-2 rtl:ml-2">{icon}</div>
<span className="text-sm">{label}</span>
</div>
<div className="flex h-5 items-center">
<input
{...rest}
ref={ref}
type="checkbox"
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
/>
</div>
</label>
);
});
const CheckboxFieldContainer = ({ children }: { children: ReactNode }) => {
return <div className="flex items-center px-3 py-2">{children}</div>;
};
CheckboxField.displayName = "CheckboxField";

View File

@ -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;

View File

@ -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;
}

View File

@ -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<User, "createdDate" | "completedOnboarding">) => {
return !user.completedOnboarding && dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT);
export const shouldShowOnboarding = (
user: Pick<User, "createdDate" | "completedOnboarding" | "organizationId">
) => {
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 }) {
<span className="text-default truncate pb-1 font-normal">
{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"}
</span>
</span>
@ -789,6 +800,7 @@ function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
}
function SideBar({ bannersHeight }: SideBarProps) {
const orgBranding = useOrgBrandingValues();
return (
<div className="relative">
<aside
@ -797,7 +809,14 @@ function SideBar({ bannersHeight }: SideBarProps) {
<div className="flex h-full flex-col justify-between py-3 lg:pt-6 ">
<header className="items-center justify-between md:hidden lg:flex">
<Link href="/event-types" className="px-2">
<Logo small />
{orgBranding ? (
<div className="flex items-center gap-2 font-medium">
{orgBranding.logo && <Avatar alt="" imageSrc={orgBranding.logo} size="sm" />}
<p className="text text-sm">{orgBranding.name}</p>
</div>
) : (
<Logo small />
)}
</Link>
<div className="flex space-x-2 rtl:space-x-reverse">
<button
@ -889,6 +908,7 @@ export function ShellMain(props: LayoutProps) {
</p>
)}
</div>
{props.beforeCTAactions}
{props.CTA && (
<div
className={classNames(
@ -904,6 +924,7 @@ export function ShellMain(props: LayoutProps) {
</header>
)}
</div>
{props.afterHeading && <>{props.afterHeading}</>}
<div className={classNames(props.flexChildrenContainer && "flex flex-1 flex-col")}>
{props.children}
</div>

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { orgDomainConfig, getOrgDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import * as constants from "@calcom/lib/constants";
describe("Org Domains Utils", () => {
describe("orgDomainConfig", () => {
it("should return a valid org domain", () => {
Object.defineProperty(constants, 'WEBAPP_URL', {value:"https://app.cal.com"});
expect(orgDomainConfig("acme.cal.com")).toEqual({
currentOrgDomain: "acme",
isValidOrgDomain: true
});
});
it("should return a non valid org domain", () => {
Object.defineProperty(constants, 'WEBAPP_URL', {value:"https://app.cal.com"});
expect(orgDomainConfig("app.cal.com")).toEqual({
currentOrgDomain: "app",
isValidOrgDomain: false
});
});
});
describe("getOrgDomain", () => {
it("should handle a prod web app url with a prod subdomain hostname", () => {
Object.defineProperty(constants, 'WEBAPP_URL', {value:"https://app.cal.com"});
expect(getOrgDomain("acme.cal.com")).toEqual("acme");
});
it("should handle a prod web app url with a staging subdomain hostname", () => {
Object.defineProperty(constants, 'WEBAPP_URL', {value:"https://app.cal.com"});
expect(getOrgDomain("acme.cal.dev")).toEqual(null);
});
it("should handle a local web app with port url with a local subdomain hostname", () => {
Object.defineProperty(constants, 'WEBAPP_URL', {value:"http://app.cal.local:3000"});
expect(getOrgDomain("acme.cal.local:3000")).toEqual("acme");
});
it("should handle a local web app with port url with a non-local subdomain hostname", () => {
Object.defineProperty(constants, 'WEBAPP_URL', {value:"http://app.cal.local:3000"});
expect(getOrgDomain("acme.cal.com:3000")).toEqual(null);
});
})
});

View File

@ -20,7 +20,11 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
const defaultOptions: CookieOption["options"] = {
domain: isENVDev ? undefined : NEXTAUTH_COOKIE_DOMAIN,
domain: isENVDev
? process.env.ORGANIZATIONS_ENABLED
? ".cal.local"
: undefined
: NEXTAUTH_COOKIE_DOMAIN,
// To enable cookies on widgets,
// https://stackoverflow.com/questions/45094712/iframe-not-reading-cookies-in-chrome
// But we need to set it as `lax` in development

View File

@ -23,7 +23,7 @@ export const defaultAvatarSrc = function ({ email, md5 }: { md5?: string; email?
* a name. It is used here to provide a consistent placeholder avatar for users
* who have not uploaded an avatar.
*/
export function getPlaceholderAvatar(avatar: string | null | undefined, name: string | null) {
export function getPlaceholderAvatar(avatar: string | null | undefined, name: string | null | undefined) {
return avatar
? avatar
: "https://eu.ui-avatars.com/api/?background=fff&color=f9f9f9&bold=true&background=000000&name=" +

View File

@ -1,12 +1,19 @@
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
export async function checkRegularUsername(_username: string) {
export async function checkRegularUsername(_username: string, currentOrgDomain?: string | null) {
const username = slugify(_username);
const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5;
const user = await prisma.user.findUnique({
where: { username },
const user = await prisma.user.findFirst({
where: {
username,
organization: currentOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
username: true,
},

View File

@ -25,6 +25,29 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
hideBranding: true,
hideBookATeamMember: true,
metadata: true,
parent: {
select: {
name: true,
logo: true,
},
},
children: {
select: {
name: true,
logo: true,
slug: true,
members: {
select: {
user: {
select: {
name: true,
username: true,
},
},
},
},
},
},
members: {
select: {
accepted: true,

View File

@ -23,6 +23,7 @@ export const telemetryEventTypes = {
pageView: "website_page_view",
},
slugReplacementAction: "slug_replacement_action",
org_created: "org_created",
};
export function collectPageParameters(

View File

@ -217,6 +217,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
twoFactorSecret: null,
verified: false,
weekStart: "",
organizationId: null,
...user,
};
};

View File

@ -0,0 +1,48 @@
/*
Warnings:
- A unique constraint covering the columns `[slug,parentId]` on the table `Team` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[email,username]` on the table `users` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[username,organizationId]` on the table `users` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "Team_slug_key";
-- DropIndex
DROP INDEX "users_email_idx";
-- DropIndex
DROP INDEX "users_username_key";
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "parentId" INTEGER;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "organizationId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Team_slug_parentId_key" ON "Team"("slug", "parentId");
-- CreateIndex
CREATE UNIQUE INDEX "Team_slug_parentId_key_null" ON "Team"("slug", ("parentId" IS NULL)) WHERE "parentId" IS NULL;
-- CreateIndex
CREATE UNIQUE INDEX "users_email_username_key" ON "users"("email", "username");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_organizationId_key" ON "users"("username", "organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_organizationId_key_null" ON "users"("username", ("organizationId" IS NULL)) WHERE "organizationId" IS NULL;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- FeatureFlags
INSERT INTO "Feature" (slug, enabled, description, "type")
VALUES ('organizations', true, 'Manage organizations with multiple teams', 'OPERATIONAL')
ON CONFLICT (slug) DO NOTHING;

View File

@ -166,10 +166,10 @@ enum UserPermissionRole {
model User {
id Int @id @default(autoincrement())
username String? @unique
username String?
name String?
/// @zod.email()
email String @unique
email String
emailVerified DateTime?
password String?
bio String?
@ -225,8 +225,16 @@ model User {
routingForms App_RoutingForms_Form[] @relation("routing-form")
verifiedNumbers VerifiedNumber[]
hosts Host[]
organizationId Int?
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
// Linking account code for orgs v2
//linkedByUserId Int?
//linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade)
//linkedUsers User[] @relation("linked_account")*/
@@index([email])
@@unique([email])
@@unique([email, username])
@@unique([username, organizationId])
@@index([emailVerified])
@@index([identityProvider])
@@index([identityProviderId])
@ -238,7 +246,7 @@ model Team {
/// @zod.min(1)
name String
/// @zod.min(1)
slug String? @unique
slug String?
logo String?
appLogo String?
appIconLogo String?
@ -255,8 +263,14 @@ model Team {
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
verifiedNumbers VerifiedNumber[]
parentId Int?
parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade)
children Team[] @relation("organization")
orgUsers User[] @relation("scope")
inviteToken VerificationToken?
webhooks Webhook[]
@@unique([slug, parentId])
}
enum MembershipRole {

View File

@ -142,7 +142,10 @@ async function seedAppData() {
],
user: {
connect: {
username: "pro",
email_username: {
username: "pro",
email: "pro@example.com",
},
},
},
name: seededForm.name,

View File

@ -50,7 +50,7 @@ async function createUserAndEventType(opts: {
};
const user = await prisma.user.upsert({
where: { email: opts.user.email },
where: { email_username: { email: opts.user.email, username: opts.user.username } },
update: userData,
create: userData,
});

View File

@ -57,6 +57,17 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
},
},
successRedirectUrl: true,
team: {
select: {
logo: true,
parent: {
select: {
logo: true,
name: true,
},
},
},
},
});
export const availiblityPageEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
@ -103,4 +114,15 @@ export const availiblityPageEventTypeSelect = Prisma.validator<Prisma.EventTypeS
timeZone: true,
},
},
team: {
select: {
logo: true,
parent: {
select: {
logo: true,
name: true,
},
},
},
},
});

View File

@ -312,6 +312,7 @@ export const teamMetadataSchema = z
paymentId: z.string(),
subscriptionId: z.string().nullable(),
subscriptionItemId: z.string().nullable(),
isOrganization: z.boolean().nullable(),
})
.partial()
.nullable();

View File

@ -32,6 +32,7 @@ const ENDPOINTS = [
"saml",
"slots",
"teams",
"organizations",
"users",
"viewer",
"webhook",

View File

@ -2,10 +2,10 @@ import type { Session } from "next-auth";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { Maybe } from "@trpc/server";
import { TRPCError } from "@trpc/server";
import type { TRPCContextInner } from "../createContext";
import { middleware } from "../trpc";
@ -69,7 +69,14 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
trialEndsAt: true,
metadata: true,
role: true,
organizationId: true,
allowDynamicBooking: true,
organization: {
select: {
slug: true,
metadata: true,
},
},
},
});
@ -84,12 +91,17 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
}
const userMetaData = userMetadata.parse(user.metadata || {});
const orgMetadata = teamMetadataSchema.parse(user.organization?.metadata || {});
const rawAvatar = user.avatar;
// This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url
user.avatar = rawAvatar ? `${WEBAPP_URL}/${user.username}/avatar.png` : defaultAvatarSrc({ email });
const locale = user?.locale || ctx.locale;
return {
...user,
organization: {
...user.organization,
metadata: orgMetadata,
},
id,
rawAvatar,
email,

View File

@ -7,6 +7,7 @@ type MeOptions = {
};
export const meHandler = async ({ ctx }: MeOptions) => {
const crypto = await import("crypto");
const { user } = ctx;
// Destructuring here only makes it more illegible
// pick only the part we want to expose in the API
@ -15,6 +16,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
name: user.name,
username: user.username,
email: user.email,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
startTime: user.startTime,
endTime: user.endTime,
bufferTime: user.bufferTime,
@ -39,5 +41,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
metadata: user.metadata,
defaultBookerLayouts: user.defaultBookerLayouts,
allowDynamicBooking: user.allowDynamicBooking,
organizationId: user.organizationId,
organization: user.organization,
};
};

View File

@ -41,7 +41,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
throw new TRPCError({ code: "BAD_REQUEST", message: t(layoutError) });
}
if (input.username) {
if (input.username && !user.organizationId) {
const username = slugify(input.username);
// Only validate if we're changing usernames
if (username !== user.username) {

View File

@ -14,6 +14,7 @@ import { bookingsRouter } from "./bookings/_router";
import { deploymentSetupRouter } from "./deploymentSetup/_router";
import { eventTypesRouter } from "./eventTypes/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { viewerOrganizationsRouter } from "./organizations/_router";
import { paymentsRouter } from "./payments/_router";
import { slotsRouter } from "./slots/_router";
import { ssoRouter } from "./sso/_router";
@ -32,6 +33,7 @@ export const viewerRouter = mergeRouters(
eventTypes: eventTypesRouter,
availability: availabilityRouter,
teams: viewerTeamsRouter,
organizations: viewerOrganizationsRouter,
webhook: webhookRouter,
apiKeys: apiKeysRouter,
slots: slotsRouter,

View File

@ -5,6 +5,7 @@ import { CAL_URL } from "@calcom/lib/constants";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@ -81,6 +82,8 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
id: true,
name: true,
slug: true,
parentId: true,
metadata: true,
members: {
select: {
userId: true,
@ -190,27 +193,63 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
},
});
const teamMemberships = user.teams.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
}));
const compareMembership = (mship1: MembershipRole, mship2: MembershipRole) => {
const mshipToNumber = (mship: MembershipRole) =>
Object.keys(MembershipRole).findIndex((mmship) => mmship === mship);
return mshipToNumber(mship1) > mshipToNumber(mship2);
};
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
profile: {
name: membership.team.name,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
slug: membership.team.slug ? "team/" + membership.team.slug : null,
},
metadata: {
membershipCount: membership.team.members.length,
readOnly: membership.role === MembershipRole.MEMBER,
},
eventTypes: membership.team.eventTypes
.map(mapEventType)
.filter((evType) => evType.userId === null || evType.userId === ctx.user.id)
.filter((evType) =>
membership.role === MembershipRole.MEMBER ? evType.schedulingType !== SchedulingType.MANAGED : true
),
}))
user.teams
.filter((mmship) => {
const metadata = teamMetadataSchema.parse(mmship.team.metadata);
return !metadata?.isOrganization;
})
.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
teamId: membership.team.id,
membershipRole:
orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: membership.role,
profile: {
name: membership.team.name,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
slug: membership.team.slug
? !membership.team.parentId
? `/team`
: "" + membership.team.slug
: null,
},
metadata: {
membershipCount: membership.team.members.length,
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
eventTypes: membership.team.eventTypes
.map(mapEventType)
.filter((evType) => evType.userId === null || evType.userId === ctx.user.id)
.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
),
};
})
);
return {
// don't display event teams without event types,

View File

@ -0,0 +1,116 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZVerifyCodeInputSchema } from "./verifyCode.schema";
type OrganizationsRouterHandlerCache = {
create?: typeof import("./create.handler").createHandler;
update?: typeof import("./update.handler").updateHandler;
verifyCode?: typeof import("./verifyCode.handler").verifyCodeHandler;
createTeams?: typeof import("./createTeams.handler").createTeamsHandler;
setPassword?: typeof import("./setPassword.handler").setPasswordHandler;
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
};
const UNSTABLE_HANDLER_CACHE: OrganizationsRouterHandlerCache = {};
export const viewerOrganizationsRouter = router({
create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.create) {
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.create) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.create({
ctx,
input,
});
}),
update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.update) {
UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.update) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.update({
ctx,
input,
});
}),
verifyCode: authedProcedure.input(ZVerifyCodeInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.verifyCode) {
UNSTABLE_HANDLER_CACHE.verifyCode = await import("./verifyCode.handler").then(
(mod) => mod.verifyCodeHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.verifyCode) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.verifyCode({
ctx,
input,
});
}),
createTeams: authedProcedure.input(ZCreateTeamsSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.createTeams) {
UNSTABLE_HANDLER_CACHE.createTeams = await import("./createTeams.handler").then(
(mod) => mod.createTeamsHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.createTeams) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.createTeams({
ctx,
input,
});
}),
setPassword: authedProcedure.input(ZSetPasswordSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.setPassword) {
UNSTABLE_HANDLER_CACHE.setPassword = await import("./setPassword.handler").then(
(mod) => mod.setPasswordHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.setPassword) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.setPassword({
ctx,
input,
});
}),
getBrand: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getBrand) {
UNSTABLE_HANDLER_CACHE.getBrand = await import("./getBrand.handler").then((mod) => mod.getBrandHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getBrand) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getBrand({
ctx,
});
}),
});

View File

@ -0,0 +1,132 @@
import { createHash } from "crypto";
import { totp } from "otplib";
import { sendOrganizationEmailVerification } from "@calcom/emails";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { IS_PRODUCTION, IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TCreateInputSchema } from "./create.schema";
type CreateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateInputSchema;
};
const vercelCreateDomain = async (domain: string) => {
const response = await fetch(
`https://api.vercel.com/v8/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`,
{
body: `{\n "name": "${domain}.${subdomainSuffix()}"\n}`,
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_VERCEL}`,
"Content-Type": "application/json",
},
method: "POST",
}
);
const data = await response.json();
// Domain is already owned by another team but you can request delegation to access it
if (data.error?.code === "forbidden")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_team" });
// Domain is already being used by a different project
if (data.error?.code === "domain_taken")
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_project" });
return true;
};
export const createHandler = async ({ input }: CreateOptions) => {
const { slug, name, adminEmail, adminUsername, check } = input;
const userCollisions = await prisma.user.findUnique({
where: {
email: adminEmail,
},
});
const slugCollisions = await prisma.team.findFirst({
where: {
slug: slug,
metadata: {
path: ["isOrganization"],
equals: true,
},
},
});
if (slugCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "organization_url_taken" });
if (userCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "admin_email_taken" });
const password = createHash("md5")
.update(`${adminEmail}${process.env.CALENDSO_ENCRYPTION_KEY}`)
.digest("hex");
const hashedPassword = await hashPassword(password);
if (check === false) {
const createOwnerOrg = await prisma.user.create({
data: {
username: adminUsername,
email: adminEmail,
emailVerified: new Date(),
password: hashedPassword,
organization: {
create: {
name,
...(!IS_TEAM_BILLING_ENABLED && { slug }),
metadata: {
requestedSlug: slug,
isOrganization: true,
},
},
},
},
});
if (IS_PRODUCTION) await vercelCreateDomain(slug);
await prisma.membership.create({
data: {
userId: createOwnerOrg.id,
role: MembershipRole.OWNER,
accepted: true,
teamId: createOwnerOrg.organizationId!,
},
});
return { user: { ...createOwnerOrg, password } };
} else {
const language = await getTranslation(input.language ?? "en", "common");
const secret = createHash("md5")
.update(adminEmail + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
totp.options = { step: 90 };
const code = totp.generate(secret);
await sendOrganizationEmailVerification({
user: {
email: adminEmail,
},
code,
language,
});
}
// Sync Services: Close.com
//closeComUpsertOrganizationUser(createTeam, ctx.user, MembershipRole.OWNER);
return { checked: true };
};

View File

@ -0,0 +1,14 @@
import { z } from "zod";
import slugify from "@calcom/lib/slugify";
export const ZCreateInputSchema = z.object({
name: z.string(),
slug: z.string().transform((val) => slugify(val.trim())),
adminEmail: z.string().email(),
adminUsername: z.string(),
check: z.boolean().default(true),
language: z.string().optional(),
});
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;

View File

@ -0,0 +1,63 @@
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TCreateTeamsSchema } from "./createTeams.schema";
type CreateTeamsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateTeamsSchema;
};
export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => {
const { teamNames, orgId } = input;
const organization = await prisma.team.findFirst({ where: { id: orgId }, select: { metadata: true } });
const metadata = teamMetadataSchema.parse(organization?.metadata);
if (!metadata?.requestedSlug) throw new TRPCError({ code: "BAD_REQUEST", message: "no_organization" });
const userMembership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: orgId,
},
select: {
userId: true,
role: true,
},
});
// TODO test this check works
if (!userMembership || userMembership.role !== MembershipRole.OWNER)
throw new TRPCError({ code: "BAD_REQUEST", message: "not_authorized" });
const [teamSlugs, userSlugs] = await prisma.$transaction([
prisma.team.findMany({ where: { parentId: orgId }, select: { slug: true } }),
prisma.user.findMany({ where: { organizationId: orgId }, select: { username: true } }),
]);
const existingSlugs = teamSlugs
.flatMap((ts) => ts.slug ?? [])
.concat(userSlugs.flatMap((us) => us.username ?? []));
const duplicatedSlugs = existingSlugs.filter((slug) => teamNames.includes(slug));
await prisma.team.createMany({
data: teamNames.flatMap((name) => {
if (!duplicatedSlugs.includes(name)) {
return { name, parentId: orgId, slug: slugify(name) };
} else {
return [];
}
}),
});
return { duplicatedSlugs };
};

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const ZCreateTeamsSchema = z.object({
teamNames: z.string().array(),
orgId: z.number(),
});
export type TCreateTeamsSchema = z.infer<typeof ZCreateTeamsSchema>;

View File

@ -0,0 +1,36 @@
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type VerifyCodeOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const getBrandHandler = async ({ ctx }: VerifyCodeOptions) => {
const { user } = ctx;
if (!user.organizationId) return null;
const team = await prisma.team.findFirst({
where: {
id: user.organizationId,
},
select: {
logo: true,
name: true,
slug: true,
metadata: true,
},
});
const metadata = teamMetadataSchema.parse(team?.metadata);
const slug = team?.slug || metadata?.requestedSlug;
return {
...team,
metadata,
slug,
};
};

View File

@ -0,0 +1,57 @@
import { createHash } from "crypto";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TSetPasswordSchema } from "./setPassword.schema";
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TSetPasswordSchema;
};
export const setPasswordHandler = async ({ ctx, input }: UpdateOptions) => {
const { newPassword } = input;
const user = await prisma.user.findFirst({
where: {
id: ctx.user.id,
},
select: {
password: true,
email: true,
},
});
if (!user) throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" });
if (!user.password) throw new TRPCError({ code: "BAD_REQUEST", message: "Password not set by default" });
const generatedPassword = createHash("md5")
.update(`${user?.email ?? ""}${process.env.CALENDSO_ENCRYPTION_KEY}`)
.digest("hex");
const isCorrectPassword = await verifyPassword(generatedPassword, user?.password);
if (!isCorrectPassword)
throw new TRPCError({
code: "BAD_REQUEST",
message: "The password set by default doesn't match your existing one. Contact an app admin.",
});
const hashedPassword = await hashPassword(newPassword);
await prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
password: hashedPassword,
},
});
return { update: true };
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZSetPasswordSchema = z.object({
newPassword: z.string(),
});
export type TSetPasswordSchema = z.infer<typeof ZSetPasswordSchema>;

View File

@ -0,0 +1,44 @@
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TUpdateInputSchema } from "./update.schema";
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TUpdateInputSchema;
};
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const { logo, bio, orgId } = input;
const userMembership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: orgId,
},
select: {
userId: true,
role: true,
},
});
if (!userMembership || userMembership.role !== MembershipRole.OWNER)
throw new TRPCError({ code: "BAD_REQUEST", message: "not_authorized" });
await prisma.team.update({
where: {
id: orgId,
},
data: {
bio,
logo,
},
});
return { update: true, userId: userMembership.userId };
};

View File

@ -0,0 +1,16 @@
import { z } from "zod";
export const ZUpdateInputSchema = z.object({
orgId: z
.string()
.regex(/^\d+$/)
.transform((id) => parseInt(id)),
bio: z.string().optional(),
logo: z
.string()
.optional()
.nullable()
.transform((v) => v || null),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;

View File

@ -0,0 +1,32 @@
import { createHash } from "crypto";
import { totp } from "otplib";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { ZVerifyCodeInputSchema } from "./verifyCode.schema";
type VerifyCodeOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZVerifyCodeInputSchema;
};
export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
const { email, code } = input;
const { user } = ctx;
if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
const secret = createHash("md5")
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
const isValidToken = totp.check(code, secret);
if (!isValidToken) throw new TRPCError({ code: "BAD_REQUEST", message: "invalid_code" });
return isValidToken;
};

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const ZVerifyCodeInputSchema = z.object({
email: z.string().email(),
code: z.string(),
});
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;

View File

@ -26,7 +26,6 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
const translation = await getTranslation(input.language ?? "en", "common");
const team = await prisma.team.findFirst({
@ -35,7 +34,8 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
},
});
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
if (!team)
throw new TRPCError({ code: "NOT_FOUND", message: `${input.isOrg ? "Organization" : "Team"} not found` });
const emailsToInvite = Array.isArray(input.usernameOrEmail)
? input.usernameOrEmail
@ -48,6 +48,13 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
},
});
if (input.isOrg && invitee) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`,
});
}
if (!invitee) {
// liberal email match
@ -62,6 +69,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
data: {
email: usernameOrEmail,
invitedTo: input.teamId,
...(input.isOrg && { organizationId: input.teamId }),
teams: {
create: {
teamId: input.teamId,
@ -80,14 +88,15 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
expires: new Date(new Date().setHours(168)), // +1 week
},
});
if (ctx?.user?.name && team?.name) {
if (team?.name) {
await sendTeamInviteEmail({
language: translation,
from: ctx.user.name,
from: ctx.user.name || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team.name,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, // we know that the user has not completed onboarding yet, so we can redirect them to the onboarding flow
isCalcomMember: false,
isOrg: input.isOrg,
});
}
} else {
@ -148,6 +157,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
to: sendTo,
teamName: team.name,
...inviteTeamOptions,
isOrg: input.isOrg,
});
}
}

View File

@ -13,6 +13,7 @@ export const ZInviteMemberInputSchema = z.object({
role: z.nativeEnum(MembershipRole),
language: z.string(),
sendEmailInvitation: z.boolean(),
isOrg: z.boolean().default(false),
});
export type TInviteMemberInputSchema = z.infer<typeof ZInviteMemberInputSchema>;

View File

@ -16,6 +16,7 @@ declare module "next-auth" {
email_verified?: boolean;
impersonatedByUID?: number;
belongsToActiveTeam?: boolean;
organizationId?: number | null;
username?: PrismaUser["username"];
role?: PrismaUser["role"] | "INACTIVE_ADMIN";
}
@ -30,5 +31,6 @@ declare module "next-auth/jwt" {
role?: UserPermissionRole | "INACTIVE_ADMIN" | null;
impersonatedByUID?: number | null;
belongsToActiveTeam?: boolean;
organizationId?: number | null;
}
}

Some files were not shown because too many files have changed in this diff Show More