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
2024-04-18 21:54:16 -07:00
committed by GitHub
parent 5695ba7c13
commit d25c043046
73 changed files with 644 additions and 770 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
...app,
credentialOwner: {
name: team.name,
avatar: team.logo,
avatar: team.logoUrl,
teamId: team.teamId,
credentialId: team.credentialId,
readOnly: !team.isAdmin,
@@ -2,7 +2,7 @@ import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
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 { WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppFrontendPayload } from "@calcom/types/App";
@@ -124,8 +124,8 @@ export const InstallAppButtonChild = ({
disabled={isInstalled}
CustomStartIcon={
<Avatar
alt={team.logo || ""}
imageSrc={team.logo || `${WEBAPP_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
alt={team.logoUrl || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)} // if no image, use default avatar
size="sm"
/>
}
@@ -115,7 +115,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
// credentialIds: team?.credentialId ? [team.credentialId] : [],
credentialOwner: {
name: team.name,
avatar: team.logo,
avatar: team.logoUrl,
teamId: team.teamId,
credentialId: team.credentialId,
},
@@ -33,7 +33,7 @@ const UserProfile = () => {
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (_data, context) => {
if (context.avatar) {
if (context.avatarUrl) {
showToast(t("your_user_profile_updated_successfully"), "success");
await utils.viewer.me.refetch();
} else
@@ -74,7 +74,7 @@ const UserProfile = () => {
event.preventDefault();
const enteredAvatar = avatarRef.current?.value;
mutation.mutate({
avatar: enteredAvatar,
avatarUrl: enteredAvatar,
});
}
@@ -33,7 +33,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: {
id: true,
name: true,
logo: true,
logoUrl: true,
},
},
},
@@ -98,6 +98,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isPrivate: true,
isOrganization: true,
metadata: true,
logoUrl: true,
},
},
},
@@ -46,6 +46,7 @@ export type UserPageProps = {
markdownStrippedBio: string;
safeBio: string;
entity: {
logoUrl?: string | null;
considerUnpublished: boolean;
orgSlug?: string | null;
name?: string | null;
@@ -98,12 +99,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const usersWithoutAvatar = usersInOrgContext.map((user) => {
const { avatar: _1, ...rest } = user;
return rest;
});
const isDynamicGroup = usersWithoutAvatar.length > 1;
const isDynamicGroup = usersInOrgContext.length > 1;
log.debug(safeStringify({ usersInOrgContext, isValidOrgDomain, currentOrgDomain, isDynamicGroup }));
if (isDynamicGroup) {
@@ -114,40 +110,27 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
permanent: false,
destination: destinationUrl,
},
} as {
redirect: {
permanent: false;
destination: string;
};
};
} as const;
}
const users = usersWithoutAvatar.map((user) => ({
...user,
avatar: `/${user.username}/avatar.png`,
}));
const isNonOrgUser = (user: { profile: UserProfile }) => {
return !user.profile?.organization;
};
const isThereAnyNonOrgUser = users.some(isNonOrgUser);
const isThereAnyNonOrgUser = usersInOrgContext.some(isNonOrgUser);
if (!users.length || (!isValidOrgDomain && !isThereAnyNonOrgUser)) {
if (!usersInOrgContext.length || (!isValidOrgDomain && !isThereAnyNonOrgUser)) {
return {
notFound: true,
} as {
notFound: true;
};
} as const;
}
const [user] = users; //to be used when dealing with single user, not dynamic group
const [user] = usersInOrgContext; //to be used when dealing with single user, not dynamic group
const profile = {
name: user.name || user.username || "",
image: getUserAvatarUrl({
...user,
profile: user.profile,
avatarUrl: user.avatarUrl,
}),
theme: user.theme,
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
@@ -183,11 +166,11 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const safeBio = markdownToSafeHTML(user.bio) || "";
const markdownStrippedBio = stripMarkdown(user?.bio || "");
const org = usersWithoutAvatar[0].profile.organization;
const org = usersInOrgContext[0].profile.organization;
return {
props: {
users: users.map((user) => ({
users: usersInOrgContext.map((user) => ({
name: user.name,
username: user.username,
bio: user.bio,
@@ -197,6 +180,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
away: user.away,
})),
entity: {
...(org?.logoUrl ? { logoUrl: org?.logoUrl } : {}),
considerUnpublished: !isARedirectFromNonOrgLink && org?.slug === null,
orgSlug: currentOrgDomain,
name: org?.name ?? null,
@@ -3,7 +3,6 @@
import classNames from "classnames";
import type { InferGetServerSidePropsType } from "next";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Toaster } from "react-hot-toast";
import {
@@ -23,7 +22,6 @@ import { type getServerSideProps } from "./users-public-view.getServerSideProps"
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
const searchParams = useSearchParams();
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(profile.theme);
@@ -43,8 +41,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
...query
} = useRouterQuery();
const isRedirect = searchParams?.get("redirected") === "true" || false;
const fromUserNameRedirected = searchParams?.get("username") || "";
/*
const telemetry = useTelemetry();
useEffect(() => {
@@ -286,7 +286,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
in: userIds as number[],
},
},
select: { id: true, name: true, email: true, avatar: true, username: true },
select: { id: true, name: true, email: true, avatarUrl: true, username: true },
});
const userHashMap = new Map();
-154
View File
@@ -1,154 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
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
* Avatars being public, we need not worry about others accessing it.
*/
orgId: z.string().transform((s) => Number(s)),
})
.partial();
async function getIdentityData(req: NextApiRequest) {
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req);
const org = isValidOrgDomain ? currentOrgDomain : null;
const orgQuery = orgId
? {
id: orgId,
}
: org
? whereClauseForOrgWithSlugOrRequestedSlug(org)
: null;
if (username) {
const users = await prisma.user.findMany({
where: {
...(orgQuery
? {
profiles: {
some: {
username,
organization: orgQuery,
},
},
}
: {
username,
// If a user is moved, it isn't actually available outside of the organization. So, for non-org domain check for movedToProfileId
movedToProfileId: null,
}),
},
select: { avatar: true, email: true },
});
if (users.length > 1) {
throw new Error(`More than one user found for username "${username}"`);
}
const [user] = users;
return {
name: username,
email: user?.email,
avatar: user?.avatar,
org,
};
}
if (teamname) {
const team = await prisma.team.findFirst({
where: {
slug: teamname,
parent: orgQuery,
},
select: { logo: true },
});
return {
org,
name: teamname,
email: null,
avatar: getPlaceholderAvatar(team?.logo, teamname),
};
}
if (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,
email: null,
avatar: getPlaceholderAvatar(org?.logo, org?.name),
};
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const identity = await getIdentityData(req);
const img = identity?.avatar;
// If image isn't set or links to this route itself, use default avatar
if (!img) {
if (identity?.org) {
res.setHeader("x-cal-org", identity.org);
}
res.writeHead(302, {
Location: AVATAR_FALLBACK,
});
return res.end();
}
if (!img.includes("data:image")) {
if (identity.org) {
res.setHeader("x-cal-org", identity.org);
}
res.writeHead(302, { Location: img });
return res.end();
}
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
if (identity.org) {
res.setHeader("x-cal-org", identity.org);
}
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
});
res.end(imageResp);
}
+10 -27
View File
@@ -22,7 +22,6 @@ import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import type { Ensure } from "@calcom/types/utils";
import {
Alert,
Button,
@@ -85,7 +84,7 @@ type Email = {
export type FormValues = {
username: string;
avatar: string;
avatarUrl: string | null;
name: string;
email: string;
bio: string;
@@ -98,15 +97,10 @@ const ProfileView = () => {
const { update } = useSession();
const { data: user, isPending } = trpc.viewer.me.useQuery({ includePasswordAdded: true });
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
enabled: !isPending && !user?.avatarUrl,
});
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
utils.viewer.shouldVerifyEmail.invalidate();
if (res.hasEmailBeenChanged && res.sendEmailVerification) {
@@ -250,10 +244,7 @@ const ProfileView = () => {
const userEmail = user.email || "";
const defaultValues = {
username: user.username || "",
avatar: getUserAvatarUrl({
...user,
profile: user.profile,
}),
avatarUrl: user.avatarUrl,
name: user.name || "",
email: userEmail,
bio: user.bio || "",
@@ -284,7 +275,7 @@ const ProfileView = () => {
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isPending={updateProfileMutation.isPending}
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
isFallbackImg={!user.avatarUrl}
user={user}
userOrganization={user.organization}
onSubmit={(values) => {
@@ -529,7 +520,7 @@ const ProfileForm = ({
const profileFormSchema = z.object({
username: z.string(),
avatar: z.string(),
avatarUrl: z.string().nullable(),
name: z
.string()
.trim()
@@ -622,17 +613,9 @@ const ProfileForm = ({
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => {
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
const organization =
userOrganization && userOrganization.id
? {
...(userOrganization as Ensure<NonNullable<typeof user.organization>, "id">),
slug: userOrganization.slug || null,
requestedSlug: userOrganization.metadata?.requestedSlug || null,
}
: null;
name="avatarUrl"
render={({ field: { value, onChange } }) => {
const showRemoveAvatarButton = value !== null;
return (
<>
<UserAvatar data-testid="profile-upload-avatar" previewSrc={value} size="lg" user={user} />
@@ -644,9 +627,9 @@ const ProfileForm = ({
id="avatar-upload"
buttonMsg={t("upload_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
onChange(newAvatar);
}}
imageSrc={value}
imageSrc={getUserAvatarUrl({ avatarUrl: value })}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"}
/>
@@ -654,7 +637,7 @@ const ProfileForm = ({
<Button
color="secondary"
onClick={() => {
formMethods.setValue("avatar", "", { shouldDirty: true });
onChange(null);
}}>
{t("remove")}
</Button>
+3 -5
View File
@@ -13,7 +13,7 @@ import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getOrgAvatarUrl, getTeamAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
@@ -67,6 +67,7 @@ function TeamPage({
<div className="flex h-full min-h-[100dvh] items-center justify-center">
<UnpublishedEntity
{...{ [slugPropertyName]: team.parent ? parentSlug : teamSlug }}
logoUrl={team.parent?.logoUrl || team.logoUrl}
name={team.parent ? team.parent.name : team.name}
/>
</div>
@@ -161,10 +162,7 @@ function TeamPage({
</div>
);
const profileImageSrc =
isValidOrgDomain || team.isOrganization
? getOrgAvatarUrl({ slug: currentOrgDomain, logoUrl: team.logoUrl })
: getTeamAvatarUrl({ slug: team.slug, logoUrl: team.logoUrl, organizationId: team.parent?.id });
const profileImageSrc = getPlaceholderAvatar(team.logoUrl || team.parent?.logoUrl, team.name);
return (
<>
@@ -74,10 +74,6 @@ test.describe("Change username on settings", () => {
});
expect(updatedUser.username).toBe("demo.username");
// Check if user avatar can be accessed and response headers contain 'image/' in the content type
const response = await page.goto("/demo.username/avatar.png");
expect(response?.headers()?.["content-type"]).toContain("image/");
});
test("User can update to PREMIUM username", async ({ page, users }, testInfo) => {
+158 -10
View File
@@ -1,15 +1,18 @@
import { expect } from "@playwright/test";
import path from "path";
import { CAL_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { test } from "../lib/fixtures";
test.describe("UploadAvatar", async () => {
test("can upload an image", async ({ page, users }) => {
const user = await users.create({});
test.describe("User Avatar", async () => {
test("it can upload a user profile image", async ({ page, users }) => {
const user = await users.create({ name: "John Doe" });
await user.apiLogin();
let objectKey: string;
await test.step("Can upload an initial picture", async () => {
await page.goto("/settings/my-account/profile");
@@ -26,8 +29,6 @@ test.describe("UploadAvatar", async () => {
await page.getByTestId("upload-avatar").click();
await page.locator("input[name='name']").fill(user.email);
await page.getByText("Update").click();
await page.waitForSelector("text=Settings updated successfully");
@@ -41,18 +42,165 @@ test.describe("UploadAvatar", async () => {
},
});
// Wait for the avatar image with the http src to appear instead of the data uri
const avatar = page.getByTestId("profile-upload-avatar").locator("img[src^=http]");
objectKey = response.objectKey;
const src = await avatar.getAttribute("src");
const avatarImage = page.getByTestId("profile-upload-avatar").locator("img");
await expect(src).toContain(response.objectKey);
await expect(avatarImage).toHaveAttribute("src", new RegExp(`^\/api\/avatar\/${objectKey}\.png$`));
const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, {
const urlResponse = await page.request.get((await avatarImage.getAttribute("src")) || "", {
maxRedirects: 0,
});
await expect(urlResponse?.status()).toBe(200);
});
await test.step("View avatar on the public page", async () => {
await page.goto(`/${user.username}`);
await expect(page.locator(`img`)).toHaveAttribute(
"src",
new RegExp(`\/api\/avatar\/${objectKey}\.png$`)
);
// verify objectKey is passed to the OG image
// yes, OG image URI encodes at multiple places.. don't want to mess with that.
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
new RegExp(
encodeURIComponent(`meetingImage=${encodeURIComponent(`${CAL_URL}/api/avatar/${objectKey}.png`)}`)
)
);
});
});
});
test.describe("Team Logo", async () => {
test("it can upload a team logo image", async ({ page, users }) => {
const user = await users.create(undefined, { hasTeam: true });
const { team } = await user.getFirstTeamMembership();
await user.apiLogin();
await page.goto(`/settings/teams/${team.id}/profile`);
await test.step("Can upload an initial picture", async () => {
await page.getByTestId("open-upload-avatar-dialog").click();
const [fileChooser] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting.
page.waitForEvent("filechooser"),
// Opens the file chooser.
page.getByTestId("open-upload-image-filechooser").click(),
]);
await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`);
await page.getByTestId("upload-avatar").click();
await page.getByText("Update").click();
await page.waitForSelector("text=Your team has been updated successfully.");
const response = await prisma.avatar.findUniqueOrThrow({
where: {
teamId_userId_isBanner: {
userId: 0,
teamId: team.id,
isBanner: false,
},
},
});
const avatarImage = page.getByTestId("profile-upload-logo").locator("img");
await expect(avatarImage).toHaveAttribute(
"src",
new RegExp(`^\/api\/avatar\/${response.objectKey}\.png$`)
);
const urlResponse = await page.request.get((await avatarImage.getAttribute("src")) || "", {
maxRedirects: 0,
});
await expect(urlResponse?.status()).toBe(200);
await expect(
page.getByTestId("tab-teams").locator(`img[src="/api/avatar/${response.objectKey}.png"]`)
).toBeVisible();
});
});
});
test.describe("Organization Logo", async () => {
test("it can upload a organization logo image", async ({ page, users, orgs }) => {
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true });
const { team: org } = await owner.getOrgMembership();
await owner.apiLogin();
await page.goto("/settings/organizations/profile");
let objectKey: string;
await test.step("Can upload an initial picture", async () => {
await page.getByTestId("open-upload-avatar-dialog").click();
const [fileChooser] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting.
page.waitForEvent("filechooser"),
// Opens the file chooser.
page.getByTestId("open-upload-image-filechooser").click(),
]);
await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`);
await page.getByTestId("upload-avatar").click();
await page.getByText("Update").click();
await page.waitForSelector("text=Your organization updated successfully");
const response = await prisma.avatar.findUniqueOrThrow({
where: {
teamId_userId_isBanner: {
userId: 0,
teamId: org.id,
isBanner: false,
},
},
});
objectKey = response.objectKey;
const avatarImage = page.getByTestId("profile-upload-logo").locator("img");
await expect(avatarImage).toHaveAttribute(
"src",
new RegExp(`^\/api\/avatar\/${response.objectKey}\.png$`)
);
const urlResponse = await page.request.get((await avatarImage.getAttribute("src")) || "", {
maxRedirects: 0,
});
await expect(urlResponse?.status()).toBe(200);
// TODO: Implement the org logo updating in the sidebar
// this should be done in the orgBrandingContext
});
const requestedSlug = org.metadata?.requestedSlug;
await test.step("it shows the correct logo on the unpublished public page", async () => {
await page.goto(`/org/${requestedSlug}`);
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute(
"src",
new RegExp(`^\/api\/avatar\/${objectKey}\.png$`)
);
});
// TODO: add test for published team.
// unpublished works regardless of orgDomain but when it is published it does work
});
});
+7 -8
View File
@@ -9,7 +9,6 @@ test.describe.configure({ mode: "parallel" });
const title = (name: string) => `${name} is unpublished`;
const description = (entity: string) =>
`This ${entity} link is currently not available. Please contact the ${entity} owner or ask them to publish it.`;
const avatar = (slug: string, entity = "team") => `/${entity}/${slug}/avatar.png`;
test.afterAll(async ({ users }) => {
await users.deleteAll();
@@ -24,7 +23,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(team.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("team")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
await expect(page.locator(`img`)).toHaveAttribute("src", /.*/);
});
test("Regular team event type", async ({ page, users }) => {
@@ -40,7 +39,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(team.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("team")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug));
await expect(page.locator(`img`)).toHaveAttribute("src", /.*/);
});
test("Organization profile", async ({ users, page }) => {
@@ -52,7 +51,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
await expect(page.locator(`img`)).toHaveAttribute("src", /.*/);
});
test("Organization sub-team", async ({ users, page }) => {
@@ -70,7 +69,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
await expect(page.locator(`img`)).toHaveAttribute("src", /.*/);
});
test("Organization sub-team event-type", async ({ users, page }) => {
@@ -90,7 +89,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
await expect(page.locator(`img`)).toHaveAttribute("src", /.*/);
});
test("Organization user", async ({ users, page }) => {
@@ -102,7 +101,7 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
await expect(page.locator(`img`)).toHaveAttribute("src", /.*/);
});
test("Organization user event-type", async ({ users, page }) => {
@@ -115,6 +114,6 @@ test.describe("Unpublished", () => {
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
expect(await page.locator(`h2:has-text("${title(org.name)}")`).count()).toBe(1);
expect(await page.locator(`div:text("${description("organization")}")`).count()).toBe(1);
await expect(page.locator(`img`)).toHaveAttribute("src", avatar(requestedSlug, "org"));
await expect(page.locator(`img`)).toHaveAttribute("src", /.*/);
});
});
@@ -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,
})}