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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export const getBrand = async (orgId: number | null) => {
|
||||
id: orgId,
|
||||
},
|
||||
select: {
|
||||
logo: true,
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,6 @@ const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
avatarUrl: true,
|
||||
timeZone: true,
|
||||
startTime: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { createUserAndEventType } from "./seed-utils";
|
||||
|
||||
async function createManyDifferentUsersWithDifferentEventTypesAndBookings({
|
||||
async function _createManyDifferentUsersWithDifferentEventTypesAndBookings({
|
||||
tillUser,
|
||||
startFrom = 0,
|
||||
}: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user