fix: Avatar slug and cal links for cross org users (#12031)

pull/11796/head^2
Hariom Balhara 2023-10-24 16:12:36 +05:30 committed by GitHub
parent 96810b5ba1
commit b934c74c30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 351 additions and 182 deletions

View File

@ -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<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 && (
<OrganizationAvatar
alt={user.username || "user avatar"}
size="lg"
imageSrc={imageSrc}
organizationSlug={user.organization?.slug}
/>
<OrganizationMemberAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />
)}
<input
ref={avatarRef}

View File

@ -5,11 +5,15 @@ 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 { Avatar } from "@calcom/ui";
import { UserAvatar } from "@components/ui/avatar/UserAvatar";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username" | "organizationId"> & {
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 (
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
<Link
key={member.id}
href={{ pathname: `${member.orgOrigin}/${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">
<Avatar size="md" alt={member.name || ""} imageSrc={`/${member.username}/avatar.png`} />
<UserAvatar 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">

View File

@ -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<React.ComponentProps<typeof Avatar>, "alt" | "imageSrc"> & {
user: Pick<User, "organizationId" | "name" | "username">;
/**
* 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 <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
}

View File

@ -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<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: Pick<User, "organizationId" | "name" | "username">[];
};
export function UserAvatarGroup(props: UserAvatarProps) {
const { users, ...rest } = props;
return (
<AvatarGroup
{...rest}
items={users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: getUserAvatarUrl(user),
}))}
/>
);
}

View File

@ -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<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: Pick<User, "organizationId" | "name" | "username">[];
organization: Pick<Team, "slug" | "name">;
};
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 <AvatarGroup {...rest} items={items} />;
}

View File

@ -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<typeof getServerSide
"max-w-3xl px-4 py-24"
)}>
<div className="mb-8 text-center">
<OrganizationAvatar
imageSrc={profile.image}
<OrganizationMemberAvatar
size="xl"
alt={profile.name}
organizationSlug={profile.organizationSlug}
user={{
organizationId: profile.organization?.id,
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 mb-1 text-3xl" data-testid="name-title">
{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<User, "away" | "name" | "username" | "bio" | "verified">[];
themeBasis: string | null;
@ -286,6 +302,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
select: {
slug: true,
name: true,
metadata: true,
},
},
theme: true,
@ -313,6 +330,10 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = 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<UserPageProps> = 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);

View File

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

View File

@ -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<typeof user.organization, "id">),
slug: userOrganization.slug || null,
requestedSlug: userOrganization.metadata?.requestedSlug || null,
}
: null;
return (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
<OrganizationMemberAvatar
previewSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
user={user}
organization={organization}
/>
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>

View File

@ -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
<TextField
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}

View File

@ -11,7 +11,7 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { WEBAPP_URL } from "@calcom/lib/constants";
@ -27,7 +27,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { Avatar, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
@ -35,6 +35,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import Team from "@components/team/screens/Team";
import { UserAvatarGroup } from "@components/ui/avatar/UserAvatarGroup";
import { ssrInit } from "@server/lib/ssr";
@ -111,15 +112,11 @@ function TeamPage({
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1 self-center">
<AvatarGroup
<UserAvatarGroup
truncateAfter={4}
className="flex flex-shrink-0"
size="sm"
items={type.users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: `/${user.username}/avatar.png` || "",
}))}
users={type.users}
/>
</div>
</Link>
@ -149,17 +146,11 @@ function TeamPage({
</span>
</div>
</div>
<AvatarGroup
<UserAvatarGroup
className="mr-6"
size="sm"
truncateAfter={4}
items={team.members
.filter((mem) => 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)}
/>
</Link>
</li>
@ -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 || ""),
};
})
: [];

View File

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

View File

@ -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 (
<>
<AvatarGroup size="sm" className="border-muted" items={uniqueAvatars} />
{entity.orgSlug ? (
<UserAvatarGroupWithOrg
size="sm"
className="border-muted"
organization={{
slug: entity.orgSlug,
name: entity.name || "",
}}
users={shownUsers}
/>
) : (
<UserAvatarGroup size="sm" className="border-muted" users={shownUsers} />
)}
<p className="text-subtle text-sm font-semibold">
{showOnlyProfileName
? profile.name

View File

@ -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 (
<Avatar
data-testid="organization-avatar"
size={size}
imageSrc={imageSrc}
alt={alt}
indicator={
organizationSlug ? (
<div
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-6 w-6" : "h-10 w-10")}>
<img
src={`/org/${organizationSlug}/avatar.png`}
alt={alt}
className="flex h-full items-center justify-center rounded-full"
/>
</div>
) : null
}
/>
);
};
export default OrganizationAvatar;

View File

@ -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<typeof UserAvatar> & {
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 (
<UserAvatar
data-testid="organization-avatar"
size={size}
user={user}
previewSrc={previewSrc}
indicator={
organization ? (
<div
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-6 w-6" : "h-10 w-10")}>
<img
src={getOrgAvatarUrl(organization)}
alt={user.username || ""}
className="flex h-full items-center justify-center rounded-full"
/>
</div>
) : null
}
{...rest}
/>
);
};
export default OrganizationMemberAvatar;

View File

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

View File

@ -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) {
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between truncate sm:flex-row">
<div className="flex">
<Avatar
size="sm"
imageSrc={`${bookerUrl}/${props.member.username}/avatar.png`}
alt={name || ""}
className="h-10 w-10 rounded-full"
/>
<UserAvatar size="sm" user={props.member} className="h-10 w-10 rounded-full" />
<div className="ms-3 inline-block">
<div className="mb-1 flex">
<span className="text-default mr-2 text-sm font-bold leading-4">{name}</span>

View File

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

View File

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

View File

@ -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 = ({
<Avatar
size="mdLg"
className="overflow-visible"
imageSrc={`${orgBranding ? getOrgFullDomain(orgBranding.slug) : CAL_URL}/${
imageSrc={`${orgBranding ? getOrgFullOrigin(orgBranding.slug) : CAL_URL}/${
children.owner.username
}/avatar.png`}
alt={children.owner.name || children.owner.email || ""}

View File

@ -79,6 +79,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
brandColor: true,
darkBrandColor: true,
theme: true,
organizationId: true,
metadata: true,
},
},
@ -93,6 +94,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
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<User, "username" | "name" | "weekStart"> }) {
function mapHostsToUsers(host: { user: Pick<User, "username" | "name" | "weekStart" | "organizationId"> }) {
return {
username: host.user.username,
name: host.user.name,
weekStart: host.user.weekStart,
organizationId: host.user.organizationId,
};
}

View File

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

View File

@ -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 (
<div className="max-w-64 flex flex-shrink-0 items-center gap-2 overflow-hidden">
<Avatar size="sm" alt={username || email} imageSrc={`/${username}/avatar.png`} />
<UserAvatar
size="sm"
user={{
username,
name,
organizationId,
}}
/>
<div className="">
<div className="text-emphasis max-w-64 truncate text-sm font-medium" title={email}>
{username || "No username"}

View File

@ -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<User, "username" | "organizationId">) => {
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`;
};

View File

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

View File

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

View File

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

View File

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

View File

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