From b934c74c30bb77bd7a5015b9357c01ef74af664c Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 24 Oct 2023 16:12:36 +0530 Subject: [PATCH] fix: Avatar slug and cal links for cross org users (#12031) --- .../steps-views/UserProfile.tsx | 18 +++--- apps/web/components/team/screens/Team.tsx | 14 +++-- apps/web/components/ui/avatar/UserAvatar.tsx | 19 ++++++ .../components/ui/avatar/UserAvatarGroup.tsx | 20 ++++++ .../ui/avatar/UserAvatarGroupWithOrg.tsx | 30 +++++++++ apps/web/pages/[user].tsx | 42 ++++++++++--- apps/web/pages/api/user/avatar.ts | 50 +++++++-------- .../web/pages/settings/my-account/profile.tsx | 22 +++++-- apps/web/pages/signup.tsx | 4 +- apps/web/pages/team/[slug].tsx | 25 +++----- .../features/auth/lib/next-auth-options.ts | 4 +- .../components/event-meta/Members.tsx | 63 +++++-------------- .../components/OrganizationAvatar.tsx | 32 ---------- .../components/OrganizationMemberAvatar.tsx | 47 ++++++++++++++ .../ee/organizations/lib/orgDomains.ts | 34 +++++++++- .../ee/teams/components/MemberListItem.tsx | 10 +-- .../ee/teams/pages/team-profile-view.tsx | 4 +- .../features/ee/users/server/trpc-router.ts | 3 + .../components/ChildrenEventTypeSelect.tsx | 4 +- .../features/eventtypes/lib/getPublicEvent.ts | 12 ++-- packages/features/shell/Shell.tsx | 4 +- .../components/AvailabilitySliderTable.tsx | 16 ++++- packages/lib/getAvatarUrl.ts | 24 +++++++ packages/lib/getEventTypeById.ts | 6 +- packages/lib/server/getBrand.ts | 4 +- packages/lib/server/queries/teams/index.ts | 9 ++- .../routers/loggedInViewer/me.handler.ts | 7 +-- .../team/listTeamAvailability.handler.ts | 6 ++ 28 files changed, 351 insertions(+), 182 deletions(-) create mode 100644 apps/web/components/ui/avatar/UserAvatar.tsx create mode 100644 apps/web/components/ui/avatar/UserAvatarGroup.tsx create mode 100644 apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx delete mode 100644 packages/features/ee/organizations/components/OrganizationAvatar.tsx create mode 100644 packages/features/ee/organizations/components/OrganizationMemberAvatar.tsx create mode 100644 packages/lib/getAvatarUrl.ts diff --git a/apps/web/components/getting-started/steps-views/UserProfile.tsx b/apps/web/components/getting-started/steps-views/UserProfile.tsx index f197ff4461..79f4fd9076 100644 --- a/apps/web/components/getting-started/steps-views/UserProfile.tsx +++ b/apps/web/components/getting-started/steps-views/UserProfile.tsx @@ -3,12 +3,13 @@ import type { FormEvent } from "react"; import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import { useLocale } from "@calcom/lib/hooks/useLocale"; 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 { ArrowRight } from "@calcom/ui/components/icon"; @@ -96,16 +97,19 @@ const UserProfile = () => { }, ]; + const organization = + user.organization && user.organization.id + ? { + ...(user.organization as Ensure), + slug: user.organization.slug || null, + requestedSlug: user.organization.metadata?.requestedSlug || null, + } + : null; return (
{user && ( - + )} , "inviteToken">; type MembersType = TeamType["members"]; -type MemberType = Pick & { safeBio: string | null }; +type MemberType = Pick & { + safeBio: string | null; + orgOrigin: string; +}; const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => { const routerQuery = useRouterQuery(); @@ -20,9 +24,11 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery; return ( - +
- +

{member.name}

diff --git a/apps/web/components/ui/avatar/UserAvatar.tsx b/apps/web/components/ui/avatar/UserAvatar.tsx new file mode 100644 index 0000000000..63fa676676 --- /dev/null +++ b/apps/web/components/ui/avatar/UserAvatar.tsx @@ -0,0 +1,19 @@ +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import type { User } from "@calcom/prisma/client"; +import { Avatar } from "@calcom/ui"; + +type UserAvatarProps = Omit, "alt" | "imageSrc"> & { + user: Pick; + /** + * Useful when allowing the user to upload their own avatar and showing the avatar before it's uploaded + */ + previewSrc?: string | null; +}; + +/** + * It is aware of the user's organization to correctly show the avatar from the correct URL + */ +export function UserAvatar(props: UserAvatarProps) { + const { user, previewSrc, ...rest } = props; + return ; +} diff --git a/apps/web/components/ui/avatar/UserAvatarGroup.tsx b/apps/web/components/ui/avatar/UserAvatarGroup.tsx new file mode 100644 index 0000000000..ad3909641e --- /dev/null +++ b/apps/web/components/ui/avatar/UserAvatarGroup.tsx @@ -0,0 +1,20 @@ +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import type { User } from "@calcom/prisma/client"; +import { AvatarGroup } from "@calcom/ui"; + +type UserAvatarProps = Omit, "items"> & { + users: Pick[]; +}; +export function UserAvatarGroup(props: UserAvatarProps) { + const { users, ...rest } = props; + return ( + ({ + alt: user.name || "", + title: user.name || "", + image: getUserAvatarUrl(user), + }))} + /> + ); +} diff --git a/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx b/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx new file mode 100644 index 0000000000..9de57a0b57 --- /dev/null +++ b/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx @@ -0,0 +1,30 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import type { Team, User } from "@calcom/prisma/client"; +import { AvatarGroup } from "@calcom/ui"; + +type UserAvatarProps = Omit, "items"> & { + users: Pick[]; + organization: Pick; +}; + +export function UserAvatarGroupWithOrg(props: UserAvatarProps) { + const { users, organization, ...rest } = props; + const items = [ + { + image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`, + alt: organization.name || undefined, + title: organization.name, + }, + ].concat( + users.map((user) => { + return { + image: getUserAvatarUrl(user), + alt: user.name || undefined, + title: user.name || user.username || "", + }; + }) + ); + users.unshift(); + return ; +} diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 1392af4cfa..1e927a4539 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -11,7 +11,7 @@ import { useEmbedStyles, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; -import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; @@ -25,7 +25,7 @@ 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 } from "@calcom/prisma/zod-utils"; +import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { HeadSeo, UnpublishedEntity } from "@calcom/ui"; import { Verified, ArrowRight } from "@calcom/ui/components/icon"; @@ -99,11 +99,22 @@ export function UserPage(props: InferGetServerSidePropsType
-

{profile.name} @@ -226,8 +237,13 @@ export type UserPageProps = { theme: string | null; brandColor: string; darkBrandColor: string; - organizationSlug: string | null; + organization: { + requestedSlug: string | null; + slug: string | null; + id: number | null; + }; allowSEOIndexing: boolean; + username: string | null; }; users: Pick[]; themeBasis: string | null; @@ -286,6 +302,7 @@ export const getServerSideProps: GetServerSideProps = async (cont select: { slug: true, name: true, + metadata: true, }, }, theme: true, @@ -313,6 +330,10 @@ export const getServerSideProps: GetServerSideProps = async (cont const users = usersWithoutAvatar.map((user) => ({ ...user, + organization: { + ...user.organization, + metadata: user.organization?.metadata ? teamMetadataSchema.parse(user.organization.metadata) : null, + }, avatar: `/${user.username}/avatar.png`, })); @@ -344,8 +365,13 @@ export const getServerSideProps: GetServerSideProps = async (cont theme: user.theme, brandColor: user.brandColor, darkBrandColor: user.darkBrandColor, - organizationSlug: user.organization?.slug ?? null, allowSEOIndexing: user.allowSEOIndexing ?? true, + username: user.username, + organization: { + id: user.organizationId, + slug: user.organization?.slug ?? null, + requestedSlug: user.organization?.metadata?.requestedSlug ?? null, + }, }; const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id); diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index fcf0ce7d09..6f6cabeaf7 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -1,15 +1,23 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; -import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { + orgDomainConfig, + whereClauseForOrgWithSlugOrRequestedSlug, +} from "@calcom/features/ee/organizations/lib/orgDomains"; import { AVATAR_FALLBACK } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; +const log = logger.getSubLogger({ prefix: ["team/[slug]"] }); const querySchema = z .object({ username: z.string(), teamname: z.string(), + /** + * Passed when we want to fetch avatar of a particular organization + */ orgSlug: z.string(), /** * Allow fetching avatar of a particular organization @@ -30,11 +38,11 @@ async function getIdentityData(req: NextApiRequest) { id: orgId, } : org - ? getSlugOrRequestedSlug(org) + ? whereClauseForOrgWithSlugOrRequestedSlug(org) : null; if (username) { - let user = await prisma.user.findFirst({ + const user = await prisma.user.findFirst({ where: { username, organization: orgQuery, @@ -42,27 +50,6 @@ async function getIdentityData(req: NextApiRequest) { select: { avatar: true, email: true }, }); - /** - * TEMPORARY CODE STARTS - TO BE REMOVED after mono-user schema is implemented - * Try the non-org user temporarily to support users part of a team but not part of the organization - * This is needed because of a situation where we migrate a user and the team to ORG but not all the users in the team to the ORG. - * Eventually, all users will be migrated to the ORG but this is when user by user migration happens initially. - */ - // No user found in the org, try the non-org user that might be part of the team that's part of an org - if (!user && orgQuery) { - // The only side effect this code could have is that it could serve the avatar of a non-org member from the org domain but as long as the username isn't taken by an org member. - user = await prisma.user.findFirst({ - where: { - username, - organization: null, - }, - select: { avatar: true, email: true }, - }); - } - /** - * TEMPORARY CODE ENDS - */ - return { name: username, email: user?.email, @@ -79,6 +66,7 @@ async function getIdentityData(req: NextApiRequest) { }, select: { logo: true }, }); + return { org, name: teamname, @@ -86,15 +74,25 @@ async function getIdentityData(req: NextApiRequest) { avatar: getPlaceholderAvatar(team?.logo, teamname), }; } + if (orgSlug) { - const org = await prisma.team.findFirst({ - where: getSlugOrRequestedSlug(orgSlug), + const orgs = await prisma.team.findMany({ + where: { + ...whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), + }, select: { slug: true, logo: true, name: true, }, }); + + if (orgs.length > 1) { + // This should never happen, but instead of throwing error, we are just logging to be able to observe when it happens. + log.error("More than one organization found for slug", orgSlug); + } + + const org = orgs[0]; return { org: org?.slug, name: org?.name, diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 6b57135292..bfe82cdc27 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -6,7 +6,7 @@ import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; -import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; @@ -19,6 +19,7 @@ import type { TRPCClientErrorLike } from "@calcom/trpc/client"; import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; +import type { Ensure } from "@calcom/types/utils"; import { Alert, Button, @@ -251,6 +252,7 @@ const ProfileView = () => { isLoading={updateProfileMutation.isLoading} isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)} userAvatar={user.avatar} + user={user} userOrganization={user.organization} onSubmit={(values) => { if (values.email !== user.email && isCALIdentityProvider) { @@ -396,6 +398,7 @@ const ProfileForm = ({ isLoading = false, isFallbackImg, userAvatar, + user, userOrganization, }: { defaultValues: FormValues; @@ -404,6 +407,7 @@ const ProfileForm = ({ isLoading: boolean; isFallbackImg: boolean; userAvatar: string; + user: RouterOutputs["viewer"]["me"]; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; }) => { const { t } = useLocale(); @@ -443,13 +447,21 @@ const ProfileForm = ({ name="avatar" render={({ field: { value } }) => { const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value); + const organization = + userOrganization && userOrganization.id + ? { + ...(userOrganization as Ensure), + slug: userOrganization.slug || null, + requestedSlug: userOrganization.metadata?.requestedSlug || null, + } + : null; return ( <> -

{t("profile_picture")}

diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 8aceea51ba..21f3459f1f 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -8,7 +8,7 @@ import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { useFlagMap } from "@calcom/features/flags/context/provider"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; @@ -159,7 +159,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
- ({ - alt: user.name || "", - title: user.name || "", - image: `/${user.username}/avatar.png` || "", - }))} + users={type.users} />
@@ -149,17 +146,11 @@ function TeamPage({

- mem.subteams?.includes(ch.slug) && mem.accepted) - .map((member) => ({ - alt: member.name || "", - image: `/${member.username}/avatar.png`, - title: member.name || "", - }))} + users={team.members.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)} /> @@ -373,7 +364,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => subteams: member.subteams, username: member.username, accepted: member.accepted, + organizationId: member.organizationId, safeBio: markdownToSafeHTML(member.bio || ""), + orgOrigin: getOrgFullOrigin(member.organization?.slug || ""), }; }) : []; diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 6e21b15b96..97cc306065 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -8,7 +8,7 @@ import GoogleProvider from "next-auth/providers/google"; import checkLicense from "@calcom/features/ee/common/server/checkLicense"; import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider"; -import { getOrgFullDomain, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; +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"; @@ -471,7 +471,7 @@ export const AUTH_OPTIONS: AuthOptions = { id: organization.id, name: organization.name, slug: organization.slug ?? parsedOrgMetadata?.requestedSlug ?? "", - fullDomain: getOrgFullDomain(organization.slug ?? parsedOrgMetadata?.requestedSlug ?? ""), + fullDomain: getOrgFullOrigin(organization.slug ?? parsedOrgMetadata?.requestedSlug ?? ""), domainSuffix: subdomainSuffix(), } : undefined, diff --git a/packages/features/bookings/components/event-meta/Members.tsx b/packages/features/bookings/components/event-meta/Members.tsx index 3101892eaa..29686e8f1f 100644 --- a/packages/features/bookings/components/event-meta/Members.tsx +++ b/packages/features/bookings/components/event-meta/Members.tsx @@ -1,9 +1,6 @@ -import { usePathname } from "next/navigation"; - -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { SchedulingType } from "@calcom/prisma/enums"; -import { AvatarGroup } from "@calcom/ui"; +import { UserAvatarGroup } from "@calcom/web/components/ui/avatar/UserAvatarGroup"; +import { UserAvatarGroupWithOrg } from "@calcom/web/components/ui/avatar/UserAvatarGroupWithOrg"; import type { PublicEvent } from "../../types"; @@ -18,17 +15,7 @@ export interface EventMembersProps { entity: PublicEvent["entity"]; } -type Avatar = { - title: string; - image: string | undefined; - alt: string | undefined; - href: string | undefined; -}; - -type AvatarWithRequiredImage = Avatar & { image: string }; - export const EventMembers = ({ schedulingType, users, profile, entity }: EventMembersProps) => { - const pathname = usePathname(); const showMembers = schedulingType !== SchedulingType.ROUND_ROBIN; const shownUsers = showMembers ? users : []; @@ -38,40 +25,22 @@ export const EventMembers = ({ schedulingType, users, profile, entity }: EventMe !users.length || (profile.name !== users[0].name && schedulingType === SchedulingType.COLLECTIVE); - const avatars: Avatar[] = shownUsers.map((user) => ({ - title: `${user.name || user.username}`, - image: "image" in user ? `${user.image}` : `/${user.username}/avatar.png`, - alt: user.name || undefined, - href: `/${user.username}`, - })); - - // Add organization avatar - if (entity.orgSlug) { - avatars.unshift({ - title: `${entity.name}`, - image: `${WEBAPP_URL}/team/${entity.orgSlug}/avatar.png`, - alt: entity.name || undefined, - href: getOrgFullDomain(entity.orgSlug), - }); - } - - // Add profile later since we don't want to force creating an avatar for this if it doesn't exist. - avatars.unshift({ - title: `${profile.name || profile.username}`, - image: "logo" in profile && profile.logo ? `${profile.logo}` : undefined, - alt: profile.name || undefined, - href: profile.username - ? `${CAL_URL}${pathname?.indexOf("/team/") !== -1 ? "/team" : ""}/${profile.username}` - : undefined, - }); - - const uniqueAvatars = avatars - .filter((item): item is AvatarWithRequiredImage => !!item.image) - .filter((item, index, self) => self.findIndex((t) => t.image === item.image) === index); - return ( <> - + {entity.orgSlug ? ( + + ) : ( + + )} +

{showOnlyProfileName ? profile.name diff --git a/packages/features/ee/organizations/components/OrganizationAvatar.tsx b/packages/features/ee/organizations/components/OrganizationAvatar.tsx deleted file mode 100644 index 85a361a291..0000000000 --- a/packages/features/ee/organizations/components/OrganizationAvatar.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import classNames from "@calcom/lib/classNames"; -import { Avatar } from "@calcom/ui"; -import type { AvatarProps } from "@calcom/ui"; - -type OrganizationAvatarProps = AvatarProps & { - organizationSlug: string | null | undefined; -}; - -const OrganizationAvatar = ({ size, imageSrc, alt, organizationSlug, ...rest }: OrganizationAvatarProps) => { - return ( - - {alt} -

- ) : null - } - /> - ); -}; - -export default OrganizationAvatar; diff --git a/packages/features/ee/organizations/components/OrganizationMemberAvatar.tsx b/packages/features/ee/organizations/components/OrganizationMemberAvatar.tsx new file mode 100644 index 0000000000..7c898776c7 --- /dev/null +++ b/packages/features/ee/organizations/components/OrganizationMemberAvatar.tsx @@ -0,0 +1,47 @@ +import classNames from "@calcom/lib/classNames"; +import { getOrgAvatarUrl } from "@calcom/lib/getAvatarUrl"; +// import { Avatar } from "@calcom/ui"; +import { UserAvatar } from "@calcom/web/components/ui/avatar/UserAvatar"; + +type OrganizationMemberAvatarProps = React.ComponentProps & { + organization: { + id: number; + slug: string | null; + requestedSlug: string | null; + } | null; +}; + +/** + * Shows the user's avatar along with a small organization's avatar + */ +const OrganizationMemberAvatar = ({ + size, + user, + organization, + previewSrc, + ...rest +}: OrganizationMemberAvatarProps) => { + return ( + + {user.username +
+ ) : null + } + {...rest} + /> + ); +}; + +export default OrganizationMemberAvatar; diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 68f6425fad..8c55dd5929 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import slugify from "@calcom/lib/slugify"; /** @@ -18,6 +19,12 @@ export function getOrgSlug(hostname: string) { const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`; return testHostname.endsWith(`.${ahn}`); }); + logger.debug(`getOrgSlug: ${hostname} ${currentHostname}`, { + ALLOWED_HOSTNAMES, + WEBAPP_URL, + currentHostname, + hostname, + }); if (currentHostname) { // Define which is the current domain/subdomain const slug = hostname.replace(`.${currentHostname}` ?? "", ""); @@ -29,6 +36,7 @@ export function getOrgSlug(hostname: string) { export function orgDomainConfig(hostname: string, fallback?: string | string[]) { const currentOrgDomain = getOrgSlug(hostname); const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain); + logger.debug(`orgDomainConfig: ${hostname} ${currentOrgDomain} ${isValidOrgDomain}`); if (isValidOrgDomain || !fallback) { return { currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null, @@ -48,10 +56,14 @@ export function subdomainSuffix() { return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join("."); } -export function getOrgFullDomain(slug: string, options: { protocol: boolean } = { protocol: true }) { +export function getOrgFullOrigin(slug: string, options: { protocol: boolean } = { protocol: true }) { + if (!slug) return WEBAPP_URL; return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}`; } +/** + * @deprecated You most probably intend to query for an organization only, use `whereClauseForOrgWithSlugOrRequestedSlug` instead which will only return the organization and not a team accidentally. + */ export function getSlugOrRequestedSlug(slug: string) { const slugifiedValue = slugify(slug); return { @@ -67,6 +79,26 @@ export function getSlugOrRequestedSlug(slug: string) { } satisfies Prisma.TeamWhereInput; } +export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) { + const slugifiedValue = slugify(slug); + + return { + OR: [ + { slug: slugifiedValue }, + { + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }, + ], + metadata: { + path: ["isOrganization"], + equals: true, + }, + } satisfies Prisma.TeamWhereInput; +} + export function userOrgQuery(hostname: string, fallback?: string | string[]) { const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(hostname, fallback); return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null; diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index e460d52e46..1bfaa68b60 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -11,7 +11,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { - Avatar, Button, ButtonGroup, ConfirmationDialogContent, @@ -29,6 +28,7 @@ import { Tooltip, } from "@calcom/ui"; import { ExternalLink, MoreHorizontal, Edit2, Lock, UserX } from "@calcom/ui/components/icon"; +import { UserAvatar } from "@calcom/web/components/ui/avatar/UserAvatar"; import MemberChangeRoleModal from "./MemberChangeRoleModal"; import TeamAvailabilityModal from "./TeamAvailabilityModal"; @@ -141,13 +141,7 @@ export default function MemberListItem(props: Props) {
- - +
{name} diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index d9cd6ab6e2..69179974d0 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -8,7 +8,7 @@ import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -235,7 +235,7 @@ const ProfileView = () => { value={value} addOnLeading={ team.parent && orgBranding - ? `${getOrgFullDomain(orgBranding?.slug, { protocol: false })}/` + ? `${getOrgFullOrigin(orgBranding?.slug, { protocol: false })}/` : `${WEBAPP_URL}/team/` } onChange={(e) => { diff --git a/packages/features/ee/users/server/trpc-router.ts b/packages/features/ee/users/server/trpc-router.ts index fd4fec9115..d0789d81da 100644 --- a/packages/features/ee/users/server/trpc-router.ts +++ b/packages/features/ee/users/server/trpc-router.ts @@ -33,6 +33,9 @@ const userBodySchema = User.pick({ avatar: true, }); +/** + * @deprecated in favour of @calcom/lib/getAvatarUrl + */ /** This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url */ export function getAvatarUrlFromUser(user: { avatar: string | null; diff --git a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx index d6cc851900..328644da30 100644 --- a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx +++ b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx @@ -2,7 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import type { Props } from "react-select"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { classNames } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -63,7 +63,7 @@ export const ChildrenEventTypeSelect = ({ ()({ brandColor: true, darkBrandColor: true, theme: true, + organizationId: true, metadata: true, }, }, @@ -93,6 +94,7 @@ const publicEventSelect = Prisma.validator()({ metadata: true, brandColor: true, darkBrandColor: true, + organizationId: true, organization: { select: { name: true, @@ -130,6 +132,7 @@ export const getPublicEvent = async ( brandColor: true, darkBrandColor: true, theme: true, + organizationId: true, organization: { select: { slug: true, @@ -291,23 +294,24 @@ function getUsersFromEvent(event: Event) { if (!owner) { return null; } - const { username, name, weekStart } = owner; - return [{ username, name, weekStart }]; + const { username, name, weekStart, organizationId } = owner; + return [{ username, name, weekStart, organizationId }]; } async function getOwnerFromUsersArray(prisma: PrismaClient, eventTypeId: number) { const { users } = await prisma.eventType.findUniqueOrThrow({ where: { id: eventTypeId }, - select: { users: { select: { username: true, name: true, weekStart: true } } }, + select: { users: { select: { username: true, name: true, weekStart: true, organizationId: true } } }, }); if (!users.length) return null; return [users[0]]; } -function mapHostsToUsers(host: { user: Pick }) { +function mapHostsToUsers(host: { user: Pick }) { return { username: host.user.username, name: host.user.name, weekStart: host.user.weekStart, + organizationId: host.user.organizationId, }; } diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index af669bd647..0975f4d055 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -12,7 +12,7 @@ import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge"; import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner"; import { OrgUpgradeBanner } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem"; import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components"; import { useFlagMap } from "@calcom/features/flags/context/provider"; @@ -797,7 +797,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) { const publicPageUrl = useMemo(() => { if (!user?.org?.id) return `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user?.username}`; - const publicPageUrl = orgBranding?.slug ? getOrgFullDomain(orgBranding.slug) : ""; + const publicPageUrl = orgBranding?.slug ? getOrgFullOrigin(orgBranding.slug) : ""; return publicPageUrl; }, [orgBranding?.slug, user?.username, user?.org?.id]); diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx index bdedad5d1c..d1e5b103b9 100644 --- a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx +++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx @@ -8,7 +8,8 @@ import type { DateRange } from "@calcom/lib/date-ranges"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; -import { Avatar, Button, ButtonGroup, DataTable } from "@calcom/ui"; +import { Button, ButtonGroup, DataTable } from "@calcom/ui"; +import { UserAvatar } from "@calcom/web/components/ui/avatar/UserAvatar"; import { UpgradeTip } from "../../tips/UpgradeTip"; import { TBContext, createTimezoneBuddyStore } from "../store"; @@ -18,6 +19,8 @@ import { TimeDial } from "./TimeDial"; export interface SliderUser { id: number; username: string | null; + name: string | null; + organizationId: number; email: string; timeZone: string; role: MembershipRole; @@ -78,10 +81,17 @@ export function AvailabilitySliderTable() { accessorFn: (data) => data.email, header: "Member", cell: ({ row }) => { - const { username, email, timeZone } = row.original; + const { username, email, timeZone, name, organizationId } = row.original; return (
- +
{username || "No username"} diff --git a/packages/lib/getAvatarUrl.ts b/packages/lib/getAvatarUrl.ts new file mode 100644 index 0000000000..2c971be827 --- /dev/null +++ b/packages/lib/getAvatarUrl.ts @@ -0,0 +1,24 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { AVATAR_FALLBACK } from "@calcom/lib/constants"; +import type { User, Team } from "@calcom/prisma/client"; + +/** + * Gives an organization aware avatar url for a user + * It ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed + */ +export const getUserAvatarUrl = (user: Pick) => { + if (!user.username) return AVATAR_FALLBACK; + // avatar.png automatically redirects to fallback avatar if user doesn't have one + return `${WEBAPP_URL}/${user.username}/avatar.png${ + user.organizationId ? `?orgId=${user.organizationId}` : "" + }`; +}; + +export const getOrgAvatarUrl = (org: { + id: Team["id"]; + slug: Team["slug"]; + requestedSlug: string | null; +}) => { + const slug = org.slug ?? org.requestedSlug; + return `${WEBAPP_URL}/org/${slug}/avatar.png`; +}; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 3f96aa9e36..7637446d80 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -4,7 +4,7 @@ import { getLocationGroupedOptions } from "@calcom/app-store/server"; import type { StripeData } from "@calcom/app-store/stripepayment/lib/server"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import type { LocationObject } from "@calcom/core/location"; -import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; @@ -298,7 +298,7 @@ export default async function getEventTypeById({ const eventTypeUsers: ((typeof eventType.users)[number] & { avatar: string })[] = eventType.users.map( (user) => ({ ...user, - avatar: `${eventType.team?.parent?.slug ? getOrgFullDomain(eventType.team?.parent?.slug) : CAL_URL}/${ + avatar: `${eventType.team?.parent?.slug ? getOrgFullOrigin(eventType.team?.parent?.slug) : CAL_URL}/${ user.username }/avatar.png`, }) @@ -348,7 +348,7 @@ export default async function getEventTypeById({ ...member.user, avatar: `${ eventTypeObject.team?.parent?.slug - ? getOrgFullDomain(eventTypeObject.team?.parent?.slug) + ? getOrgFullOrigin(eventTypeObject.team?.parent?.slug) : CAL_URL }/${member.user.username}/avatar.png`, }; diff --git a/packages/lib/server/getBrand.ts b/packages/lib/server/getBrand.ts index 98ac5d4b3e..ad4066e9f1 100644 --- a/packages/lib/server/getBrand.ts +++ b/packages/lib/server/getBrand.ts @@ -1,4 +1,4 @@ -import { subdomainSuffix, getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { subdomainSuffix, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { prisma } from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -19,7 +19,7 @@ export const getBrand = async (orgId: number | null) => { }); const metadata = teamMetadataSchema.parse(org?.metadata); const slug = (org?.slug || metadata?.requestedSlug) as string; - const fullDomain = getOrgFullDomain(slug); + const fullDomain = getOrgFullOrigin(slug); const domainSuffix = subdomainSuffix(); return { diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 2d1fe4189b..62e2411618 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -1,7 +1,7 @@ import { Prisma } from "@prisma/client"; import { getAppFromSlug } from "@calcom/app-store/utils"; -import { getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin, getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; import prisma, { baseEventTypeSelect } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -30,6 +30,12 @@ export async function getTeamWithMembers(args: { name: true, id: true, bio: true, + organizationId: true, + organization: { + select: { + slug: true, + }, + }, teams: { select: { team: { @@ -163,6 +169,7 @@ export async function getTeamWithMembers(args: { ? obj.user.teams.filter((obj) => obj.team.slug !== orgSlug).map((obj) => obj.team.slug) : null, avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`, + orgOrigin: getOrgFullOrigin(obj.user.organization?.slug || ""), connectedApps: !isTeamView ? credentials?.map((cred) => { const appSlug = cred.app?.slug; diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts index 8463fc4058..3b53cfa0c6 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -1,5 +1,4 @@ -import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; type MeOptions = { @@ -25,9 +24,7 @@ export const meHandler = async ({ ctx }: MeOptions) => { locale: user.locale, timeFormat: user.timeFormat, timeZone: user.timeZone, - avatar: `${user.organization?.slug ? getOrgFullDomain(user.organization.slug) : WEBAPP_URL}/${ - user.username - }/avatar.png`, + avatar: getUserAvatarUrl(user), createdDate: user.createdDate, trialEndsAt: user.trialEndsAt, defaultScheduleId: user.defaultScheduleId, diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts index 3213573854..96f20707a1 100644 --- a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -41,7 +41,9 @@ async function getTeamMembers({ user: { select: { id: true, + organizationId: true, username: true, + name: true, email: true, timeZone: true, defaultScheduleId: true, @@ -63,6 +65,8 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) { if (!member.user.defaultScheduleId) { return { id: member.user.id, + organizationId: member.user.organizationId, + name: member.user.name, username: member.user.username, email: member.user.email, timeZone: member.user.timeZone, @@ -89,6 +93,8 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) { id: member.user.id, username: member.user.username, email: member.user.email, + organizationId: member.user.organizationId, + name: member.user.name, timeZone, role: member.role, defaultScheduleId: member.user.defaultScheduleId ?? -1,