feat: Organizations (#8993)
* Initial commit * Adding feature flag * feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209) * Change scopedMembers to orgMembers * Change to orgUsers * Letting duplicate slugs for teams to support orgs * Covering null on unique clauses * Supporting having the orgId in the session cookie * feat: organization event type filter (#9253) Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * Missing changes to support orgs schema changes * 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> * 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> * Vercel subdomain creation in PROD only * Making sure we let localhost still work * Feedback * Type check fixes * 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> * 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> * Type errors * Refactor and type fixes * Update orgDomains.ts * Feedback * Reverting * NIT * fix issue getting org slug from domain * Improving orgDomains util * Host comes with port * Update useRouterQuery.ts * Feedback * Feedback * Feedback * Feedback: SSR for user event-types to have org context * 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 * Removes the need for useEffect here * Feedback * Feedback * Cookie domain needs a dot * Type fix * Update apps/web/public/static/locales/en/common.json Co-authored-by: Omar López <zomars@me.com> * Update packages/emails/src/templates/OrganizationAccountVerifyEmail.tsx * Feedback --------- Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com> Co-authored-by: Efraín Rochín <roae.85@gmail.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>pull/9498/head
parent
9db4a04cd9
commit
c5483c81de
17
.env.example
17
.env.example
|
@ -5,6 +5,7 @@
|
|||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
# - ORGANIZATIONS
|
||||
|
||||
# - LICENSE (DEPRECATED) ************************************************************************************
|
||||
# https://github.com/calcom/cal.com/blob/main/LICENSE
|
||||
|
@ -32,6 +33,8 @@ PRISMA_GENERATE_DATAPROXY=
|
|||
# ***********************************************************************************************************
|
||||
|
||||
# - SHARED **************************************************************************************************
|
||||
# Set this to http://app.cal.local:3000 if you want to enable organizations, and
|
||||
# check variable ORGANIZATIONS_ENABLED at the bottom of this file
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||
|
@ -183,3 +186,17 @@ CSP_POLICY=
|
|||
EDGE_CONFIG=
|
||||
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||
|
||||
# - ORGANIZATIONS *******************************************************************************************
|
||||
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
||||
# This is mainly needed locally, because for orgs to work a full domain name needs to point
|
||||
# to the app, i.e. app.cal.local instead of using localhost, which is very disruptive
|
||||
#
|
||||
# This variable should only be set to 1 or true if you are in a non-prod environment and you want to
|
||||
# use organizations
|
||||
ORGANIZATIONS_ENABLED=
|
||||
|
||||
# Vercel Config to create subdomains for organizations
|
||||
PROJECT_ID_VERCEL=
|
||||
TEAM_ID_VERCEL=
|
||||
AUTH_BEARER_TOKEN_VERCEL=
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>({
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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" };
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,20 +1,18 @@
|
|||
import type { GetStaticPropsContext } from "next";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getStaticProps as _getStaticProps } from "../[type]";
|
||||
|
||||
export { getStaticPaths } from "../[type]";
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
|
||||
export { default } from "../[type]";
|
||||
|
||||
export const getStaticProps = async (context: GetStaticPropsContext) => {
|
||||
const staticResponse = await _getStaticProps(context);
|
||||
if (staticResponse.notFound) {
|
||||
return staticResponse;
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssrResponse = await _getServerSideProps(context);
|
||||
if (ssrResponse.notFound) {
|
||||
return ssrResponse;
|
||||
}
|
||||
return {
|
||||
...staticResponse,
|
||||
...ssrResponse,
|
||||
props: {
|
||||
...staticResponse.props,
|
||||
...ssrResponse.props,
|
||||
isEmbed: true,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/organizations/api/subteams";
|
|
@ -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);
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -7,10 +7,13 @@ import type { FC } from "react";
|
|||
import { useEffect, useState, memo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { OrganizationEventTypeFilter } from "@calcom/features/eventtypes/components/OrganizationEventTypeFilter";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -44,6 +47,8 @@ import {
|
|||
HeadSeo,
|
||||
Skeleton,
|
||||
Label,
|
||||
VerticalDivider,
|
||||
Alert,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
ArrowDown,
|
||||
|
@ -59,9 +64,11 @@ import {
|
|||
Trash,
|
||||
Upload,
|
||||
Users,
|
||||
User as UserIcon,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
@ -74,6 +81,7 @@ interface EventTypeListHeadingProps {
|
|||
profile: EventTypeGroupProfile;
|
||||
membershipCount: number;
|
||||
teamId?: number | null;
|
||||
orgSlug?: string;
|
||||
}
|
||||
|
||||
type EventTypeGroup = EventTypeGroups[number];
|
||||
|
@ -194,6 +202,7 @@ const MemoizedItem = memo(Item);
|
|||
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
|
||||
|
@ -362,7 +371,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 +698,7 @@ const EventTypeListHeading = ({
|
|||
profile,
|
||||
membershipCount,
|
||||
teamId,
|
||||
orgSlug,
|
||||
}: EventTypeListHeadingProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
@ -727,7 +739,9 @@ const EventTypeListHeading = ({
|
|||
)}
|
||||
{profile?.slug && (
|
||||
<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>
|
||||
|
@ -782,6 +796,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 +841,24 @@ const EventTypesPage = () => {
|
|||
const router = useRouter();
|
||||
const { open } = useIntercom();
|
||||
const { query } = router;
|
||||
const { data: user } = useMeQuery();
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
||||
const orgBranding = useOrgBrandingValues();
|
||||
|
||||
function closeBanner() {
|
||||
setShowProfileBanner(false);
|
||||
document.cookie = `calcom-profile-banner=1;max-age=${60 * 60 * 24 * 90}`; // 3 months
|
||||
showToast(t("we_wont_show_again"), "success");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.openIntercom && query?.openIntercom === "true") {
|
||||
open();
|
||||
}
|
||||
setShowProfileBanner(
|
||||
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
@ -810,6 +873,8 @@ const EventTypesPage = () => {
|
|||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA />}>
|
||||
<WithQuery
|
||||
customLoader={<SkeletonLoader />}
|
||||
|
@ -826,6 +891,7 @@ const EventTypesPage = () => {
|
|||
profile={group.profile}
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
teamId={group.teamId}
|
||||
orgSlug={orgBranding?.slug}
|
||||
/>
|
||||
|
||||
<EventTypeList
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -86,6 +86,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
parent: {
|
||||
select: {
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
|
|
|
@ -117,7 +117,7 @@
|
|||
"team_info": "Team Info",
|
||||
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
|
||||
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
|
||||
"user_invited_you": "{{user}} invited you to join the team {{team}} on {{appName}}",
|
||||
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
|
||||
"hidden_team_member_title": "You are hidden in this team",
|
||||
"hidden_team_member_message": "Your seat is not paid for, either Upgrade to PRO or let the team owner know they can pay for your seat.",
|
||||
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
|
||||
|
@ -238,6 +238,7 @@
|
|||
"done": "Done",
|
||||
"all_done": "All done!",
|
||||
"all_apps": "All",
|
||||
"yours":"Yours",
|
||||
"available_apps": "Available Apps",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
"finish": "Finish",
|
||||
|
@ -540,6 +541,8 @@
|
|||
"team_description": "A few sentences about your team. This will appear on your team's url page.",
|
||||
"members": "Members",
|
||||
"member": "Member",
|
||||
"number_member_one": "{{count}} member",
|
||||
"number_member_other": "{{count}} members",
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"administrator_user": "Administrator user",
|
||||
|
@ -680,6 +683,7 @@
|
|||
"create_team_to_get_started": "Create a team to get started",
|
||||
"teams": "Teams",
|
||||
"team": "Team",
|
||||
"organization": "Organization",
|
||||
"team_billing": "Team Billing",
|
||||
"team_billing_description": "Manage billing for your team",
|
||||
"upgrade_to_flexible_pro_title": "We've changed billing for teams",
|
||||
|
@ -1611,10 +1615,10 @@
|
|||
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.",
|
||||
"organizer_timezone": "Organizer timezone",
|
||||
"email_user_cta": "View Invitation",
|
||||
"email_no_user_invite_heading": "You’ve been invited to join a team on {{appName}}",
|
||||
"email_no_user_invite_heading": "You’ve been invited to join a {{appName}} {{entity}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your team in no time.",
|
||||
"email_user_invite_subheading": "{{invitedBy}} has invited you to join their {{entity}} `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your {{entity}} to schedule meetings without the email tennis.",
|
||||
"email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your {{entity}} in no time.",
|
||||
"email_no_user_step_one": "Choose your username",
|
||||
"email_no_user_step_two": "Connect your calendar account",
|
||||
"email_no_user_step_three": "Set your Availability",
|
||||
|
@ -1657,7 +1661,7 @@
|
|||
"show_on_booking_page":"Show on booking page",
|
||||
"get_started_zapier_templates": "Get started with Zapier templates",
|
||||
"team_is_unpublished": "{{team}} is unpublished",
|
||||
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
|
||||
"team_is_unpublished_description": "This {{entity}} link is currently not available. Please contact the {{entity}} owner or ask them publish it.",
|
||||
"team_member": "Team member",
|
||||
"a_routing_form": "A Routing Form",
|
||||
"form_description_placeholder": "Form Description",
|
||||
|
@ -1814,7 +1818,6 @@
|
|||
"book_my_cal": "Book my Cal",
|
||||
"invite_as":"Invite as",
|
||||
"form_updated_successfully":"Form updated successfully.",
|
||||
"email_not_cal_member_cta": "Join your team",
|
||||
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
|
||||
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
|
||||
"disable_host_confirmation_emails": "Disable default confirmation emails for host",
|
||||
|
@ -1825,7 +1828,46 @@
|
|||
"google_workspace_admin_tooltip":"You must be a Workspace Admin to use this feature",
|
||||
"first_event_type_webhook_description": "Create your first webhook for this event type",
|
||||
"create_for": "Create for",
|
||||
"setup_organization": "Setup an Organization",
|
||||
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
|
||||
"organization_banner_title": "Manage organizations with multiple teams",
|
||||
"set_up_your_organization": "Set up your organization",
|
||||
"organizations_description": "Organizations are shared environments where teams can create shared event types, apps, workflows and more.",
|
||||
"organization_url_taken": "This URL is already taken",
|
||||
"must_enter_organization_name": "Must enter an organization name",
|
||||
"must_enter_organization_admin_email": "Must enter your organization email address",
|
||||
"admin_email": "Your organization email address",
|
||||
"admin_username": "Administrator's username",
|
||||
"organization_name": "Organization name",
|
||||
"organization_url": "Organization URL",
|
||||
"organization_verify_header" :"Verify your organization email",
|
||||
"organization_verify_email_body":"Please use the code below to verify your email address to continue setting up your organization.",
|
||||
"additional_url_parameters": "Additional URL parameters",
|
||||
"about_your_organization": "About your organization",
|
||||
"about_your_organization_description": "Organizations are shared environments where you can create multiple teams with shared members, event types, apps, workflows and more.",
|
||||
"create_your_teams": "Create your teams",
|
||||
"create_your_teams_description": "Start scheduling together by adding your team members to your organisation",
|
||||
"invite_organization_admins": "Invite your organization admins",
|
||||
"invite_organization_admins_description": "These admins will have access to all teams in your organization. You can add team admins and members later.",
|
||||
"set_a_password": "Set a password",
|
||||
"set_a_password_description": "This will create a new user account with your organization email and this password.",
|
||||
"organization_logo": "Organization Logo",
|
||||
"organization_about_description": "A few sentences about your organization. This will appear on your organization public profile page.",
|
||||
"ill_do_this_later": "I'll do this later",
|
||||
"verify_your_email": "Verify your email",
|
||||
"enter_digit_code": "Enter the 6 digit code we sent to {{email}}",
|
||||
"verify_email_organization": "Verify your email to create an organization",
|
||||
"code_provided_invalid": "The code provided is not valid, try again",
|
||||
"email_already_used": "Email already being used",
|
||||
"duplicated_slugs_warning": "The following teams couldn't be created due to duplicated slugs: {{slugs}}",
|
||||
"team_names_empty": "Team names can't be empty",
|
||||
"team_names_repeated": "Team names can't be repeated",
|
||||
"user_belongs_organization": "User belongs to an organization",
|
||||
"no_teams_yet": "This organization has no teams yet",
|
||||
"no_teams_yet_description": "if you are an administrator, be sure to create teams to be shown here.",
|
||||
"set_up": "Set up",
|
||||
"set_up_your_profile": "Set up your profile",
|
||||
"set_up_your_profile_description": "Let people know who you are within {{orgName}}, and when they engage with your public link.",
|
||||
"sender_id_info": "Name or number shown as the sender of an SMS (some countries do not allow alphanumeric sender IDs)",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { APP_NAME, SUPPORT_MAIL_ADDRESS, COMPANY_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { BaseEmailHtml } from "../components";
|
||||
|
||||
export type OrganizationEmailVerify = {
|
||||
language: TFunction;
|
||||
user: {
|
||||
email: string;
|
||||
};
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const OrganisationAccountVerifyEmail = (
|
||||
props: OrganizationEmailVerify & Partial<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", { companyName: COMPANY_NAME })}</>
|
||||
</a>
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
</BaseEmailHtml>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -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: "",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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) }),
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
|
||||
export { AboutOrganizationForm } from "./AboutOrganizationForm";
|
||||
export { SetPasswordForm } from "./SetPasswordForm";
|
||||
export { AddNewOrgAdminsForm } from "./AddNewOrgAdminsForm";
|
||||
export { AddNewTeamsForm } from "./AddNewTeamsForm";
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useOrgBrandingValues() {
|
||||
return trpc.viewer.organizations.getBrand.useQuery().data;
|
||||
}
|
|
@ -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(".");
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export interface NewOrganizationFormValues {
|
||||
name: string;
|
||||
slug: string;
|
||||
logo: string;
|
||||
adminEmail: string;
|
||||
}
|
|
@ -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")}
|
||||
|
|
|
@ -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";
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
})
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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=" +
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -23,6 +23,7 @@ export const telemetryEventTypes = {
|
|||
pageView: "website_page_view",
|
||||
},
|
||||
slugReplacementAction: "slug_replacement_action",
|
||||
org_created: "org_created",
|
||||
};
|
||||
|
||||
export function collectPageParameters(
|
||||
|
|
|
@ -217,6 +217,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
|
|||
twoFactorSecret: null,
|
||||
verified: false,
|
||||
weekStart: "",
|
||||
organizationId: null,
|
||||
...user,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -142,7 +142,10 @@ async function seedAppData() {
|
|||
],
|
||||
user: {
|
||||
connect: {
|
||||
username: "pro",
|
||||
email_username: {
|
||||
username: "pro",
|
||||
email: "pro@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
name: seededForm.name,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -32,6 +32,7 @@ const ENDPOINTS = [
|
|||
"saml",
|
||||
"slots",
|
||||
"teams",
|
||||
"organizations",
|
||||
"users",
|
||||
"viewer",
|
||||
"webhook",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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>;
|
|
@ -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 };
|
||||
};
|
|
@ -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>;
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZSetPasswordSchema = z.object({
|
||||
newPassword: z.string(),
|
||||
});
|
||||
|
||||
export type TSetPasswordSchema = z.infer<typeof ZSetPasswordSchema>;
|
|
@ -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 };
|
||||
};
|
|
@ -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>;
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,11 +9,13 @@ import { ChevronDown } from "../icon";
|
|||
export const AnimatedPopover = ({
|
||||
text,
|
||||
count,
|
||||
popoverTriggerClassNames,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
count?: number;
|
||||
children: React.ReactNode;
|
||||
popoverTriggerClassNames?: string;
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
@ -44,7 +46,10 @@ export const AnimatedPopover = ({
|
|||
<Popover.Trigger asChild>
|
||||
<div
|
||||
ref={ref}
|
||||
className="hover:border-emphasis border-default text-default hover:text-emphasis mb-2 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
|
||||
className={classNames(
|
||||
"hover:border-emphasis border-default text-default hover:text-emphasis mb-2 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1",
|
||||
popoverTriggerClassNames
|
||||
)}>
|
||||
<div className="max-w-36 flex items-center">
|
||||
<Tooltip content={text}>
|
||||
<div className="truncate">
|
||||
|
@ -63,7 +68,7 @@ export const AnimatedPopover = ({
|
|||
<Popover.Content side="bottom" align={align} asChild>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default border-default absolute z-50 mt-2 max-h-64 w-56 overflow-y-scroll rounded-md border py-[2px] shadow-sm focus-within:outline-none",
|
||||
"bg-default border-default scroll-bar absolute z-50 mt-2 max-h-64 w-56 overflow-y-scroll rounded-md border py-[2px] shadow-sm focus-within:outline-none",
|
||||
align === "end" && "-translate-x-[228px]"
|
||||
)}>
|
||||
{children}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue