feat: New Organization Schema along with the ability to invite existing users to an organization (#13002)

* Add profile table

Copy to profile table

Update schema

Migrations

* wip

* Get switcher working

* Add organization seeder

* wip

* Remove dead code

* Fix unit tests

* relevantProfile -> profile

* cache bust

* One more comment

* Squash migrations

* Copy to profile table

* Update schema

* Fix existing user invite flow

* Add profile table

* Copy to profile table

* Update schema

* Format

* Add profile table

Copy to profile table

Update schema

Migrations

* Format

* wip

Get switcher working

Add organization seeder

wip

Remove dead code

Fix unit tests

relevantProfile -> profile

cache bust

One more comment

Squash migrations

Copy to profile table

Update schema

Fix existing user invite flow

Add profile table

Copy to profile table

Update schema

chore: small improvements to signup (#13227)

* removed features on mobile, better background color

* nit

* dark mode nits

New Crowdin translations by Github Action

fix: event reschedule workflow trigger (#13122)

* fix event reschedule workflow trigger

* uncomment previously failed test

* review fix

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>

chore: added community support to help (#13213)

refactor: Abstract `handleSeats` from `handleNewBooking` (#12062)

* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Erik <erik@erosemberg.com>

fix: Dynamic group booking  org-migration redirect (#13223)

* fix: Dynamic group booking redirect

* Fix flaky private team test

test: Check availability tab and all your functions (teste2e-availability) (#12434)

* add changes

* add changes

* add changes

* add changes

* Requested changes

* Change way to grab element

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

fix: Reschedule a cancelled booking (#13170)

* fix: Reschedule a cancelled booking

* canceled instead of cancelled

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

v3.7.0

test: In Team Event type when location is Organizer Default App and U… (#12939)

* test: In Team Event type when location is Organizer Default App and User Metadata is emoty

* chore: use variable

* fix: type error

test: add test for invalid cal video (#13103)

* test: add test for invalid cal video

* chore: add confirm

* chore: fix type error

* chore: update code

* test: add unit test

* chore: type error

* chore

* chore: type

* chore: fix test

* chore

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

chore: add error message for no availability (#13230)

* chore: add error message for no default user availability

* chore: check only availability

* chore: change message

* chore: add eventType

fix: Team revert with members (#13228)

refactor: Refactor seats logic (#12905)

* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

* Move handleSeats

* Abstract lastAttendeeDeleteBooking

* Create function for rescheduling seated events

* Fix imports on reschedule seats function

* Fix imports

* Import handleSeats function

* Fix rescheduleUid type

* Refactor owner reschedule to new time slot

* Refactor combine two booking times together

* Reschedule as an attendee

* Refactor createNewSeat

* Remove old handleSeats

* Remove lastAttendeeDeleteBooking from handleNewBooking

* Test for new attendee right params are passed

* Unit test params for reschedule

* Typo fix

* Clean up

* Create new seat test

* Test when attendee already signs up for booking

* Type fix

* Test reschedule move attendee to existing booking

* On reschedule create new booking

* Test on last attendee cancel booking

* Owner reschedule to new time slot

* Owner rescheduling, merge two bookings together

* Test: when merging more than available seats, then fail

* Test: fail when event is full

* Remove duplicate E2E tests

* Clean up

* Rename `addVideoCallDataToEvt` to `addVideoCallDataToEvent`

* Refactor `calcAppsStatus`

* Assign `evt` to resutl of `addVideoCallDataToEvent`

* Use prisma.transaction when moving attendees

* Clean create seat call

* Use ErrorCode enum

* Use attendeeRescheduledSeatedBooking function

* Await function

* Prevent double triggering of workflows

* Use inviteeToAdd in createNewSeat

* Remove unused error code

* Remove old handleSeats file

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Erik <erik@erosemberg.com>

feat: Allow admins to filter bookings only by attendee emails (#13236)

* chore: Allow admins to filter bookings only by attendee emails

* Fix type error

Fixed [CAL-2930] Org branding isnt centered in shell #13225 (#13237)

Add profile table

Copy to profile table

Update schema

Migrations

Format

* wip

* Add profile relation everywhere

* Fix schema

* Fix invite handler

* Add profileId during eventTypec reation

* Get dashboard working with profile relatioon

* Add workflow create repository

* Add profileId to host

* Add profileId to credential table

* Add destinationCalendar repository

* Add profileId to membership

* Revert all profile related changes

* Add acme and dunder

* Fix membership profileId usgae

* Various fixes

* Some renaming

* Fix failing tests

* Debugging next-aut

* Consolidate migrations

* Testing fixes

* Fix unit tests

* Self review feedback

* PR feedback

* Fix orgMigration as well

* Add profile schema and copy users to it

* Remove migrations from here

* Fix type issues

* Fix type error after main merge

* Remove movedFromUserId

* Add missing migration file

* Self review addressed

* Joe's review addressed

* Fix profile switcher

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2024-02-02 21:34:13 +00:00
committed by GitHub
co-authored by Joe Au-Yeung CarinaWolli Morgan Erik gitstart-calcom gitstart-calcom Keith Williams Carina Wollendorfer Peer Richelsen
parent 42a3448abb
commit 12466fb064
178 changed files with 4091 additions and 1262 deletions
@@ -8,7 +8,6 @@ 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 type { Ensure } from "@calcom/types/utils";
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { UserAvatar } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
@@ -97,18 +96,10 @@ const UserProfile = () => {
},
];
const organization =
user.organization && user.organization.id
? {
...(user.organization as Ensure<typeof user.organization, "id">),
slug: user.organization.slug || null,
requestedSlug: user.organization.metadata?.requestedSlug || null,
}
: null;
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />}
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
<input
ref={avatarRef}
type="hidden"
+7 -2
View File
@@ -5,11 +5,16 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import type { UserProfile } from "@calcom/types/UserProfile";
import { UserAvatar } from "@calcom/ui";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username" | "organizationId"> & {
type MemberType = Pick<
MembersType[number],
"id" | "name" | "bio" | "username" | "organizationId" | "avatarUrl"
> & {
profile: Omit<UserProfile, "upId">;
safeBio: string | null;
bookerUrl: string;
};
@@ -27,7 +32,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
key={member.id}
href={{ pathname: `${member.bookerUrl}/${member.username}`, query: queryParamsToForward }}>
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
<UserAvatar size="md" user={member} />
<UserAvatar noOrganizationIndicator size="md" user={member} />
<section className="mt-2 line-clamp-4 w-full space-y-1">
<p className="text-default font-medium">{member.name}</p>
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
@@ -5,6 +5,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getMultipleDurationValue } 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 { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
@@ -56,19 +57,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return notFound;
}
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
away: true,
hideBranding: true,
},
const [user] = await UserRepository.findUsersByUsername({
usernameList: [username],
orgSlug: org,
});
if (!user) {
+5 -2
View File
@@ -1589,17 +1589,20 @@ async function createTeamOutsideOrg(
});
}
async function createUserOutsideOrg(data: Omit<Prisma.UserCreateArgs["data"], "organization">) {
async function createUserOutsideOrg(
data: Omit<Prisma.UserCreateArgs["data"], "organization" | "movedToProfile">
) {
return await prismock.user.create({
data: {
...data,
movedToProfileId: null,
organizationId: null,
},
});
}
async function createUserInsideTheOrg(
data: Omit<Prisma.UserUncheckedCreateInput, "organization" | "organizationId" | "id">,
data: Omit<Prisma.UserUncheckedCreateInput, "organization" | "organizationId" | "id" | "movedToProfileId">,
orgId: number
) {
const org = await prismock.team.findUnique({
+37 -3
View File
@@ -3,6 +3,7 @@ import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomai
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { RedirectType } from "@calcom/prisma/client";
@@ -327,7 +328,7 @@ async function setOrgSlugIfNotSet(
slug: string | null;
},
orgMetadata: {
requestedSlug?: string | undefined;
requestedSlug?: string | null | undefined;
},
targetOrgId: number
) {
@@ -348,7 +349,7 @@ async function setOrgSlugIfNotSet(
function assertUserPartOfOrgAndRemigrationAllowed(
userToMoveToOrg: {
organizationId: User["organizationId"];
organizationId: number | null;
},
targetOrgId: number,
targetOrgUsername: string,
@@ -384,7 +385,7 @@ async function getTeamOrThrowError(targetOrgId: number) {
function assertUserPartOfOtherOrg(
userToMoveToOrg: {
organizationId: User["organizationId"];
organizationId: number | null;
} | null,
userName: string | undefined,
userId: number | undefined,
@@ -594,6 +595,35 @@ async function dbMoveUserToOrg({
},
},
});
await prisma.profile.upsert({
create: {
uid: ProfileRepository.generateProfileUid(),
userId: userToMoveToOrg.id,
organizationId: targetOrgId,
username: targetOrgUsername,
movedFromUser: {
connect: {
id: userToMoveToOrg.id,
},
},
},
update: {
organizationId: targetOrgId,
username: targetOrgUsername,
movedFromUser: {
connect: {
id: userToMoveToOrg.id,
},
},
},
where: {
userId_organizationId: {
userId: userToMoveToOrg.id,
organizationId: targetOrgId,
},
},
});
}
async function moveTeamsWithoutMembersToOrg({
@@ -841,6 +871,10 @@ async function dbRemoveUserFromOrg({
},
},
});
await ProfileRepository.deleteMany({
userIds: [userToRemoveFromOrg.id],
});
}
async function removeMembership({
@@ -121,7 +121,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
name: member.name,
id: member.id,
avatarUrl: member.avatarUrl,
bio: member.bio,
profile: member.profile,
subteams: member.subteams,
username: member.username,
accepted: member.accepted,
@@ -729,9 +729,9 @@ const EventTypeListHeading = ({
return (
<div className="mb-4 flex items-center space-x-2">
<Avatar
alt={profile?.name || ""}
alt={profile.name || ""}
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
imageSrc={`${bookerUrl}${teamId ? "/team" : ""}/${profile.slug}/avatar.png`}
imageSrc={profile.image}
size="md"
className="mt-1 inline-flex justify-center"
/>
@@ -739,7 +739,7 @@ const EventTypeListHeading = ({
<Link
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
className="text-emphasis font-bold">
{profile?.name || ""}
{profile.name || ""}
</Link>
{membershipCount && teamId && (
<span className="text-subtle relative -top-px me-2 ms-2 text-xs">
@@ -751,13 +751,13 @@ const EventTypeListHeading = ({
</Link>
</span>
)}
{profile?.slug && (
{profile.slug && (
<Link href={`${bookerUrl}/${profile.slug}`} className="text-subtle block text-xs">
{`${bookerUrl.replace("https://", "").replace("http://", "")}/${profile.slug}`}
</Link>
)}
</div>
{!profile?.slug && !!teamId && (
{!profile.slug && !!teamId && (
<button onClick={() => publishTeamMutation.mutate({ teamId })}>
<Badge variant="gray" className="-ml-2 mb-1">
{t("upgrade")}
@@ -816,7 +816,7 @@ const CTA = ({ data }: { data: GetByViewerResponse }) => {
const Actions = () => {
return (
<div className="hidden items-center md:flex">
<TeamsFilter popoverTriggerClassNames="mb-0" showVerticalDivider={true} />
<TeamsFilter useProfileFilter popoverTriggerClassNames="mb-0" showVerticalDivider={true} />
</div>
);
};
@@ -853,7 +853,6 @@ const Main = ({
}) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
if (!data || status === "pending") {
return <SkeletonLoader />;
@@ -4,23 +4,26 @@ import { encode } from "querystring";
import type { z } from "zod";
import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import type { EmbedProps } from "@lib/withEmbedSsr";
import { ssrInit } from "@server/lib/ssr";
const log = logger.getSubLogger({ prefix: ["[[pages/[user]]]"] });
export type UserPageProps = {
trpcState: DehydratedState;
profile: {
@@ -33,11 +36,13 @@ export type UserPageProps = {
requestedSlug: string | null;
slug: string | null;
id: number | null;
};
} | null;
allowSEOIndexing: boolean;
username: string | null;
};
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
users: (Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl"> & {
profile: UserProfile;
})[];
themeBasis: string | null;
markdownStrippedBio: string;
safeBio: string;
@@ -106,7 +111,7 @@ export const getEventTypesWithHiddenFromDB = async (userId: number) => {
return eventTypes.reduce<typeof eventTypes>((eventTypes, eventType) => {
const parsedMetadata = EventTypeMetaDataSchema.safeParse(eventType.metadata);
if (!parsedMetadata.success) {
logger.error(parsedMetadata.error);
log.error(parsedMetadata.error);
return eventTypes;
}
eventTypes.push({
@@ -124,7 +129,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
let outOfOffice = false;
const isDynamicGroup = usernameList.length > 1;
if (usernameList.length === 1) {
const result = await handleUserRedirection({ username: usernameList[0] });
if (result && result.outOfOffice) {
@@ -136,6 +141,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
}
if (!isOrgContext) {
// If there is no org context, see if some redirect is setup due to org migration
const redirect = await getTemporaryOrgRedirect({
slugs: usernameList,
redirectType: RedirectType.User,
@@ -148,51 +154,22 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
}
}
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
username: true,
email: true,
name: true,
bio: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
avatarUrl: true,
organizationId: true,
organization: {
select: {
slug: true,
name: true,
metadata: true,
},
},
theme: true,
away: true,
verified: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
},
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const users = usersWithoutAvatar.map((user) => ({
...user,
organization: {
...user.organization,
metadata: user.organization?.metadata ? teamMetadataSchema.parse(user.organization.metadata) : null,
},
avatar: `/${user.username}/avatar.png`,
}));
const usersWithoutAvatar = usersInOrgContext.map((user) => {
const { avatar: _1, ...rest } = user;
return rest;
});
const isDynamicGroup = usersWithoutAvatar.length > 1;
log.debug(safeStringify({ usersInOrgContext, isValidOrgDomain, currentOrgDomain, isDynamicGroup }));
if (isDynamicGroup) {
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
logger.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
log.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
return {
redirect: {
permanent: false,
@@ -206,7 +183,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
};
}
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
const users = usersWithoutAvatar.map((user) => ({
...user,
avatar: `/${user.username}/avatar.png`,
}));
const isNonOrgUser = (user: { profile: UserProfile }) => {
return !user.profile?.organization;
};
const isThereAnyNonOrgUser = users.some(isNonOrgUser);
if (!users.length || (!isValidOrgDomain && !isThereAnyNonOrgUser)) {
return {
notFound: true,
} as {
@@ -225,11 +213,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: {
id: user.organizationId,
slug: user.organization?.slug ?? null,
requestedSlug: user.organization?.metadata?.requestedSlug ?? null,
},
organization: user.profile.organization,
};
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id);
@@ -248,7 +232,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
// if profile only has one public event-type, redirect to it
if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) {
// Redirect but don't change the URL
const urlDestination = `/${user.username}/${eventTypes[0].slug}`;
const urlDestination = `/${user.profile.username}/${eventTypes[0].slug}`;
const { query } = context;
const urlQuery = new URLSearchParams(encode(query));
@@ -263,7 +247,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const safeBio = markdownToSafeHTML(user.bio) || "";
const markdownStrippedBio = stripMarkdown(user?.bio || "");
const org = usersWithoutAvatar[0].organization;
const org = usersWithoutAvatar[0].profile.organization;
return {
props: {
@@ -274,6 +258,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
avatarUrl: user.avatarUrl,
away: usernameList.length === 1 ? outOfOffice : user.away,
verified: user.verified,
profile: user.profile,
})),
entity: {
isUnpublished: org?.slug === null,
@@ -111,19 +111,11 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
<UserAvatar
size="xl"
user={{
organizationId: profile.organization?.id,
avatarUrl: user.avatarUrl,
profile: user.profile,
name: profile.name,
username: profile.username,
}}
organization={
profile.organization?.id
? {
id: profile.organization.id,
slug: profile.organization.slug,
requestedSlug: null,
}
: null
}
/>
<h1 className="font-cal text-emphasis my-1 text-3xl" data-testid="name-title">
{profile.name}
@@ -5,10 +5,10 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -39,22 +39,13 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
}
}
const users = await prisma.user.findMany({
where: {
username: {
in: usernames,
},
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
allowDynamicBooking: true,
},
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList: usernames,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const users = usersInOrgContext;
if (!users.length) {
return {
notFound: true,
@@ -129,16 +120,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const user = await prisma.user.findFirst({
where: {
username,
organization: userOrgQuery(context.req, context.params?.orgSlug),
},
select: {
id: true,
hideBranding: true,
allowSEOIndexing: true,
},
const [user] = await UserRepository.findUsersByUsername({
usernameList: [username],
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
if (!user) {
@@ -3,6 +3,7 @@ import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
@@ -33,11 +34,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
timeZone: true,
name: true,
email: true,
organization: {
select: {
calVideoLogo: true,
},
},
username: true,
},
},
references: {
@@ -63,6 +60,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const profile = booking.user
? (
await UserRepository.enrichUserWithItsProfile({
user: booking.user,
})
).profile
: null;
//daily.co calls have a 60 minute exit buffer when a user enters a call when it's not available it will trigger the modals
const now = new Date();
const exitDate = new Date(now.getTime() - 60 * 60 * 1000);
@@ -102,6 +107,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking: {
...bookingObj,
...(bookingObj.description && { description: md.render(bookingObj.description) }),
user: bookingObj.user
? {
...bookingObj.user,
organization: profile?.organization,
}
: bookingObj.user,
},
trpcState: ssr.dehydrate(),
},
+10
View File
@@ -8,6 +8,7 @@ import { UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@calcom/trpc/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import type { UserProfile } from "@calcom/types/UserProfile";
enum DirectAction {
ACCEPT = "accept",
@@ -46,7 +47,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
id: userId,
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
+19 -3
View File
@@ -42,14 +42,30 @@ async function getIdentityData(req: NextApiRequest) {
: null;
if (username) {
const user = await prisma.user.findFirst({
const users = await prisma.user.findMany({
where: {
username,
organization: orgQuery,
...(orgQuery
? {
profiles: {
some: {
username,
organization: orgQuery,
},
},
}
: {
username,
// If a user is moved, it isn't actually available outside of the organization. So, for non-org domain check for movedToProfileId
movedToProfileId: null,
}),
},
select: { avatar: true, email: true },
});
if (users.length > 1) {
throw new Error(`More than one user found for username "${username}"`);
}
const [user] = users;
return {
name: username,
email: user?.email,
@@ -230,7 +230,10 @@ const ProfileView = () => {
const defaultValues = {
username: user.username || "",
avatar: getUserAvatarUrl(user),
avatar: getUserAvatarUrl({
...user,
profile: user.profile,
}),
name: user.name || "",
email: user.email || "",
bio: user.bio || "",
@@ -443,20 +446,14 @@ const ProfileForm = ({
const organization =
userOrganization && userOrganization.id
? {
...(userOrganization as Ensure<typeof user.organization, "id">),
...(userOrganization as Ensure<NonNullable<typeof user.organization>, "id">),
slug: userOrganization.slug || null,
requestedSlug: userOrganization.metadata?.requestedSlug || null,
}
: null;
return (
<>
<UserAvatar
data-testid="profile-upload-avatar"
previewSrc={value}
size="lg"
user={user}
organization={organization}
/>
<UserAvatar data-testid="profile-upload-avatar" previewSrc={value} size="lg" user={user} />
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
<div className="flex gap-2">
+1 -1
View File
@@ -30,7 +30,7 @@ export const createOrgsFixture = (page: Page) => {
};
};
async function createOrgInDb({
export async function createOrgInDb({
name,
slug,
requestedSlug,
+92 -26
View File
@@ -4,10 +4,12 @@ import type { Team } from "@prisma/client";
import { Prisma as PrismaType } from "@prisma/client";
import { hashSync as hash } from "bcryptjs";
import { uuid } from "short-uuid";
import { v4 } from "uuid";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType, TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -137,7 +139,21 @@ const createTeamAndAddUser = async (
await createTeamWorkflow(user, team);
data.children = { connect: [{ id: team.id }] };
}
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
data.orgProfiles = isOrg
? {
create: [
{
uid: ProfileRepository.generateProfileUid(),
username: user.username ?? user.email.split("@")[0],
user: {
connect: {
id: user.id,
},
},
},
],
}
: undefined;
data.parent = organizationId ? { connect: { id: organizationId } } : undefined;
const team = await prisma.team.create({
data,
@@ -181,7 +197,11 @@ export const createUsersFixture = (
};
},
create: async (
opts?: CustomUserOpts | null,
opts?:
| (CustomUserOpts & {
organizationId?: number | null;
})
| null,
scenario: {
seedRoutingForms?: boolean;
hasTeam?: true;
@@ -199,6 +219,9 @@ export const createUsersFixture = (
) => {
const _user = await prisma.user.create({
data: createUser(workerInfo, opts),
include: {
profiles: true,
},
});
let defaultEventTypes: SupportedTestEventTypes[] = [
@@ -212,6 +235,9 @@ export const createUsersFixture = (
for (const eventTypeData of defaultEventTypes) {
eventTypeData.owner = { connect: { id: _user.id } };
eventTypeData.users = { connect: { id: _user.id } };
if (_user.profiles[0]) {
eventTypeData.profile = { connect: { id: _user.profiles[0].id } };
}
await prisma.eventType.create({
data: eventTypeData,
});
@@ -370,7 +396,7 @@ export const createUsersFixture = (
const teamEvent = await createTeamEventType(user, team, scenario);
if (scenario.teammates) {
// Create Teammate users
const teamMatesIds = [];
const teamMates = [];
for (const teammateObj of scenario.teammates) {
const teamUser = await prisma.user.create({
data: createUser(workerInfo, teammateObj),
@@ -402,19 +428,49 @@ export const createUsersFixture = (
}),
store.page
);
teamMatesIds.push(teamUser.id);
teamMates.push(teamUser);
store.users.push(teammateFixture);
}
// Add Teammates to OrgUsers
if (scenario.isOrg) {
const orgProfilesCreate = teamMates
.map((teamUser) => ({
user: {
connect: {
id: teamUser.id,
},
},
uid: v4(),
username: teamUser.username || teamUser.email.split("@")[0],
}))
.concat([
{
user: { connect: { id: user.id } },
uid: v4(),
username: user.username || user.email.split("@")[0],
},
]);
const existingProfiles = await prisma.profile.findMany({
where: {
userId: _user.id,
},
});
await prisma.team.update({
where: {
id: team.id,
},
data: {
orgUsers: {
connect: teamMatesIds.map((userId) => ({ id: userId })).concat([{ id: user.id }]),
},
orgProfiles: _user.profiles.length
? {
connect: _user.profiles.map((profile) => ({ id: profile.id })),
}
: {
create: orgProfilesCreate.filter(
(profile) => !existingProfiles.map((p) => p.userId).includes(profile.user.connect.id)
),
},
},
});
}
@@ -615,7 +671,11 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
// creates the actual user in the db.
const createUser = (
workerInfo: WorkerInfo,
opts?: CustomUserOpts | null
opts?:
| (CustomUserOpts & {
organizationId?: number | null;
})
| null
): PrismaType.UserUncheckedCreateInput => {
// build a unique name for our user
const uname =
@@ -666,25 +726,31 @@ const createUser = (
throw new Error("Missing role for user in organization");
}
return {
organizationId: organizationId || null,
...(organizationId
? {
teams: {
// Create membership
create: [
{
team: {
connect: {
id: organizationId,
},
},
accepted: true,
role: MembershipRole.ADMIN,
},
],
profiles: {
create: {
uid: ProfileRepository.generateProfileUid(),
username: uname,
organization: {
connect: {
id: organizationId,
},
}
: null),
},
},
},
teams: {
// Create membership
create: [
{
team: {
connect: {
id: organizationId,
},
},
accepted: true,
role: MembershipRole.ADMIN,
},
],
},
};
}
};
@@ -286,7 +286,7 @@ test.describe("Organization", () => {
page,
emails,
invitedUserEmail,
`${team.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
`${team.name}'s admin invited you to join the team ${team.name} of organization ${org.name} on Cal.com`,
"signup?token"
);
@@ -1490,6 +1490,7 @@
"password_updated": "Password updated!",
"pending_payment": "Pending payment",
"pending_invites": "Pending Invites",
"pending_organization_invites": "Pending Organization Invites",
"not_on_cal": "Not on {{appName}}",
"no_calendar_installed": "No calendar installed",
"no_calendar_installed_description": "You have not yet connected any of your calendars",
@@ -269,7 +269,20 @@ function getTrpcHandlerData({
...getSampleUserInSession(),
...user,
avatarUrl: user.avatarUrl || null,
} satisfies TrpcSessionUser,
profile: {
upId: "",
id: 1,
name: "",
avatarUrl: "",
startTime: 0,
endTime: 0,
username: user.username || "",
organizationId: null,
organization: null,
bufferTime: 5,
avatar: null,
},
} as unknown as NonNullable<TrpcSessionUser>,
},
input: input,
};
@@ -12,6 +12,7 @@ it("correctly handles 1 day", async () => {
endTime: new Date(Date.UTC(1970, 1, 1, 17, 0, 0, 0)),
date: null,
scheduleId: 1,
profileId: null,
};
const result = availabilityAsString(availability, {
@@ -32,6 +33,7 @@ it("correctly handles all days", async () => {
endTime: new Date(Date.UTC(1970, 1, 1, 17, 0, 0, 0)),
date: null,
scheduleId: 1,
profileId: null,
};
const result = availabilityAsString(availability, {
@@ -52,6 +54,7 @@ it("correctly handles staggered days", async () => {
endTime: new Date(Date.UTC(1970, 1, 1, 17, 0, 0, 0)),
date: null,
scheduleId: 1,
profileId: null,
};
const result = availabilityAsString(availability, {
@@ -72,6 +75,7 @@ it("correctly produces days and times - 12 hours", async () => {
endTime: new Date(Date.UTC(1970, 1, 1, 17, 0, 0, 0)),
date: null,
scheduleId: 1,
profileId: null,
};
const result = availabilityAsString(availability, {
@@ -92,6 +96,7 @@ it("correctly produces days and times - 24 hours", async () => {
endTime: new Date(Date.UTC(1970, 1, 1, 17, 0, 0, 0)),
date: null,
scheduleId: 1,
profileId: null,
};
const result = availabilityAsString(availability, {
@@ -41,6 +41,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
@@ -60,6 +61,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
@@ -85,6 +87,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
@@ -120,6 +123,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
@@ -166,6 +170,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: "somestring",
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
@@ -199,6 +204,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(result.newUserIds).toEqual([]);
expect(result.oldUserIds).toEqual([]);
@@ -223,6 +229,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
// Have been called
expect(result.newUserIds).toEqual([5]);
@@ -259,6 +266,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
@@ -305,6 +313,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
@@ -364,6 +373,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
profileId: null,
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
@@ -16,6 +16,7 @@ import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/date-fns";
import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
@@ -104,6 +105,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
}[];
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
weekStart?: string;
profiles?: Prisma.ProfileUncheckedCreateWithoutUserInput[];
};
export type InputEventType = {
@@ -455,6 +457,7 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma
include: {
credentials: true,
teams: true,
profiles: true,
schedules: {
include: {
availability: true,
@@ -545,6 +548,15 @@ async function addUsers(users: InputUser[]) {
},
};
}
if (user.profiles) {
newUser.profiles = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error Not sure why this is not working
createMany: {
data: user.profiles,
},
};
}
prismaUsersCreate.push(newUser);
}
@@ -937,6 +949,7 @@ export function getOrganizer({
weekStart,
teams,
organizationId,
profiles: [],
metadata,
};
}
@@ -965,8 +978,18 @@ export function getScenarioData(
) {
const users = [organizer, ...usersApartFromOrganizer];
if (org) {
const orgId = org.id;
if (!orgId) {
throw new Error("If org is specified org.id is required");
}
users.forEach((user) => {
user.organizationId = org.id;
user.profiles = [
{
organizationId: orgId,
username: user.username || "",
uid: ProfileRepository.generateProfileUid(),
},
];
});
}
@@ -19,6 +19,7 @@ export function getMockRequestDataForBooking({
}: {
data: Partial<ReturnType<typeof getBasicMockRequestDataForBooking>> & {
eventTypeId: number;
user?: string;
rescheduleUid?: string;
bookingUid?: string;
recurringEventId?: string;
@@ -8,7 +8,10 @@ export const getSampleUserInSession = function () {
organization: {
isOrgAdmin: false,
metadata: null,
id: 1,
requestedSlug: null,
},
profile: null,
defaultScheduleId: null,
name: "",
defaultBookerLayouts: null,
+7 -3
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { UserProfile } from "@calcom/types/UserProfile";
export async function checkInstalled(slug: string, userId: number) {
const alreadyInstalled = await prisma.credential.findFirst({
@@ -17,7 +18,10 @@ export async function checkInstalled(slug: string, userId: number) {
type InstallationArgs = {
appType: string;
userId: number;
user: {
id: number;
profile: UserProfile;
};
slug: string;
key?: Prisma.InputJsonValue;
teamId?: number;
@@ -28,7 +32,7 @@ type InstallationArgs = {
export async function createDefaultInstallation({
appType,
userId,
user,
slug,
key = {},
teamId,
@@ -40,7 +44,7 @@ export async function createDefaultInstallation({
data: {
type: appType,
key,
...(teamId ? { teamId } : { userId }),
...(teamId ? { teamId } : { userId: user.id }),
appId: slug,
subscriptionId,
paymentStatus,
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://amie.so/signup",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -17,7 +17,7 @@ export async function getHandler(req: NextApiRequest) {
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
userId: session.user.id,
user: session.user,
slug,
key: {},
teamId,
@@ -33,7 +33,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
userId: session.user.id,
user: session.user,
slug,
key: {
apiKey,
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://cron.com",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://go.cal.com/deel",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "/apps/make/setup",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
newTab: true,
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -15,7 +15,7 @@ export async function getHandler(req: NextApiRequest) {
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
userId: session.user.id,
user: session.user,
slug,
key: {},
});
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://pipedream.com/apps/cal-com",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+4 -3
View File
@@ -16,13 +16,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Check that user is authenticated
req.session = await getServerSession({ req, res });
const { teamId } = req.query;
const userId = req.session?.user.id;
if (!userId) {
const user = req.session?.user;
if (!user) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
const userId = user.id;
await createDefaultInstallation({
appType: `${appConfig.slug}_other_calendar`,
userId: userId,
user,
slug: appConfig.slug,
key: {},
teamId: Number(teamId),
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -13,7 +13,7 @@ const handler: AppDeclarativeHandler = {
url: "raycast://extensions/eluce2/cal-com-share-meeting-links?source=webstore",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -15,7 +15,7 @@ export async function getHandler(req: NextApiRequest) {
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
userId: session.user.id,
user: session.user,
slug,
key: {},
});
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -1,7 +1,6 @@
import { stringify } from "querystring";
import z from "zod";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import { TRPCError } from "@calcom/trpc/server";
@@ -34,15 +33,16 @@ export const getServerSideProps = async function getServerSideProps(
notFound: true,
};
}
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const { currentOrgDomain } = orgDomainConfig(context.req);
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
user: {
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
...ProfileRepository._getPrismaWhereForProfilesOfOrg({ orgSlug: currentOrgDomain }),
},
},
});
@@ -19,19 +19,16 @@ export const getServerSideProps = async function getServerSideProps(
notFound: true,
};
}
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const { currentOrgDomain } = orgDomainConfig(context.req);
const isEmbed = params.appPages[1] === "embed";
const { ProfileRepository } = await import("@calcom/lib/server/repository/profile");
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
user: {
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
...ProfileRepository._getPrismaWhereForProfilesOfOrg({ orgSlug: currentOrgDomain }),
},
},
include: {
@@ -71,11 +71,13 @@ export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
};
export default formsHandler;
type SupportedFilters = Omit<NonNullable<NonNullable<TFormSchema>["filters"]>, "upIds"> | undefined;
export function getPrismaWhereFromFilters(
user: {
id: number;
},
filters: NonNullable<TFormSchema>["filters"]
filters: SupportedFilters
) {
const where = {
OR: [] as Prisma.App_RoutingForms_FormWhereInput[],
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://skiff.com/blog/skiff-cal-com-launch",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://example.com/link",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -13,7 +13,7 @@ const handler: AppDeclarativeHandler = {
url: "/apps/typeform/how-to-use",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://cal.com/blog/cal-plus-vimcal",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -10,7 +10,7 @@ const handler: AppDeclarativeHandler = {
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
+1 -1
View File
@@ -15,7 +15,7 @@ export async function getHandler(req: NextApiRequest) {
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
userId: session.user.id,
user: session.user,
slug,
key: {},
});
+1 -1
View File
@@ -14,7 +14,7 @@ const handler: AppDeclarativeHandler = {
url: "https://wordpress.org/plugins/cal-com/",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -7,6 +7,7 @@ import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerRequestEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => {
const seedData = { bookingUid: props.calEvent.uid, userId: props.calEvent.organizer.id };
const token = symmetricEncrypt(JSON.stringify(seedData), process.env.CALENDSO_ENCRYPTION_KEY || "");
//TODO: We should switch to using org domain if available
const actionHref = `${WEBAPP_URL}/api/link/?token=${encodeURIComponent(token)}`;
return (
<OrganizerScheduledEmail
@@ -11,6 +11,7 @@ type TeamInvite = {
teamName: string;
joinLink: string;
isCalcomMember: boolean;
isAutoJoin: boolean;
isOrg: boolean;
parentTeamName: string | undefined;
};
@@ -75,7 +76,9 @@ export const TeamInviteEmail = (
</p>
<div style={{ display: "flex", justifyContent: "center" }}>
<CallToAction
label={props.language(props.isCalcomMember ? "email_user_cta" : "create_your_account")}
label={props.language(
props.isCalcomMember ? (props.isAutoJoin ? "login" : "email_user_cta") : "create_your_account"
)}
href={props.joinLink}
endIconName="linkIcon"
/>
@@ -163,34 +166,3 @@ export const TeamInviteEmail = (
</V2BaseEmailHtml>
);
};
const EmailStep = (props: { translationString: string; iconsrc: string }) => {
return (
<div style={{ display: "flex", alignItems: "center" }}>
<div
style={{
backgroundColor: "#E5E7EB",
borderRadius: "48px",
minHeight: "48px",
minWidth: "48px",
display: "flex",
justifyContent: "center",
alignItems: "center",
color: "white",
marginRight: "16px",
}}>
<img src={props.iconsrc} alt="#" style={{ height: "24px", width: "auto" }} />
</div>
<p
style={{
fontStyle: "normal",
fontWeight: 500,
fontSize: "18px",
lineHeight: "20px",
color: "#black",
}}>
<>{props.translationString}</>
</p>
</div>
);
};
@@ -12,6 +12,10 @@ export type TeamInvite = {
teamName: string;
joinLink: string;
isCalcomMember: boolean;
/**
* We ideally should have a separate email for auto-join(when a user is automatically accepted into a team/org), but we don't have one yet.
*/
isAutoJoin: boolean;
isOrg: boolean;
parentTeamName: string | undefined;
};
+29 -2
View File
@@ -5,8 +5,12 @@ import { getToken } from "next-auth/jwt";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { CAL_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["getServerSession"] });
/**
* Stores the session in memory using the stringified token as the key.
*
@@ -29,6 +33,7 @@ export async function getServerSession(options: {
res?: NextApiResponse | GetServerSidePropsContext["res"];
authOptions?: AuthOptions;
}) {
log.debug("Getting server session");
const { req, authOptions: { secret } = {} } = options;
const token = await getToken({
@@ -37,6 +42,7 @@ export async function getServerSession(options: {
});
if (!token || !token.email || !token.sub) {
log.debug("Couldnt get token");
return null;
}
@@ -46,7 +52,7 @@ export async function getServerSession(options: {
return cachedSession;
}
const user = await prisma.user.findUnique({
const userFromDb = await prisma.user.findUnique({
where: {
email: token.email.toLowerCase(),
},
@@ -54,12 +60,29 @@ export async function getServerSession(options: {
// cacheStrategy: { ttl: 60, swr: 1 },
});
if (!user) {
if (!userFromDb) {
log.debug("No user found");
return null;
}
const hasValidLicense = await checkLicense(prisma);
let upId = token.upId;
if (!upId) {
upId = `usr-${userFromDb.id}`;
}
if (!upId) {
log.error("No upId found for session", { userId: userFromDb.id });
return null;
}
const user = await UserRepository.enrichUserWithTheProfile({
user: userFromDb,
upId,
});
const session: Session = {
hasValidLicense,
expires: new Date(typeof token.exp === "number" ? token.exp * 1000 : Date.now()).toISOString(),
@@ -75,7 +98,10 @@ export async function getServerSession(options: {
belongsToActiveTeam: token.belongsToActiveTeam,
org: token.org,
locale: user.locale ?? undefined,
profile: user.profile,
},
profileId: token.profileId,
upId,
};
if (token?.impersonatedBy?.id) {
@@ -98,5 +124,6 @@ export async function getServerSession(options: {
CACHE.set(JSON.stringify(token), session);
log.debug("Returned session", safeStringify(session));
return session;
}
@@ -75,21 +75,5 @@ export default function CalComAdapter(prismaClient: PrismaClient) {
// @NOTE: All methods below here are not being used but leaved if they are required
unlinkAccount: (provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput) =>
prismaClient.account.delete({ where: { provider_providerAccountId } }),
async getSessionAndUser(sessionToken: string) {
const userAndSession = await prismaClient.session.findUnique({
where: { sessionToken },
include: { user: true },
});
if (!userAndSession) return null;
const { user, ...session } = userAndSession;
return { user, session };
},
createSession: (data: Prisma.SessionCreateInput) => prismaClient.session.create({ data }),
updateSession: (data: Prisma.SessionUpdateInput) =>
prismaClient.session.update({
where: { sessionToken: typeof data.sessionToken === "string" ? data.sessionToken : undefined },
data,
}),
deleteSession: (sessionToken: string) => prismaClient.session.delete({ where: { sessionToken } }),
};
}
+129 -59
View File
@@ -1,5 +1,6 @@
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
import type { AuthOptions, Session } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
import type { Provider } from "next-auth/providers";
import CredentialsProvider from "next-auth/providers/credentials";
@@ -11,11 +12,15 @@ import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/Imperso
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies";
import { isENVDev } from "@calcom/lib/env";
import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
@@ -26,6 +31,7 @@ import { isPasswordValid } from "./isPasswordValid";
import CalComAdapter from "./next-auth-custom-adapter";
import { verifyPassword } from "./verifyPassword";
const log = logger.getSubLogger({ prefix: ["next-auth-options"] });
const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}";
const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } =
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
@@ -100,38 +106,7 @@ const providers: Provider[] = [
throw new Error(ErrorCode.InternalServerError);
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email.toLowerCase(),
},
select: {
locked: true,
role: true,
id: true,
username: true,
name: true,
email: true,
metadata: true,
identityProvider: true,
password: true,
organizationId: true,
twoFactorEnabled: true,
twoFactorSecret: true,
backupCodes: true,
locale: true,
organization: {
select: {
id: true,
},
},
teams: {
include: {
team: true,
},
},
},
});
const user = await UserRepository.findByEmailAndIncludeProfiles({ email: credentials.email });
// Don't leak information about it being username or password that is invalid
if (!user) {
throw new Error(ErrorCode.IncorrectEmailPassword);
@@ -253,8 +228,8 @@ const providers: Provider[] = [
name: user.name,
role: validateRole(user.role),
belongsToActiveTeam: hasActiveTeams,
organizationId: user.organizationId,
locale: user.locale,
profile: user.allProfiles[0],
};
},
}),
@@ -291,15 +266,28 @@ if (isSAMLLoginEnabled) {
params: { grant_type: "authorization_code" },
},
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
profile: (profile) => {
profile: async (profile: {
id?: number;
firstName?: string;
lastName?: string;
email?: string;
locale?: string;
}) => {
const user = await UserRepository.findByEmailAndIncludeProfiles({ email: profile.email || "" });
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
const [userProfile] = user.allProfiles;
return {
id: profile.id || "",
id: profile.id || 0,
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
email_verified: true,
locale: profile.locale,
profile: userProfile,
};
},
options: {
@@ -351,6 +339,12 @@ if (isSAMLLoginEnabled) {
}
const { id, firstName, lastName, email } = userInfo;
const user = await UserRepository.findByEmailAndIncludeProfiles({ email });
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
const [profile] = user.allProfiles;
return {
id: id as unknown as number,
@@ -359,6 +353,7 @@ if (isSAMLLoginEnabled) {
email,
name: `${firstName} ${lastName}`.trim(),
email_verified: true,
profile,
};
},
})
@@ -427,15 +422,31 @@ export const AUTH_OPTIONS: AuthOptions = {
},
providers,
callbacks: {
async jwt({ token, user, account, trigger, session }) {
async jwt({
// Always available but with a little difference in value
token,
// Available only in case of signIn, signUp or useSession().update call.
trigger,
// Available when useSession().update is called. The value will be the POST data
session,
// Available only in the first call once the user signs in. Not available in subsequent calls
user,
// Available only in the first call once the user signs in. Not available in subsequent calls
account,
}) {
log.debug("callbacks:jwt", safeStringify({ token, user, account, trigger, session }));
// The data available in 'session' depends on what data was supplied in update method call of session
if (trigger === "update") {
return {
...token,
profileId: session?.profileId ?? token.profileId ?? null,
upId: session?.upId ?? token.upId ?? null,
locale: session?.locale ?? token.locale ?? "en",
name: session?.name ?? token.name,
username: session?.username ?? token.username,
email: session?.email ?? token.email,
};
} as JWT;
}
const autoMergeIdentities = async () => {
const existingUser = await prisma.user.findFirst({
@@ -446,16 +457,9 @@ export const AUTH_OPTIONS: AuthOptions = {
username: true,
name: true,
email: true,
organization: {
select: {
id: true,
name: true,
slug: true,
metadata: true,
},
},
role: true,
locale: true,
movedToProfileId: true,
teams: {
include: {
team: true,
@@ -470,24 +474,41 @@ export const AUTH_OPTIONS: AuthOptions = {
// Check if the existingUser has any active teams
const belongsToActiveTeam = checkIfUserBelongsToActiveTeam(existingUser);
const { teams: _teams, organization, ...existingUserWithoutTeamsField } = existingUser;
const { teams: _teams, ...existingUserWithoutTeamsField } = existingUser;
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(existingUser);
log.debug(
"callbacks:jwt:autoMergeIdentities",
safeStringify({
allProfiles,
})
);
const { upId } = determineProfile({ profiles: allProfiles, token });
const parsedOrgMetadata = teamMetadataSchema.parse(organization?.metadata ?? {});
const profile = await ProfileRepository.findByUpId(upId);
if (!profile) {
throw new Error("Profile not found");
}
const profileOrg = profile?.organization;
return {
...existingUserWithoutTeamsField,
...token,
profileId: profile.id,
upId,
belongsToActiveTeam,
org: organization
// All organizations in the token would be too big to store. It breaks the sessions request.
// So, we just set the currently switched organization only here.
org: profileOrg
? {
id: organization.id,
name: organization.name,
slug: organization.slug ?? parsedOrgMetadata?.requestedSlug ?? "",
fullDomain: getOrgFullOrigin(organization.slug ?? parsedOrgMetadata?.requestedSlug ?? ""),
id: profileOrg.id,
name: profileOrg.name,
slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "",
fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""),
domainSuffix: subdomainSuffix(),
}
: undefined,
};
: null,
} as JWT;
};
if (!user) {
return await autoMergeIdentities();
@@ -512,7 +533,9 @@ export const AUTH_OPTIONS: AuthOptions = {
belongsToActiveTeam: user?.belongsToActiveTeam,
org: user?.org,
locale: user?.locale,
};
profileId: user.profile?.id ?? token.profileId ?? null,
upId: user.profile?.upId ?? token.upId ?? null,
} as JWT;
}
// The arguments above are from the provider so we need to look up the
@@ -551,15 +574,23 @@ export const AUTH_OPTIONS: AuthOptions = {
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
org: token?.org,
locale: existingUser.locale,
};
} as JWT;
}
if (account.type === "email") {
return await autoMergeIdentities();
}
return token;
},
async session({ session, token }) {
async session({ session, token, user }) {
log.debug("callbacks:session - Session callback called", safeStringify({ session, token, user }));
const hasValidLicense = await checkLicense(prisma);
const profileId = token.profileId;
const calendsoSession: Session = {
...session,
profileId,
upId: token.upId || session.upId,
hasValidLicense,
user: {
...session.user,
@@ -576,7 +607,19 @@ export const AUTH_OPTIONS: AuthOptions = {
return calendsoSession;
},
async signIn(params) {
const { user, account, profile } = params;
const {
/**
* Available when Credentials provider is used - Has the value returned by authorize callback
*/
user,
/**
* Available when Credentials provider is used - Has the value submitted as the body of the HTTP POST submission
*/
profile,
account,
} = params;
log.debug("callbacks:signin", safeStringify(params));
if (account?.provider === "email") {
return true;
@@ -814,6 +857,9 @@ export const AUTH_OPTIONS: AuthOptions = {
return false;
},
/**
* Used to handle the navigation right after successful login or logout
*/
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
@@ -823,3 +869,27 @@ export const AUTH_OPTIONS: AuthOptions = {
},
},
};
/**
* Identifies the profile the user should be logged into.
*/
const determineProfile = ({
token,
profiles,
}: {
token: JWT;
profiles: { id: number | null; upId: string }[];
}) => {
// If profile switcher is disabled, we can only show the first profile.
if (!ENABLE_PROFILE_SWITCHER) {
return profiles[0];
}
if (token.upId) {
// Otherwise use what's in the token
return { profileId: token.profileId, upId: token.upId as string };
}
// If there is just one profile it has to be the one we want to log into.
return profiles[0];
};
@@ -127,6 +127,9 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
where: {
id: foundToken.teamId,
},
include: {
parent: true,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
@@ -157,10 +160,10 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parentId) {
if (team.parent) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
orgId: team.parentId,
org: team.parent,
});
}
}
@@ -68,6 +68,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
where: {
id: foundToken.teamId,
},
include: {
parent: true,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
@@ -97,10 +100,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parentId) {
if (team.parent) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
orgId: team.parentId,
org: team.parent,
});
}
}
+5 -1
View File
@@ -84,7 +84,11 @@ const usernameCheck = async (usernameRaw: string) => {
const username = slugify(usernameRaw);
const user = await prisma.user.findFirst({
where: { username, organizationId: null },
where: {
username,
// Simply remove it when we drop organizationId column
organizationId: null,
},
select: {
username: true,
},
@@ -1,11 +1,14 @@
import type z from "zod";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { prisma } from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { getOrgUsernameFromEmail } from "./getOrgUsernameFromEmail";
export const createOrUpdateMemberships = async ({
teamMetadata,
user,
@@ -17,13 +20,39 @@ export const createOrUpdateMemberships = async ({
}) => {
return await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
const dbUser = await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
select: {
username: true,
email: true,
},
});
// Ideally dbUser.username should never be null, but just in case.
// This method being called only during signup means that dbUser.username should be the correct org username
const orgUsername =
dbUser.username || getOrgUsernameFromEmail(dbUser.email, teamMetadata?.orgAutoAcceptEmail ?? null);
await tx.profile.upsert({
create: {
uid: ProfileRepository.generateProfileUid(),
userId: user.id,
organizationId: team.id,
username: orgUsername,
},
update: {
username: orgUsername,
},
where: {
userId_organizationId: {
userId: user.id,
organizationId: team.id,
},
},
});
}
const membership = await tx.membership.upsert({
@@ -1,6 +1,6 @@
import slugify from "@calcom/lib/slugify";
export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string | null) => {
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
@@ -1,38 +1,58 @@
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import prisma from "@calcom/prisma";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { prisma } from "@calcom/prisma";
import type { Team } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export async function joinOrganization({
organizationId,
userId,
}: {
userId: number;
organizationId: number;
}) {
return await prisma.user.update({
import { getOrgUsernameFromEmail } from "./getOrgUsernameFromEmail";
export async function joinAnyChildTeamOnOrgInvite({ userId, org }: { userId: number; org: Team }) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
data: {
organizationId: organizationId,
},
});
}
if (!user) {
throw new Error("User not found");
}
const orgMetadata = teamMetadataSchema.parse(org.metadata);
const orgUsername =
user.username || getOrgUsernameFromEmail(user.email, orgMetadata?.orgAutoAcceptEmail ?? null);
export async function joinAnyChildTeamOnOrgInvite({ userId, orgId }: { userId: number; orgId: number }) {
await prisma.$transaction([
// Simply remove this update when we remove the `organizationId` field from the user table
prisma.user.update({
where: {
id: userId,
},
data: {
organizationId: orgId,
organizationId: org.id,
},
}),
prisma.profile.upsert({
create: {
uid: ProfileRepository.generateProfileUid(),
userId: userId,
organizationId: org.id,
username: orgUsername,
},
update: {
username: orgUsername,
},
where: {
userId_organizationId: {
userId: user.id,
organizationId: org.id,
},
},
}),
prisma.membership.updateMany({
where: {
userId,
team: {
id: orgId,
id: org.id,
},
accepted: false,
},
@@ -44,7 +64,7 @@ export async function joinAnyChildTeamOnOrgInvite({ userId, orgId }: { userId: n
where: {
userId,
team: {
parentId: orgId,
parentId: org.id,
},
accepted: false,
},
@@ -54,5 +74,5 @@ export async function joinAnyChildTeamOnOrgInvite({ userId, orgId }: { userId: n
}),
]);
await updateNewTeamMemberEventTypes(userId, orgId);
await updateNewTeamMemberEventTypes(userId, org.id);
}
@@ -35,8 +35,8 @@ export const BookerSeo = (props: BookerSeoProps) => {
{ refetchOnWindowFocus: false }
);
const profileName = event?.profile?.name ?? "";
const profileImage = event?.profile?.image;
const profileName = event?.profile.name ?? "";
const profileImage = event?.profile.image;
const title = event?.title ?? "";
return (
<HeadSeo
@@ -40,7 +40,7 @@ import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import { userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
@@ -69,6 +69,7 @@ import { getPiiFreeCalendarEvent, getPiiFreeEventType, getPiiFreeUser } from "@c
import { safeStringify } from "@calcom/lib/safeStringify";
import { checkBookingLimits, checkDurationLimits, getLuckyUser } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { slugify } from "@calcom/lib/slugify";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -185,7 +186,7 @@ export async function refreshCredentials(
* Gets credentials from the user, team, and org if applicable
*
*/
export const getAllCredentials = async (
const getAllCredentials = async (
user: User & { credentials: CredentialPayload[] },
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>
) => {
@@ -223,11 +224,15 @@ export const getAllCredentials = async (
}
}
const { profile } = await UserRepository.enrichUserWithItsProfile({
user: user,
});
// If the user is a part of an organization, query for the organization's credentials
if (user?.organizationId) {
if (profile?.organizationId) {
const org = await prisma.team.findUnique({
where: {
id: user.organizationId,
id: profile.organizationId,
},
select: {
credentials: {
@@ -366,11 +371,6 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
select: credentialForCalendarServiceSelect,
},
...userSelect.select,
organization: {
select: {
slug: true,
},
},
},
},
},
@@ -409,18 +409,10 @@ const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string
if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) {
throw new Error("dynamicUserList is not properly defined or empty.");
}
const users = await prisma.user.findMany({
where: {
username: { in: dynamicUserList },
organization: userOrgQuery(req),
},
select: {
...userSelect.select,
credentials: {
select: credentialForCalendarServiceSelect,
},
metadata: true,
},
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(req);
const users = await findUsersByUsername({
usernameList: dynamicUserList,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
return users;
@@ -1320,7 +1312,6 @@ async function handler(
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
const allCredentials = await getAllCredentials(organizerUser, eventType);
const { userReschedulingIsOwner, isConfirmedByDefault } = getRequiresConfirmationFlags({
@@ -1449,11 +1440,20 @@ async function handler(
});
// For bookings made before introducing iCalSequence, assume that the sequence should start at 1. For new bookings start at 0.
const iCalSequence = getICalSequence(originalRescheduledBooking);
const organizerOrganizationProfile = await prisma.profile.findFirst({
where: {
userId: organizerUser.id,
username: dynamicUserList[0],
},
});
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
const bookerUrl = eventType.team
? await getBookerBaseUrl(eventType.team.parentId)
: await getBookerBaseUrl(organizerOrganizationId ?? null);
let evt: CalendarEvent = {
bookerUrl: eventType.team
? await getBookerBaseUrl({ organizationId: eventType.team.parentId })
: await getBookerBaseUrl(organizerUser),
bookerUrl,
type: eventType.slug,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
description: eventType.description,
@@ -2401,3 +2401,40 @@ function handleCustomInputs(
}
});
}
/**
* This method is mostly same as the one in UserRepository but it includes a lot more relations which are specific requirement here
* TODO: Figure out how to keep it in UserRepository and use it here
*/
export const findUsersByUsername = async ({
usernameList,
orgSlug,
}: {
orgSlug: string | null;
usernameList: string[];
}) => {
log.debug("findUsersByUsername", { usernameList, orgSlug });
const { where, profiles } = await UserRepository._getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
});
return (
await prisma.user.findMany({
where,
select: {
...userSelect.select,
credentials: {
select: credentialForCalendarServiceSelect,
},
metadata: true,
},
})
).map((user) => {
const profile = profiles?.find((profile) => profile.user.id === user.id) ?? null;
return {
...user,
organizationId: profile?.organizationId ?? null,
profile,
};
});
};
@@ -157,6 +157,7 @@ describe("handleNewBooking", () => {
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
@@ -1991,6 +1992,7 @@ describe("handleNewBooking", () => {
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
responses: {
email: booker.email,
@@ -4,15 +4,30 @@ import CredentialsProvider from "next-auth/providers/credentials";
import { z } from "zod";
import { getSession } from "@calcom/features/auth/lib/getSession";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { Membership } from "@calcom/prisma/client";
import type { OrgProfile, PersonalProfile, UserAsPersonalProfile } from "@calcom/types/UserProfile";
const teamIdschema = z.object({
teamId: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number().positive()),
});
type ProfileType =
| UserAsPersonalProfile
| PersonalProfile
| (Omit<OrgProfile, "organization"> & {
organization: OrgProfile["organization"] & {
members: Membership[];
};
});
const auditAndReturnNextUser = async (
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "organizationId" | "locale">,
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "locale"> & {
organizationId: number | null;
profile: ProfileType;
},
impersonatedByUID: number,
hasTeam?: boolean,
isReturningToSelf?: boolean
@@ -42,6 +57,7 @@ const auditAndReturnNextUser = async (
belongsToActiveTeam: hasTeam,
organizationId: impersonatedUser.organizationId,
locale: impersonatedUser.locale,
profile: impersonatedUser.profile,
};
if (!isReturningToSelf) {
@@ -136,7 +152,6 @@ async function getImpersonatedUser({
role: true,
name: true,
email: true,
organizationId: true,
disableImpersonation: true,
locale: true,
teams: {
@@ -154,7 +169,13 @@ async function getImpersonatedUser({
throw new Error("This user does not exist");
}
return impersonatedUser;
const profile = await findProfile(impersonatedUser);
return {
...impersonatedUser,
organizationId: profile.organization?.id ?? null,
profile,
};
}
async function isReturningToSelf({ session, creds }: { session: Session | null; creds: Credentials | null }) {
@@ -195,6 +216,8 @@ async function isReturningToSelf({ session, creds }: { session: Session | null;
if (returningUser.role !== "ADMIN" && !returningUser.organizationId) return;
const hasTeams = returningUser.teams.length >= 1;
const profile = await findProfile(returningUser);
return {
user: {
id: returningUser.id,
@@ -204,6 +227,7 @@ async function isReturningToSelf({ session, creds }: { session: Session | null;
organizationId: returningUser.organizationId,
role: returningUser.role,
username: returningUser.username,
profile,
},
impersonatedByUID,
hasTeams,
@@ -242,7 +266,6 @@ const ImpersonationProvider = CredentialsProvider({
checkGlobalPermission(session);
const impersonatedUser = await getImpersonatedUser({ session, teamId, creds });
if (session?.user.role === "ADMIN") {
if (impersonatedUser.disableImpersonation) {
throw new Error("This user has disabled Impersonation.");
@@ -302,3 +325,29 @@ const ImpersonationProvider = CredentialsProvider({
});
export default ImpersonationProvider;
async function findProfile(returningUser: { id: number; username: string | null }) {
const allOrgProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser({
id: returningUser.id,
username: returningUser.username,
});
const firstOrgProfile = allOrgProfiles[0];
const orgMembers = firstOrgProfile.organizationId
? await prisma.membership.findMany({
where: {
teamId: firstOrgProfile.organizationId,
},
})
: [];
const profile = !firstOrgProfile.organization
? firstOrgProfile
: {
...firstOrgProfile,
organization: {
...firstOrgProfile.organization,
members: orgMembers,
},
};
return profile;
}
@@ -19,6 +19,7 @@ const generateHashedLink = (id: number) => {
interface handleChildrenEventTypesProps {
eventTypeId: number;
profileId: number | null;
updatedEventType: {
schedulingType: SchedulingType | null;
slug: string;
@@ -101,6 +102,7 @@ export default async function handleChildrenEventTypes({
connectedLink,
children,
prisma,
profileId,
}: handleChildrenEventTypesProps) {
// Check we are dealing with a managed event type
if (updatedEventType?.schedulingType !== SchedulingType.MANAGED)
@@ -177,6 +179,7 @@ export default async function handleChildrenEventTypes({
newUserIds.map((userId) => {
return prisma.eventType.create({
data: {
profileId: profileId ?? null,
...managedEventTypeValues,
...unlockedEventTypeValues,
bookingLimits:
@@ -233,6 +236,7 @@ export default async function handleChildrenEventTypes({
},
data: {
...managedEventTypeValues,
profileId: profileId ?? null,
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
@@ -35,7 +35,7 @@ export default function MemberListItem(props: Props) {
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="flex">
<UserAvatar size="sm" user={user} className="h-10 w-10 rounded-full" />
<UserAvatar noOrganizationIndicator size="sm" user={user} className="h-10 w-10 rounded-full" />
<div className="ms-3 inline-block overflow-hidden">
<div className="mb-1 flex">
@@ -118,16 +118,7 @@ const MembersView = () => {
router.push("/settings");
}
},
[otherMembersError, otherTeamError]
);
useEffect(
function refactorMeWithoutEffect() {
if (data) {
router.push("/settings");
}
},
[data]
[router, otherMembersError, otherTeamError]
);
const isPending = isTeamLoading || isOrgListLoading;
@@ -138,12 +138,18 @@ export default function MemberListItem(props: Props) {
)
) : null;
});
return (
<li className="divide-subtle divide-y px-5">
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between truncate sm:flex-row">
<div className="flex">
<UserAvatar size="sm" user={props.member} className="h-10 w-10 rounded-full" />
<UserAvatar
noOrganizationIndicator
size="sm"
user={props.member}
className="h-10 w-10 rounded-full"
/>
<div className="ms-3 inline-block">
<div className="mb-1 flex" data-testid={`member-${props.member.username}`}>
<span data-testid="member-name" className="text-default mr-2 text-sm font-bold leading-4">
@@ -6,6 +6,7 @@ import InviteLinkSettingsModal from "@calcom/ee/teams/components/InviteLinkSetti
import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { isOrganization } from "@calcom/lib/entityPermissionUtils";
import { getTeamUrlSync } from "@calcom/lib/getBookerUrl/client";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -42,7 +43,6 @@ import {
X,
} from "@calcom/ui/components/icon";
import { useOrgBranding } from "../../organizations/context/provider";
import { TeamRole } from "./TeamPill";
interface Props {
@@ -86,7 +86,6 @@ export default function TeamListItem(props: Props) {
const acceptInvite = () => acceptOrLeave(true);
const declineInvite = () => acceptOrLeave(false);
const orgBranding = useOrgBranding();
const isOwner = props.team.role === MembershipRole.OWNER;
const isInvitee = !props.team.accepted;
@@ -94,7 +93,9 @@ export default function TeamListItem(props: Props) {
const { hideDropdown, setHideDropdown } = props;
if (!team) return <></>;
const teamUrl = isOrganization({ team })
? getTeamUrlSync({ orgSlug: team.slug, teamSlug: null })
: getTeamUrlSync({ orgSlug: team.parent ? team.parent.slug : null, teamSlug: team.slug });
const teamInfo = (
<div className="item-center flex px-5 py-5">
<Avatar
@@ -106,11 +107,7 @@ export default function TeamListItem(props: Props) {
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{team.name}</span>
<span className="text-muted block text-xs">
{team.slug ? (
`${getTeamUrlSync({ orgSlug: team.parent ? team.parent.slug : null, teamSlug: team.slug })}`
) : (
<Badge>{t("upgrade")}</Badge>
)}
{team.slug ? `${teamUrl}` : <Badge>{t("upgrade")}</Badge>}
</span>
</div>
</div>
@@ -2,6 +2,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { isOrganization } from "@calcom/lib/entityPermissionUtils";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -21,9 +22,14 @@ export function TeamsListing() {
const [inviteTokenChecked, setInviteTokenChecked] = useState(false);
const { data, isPending, error } = trpc.viewer.teams.list.useQuery(undefined, {
enabled: inviteTokenChecked,
});
const { data, isPending, error } = trpc.viewer.teams.list.useQuery(
{
includeOrgs: true,
},
{
enabled: inviteTokenChecked,
}
);
const { data: user } = trpc.viewer.me.useQuery();
@@ -40,8 +46,22 @@ export function TeamsListing() {
},
});
const teams = useMemo(() => data?.filter((m) => m.accepted) || [], [data]);
const invites = useMemo(() => data?.filter((m) => !m.accepted) || [], [data]);
const teams = useMemo(() => data?.filter((m) => m.accepted && !isOrganization({ team: m })) || [], [data]);
const teamInvites = useMemo(
() => data?.filter((m) => !m.accepted && !isOrganization({ team: m })) || [],
[data]
);
const organizationInvites = (data?.filter((m) => !m.accepted && isOrganization({ team: m })) || []).filter(
(orgInvite) => {
const isThereASubTeamOfTheOrganizationInInvites = teamInvites.find(
(teamInvite) => teamInvite.parentId === orgInvite.id
);
// Accepting a subteam invite automatically accepts the invite for the parent organization. So, need to show such an organization's invite
return !isThereASubTeamOfTheOrganizationInInvites;
}
);
const isCreateTeamButtonDisabled = !!(user?.organizationId && !user?.organization?.isOrgAdmin);
@@ -92,12 +112,20 @@ export function TeamsListing() {
<>
{!!error && <Alert severity="error" title={error.message} />}
{invites.length > 0 && (
{organizationInvites.length > 0 && (
<div className="bg-subtle mb-6 rounded-md p-5">
<Label className="text-emphasis pb-2 font-semibold">{t("pending_invites")}</Label>
<TeamList teams={invites} pending />
<Label className="text-emphasis pb-2 font-semibold">{t("pending_organization_invites")}</Label>
<TeamList teams={organizationInvites} pending />
</div>
)}
{teamInvites.length > 0 && (
<div className="bg-subtle mb-6 rounded-md p-5">
<Label className="text-emphasis pb-2 font-semibold">{t("pending_invites")}</Label>
<TeamList teams={teamInvites} pending />
</div>
)}
<UpgradeTip
plan="team"
title={t("calcom_is_better_with_team", { appName: APP_NAME })}
@@ -58,9 +58,10 @@ const getUserAdminTeams = async ({
},
}),
},
orderBy: {
orgUsers: { _count: "desc" },
},
// FIXME - OrgNewSchema: Fix this orderBy
// orderBy: {
// orgUsers: { _count: "desc" },
// },
});
if (teams.length && getUserInfo) {
+4 -2
View File
@@ -4,6 +4,7 @@ import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/l
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { ORGANIZATION_MIN_SEATS } from "@calcom/lib/constants";
import { isOrganization } from "@calcom/lib/entityPermissionUtils";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -120,8 +121,9 @@ export const purchaseTeamSubscription = async (input: {
const getTeamWithPaymentMetadata = async (teamId: number) => {
const team = await prisma.team.findUniqueOrThrow({
where: { id: teamId },
select: { metadata: true, members: true, _count: { select: { orgUsers: true } } },
select: { metadata: true, members: true },
});
const metadata = teamPaymentMetadataSchema.parse(team.metadata);
return { ...team, metadata };
};
@@ -155,7 +157,7 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => {
)?.quantity;
if (!subscriptionQuantity) throw new Error("Subscription not found");
if (!!team._count.orgUsers && membershipCount < ORGANIZATION_MIN_SEATS) {
if (!!isOrganization({ team }) && membershipCount < ORGANIZATION_MIN_SEATS) {
console.info(
`Org ${teamId} has less members than the min ${ORGANIZATION_MIN_SEATS}, skipping updating subscription.`
);
@@ -176,6 +176,7 @@ function UsersTableBare() {
<Avatar
size="md"
alt={`Avatar of ${user.username || "Nameless"}`}
// @ts-expect-error - Figure it out later. Ideally we should show all the profiles here for the user.
imageSrc={`${WEBAPP_URL}/${user.username}/avatar.png?orgId=${user.organizationId}`}
/>
@@ -1,4 +1,4 @@
import type { User } from "@prisma/client";
import type { User as UserType } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { LocationObject } from "@calcom/app-store/locations";
@@ -10,6 +10,7 @@ import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import type { BookerLayoutSettings } from "@calcom/prisma/zod-utils";
import {
@@ -21,6 +22,7 @@ import {
BookerLayouts,
teamMetadataSchema,
} from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
@@ -76,18 +78,14 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
select: {
user: {
select: {
id: true,
avatarUrl: true,
username: true,
name: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
organizationId: true,
organization: {
select: {
slug: true,
},
},
metadata: true,
},
},
@@ -95,6 +93,8 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
},
owner: {
select: {
id: true,
avatarUrl: true,
weekStart: true,
username: true,
name: true,
@@ -102,13 +102,6 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
metadata: true,
brandColor: true,
darkBrandColor: true,
organizationId: true,
organization: {
select: {
name: true,
slug: true,
},
},
},
},
hidden: true,
@@ -126,30 +119,12 @@ export const getPublicEvent = async (
const orgQuery = org ? getSlugOrRequestedSlug(org) : null;
// In case of dynamic group event, we fetch user's data and use the default event.
if (usernameList.length > 1) {
const users = await prisma.user.findMany({
where: {
username: {
in: usernameList,
},
organization: orgQuery,
},
select: {
username: true,
name: true,
weekStart: true,
metadata: true,
brandColor: true,
darkBrandColor: true,
theme: true,
organizationId: true,
organization: {
select: {
slug: true,
name: true,
},
},
},
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList,
orgSlug: org,
});
console.log("getPublicEvent - dynamic", usersInOrgContext);
const users = usersInOrgContext;
const defaultEvent = getDefaultEvent(eventSlug);
let locations = defaultEvent.locations ? (defaultEvent.locations as LocationObject[]) : [];
@@ -173,7 +148,7 @@ export const getPublicEvent = async (
defaultLayout: BookerLayouts.MONTH_VIEW,
} as BookerLayoutSettings;
const disableBookingTitle = !defaultEvent.isDynamic;
const unPublishedOrgUser = users.find((user) => user.organization?.slug === null);
const unPublishedOrgUser = users.find((user) => user.profile?.organization?.slug === null);
return {
...defaultEvent,
@@ -182,7 +157,7 @@ export const getPublicEvent = async (
users: users.map((user) => ({
...user,
metadata: undefined,
bookerUrl: getBookerBaseUrlSync(user.organization?.slug ?? null),
bookerUrl: getBookerBaseUrlSync(user.profile?.organization?.slug ?? null),
})),
locations: privacyFilteredLocations(locations),
profile: {
@@ -200,7 +175,7 @@ export const getPublicEvent = async (
entity: {
isUnpublished: unPublishedOrgUser !== undefined,
orgSlug: org,
name: unPublishedOrgUser?.organization?.name ?? null,
name: unPublishedOrgUser?.profile?.organization?.name ?? null,
},
isInstantEvent: false,
};
@@ -216,8 +191,17 @@ export const getPublicEvent = async (
: {
users: {
some: {
username,
organization: orgQuery,
...(orgQuery
? {
profiles: {
some: {
organization: orgQuery,
},
},
}
: {
username,
}),
},
},
team: null,
@@ -236,35 +220,61 @@ export const getPublicEvent = async (
const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {});
const teamMetadata = teamMetadataSchema.parse(event.team?.metadata || {});
const hosts = [];
for (const host of event.hosts) {
hosts.push({
...host,
user: await UserRepository.enrichUserWithItsProfile({
user: host.user,
}),
});
}
const eventWithUserProfiles = {
...event,
owner: event.owner
? await UserRepository.enrichUserWithItsProfile({
user: event.owner,
})
: null,
hosts: hosts,
};
const users = getUsersFromEvent(eventWithUserProfiles) || (await getOwnerFromUsersArray(prisma, event.id));
const users = getUsersFromEvent(event) || (await getOwnerFromUsersArray(prisma, event.id));
if (users === null) {
throw new Error("Event has no owner");
}
return {
...event,
...eventWithUserProfiles,
bookerLayouts: bookerLayoutsSchema.parse(eventMetaData?.bookerLayouts || null),
description: markdownToSafeHTML(event.description),
description: markdownToSafeHTML(eventWithUserProfiles.description),
metadata: eventMetaData,
customInputs: customInputSchema.array().parse(event.customInputs || []),
locations: privacyFilteredLocations((event.locations || []) as LocationObject[]),
locations: privacyFilteredLocations((eventWithUserProfiles.locations || []) as LocationObject[]),
bookingFields: getBookingFieldsWithSystemFields(event),
recurringEvent: isRecurringEvent(event.recurringEvent) ? parseRecurringEvent(event.recurringEvent) : null,
recurringEvent: isRecurringEvent(eventWithUserProfiles.recurringEvent)
? parseRecurringEvent(event.recurringEvent)
: null,
// Sets user data on profile object for easier access
profile: getProfileFromEvent(event),
profile: getProfileFromEvent(eventWithUserProfiles),
users,
entity: {
isUnpublished:
event.team?.slug === null ||
event.owner?.organization?.slug === null ||
event.team?.parent?.slug === null,
eventWithUserProfiles.team?.slug === null ||
eventWithUserProfiles.owner?.profile?.organization?.slug === null ||
eventWithUserProfiles.team?.parent?.slug === null,
orgSlug: org,
teamSlug: (event.team?.slug || teamMetadata?.requestedSlug) ?? null,
name: (event.owner?.organization?.name || event.team?.parent?.name || event.team?.name) ?? null,
teamSlug: (eventWithUserProfiles.team?.slug || teamMetadata?.requestedSlug) ?? null,
name:
(eventWithUserProfiles.owner?.profile?.organization?.name ||
eventWithUserProfiles.team?.parent?.name ||
eventWithUserProfiles.team?.name) ??
null,
},
isDynamic: false,
isInstantEvent: event.isInstantEvent,
isInstantEvent: eventWithUserProfiles.isInstantEvent,
assignAllTeamMembers: event.assignAllTeamMembers,
};
};
@@ -301,8 +311,20 @@ function getProfileFromEvent(event: Event) {
),
};
}
function getUsersFromEvent(event: Event) {
function getUsersFromEvent(
event: Omit<Event, "owner" | "hosts"> & {
owner:
| (Event["owner"] & {
profile: UserProfile;
})
| null;
hosts: (Omit<Event["hosts"][number], "user"> & {
user: Event["hosts"][number]["user"] & {
profile: UserProfile;
};
})[];
}
) {
const { team, hosts, owner } = event;
if (team) {
return (hosts || []).filter((host) => host.user.username).map(mapHostsToUsers);
@@ -310,14 +332,17 @@ function getUsersFromEvent(event: Event) {
if (!owner) {
return null;
}
const { username, name, weekStart, organizationId } = owner;
const { username, name, weekStart, profile, avatarUrl } = owner;
const organizationId = profile?.organization?.id ?? null;
return [
{
username,
name,
weekStart,
organizationId,
bookerUrl: getBookerBaseUrlSync(owner.organization?.slug ?? null),
avatarUrl,
profile,
bookerUrl: getBookerBaseUrlSync(owner.profile?.organization?.slug ?? null),
},
];
}
@@ -328,33 +353,48 @@ async function getOwnerFromUsersArray(prisma: PrismaClient, eventTypeId: number)
select: {
users: {
select: {
avatarUrl: true,
username: true,
name: true,
weekStart: true,
organizationId: true,
organization: {
select: {
slug: true,
},
},
id: true,
},
},
},
});
if (!users.length) return null;
return [{ ...users[0], bookerUrl: getBookerBaseUrlSync(users[0].organization?.slug ?? null) }];
const usersWithUserProfile = [];
for (const user of users) {
const { profile } = await UserRepository.enrichUserWithItsProfile({
user: user,
});
usersWithUserProfile.push({
...user,
organizationId: profile?.organization?.id ?? null,
organization: profile?.organization,
profile,
});
}
return [
{
...usersWithUserProfile[0],
bookerUrl: getBookerBaseUrlSync(usersWithUserProfile[0].organization?.slug ?? null),
},
];
}
function mapHostsToUsers(host: {
user: Pick<User, "username" | "name" | "weekStart" | "organizationId"> & {
organization: { slug: string | null } | null;
user: Pick<UserType, "username" | "name" | "weekStart" | "avatarUrl"> & {
profile: UserProfile;
};
}) {
return {
username: host.user.username,
name: host.user.name,
avatarUrl: host.user.avatarUrl,
weekStart: host.user.weekStart,
organizationId: host.user.organizationId,
bookerUrl: getBookerBaseUrlSync(host.user.organization?.slug ?? null),
organizationId: host.user.profile?.organizationId ?? null,
bookerUrl: getBookerBaseUrlSync(host.user.profile?.organization?.slug ?? null),
profile: host.user.profile,
};
}
@@ -24,10 +24,12 @@ function useFilterQuery() {
export const TeamsFilter = ({
popoverTriggerClassNames,
useProfileFilter = false,
showVerticalDivider = false,
}: {
popoverTriggerClassNames?: string;
showVerticalDivider?: boolean;
useProfileFilter?: boolean;
}) => {
const { t } = useLocale();
const session = useSession();
@@ -39,6 +41,7 @@ export const TeamsFilter = ({
const getCheckedOptionsNames = () => {
const checkedOptions: string[] = [];
const teamIds = query.teamIds;
const users = useProfileFilter ? query.upIds : query.userIds;
if (teamIds) {
const selectedTeamsNames = teams
?.filter((team) => {
@@ -50,7 +53,7 @@ export const TeamsFilter = ({
}
return `${t("team")}: ${checkedOptions.join(",")}`;
}
if (query.userIds) {
if (users) {
return t("yours");
}
return t("all");
@@ -58,6 +61,9 @@ export const TeamsFilter = ({
if (!teams || !teams.length) return null;
const userId = session.data?.user?.id || 0;
const upId = session.data?.upId || "";
const isUserInQuery = useProfileFilter ? query.upIds?.includes(upId) : query.userIds?.includes(userId);
return (
<div className="flex items-center">
<AnimatedPopover
@@ -68,7 +74,7 @@ export const TeamsFilter = ({
<FilterCheckboxField
id="all"
icon={<Layers className="h-4 w-4" />}
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
checked={!query.teamIds && !isUserInQuery}
onChange={removeAllQueryParams}
label={t("all")}
/>
@@ -76,12 +82,14 @@ export const TeamsFilter = ({
<FilterCheckboxField
id="yours"
icon={<User className="h-4 w-4" />}
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
checked={!!isUserInQuery}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("userIds", session.data?.user.id || 0);
if (useProfileFilter) pushItemToKey("upIds", upId);
else pushItemToKey("userIds", userId);
} else if (!e.target.checked) {
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
if (useProfileFilter) removeItemByKeyAndValue("upIds", upId);
else removeItemByKeyAndValue("userIds", userId);
}
}}
label={t("yours")}

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