chore: Remove all avatar/logo references (#14532)

* chore: Remove all avatar/logo references

* Updated user profile to only use avatarUrl

* fix: minor style issue

* chore: Remove redundant includeTeamLogo

* fix: Use right avatar default in event type list

* fix: placeholder avatar team profile, target

* chore: Add logoUrl/avatarUrl to JWT

* fix: Bunch of org avatar issues, fix members list

* Fix logoUrl on org pages

* More type fixes

* Hopefully final type fixes

* Another round of type fixes

* fix: UserForm ts

* fix: Handle as return types, not input types

* fix: Remove profile and add avatarUrl

* fix: notFound as const

* fix: Seeder avatarUrl params

* revert: Migration changes for easier recovery

* fix: Add explicit type to builder as avatar is now set

* Add logoUrl to unpublished entity

* Avatar test out of scope here

* fix: Use the new avatarUrl after save

* chore: Removed getOrgAvatarUrl/getTeamAvatarUrl

* fix: Update sidebar image immediately after change

* Unable to safe unnamed user, so default

* fix: Unpublished page, add organization test

* Add more tests
This commit is contained in:
Alex van Andel authored and GitHub committed 2024-04-18 21:54:16 -07:00
1 parent 5695ba7c13
commit d25c043046
73 files changed
+644 -770

No files matched your search

@@ -184,7 +184,7 @@ async function updateProfilePhoto(oAuth2Client: Auth.OAuth2Client, userId: numbe
if (userDetails.data?.picture) {
// Using updateMany here since if the user already has a profile it would throw an error because no records were found to update the profile picture
await prisma.user.updateMany({
where: { id: userId, avatarUrl: null, avatar: null },
where: { id: userId, avatarUrl: null },
data: {
avatarUrl: userDetails.data.picture,
},
+8
View File
@@ -10,4 +10,12 @@ module.exports = {
},
],
},
overrides: [
{
files: ["embed-core/playwright/**/*"],
rules: {
"no-restricted-imports": "off",
},
},
],
};
@@ -58,8 +58,6 @@ export async function getServerSession(options: {
where: {
email: token.email.toLowerCase(),
},
// TODO: Re-enable once we get confirmation from compliance that this is okay.
// cacheStrategy: { ttl: 60, swr: 1 },
});
if (!userFromDb) {
@@ -97,8 +95,7 @@ export async function getServerSession(options: {
email_verified: user.emailVerified !== null,
role: user.role,
image: getUserAvatarUrl({
...user,
profile: user.profile,
avatarUrl: user.avatarUrl,
}),
belongsToActiveTeam: token.belongsToActiveTeam,
org: token.org,
@@ -475,6 +475,7 @@ export const AUTH_OPTIONS: AuthOptions = {
select: {
id: true,
username: true,
avatarUrl: true,
name: true,
email: true,
role: true,
@@ -524,6 +525,7 @@ export const AUTH_OPTIONS: AuthOptions = {
id: profileOrg.id,
name: profileOrg.name,
slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "",
logoUrl: profileOrg.logoUrl,
fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""),
domainSuffix: subdomainSuffix(),
}
@@ -47,7 +47,6 @@ export const prefillAvatar = async ({ email }: IPrefillAvatar) => {
const data: Prisma.UserUpdateInput = {};
data.avatarUrl = avatarUrl;
data.avatar = avatar;
await prisma.user.update({
where: { email: email },
@@ -16,6 +16,8 @@ export type OrganizationBranding =
name?: string;
/** acme */
slug: string;
/** logo url */
logoUrl?: string | null;
/** https://acme.cal.com */
fullDomain: string;
/** cal.com */
@@ -43,7 +43,7 @@ export default function OtherTeamListItem(props: Props) {
<div className="item-center flex px-5 py-5">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
alt="Team Logo"
className="inline-flex justify-center"
/>
@@ -51,7 +51,7 @@ const teamProfileFormSchema = z.object({
message: "Url can only have alphanumeric characters(a-z, 0-9) and hyphen(-) symbol.",
})
.min(1, { message: "Url cannot be left empty" }),
logo: z.string(),
logoUrl: z.string().nullable(),
bio: z.string(),
});
@@ -105,7 +105,7 @@ const OtherTeamProfileView = () => {
if (team) {
form.setValue("name", team.name || "");
form.setValue("slug", team.slug || "");
form.setValue("logo", team.logo || "");
form.setValue("logoUrl", team.logoUrl);
form.setValue("bio", team.bio || "");
if (team.slug === null && (team?.metadata as Prisma.JsonObject)?.requestedSlug) {
form.setValue("slug", ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "");
@@ -179,7 +179,7 @@ const OtherTeamProfileView = () => {
handleSubmit={(values) => {
if (team) {
const variables = {
logo: values.logo,
logoUrl: values.logoUrl,
name: values.name,
slug: values.slug,
bio: values.bio,
@@ -193,18 +193,16 @@ const OtherTeamProfileView = () => {
<div className="flex items-center">
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => (
name="logoUrl"
render={({ field: { value, onChange } }) => (
<>
<Avatar alt="" imageSrc={getPlaceholderAvatar(value, team?.name as string)} size="lg" />
<Avatar alt="" imageSrc={getPlaceholderAvatar(value, team?.name)} size="lg" />
<div className="ms-4">
<ImageUploader
target="avatar"
target="logo"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo);
}}
handleAvatarChange={onChange}
imageSrc={value}
/>
</div>
@@ -218,15 +216,13 @@ const OtherTeamProfileView = () => {
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
render={({ field: { value, onChange } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value);
}}
onChange={(e) => onChange(e?.target.value)}
/>
</div>
)}
@@ -234,7 +230,7 @@ const OtherTeamProfileView = () => {
<Controller
control={form.control}
name="slug"
render={({ field: { value } }) => (
render={({ field: { value, onChange } }) => (
<div className="mt-8">
<TextField
name="slug"
@@ -245,7 +241,7 @@ const OtherTeamProfileView = () => {
}
onChange={(e) => {
form.clearErrors("slug");
form.setValue("slug", slugify(e?.target.value, true));
onChange(slugify(e?.target.value, true));
}}
/>
</div>
@@ -39,14 +39,14 @@ import { useOrgBranding } from "../../../organizations/context/provider";
const orgProfileFormSchema = z.object({
name: z.string(),
logo: z.string().nullable(),
logoUrl: z.string().nullable(),
banner: z.string().nullable(),
bio: z.string(),
});
type FormValues = {
name: string;
logo: string | null;
logoUrl: string | null;
banner: string | null;
bio: string;
slug: string;
@@ -111,7 +111,7 @@ const OrgProfileView = () => {
const defaultValues: FormValues = {
name: currentOrganisation?.name || "",
logo: currentOrganisation?.logo || "",
logoUrl: currentOrganisation?.logoUrl,
banner: currentOrganisation?.bannerUrl || "",
bio: currentOrganisation?.bio || "",
slug:
@@ -177,7 +177,7 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
},
onSuccess: async (res) => {
reset({
logo: (res.data?.logo || "") as string,
logoUrl: res.data?.logoUrl,
name: (res.data?.name || "") as string,
bio: (res.data?.bio || "") as string,
slug: defaultValues["slug"],
@@ -201,7 +201,7 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
form={form}
handleSubmit={(values) => {
const variables = {
logo: values.logo,
logoUrl: values.logoUrl,
name: values.name,
slug: values.slug,
bio: values.bio,
@@ -214,36 +214,29 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
<div className="flex items-center">
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => {
const showRemoveLogoButton = !!value;
name="logoUrl"
render={({ field: { value, onChange } }) => {
const showRemoveLogoButton = value !== null;
return (
<>
<Avatar
data-testid="profile-upload-avatar"
alt={defaultValues.name || ""}
imageSrc={getPlaceholderAvatar(value, defaultValues.name as string)}
data-testid="profile-upload-logo"
alt={form.getValues("name")}
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
size="lg"
/>
<div className="ms-4">
<div className="flex gap-2">
<ImageUploader
target="avatar"
target="logo"
id="avatar-upload"
buttonMsg={t("upload_logo")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo, { shouldDirty: true });
}}
imageSrc={value || undefined}
handleAvatarChange={onChange}
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
/>
{showRemoveLogoButton && (
<Button
color="secondary"
onClick={() => {
form.setValue("logo", null, { shouldDirty: true });
}}>
<Button color="secondary" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
@@ -259,7 +252,7 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
<Controller
control={form.control}
name="banner"
render={({ field: { value } }) => {
render={({ field: { value, onChange } }) => {
const showRemoveBannerButton = !!value;
return (
@@ -279,18 +272,12 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
uploadInstruction={t("org_banner_instructions", { height: 500, width: 1500 })}
id="banner-upload"
buttonMsg={t("upload_banner")}
handleAvatarChange={(newBanner) => {
form.setValue("banner", newBanner, { shouldDirty: true });
}}
handleAvatarChange={onChange}
imageSrc={value || undefined}
triggerButtonColor={showRemoveBannerButton ? "secondary" : "primary"}
/>
{showRemoveBannerButton && (
<Button
color="destructive"
onClick={() => {
form.setValue("banner", "", { shouldDirty: true });
}}>
<Button color="destructive" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
@@ -95,7 +95,7 @@ export default function TeamListItem(props: Props) {
<div className="item-center flex px-5 py-5">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(team?.logo || team?.parent?.logo, team?.name as string)}
imageSrc={getPlaceholderAvatar(team?.logoUrl || team?.parent?.logoUrl, team?.name as string)}
alt="Team logo"
className="inline-flex justify-center"
/>
@@ -7,13 +7,13 @@ export type UserAdminTeams = (Prisma.TeamGetPayload<{
select: {
id: true;
name: true;
logo: true;
logoUrl: true;
credentials?: true;
parent?: {
select: {
id: true;
name: true;
logo: true;
logoUrl: true;
credentials: true;
};
};
@@ -45,14 +45,14 @@ const getUserAdminTeams = async ({
select: {
id: true,
name: true,
logo: true,
logoUrl: true,
...(includeCredentials && { credentials: true }),
...(getParentInfo && {
parent: {
select: {
id: true,
name: true,
logo: true,
logoUrl: true,
credentials: true,
},
},
@@ -72,7 +72,7 @@ const getUserAdminTeams = async ({
select: {
id: true,
name: true,
avatar: true,
avatarUrl: true,
...(includeCredentials && { credentials: true }),
},
});
@@ -81,7 +81,7 @@ const getUserAdminTeams = async ({
const userObject = {
id: user.id,
name: user.name || "me",
logo: user?.avatar === "" ? null : user?.avatar,
logoUrl: user?.avatarUrl, // bit ugly, no?
isUser: true,
credentials: includeCredentials ? user.credentials : [],
parent: null,
+1 -1
View File
@@ -13,6 +13,6 @@ export interface PendingMember {
id?: number;
username: string | null;
role: MembershipRole;
avatar: string | null;
avatarUrl?: string | null;
sendInviteEmail?: boolean;
}
@@ -98,7 +98,7 @@ const ProfileView = () => {
isPending,
error,
} = trpc.viewer.teams.get.useQuery(
{ teamId, includeTeamLogo: true },
{ teamId },
{
enabled: !!teamId,
}
@@ -259,12 +259,14 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
},
async onSuccess(res) {
reset({
logo: (res?.logo || "") as string,
logo: res?.logoUrl,
name: (res?.name || "") as string,
bio: (res?.bio || "") as string,
slug: res?.slug as string,
});
await utils.viewer.teams.get.invalidate();
// TODO: Not all changes require list invalidation
await utils.viewer.teams.list.invalidate();
showToast(t("your_team_updated_successfully"), "success");
},
});
@@ -320,7 +322,7 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
}}>
<div className="border-subtle border-x px-4 py-8 sm:px-6">
{!team.parent && (
<div className="flex items-center">
<div className="flex items-center pb-8">
<Controller
control={form.control}
name="logo"
@@ -330,25 +332,22 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
return (
<>
<Avatar
alt={defaultValues.name || ""}
imageSrc={getPlaceholderAvatar(value, team?.name as string)}
alt={form.getValues("name")}
data-testid="profile-upload-logo"
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
size="lg"
/>
<div className="ms-4 flex gap-2">
<ImageUploader
target="avatar"
target="logo"
id="avatar-upload"
buttonMsg={t("upload_logo")}
handleAvatarChange={onChange}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
imageSrc={value ?? undefined}
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
/>
{showRemoveLogoButton && (
<Button
color="secondary"
onClick={() => {
onChange(null);
}}>
<Button color="secondary" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
@@ -2,6 +2,7 @@
import { noop } from "lodash";
import { Controller, useForm } from "react-hook-form";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localeOptions } from "@calcom/lib/i18n";
import { nameOfDay } from "@calcom/lib/weekday";
@@ -35,7 +36,7 @@ type OptionValues = {
identityProvider: Option;
};
type FormValues = Pick<User, "avatar" | "name" | "username" | "email" | "bio"> & OptionValues;
type FormValues = Pick<User, "avatarUrl" | "name" | "username" | "email" | "bio"> & OptionValues;
export const UserForm = ({
defaultValues,
@@ -76,9 +77,10 @@ export const UserForm = ({
{ value: "SAML", label: "SAML" },
];
const defaultLocale = defaultValues?.locale || localeOptions[0].value;
const form = useForm<FormValues>({
defaultValues: {
avatar: defaultValues?.avatar,
avatarUrl: defaultValues?.avatarUrl,
name: defaultValues?.name,
username: defaultValues?.username,
email: defaultValues?.email,
@@ -116,19 +118,25 @@ export const UserForm = ({
<div className="flex items-center">
<Controller
control={form.control}
name="avatar"
render={({ field: { value } }) => (
name="avatarUrl"
render={({ field: { value, onChange } }) => (
<>
<Avatar alt="" imageSrc={value} size="lg" />
<Avatar
alt={form.getValues("name") || ""}
imageSrc={getUserAvatarUrl({
avatarUrl: value,
})}
size="lg"
/>
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg="Change avatar"
handleAvatarChange={(newAvatar) => {
form.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
handleAvatarChange={onChange}
imageSrc={getUserAvatarUrl({
avatarUrl: value,
})}
/>
</div>
</>
@@ -1,8 +1,6 @@
import { z } from "zod";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { RedirectType } from "@calcom/prisma/enums";
import { _UserModel as User } from "@calcom/prisma/zod";
import type { inferRouterOutputs } from "@calcom/trpc";
@@ -32,30 +30,9 @@ const userBodySchema = User.pick({
identityProvider: true,
// away: true,
role: true,
avatar: true,
avatarUrl: 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;
username: string | null;
email: string;
}) {
if (!user.avatar || !user.username) return AVATAR_FALLBACK;
return `${WEBAPP_URL}/${user.username}/avatar.png`;
}
/** @see https://www.prisma.io/docs/concepts/components/prisma-client/excluding-fields#excluding-the-password-field */
function exclude<UserType, Key extends keyof UserType>(user: UserType, keys: Key[]): Omit<UserType, Key> {
for (const key of keys) {
delete user[key];
}
return user;
}
/** Reusable logic that checks for admin permissions and if the requested user exists */
//const authedAdminWithUserMiddleware = middleware();
@@ -83,15 +60,7 @@ export const userAdminRouter = router({
const { prisma } = ctx;
// TODO: Add search, pagination, etc.
const users = await prisma.user.findMany();
return users.map((user) => ({
...user,
/**
* FIXME: This should be either a prisma extension or middleware
* @see https://www.prisma.io/docs/concepts/components/prisma-client/middleware
* @see https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions/result
**/
avatar: getAvatarUrlFromUser(user),
}));
return users;
}),
add: authedAdminProcedure.input(userBodySchema).mutation(async ({ ctx, input }) => {
const { prisma } = ctx;
@@ -7,6 +7,7 @@ import { getAppFromSlug } from "@calcom/app-store/utils";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
@@ -177,8 +178,7 @@ export const getPublicEvent = async (
name: users[0].name,
weekStart: users[0].weekStart,
image: getUserAvatarUrl({
...users[0],
profile: users[0].profile,
avatarUrl: users[0].avatarUrl,
}),
brandColor: users[0].brandColor,
darkBrandColor: users[0].darkBrandColor,
@@ -336,21 +336,10 @@ function getProfileFromEvent(event: Event) {
weekStart,
image: team
? undefined
: // TODO: There must be a better way to do this, maybe a prisma middleware?
// This should come pre-proccessed from the database IMO instead of replacing everywhere
getUserAvatarUrl({
username: username || "",
profile: {
id: nonTeamprofile?.id || null,
username: username || null,
organizationId: nonTeamprofile?.organization?.id || null,
organization: nonTeamprofile?.organization
? { ...nonTeamprofile?.organization, requestedSlug: null }
: null,
},
: getUserAvatarUrl({
avatarUrl: nonTeamprofile?.avatarUrl,
}),
logo: !team ? undefined : team.logoUrl,
logo: !team ? undefined : getPlaceholderAvatar(team.logoUrl, team.name),
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
theme: profile.theme,
@@ -114,7 +114,7 @@ export const TeamsFilter = ({
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
imageSrc={getPlaceholderAvatar(team.logoUrl, team?.name as string)}
size="xs"
/>
}
@@ -129,8 +129,8 @@ export const TeamAndSelfList = () => {
}}
icon={
<Avatar
alt={team?.name || ""}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
alt={team.name || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
size="xs"
/>
}
@@ -122,7 +122,7 @@ export interface IResultTeamList {
id: number;
slug: string | null;
name: string | null;
logo: string | null;
logoUrl: string | null;
userId?: number;
isOrg?: boolean;
}
@@ -1195,7 +1195,7 @@ export const insightsRouter = router({
id: true,
slug: true,
name: true,
logo: true,
logoUrl: true,
},
});
const orgTeam = await ctx.insightsDb.team.findUnique({
@@ -1206,7 +1206,7 @@ export const insightsRouter = router({
id: true,
slug: true,
name: true,
logo: true,
logoUrl: true,
},
});
if (!orgTeam) {
@@ -1218,11 +1218,11 @@ export const insightsRouter = router({
id: orgTeam.id,
slug: orgTeam.slug,
name: orgTeam.name,
logo: orgTeam.logo,
logoUrl: orgTeam.logoUrl,
isOrg: true,
},
...teamsFromOrg.map(
(team: Prisma.TeamGetPayload<{ select: { id: true; slug: true; name: true; logo: true } }>) => {
(team: Prisma.TeamGetPayload<{ select: { id: true; slug: true; name: true; logoUrl: true } }>) => {
return {
...team,
};
@@ -1241,7 +1241,7 @@ export const insightsRouter = router({
select: {
id: true,
name: true,
logo: true,
logoUrl: true,
slug: true,
metadata: true,
},
@@ -149,7 +149,7 @@ const useTabs = () => {
tab.avatar = getUserAvatarUrl(user);
} else if (tab.href === "/settings/organizations") {
tab.name = orgBranding?.name || "organization";
tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`;
tab.avatar = getPlaceholderAvatar(orgBranding?.logoUrl, orgBranding?.name);
} else if (
tab.href === "/settings/security" &&
user?.identityProvider === IdentityProvider.GOOGLE &&
@@ -195,32 +195,12 @@ interface SettingsSidebarContainerProps {
bannersHeight?: number;
}
const SettingsSidebarContainer = ({
className = "",
navigationIsOpenedOnMobile,
bannersHeight,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const TeamListCollapsible = () => {
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { t } = useLocale();
const tabsWithPermissions = useTabs();
const [teamMenuState, setTeamMenuState] =
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
{
teamId: number | undefined;
teamMenuOpen: boolean;
}[]
>();
const { data: teams } = trpc.viewer.teams.list.useQuery();
const session = useSession();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
const searchParams = useCompatSearchParams();
useEffect(() => {
if (teams) {
const teamStates = teams?.map((team) => ({
@@ -237,6 +217,141 @@ const SettingsSidebarContainer = ({
}
}, [searchParams?.get("id"), teams]);
return (
<>
{teams &&
teamMenuState &&
teams.map((team, index: number) => {
if (!teamMenuState[index]) {
return null;
}
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
return (
<Collapsible
className="cursor-pointer"
key={team.id}
open={teamMenuState[index].teamMenuOpen}
onOpenChange={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<CollapsibleTrigger asChild>
<div
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none"
onClick={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
) : (
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{!team.parentId && (
<img
src={getPlaceholderAvatar(team.logoUrl, team.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={team.name || "Team logo"}
/>
)}
<p className="w-1/2 truncate leading-normal">{team.name}</p>
{!team.accepted && (
<Badge className="ms-3" variant="orange">
Inv.
</Badge>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
{team.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
<VerticalTabItem
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{(team.role === MembershipRole.OWNER ||
team.role === MembershipRole.ADMIN ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
<VerticalTabItem
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
</>
) : null}
</>
)}
</CollapsibleContent>
</Collapsible>
);
})}
</>
);
};
const SettingsSidebarContainer = ({
className = "",
navigationIsOpenedOnMobile,
bannersHeight,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const tabsWithPermissions = useTabs();
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
{
teamId: number | undefined;
teamMenuOpen: boolean;
}[]
>();
const session = useSession();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
// Same as above but for otherTeams
useEffect(() => {
if (otherTeams) {
@@ -299,7 +414,7 @@ const SettingsSidebarContainer = ({
<img
className="h-4 w-4 rounded-full ltr:mr-3 rtl:ml-3"
src={tab?.avatar}
alt="User Avatar"
alt="Organization Logo"
/>
)}
<Skeleton
@@ -331,7 +446,7 @@ const SettingsSidebarContainer = ({
{tab.name === "teams" && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
<div data-testid="tab-teams" className={`${!tab.children?.length ? "mb-3" : ""}`}>
<Link href={tab.href}>
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none">
{tab && tab.icon && (
@@ -349,112 +464,7 @@ const SettingsSidebarContainer = ({
</Skeleton>
</div>
</Link>
{teams &&
teamMenuState &&
teams.map((team, index: number) => {
if (!teamMenuState[index]) {
return null;
}
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
return (
<Collapsible
className="cursor-pointer"
key={team.id}
open={teamMenuState[index].teamMenuOpen}
onOpenChange={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<CollapsibleTrigger asChild>
<div
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none"
onClick={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
) : (
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{!team.parentId && (
<img
src={getPlaceholderAvatar(team.logo, team?.name as string)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={team.name || "Team logo"}
/>
)}
<p className="w-1/2 truncate leading-normal">{team.name}</p>
{!team.accepted && (
<Badge className="ms-3" variant="orange">
Inv.
</Badge>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
{team.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
<VerticalTabItem
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{(team.role === MembershipRole.OWNER ||
team.role === MembershipRole.ADMIN ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
<VerticalTabItem
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
</>
) : null}
</>
)}
</CollapsibleContent>
</Collapsible>
);
})}
<TeamListCollapsible />
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
<VerticalTabItem
name={t("add_a_team")}
@@ -530,7 +540,7 @@ const SettingsSidebarContainer = ({
</div>
{!otherTeam.parentId && (
<img
src={getPlaceholderAvatar(otherTeam.logo, otherTeam?.name as string)}
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={otherTeam.name || "Team logo"}
/>
@@ -376,7 +376,7 @@ const SettingsSidebarContainer = ({
</div>
{!team.parentId && (
<img
src={getPlaceholderAvatar(team.logo, team?.name as string)}
src={getPlaceholderAvatar(team.logoUrl, team.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={team.name || "Team logo"}
/>
@@ -523,7 +523,7 @@ const SettingsSidebarContainer = ({
</div>
{!otherTeam.parentId && (
<img
src={getPlaceholderAvatar(otherTeam.logo, otherTeam?.name as string)}
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={otherTeam.name || "Team logo"}
/>
+2 -1
View File
@@ -49,6 +49,7 @@ import {
TOP_BANNER_HEIGHT,
WEBAPP_URL,
} from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useFormbricks } from "@calcom/lib/formbricks-client";
import getBrandColours from "@calcom/lib/getBrandColours";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
@@ -922,7 +923,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
<div className="flex items-center gap-2 font-medium">
<Avatar
alt={`${orgBranding.name} logo`}
imageSrc={`${orgBranding.fullDomain}/org/${orgBranding.slug}/avatar.png`}
imageSrc={getPlaceholderAvatar(orgBranding.logoUrl, orgBranding.name)}
size="xsm"
/>
<p className="text line-clamp-1 text-sm">
@@ -4,6 +4,7 @@ import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
@@ -25,6 +26,7 @@ export interface User {
email: string;
timeZone: string;
role: MembershipRole;
avatarUrl: string | null;
accepted: boolean;
disableImpersonation: boolean;
completedOnboarding: boolean;
@@ -163,10 +165,16 @@ export function UserListTable() {
accessorFn: (data) => data.email,
header: `Member (${totalDBRowCount})`,
cell: ({ row }) => {
const { username, email } = row.original;
const { username, email, avatarUrl } = row.original;
return (
<div className="flex items-center gap-2">
<Avatar size="sm" alt={username || email} imageSrc={`${domain}/${username}/avatar.png`} />
<Avatar
size="sm"
alt={username || email}
imageSrc={getUserAvatarUrl({
avatarUrl,
})}
/>
<div className="">
<div
data-testid={`member-${username}-username`}
@@ -2,7 +2,8 @@
import { orderBy } from "lodash";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { getOrgAvatarUrl, getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
@@ -184,9 +185,7 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
slug: profile.username,
name: profile.name,
image: getUserAvatarUrl({
username: profile.username,
avatarUrl: profile.avatarUrl,
profile: profile,
}),
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
},
@@ -255,18 +254,9 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
? orgMembership
: membership.role,
profile: {
image: team.parentId
? getOrgAvatarUrl({
slug: team.parent?.slug || null,
logoUrl: team.parent?.logoUrl,
requestedSlug: team.slug,
})
: getTeamAvatarUrl({
slug: team.slug,
logoUrl: team.logoUrl,
requestedSlug: team.metadata?.requestedSlug ?? null,
organizationId: team.parentId,
}),
image: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
name: team.name,
slug,
},
+4 -44
View File
@@ -1,61 +1,21 @@
import { z } from "zod";
import { AVATAR_FALLBACK, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import type { Team, User } from "@calcom/prisma/client";
import type { UserProfile } from "@calcom/types/UserProfile";
import { AVATAR_FALLBACK, CAL_URL } from "@calcom/lib/constants";
import type { User } 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
* It should always return a fully formed url
*/
export const getUserAvatarUrl = (
user:
| (Pick<User, "username"> & {
profile: Omit<UserProfile, "upId">;
avatarUrl: string | null;
})
| undefined
) => {
export const getUserAvatarUrl = (user: Pick<User, "avatarUrl"> | undefined) => {
if (user?.avatarUrl) {
const isAbsoluteUrl = z.string().url().safeParse(user.avatarUrl).success;
if (isAbsoluteUrl) {
return user.avatarUrl;
} else {
return CAL_URL + user.avatarUrl;
}
}
if (!user?.username) return CAL_URL + AVATAR_FALLBACK;
// avatar.png automatically redirects to fallback avatar if user doesn't have one
return `${CAL_URL}/${user.profile?.username}/avatar.png${
user.profile?.organizationId ? `?orgId=${user.profile.organizationId}` : ""
}`;
};
export function getTeamAvatarUrl(
team: Pick<Team, "slug"> & {
organizationId?: number | null;
logoUrl?: string | null;
requestedSlug?: string | null;
}
) {
if (team.logoUrl) {
return team.logoUrl;
}
const slug = team.slug ?? team.requestedSlug;
return `${WEBAPP_URL}/team/${slug}/avatar.png${team.organizationId ? `?orgId=${team.organizationId}` : ""}`;
}
export const getOrgAvatarUrl = (
org: Pick<Team, "slug"> & {
logoUrl?: string | null;
requestedSlug?: string | null;
}
) => {
if (org.logoUrl) {
return org.logoUrl;
}
const slug = org.slug ?? org.requestedSlug;
return `${WEBAPP_URL}/org/${slug}/avatar.png`;
return CAL_URL + AVATAR_FALLBACK;
};
+1 -1
View File
@@ -11,7 +11,7 @@ export const getBrand = async (orgId: number | null) => {
id: orgId,
},
select: {
logo: true,
logoUrl: true,
name: true,
slug: true,
metadata: true,
+2 -5
View File
@@ -11,7 +11,6 @@ import {
unlockedManagedEventTypeProps,
} from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
import { getBookerBaseUrlSync } from "../../../getBookerUrl/client";
import { getTeam, getOrg } from "../../repository/team";
import { UserRepository } from "../../repository/user";
@@ -23,7 +22,6 @@ export async function getTeamWithMembers(args: {
slug?: string;
userId?: number;
orgSlug?: string | null;
includeTeamLogo?: boolean;
isTeamView?: boolean;
currentOrg?: Pick<Team, "id"> | null;
/**
@@ -31,7 +29,7 @@ export async function getTeamWithMembers(args: {
*/
isOrgView?: boolean;
}) {
const { id, slug, currentOrg: _currentOrg, userId, orgSlug, isTeamView, isOrgView, includeTeamLogo } = args;
const { id, slug, currentOrg: _currentOrg, userId, orgSlug, isTeamView, isOrgView } = args;
// This should improve performance saving already app data found.
const appDataMap = new Map();
@@ -87,7 +85,6 @@ export async function getTeamWithMembers(args: {
name: true,
slug: true,
isOrganization: true,
...(!!includeTeamLogo ? { logo: true } : {}),
logoUrl: true,
bio: true,
hideBranding: true,
@@ -101,6 +98,7 @@ export async function getTeamWithMembers(args: {
name: true,
isPrivate: true,
isOrganization: true,
logoUrl: true,
metadata: true,
},
},
@@ -183,7 +181,6 @@ export async function getTeamWithMembers(args: {
.filter((membership) => membership.team.id !== teamOrOrg.id)
.map((membership) => membership.team.slug)
: null,
avatar: `${WEBAPP_URL}/${m.user.username}/avatar.png`,
bookerUrl: getBookerBaseUrlSync(profile?.organization?.slug || ""),
connectedApps: !isTeamView
? credentials?.map((cred) => {
@@ -40,6 +40,7 @@ const organizationSelect = {
slug: true,
name: true,
metadata: true,
logoUrl: true,
calVideoLogo: true,
bannerUrl: true,
};
-1
View File
@@ -30,7 +30,6 @@ const userSelect = Prisma.validator<Prisma.UserSelect>()({
email: true,
emailVerified: true,
bio: true,
avatar: true,
avatarUrl: true,
timeZone: true,
startTime: true,
+42 -2
View File
@@ -193,12 +193,53 @@ export const buildCalendarEvent = (
};
type UserPayload = Prisma.UserGetPayload<{
include: {
select: {
locked: true;
name: true;
email: true;
timeZone: true;
username: true;
id: true;
allowDynamicBooking: true;
credentials: true;
destinationCalendar: true;
availability: true;
selectedCalendars: true;
schedules: true;
avatarUrl: true;
away: true;
backupCodes: true;
bio: true;
brandColor: true;
completedOnboarding: true;
createdDate: true;
bufferTime: true;
darkBrandColor: true;
defaultScheduleId: true;
disableImpersonation: true;
emailVerified: true;
endTime: true;
hideBranding: true;
identityProvider: true;
identityProviderId: true;
invitedTo: true;
locale: true;
metadata: true;
role: true;
startTime: true;
theme: true;
appTheme: true;
timeFormat: true;
trialEndsAt: true;
twoFactorEnabled: true;
twoFactorSecret: true;
verified: true;
weekStart: true;
organizationId: true;
allowSEOIndexing: true;
receiveMonthlyDigestEmail: true;
movedToProfileId: true;
isPlatformManaged: true;
};
}>;
export const buildUser = <T extends Partial<UserPayload>>(
@@ -213,7 +254,6 @@ export const buildUser = <T extends Partial<UserPayload>>(
id: 0,
allowDynamicBooking: true,
availability: [],
avatar: "",
avatarUrl: "",
away: false,
backupCodes: null,
-3
View File
@@ -5,7 +5,6 @@
*/
import { createTeamAndAddUsers, createUserAndEventType } from "./seed-utils";
const avatar = "";
const getEventTypes = (numberOfEventTypes: number) => {
const eventTypes = Array<{
title: string;
@@ -46,7 +45,6 @@ async function createTeamsWithEventTypes({
password: `enterprise-member-${i + 1}`,
username: `enterprise-member-${i + 1}`,
theme: "light",
avatar,
},
})
);
@@ -86,7 +84,6 @@ export default async function main() {
password: "enterprise",
username: `enterprise`,
theme: "light",
avatar,
},
eventTypes: getEventTypes(100),
});
+1 -1
View File
@@ -13,7 +13,7 @@ import { BookingStatus } from "@calcom/prisma/enums";
import { createUserAndEventType } from "./seed-utils";
async function createManyDifferentUsersWithDifferentEventTypesAndBookings({
async function _createManyDifferentUsersWithDifferentEventTypesAndBookings({
tillUser,
startFrom = 0,
}: {
+2 -2
View File
@@ -24,7 +24,7 @@ export async function createUserAndEventType({
timeZone?: string;
role?: UserPermissionRole;
theme?: "dark" | "light";
avatar?: string;
avatarUrl?: string | null;
};
eventTypes?: Array<
Prisma.EventTypeUncheckedCreateInput & {
@@ -38,7 +38,7 @@ export async function createUserAndEventType({
appId: string;
} | null)[];
}) {
const { password, ...restOfUser } = user;
const { password: _password, ...restOfUser } = user;
const userData = {
...restOfUser,
emailVerified: new Date(),
+6 -6
View File
@@ -56,17 +56,17 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
name: true,
email: true,
bio: true,
avatar: true,
avatarUrl: true,
theme: true,
},
},
successRedirectUrl: true,
team: {
select: {
logo: true,
logoUrl: true,
parent: {
select: {
logo: true,
logoUrl: true,
name: true,
},
},
@@ -111,7 +111,7 @@ export const availiblityPageEventTypeSelect = Prisma.validator<Prisma.EventTypeS
users: {
select: {
id: true,
avatar: true,
avatarUrl: true,
name: true,
username: true,
hideBranding: true,
@@ -120,10 +120,10 @@ export const availiblityPageEventTypeSelect = Prisma.validator<Prisma.EventTypeS
},
team: {
select: {
logo: true,
logoUrl: true,
parent: {
select: {
logo: true,
logoUrl: true,
name: true,
},
},
@@ -26,7 +26,6 @@ import { ZWorkflowOrderInputSchema } from "./workflowOrder.schema";
type AppsRouterHandlerCache = {
me?: typeof import("./me.handler").meHandler;
shouldVerifyEmail?: typeof import("./shouldVerifyEmail.handler").shouldVerifyEmailHandler;
avatar?: typeof import("./avatar.handler").avatarHandler;
deleteMe?: typeof import("./deleteMe.handler").deleteMeHandler;
deleteMeWithoutPassword?: typeof import("./deleteMeWithoutPassword.handler").deleteMeWithoutPasswordHandler;
away?: typeof import("./away.handler").awayHandler;
@@ -64,19 +63,6 @@ const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
export const loggedInViewerRouter = router({
me,
avatar: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.avatar) {
UNSTABLE_HANDLER_CACHE.avatar = (await import("./avatar.handler")).avatarHandler;
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.avatar) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.avatar({ ctx });
}),
deleteMe: authedProcedure.input(ZDeleteMeInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.deleteMe) {
UNSTABLE_HANDLER_CACHE.deleteMe = (await import("./deleteMe.handler")).deleteMeHandler;
@@ -1,22 +0,0 @@
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type AvatarOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const avatarHandler = async ({ ctx }: AvatarOptions) => {
const data = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
avatar: true,
},
});
return {
avatar: data?.avatar,
};
};
@@ -1 +0,0 @@
export {};
@@ -28,7 +28,7 @@ type TeamQuery = Prisma.TeamGetPayload<{
select: typeof import("@calcom/prisma/selects/credential").credentialForCalendarServiceSelect;
};
name: true;
logo: true;
logoUrl: true;
members: {
select: {
role: true;
@@ -72,7 +72,7 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
select: credentialForCalendarServiceSelect,
},
name: true,
logo: true,
logoUrl: true,
members: {
where: {
userId: user.id,
@@ -88,7 +88,7 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
select: credentialForCalendarServiceSelect,
},
name: true,
logo: true,
logoUrl: true,
members: {
where: {
userId: user.id,
@@ -150,7 +150,7 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
return {
teamId: team.id,
name: team.name,
logo: team.logo,
logoUrl: team.logoUrl,
credentialId: c.id,
isAdmin:
team.members[0].role === MembershipRole.ADMIN ||
@@ -1,5 +1,6 @@
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { withRoleCanCreateEntity } from "@calcom/lib/entityPermissionUtils";
import { getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { PrismaClient } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -16,7 +17,6 @@ type TeamsAndUserProfileOptions = {
export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOptions) => {
const { prisma } = ctx;
const profile = ctx.user.profile;
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
@@ -26,7 +26,6 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti
id: true,
username: true,
name: true,
avatar: true,
teams: {
where: {
accepted: true,
@@ -37,6 +36,7 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti
select: {
id: true,
isOrganization: true,
logoUrl: true,
name: true,
slug: true,
metadata: true,
@@ -72,8 +72,7 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti
name: user.name,
slug: user.username,
image: getUserAvatarUrl({
...user,
profile: profile,
avatarUrl: user.avatarUrl,
}),
readOnly: false,
},
@@ -81,11 +80,7 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti
teamId: membership.team.id,
name: membership.team.name,
slug: membership.team.slug ? `team/${membership.team.slug}` : null,
image: getTeamAvatarUrl({
slug: membership.team.slug,
requestedSlug: membership.team.metadata?.requestedSlug ?? null,
organizationId: membership.team.parentId,
}),
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
role: membership.role,
readOnly: !withRoleCanCreateEntity(membership.role),
})),
@@ -79,8 +79,6 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const data: Prisma.UserUpdateInput = {
...rest,
// DO NOT OVERWRITE AVATAR.
avatar: undefined,
metadata: userMetadata,
secondaryEmails: undefined,
};
@@ -198,22 +196,14 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
data.identityProviderId = null;
}
// if defined AND a base 64 string, upload and set the avatar URL
if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) {
const avatar = await resizeBase64Image(input.avatar);
// if defined AND a base 64 string, upload and update the avatar URL
if (input.avatarUrl && input.avatarUrl.startsWith("data:image/png;base64,")) {
data.avatarUrl = await uploadAvatar({
avatar,
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
});
// as this is still used in the backwards compatible endpoint, we also write it here
// to ensure no data loss.
data.avatar = avatar;
}
// Unset avatar url if avatar is empty string.
if ("" === input.avatar) {
data.avatarUrl = null;
data.avatar = null;
}
if (input.completedOnboarding) {
const userTeams = await prisma.user.findFirst({
where: {
@@ -374,9 +364,6 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
}
}
// don't return avatar, we don't need it anymore.
delete input.avatar;
if (secondaryEmails.length) {
const recordsToDelete = secondaryEmails
.filter((secondaryEmail) => secondaryEmail.isDeleted)
@@ -13,7 +13,7 @@ export const ZUpdateProfileInputSchema = z.object({
name: z.string().max(FULL_NAME_LENGTH_MAX_LIMIT).optional(),
email: z.string().optional(),
bio: z.string().optional(),
avatar: z.string().nullable().optional(),
avatarUrl: z.string().nullable().optional(),
timeZone: z.string().optional(),
weekStart: z.string().optional(),
hideBranding: z.boolean().optional(),
@@ -30,7 +30,7 @@ export const getOtherTeamHandler = async ({ input }: GetOptions) => {
id: true,
name: true,
slug: true,
logo: true,
logoUrl: true,
bio: true,
metadata: true,
isPrivate: true,
@@ -69,6 +69,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
id: true,
username: true,
email: true,
avatarUrl: true,
timeZone: true,
disableImpersonation: true,
completedOnboarding: true,
@@ -111,6 +112,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
accepted: membership.accepted,
disableImpersonation: user.disableImpersonation,
completedOnboarding: user.completedOnboarding,
avatarUrl: user.avatarUrl,
teams: user.teams
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team
.map((team) => {
@@ -89,7 +89,6 @@ export const listOtherTeamMembers = async ({ input }: ListOptions) => {
username: true,
name: true,
email: true,
avatar: true,
avatarUrl: true,
},
},
@@ -6,6 +6,7 @@ import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { uploadLogo } from "@calcom/lib/server/uploadLogo";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import type { PrismaClient } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -22,6 +23,70 @@ type UpdateOptions = {
input: TUpdateInputSchema;
};
const updateOrganizationSettings = async ({
organizationId,
input,
tx,
}: {
organizationId: number;
input: TUpdateInputSchema;
tx: Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0];
}) => {
// if lockEventTypeCreation isn't given we don't do anything.
if (typeof input.lockEventTypeCreation === "undefined") {
return;
}
await tx.organizationSettings.update({
where: {
organizationId,
},
data: {
lockEventTypeCreationForUsers: !!input.lockEventTypeCreation,
},
});
if (input.lockEventTypeCreation) {
switch (input.lockEventTypeCreationOptions) {
case "HIDE":
await tx.eventType.updateMany({
where: {
teamId: null, // Not assigned to a team
parentId: null, // Not a managed event type
owner: {
profiles: {
some: {
organizationId,
},
},
},
},
data: {
hidden: true,
},
});
break;
case "DELETE":
await tx.eventType.deleteMany({
where: {
teamId: null, // Not assigned to a team
parentId: null, // Not a managed event type
owner: {
profiles: {
some: {
organizationId,
},
},
},
},
});
break;
default:
break;
}
}
};
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
// A user can only have one org so we pass in their currentOrgId here
const currentOrgId = ctx.user?.organization?.id || input.orgId;
@@ -63,6 +128,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), prevOrganisation.metadata);
const data: Prisma.TeamUpdateArgs["data"] = {
logoUrl: input.logoUrl,
name: input.name,
calVideoLogo: input.calVideoLogo,
bio: input.bio,
@@ -88,14 +154,11 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
data.bannerUrl = null;
}
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
data.logo = input.logo;
if (input.logoUrl && input.logoUrl.startsWith("data:image/png;base64,")) {
data.logoUrl = await uploadLogo({
logo: input.logo,
logo: await resizeBase64Image(input.logoUrl),
teamId: currentOrgId,
});
} else if (typeof input.logo !== "undefined" && !input.logo) {
data.logo = data.logoUrl = null;
}
if (input.slug) {
@@ -122,55 +185,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
data,
});
await tx.organizationSettings.update({
where: {
organizationId: currentOrgId,
},
data: {
lockEventTypeCreationForUsers: !!input.lockEventTypeCreation,
},
});
if (input.lockEventTypeCreation) {
switch (input.lockEventTypeCreationOptions) {
case "HIDE":
await tx.eventType.updateMany({
where: {
teamId: null, // Not assigned to a team
parentId: null, // Not a managed event type
owner: {
profiles: {
some: {
organizationId: currentOrgId,
},
},
},
},
data: {
hidden: true,
},
});
break;
case "DELETE":
await tx.eventType.deleteMany({
where: {
teamId: null, // Not assigned to a team
parentId: null, // Not a managed event type
owner: {
profiles: {
some: {
organizationId: currentOrgId,
},
},
},
},
});
break;
default:
break;
}
}
await updateOrganizationSettings({ tx, input, organizationId: currentOrgId });
return updatedOrganisation;
});
@@ -178,7 +193,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
// Sync Services: Close.com
if (prevOrganisation) closeComUpdateTeam(prevOrganisation, updatedOrganisation);
return { update: true, userId: ctx.user.id, data };
return { update: true, userId: ctx.user.id, data: updatedOrganisation };
};
export default updateHandler;
@@ -1,6 +1,5 @@
import { z } from "zod";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const ZUpdateInputSchema = z.object({
@@ -12,11 +11,7 @@ export const ZUpdateInputSchema = z.object({
.or(z.number())
.optional(),
bio: z.string().optional(),
logo: z
.string()
.transform(async (val) => await resizeBase64Image(val))
.optional()
.nullable(),
logoUrl: z.string().optional().nullable(),
calVideoLogo: z
.string()
.optional()
@@ -111,7 +111,6 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
name: input.name,
timeZone: input.timeZone,
username: input.username,
avatar: undefined,
};
if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) {
@@ -120,11 +119,9 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
avatar,
userId: user.id,
});
data.avatar = avatar;
}
if (input.avatar === "") {
data.avatarUrl = null;
data.avatar = null;
}
// Update user
@@ -1,6 +1,8 @@
import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { uploadLogo } from "@calcom/lib/server/uploadLogo";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -47,7 +49,7 @@ const generateCheckoutSession = async ({
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { user } = ctx;
const { slug, name, logo } = input;
const { slug, name } = input;
const isOrgChildTeam = !!user.profile?.organizationId;
// For orgs we want to create teams under the org
@@ -95,7 +97,6 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
data: {
slug,
name,
logo,
members: {
create: {
userId: ctx.user.id,
@@ -106,7 +107,21 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
...(isOrgChildTeam && { parentId: user.profile?.organizationId }),
},
});
// Upload logo, create doesn't allow logo removal
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
const logoUrl = await uploadLogo({
logo: await resizeBase64Image(input.logo),
teamId: createdTeam.id,
});
await prisma.team.update({
where: {
id: createdTeam.id,
},
data: {
logoUrl,
},
});
}
// Sync Services: Close.com
closeComUpsertTeamUser(createdTeam, ctx.user, MembershipRole.OWNER);
@@ -19,7 +19,6 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
id: input.teamId,
currentOrg: ctx.user.profile?.organization ?? null,
userId: ctx.user.organization?.isOrgAdmin ? undefined : ctx.user.id,
includeTeamLogo: input.includeTeamLogo,
isOrgView: input?.isOrg,
});
@@ -3,7 +3,6 @@ import { z } from "zod";
export const ZGetInputSchema = z.object({
teamId: z.number(),
isOrg: z.boolean().optional(),
includeTeamLogo: z.boolean().optional(),
});
export type TGetInputSchema = z.infer<typeof ZGetInputSchema>;
@@ -26,7 +26,6 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
id: true,
name: true,
slug: true,
logo: true,
logoUrl: true,
isOrganization: true,
metadata: true,
@@ -44,14 +43,13 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
if (input?.includeOrgs) return true;
return !mmship.team.isOrganization;
})
.map(({ team: { inviteTokens, logo, logoUrl, ..._team }, ...membership }) => ({
.map(({ team: { inviteTokens, ...team }, ...membership }) => ({
role: membership.role,
accepted: membership.accepted,
..._team,
logo: logoUrl || logo,
metadata: teamMetadataSchema.parse(_team.metadata),
...team,
metadata: teamMetadataSchema.parse(team.metadata),
/** To prevent breaking we only return non-email attached token here, if we have one */
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${_team.id}`),
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${team.id}`),
}));
};
@@ -59,10 +59,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
};
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
data.logo = input.logo;
data.logoUrl = await uploadLogo({ teamId: input.id, logo: input.logo });
} else if (typeof input.logo !== "undefined" && !input.logo) {
data.logo = data.logoUrl = null;
data.logoUrl = null;
}
if (
@@ -131,7 +130,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam);
return {
logo: updatedTeam.logo,
logoUrl: updatedTeam.logoUrl,
name: updatedTeam.name,
bio: updatedTeam.bio,
slug: updatedTeam.slug,
@@ -53,7 +53,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
},
select: {
username: true,
avatar: true,
avatarUrl: true,
name: true,
webhooks: true,
teams: {
+4
View File
@@ -27,10 +27,12 @@ declare module "next-auth" {
id: number;
name?: string;
slug: string;
logoUrl?: string | null;
fullDomain: string;
domainSuffix: string;
};
username?: PrismaUser["username"];
avatarUrl?: PrismaUser["avatarUrl"];
role?: PrismaUser["role"] | "INACTIVE_ADMIN";
locale?: string | null;
profile: UserProfile;
@@ -42,6 +44,7 @@ declare module "next-auth/jwt" {
id?: string | number;
name?: string | null;
username?: string | null;
avatarUrl?: string | null;
email?: string | null;
upId?: string;
profileId?: number | null;
@@ -55,6 +58,7 @@ declare module "next-auth/jwt" {
id: number;
name?: string;
slug: string;
logoUrl?: string | null;
fullDomain: string;
domainSuffix: string;
};
+3 -3
View File
@@ -7,7 +7,7 @@ import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
@@ -273,8 +273,8 @@ const InstallAppButtonChild = ({
key={team.id}
CustomStartIcon={
<Avatar
alt={team.logo || ""}
imageSrc={team.logo || `${WEBAPP_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
alt={team.name || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)} // if no image, use default avatar
size="sm"
/>
}
+3 -3
View File
@@ -1,5 +1,6 @@
import { classNames } from "@calcom/lib";
import { getOrgAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { User } from "@calcom/prisma/client";
import type { UserProfile } from "@calcom/types/UserProfile";
import { Avatar } from "@calcom/ui";
@@ -39,13 +40,12 @@ function OrganizationIndicator({
organization,
user,
}: Pick<UserAvatarProps, "size" | "user"> & { organization: Organization }) {
const organizationUrl = organization.logoUrl ?? getOrgAvatarUrl(organization);
const indicatorSize = size && indicatorBySize[size];
return (
<div className={classNames("absolute bottom-0 right-0 z-10", indicatorSize)}>
<img
data-testid="organization-logo"
src={organizationUrl}
src={getPlaceholderAvatar(organization.logoUrl, organization.slug)}
alt={user.username || ""}
className="flex h-full items-center justify-center rounded-full"
/>
@@ -142,7 +142,7 @@ export default function BannerUploader({
color={triggerButtonColor ?? "secondary"}
type="button"
disabled={disabled}
data-testid="open-upload-avatar-dialog"
data-testid={`open-upload-${target}-dialog`}
className="cursor-pointer py-1 text-sm">
{buttonMsg}
</Button>
@@ -1,3 +1,4 @@
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen, Avatar } from "@calcom/ui";
@@ -12,6 +13,8 @@ export type UnpublishedEntityProps = {
* It conveys two things - Slug for the team and that it is an organization infact
*/
orgSlug?: string | null;
/* logo url for entity */
logoUrl?: string | null;
/**
* Team or Organization name
*/
@@ -24,13 +27,7 @@ export function UnpublishedEntity(props: UnpublishedEntityProps) {
return (
<div className="m-8 flex items-center justify-center">
<EmptyScreen
avatar={
<Avatar
alt={slug ?? ""}
imageSrc={props.orgSlug ? `/org/${slug}/avatar.png` : `/team/${slug}/avatar.png`}
size="lg"
/>
}
avatar={<Avatar alt={slug ?? ""} imageSrc={getPlaceholderAvatar(props.logoUrl, slug)} size="lg" />}
headline={t("team_is_unpublished", {
team: props.name,
})}