feat: Base implementation of v2 of avatars (#12369)
* feat: Base implementation of v2 of avatars * Make avatarUrl and logoUrl entirely optional * Made necessary backwards compat changes * fix: type errors * Fix: OG image * fix types * Consistency with other behaviour, ux tweak --------- Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
c55b36f235
commit
4f26ca1a7b
|
@ -14,6 +14,6 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageS
|
||||||
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
||||||
*/
|
*/
|
||||||
export function UserAvatar(props: UserAvatarProps) {
|
export function UserAvatar(props: UserAvatarProps) {
|
||||||
const { user, previewSrc, ...rest } = props;
|
const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props;
|
||||||
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
|
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||||
description={markdownStrippedBio}
|
description={markdownStrippedBio}
|
||||||
meeting={{
|
meeting={{
|
||||||
title: markdownStrippedBio,
|
title: markdownStrippedBio,
|
||||||
profile: { name: `${profile.name}`, image: null },
|
profile: { name: `${profile.name}`, image: user.avatarUrl || null },
|
||||||
users: [{ username: `${user.username}`, name: `${user.name}` }],
|
users: [{ username: `${user.username}`, name: `${user.name}` }],
|
||||||
}}
|
}}
|
||||||
nextSeoProps={{
|
nextSeoProps={{
|
||||||
|
@ -245,7 +245,7 @@ export type UserPageProps = {
|
||||||
allowSEOIndexing: boolean;
|
allowSEOIndexing: boolean;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
};
|
};
|
||||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
users: Pick<User, "away" | "name" | "username" | "bio" | "verified" | "avatarUrl">[];
|
||||||
themeBasis: string | null;
|
themeBasis: string | null;
|
||||||
markdownStrippedBio: string;
|
markdownStrippedBio: string;
|
||||||
safeBio: string;
|
safeBio: string;
|
||||||
|
@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||||
metadata: true,
|
metadata: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
|
avatarUrl: true,
|
||||||
organizationId: true,
|
organizationId: true,
|
||||||
organization: {
|
organization: {
|
||||||
select: {
|
select: {
|
||||||
|
@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||||
image: user.avatar,
|
image: user.avatar,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
brandColor: user.brandColor,
|
brandColor: user.brandColor,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
darkBrandColor: user.darkBrandColor,
|
darkBrandColor: user.darkBrandColor,
|
||||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
away: user.away,
|
away: user.away,
|
||||||
verified: user.verified,
|
verified: user.verified,
|
||||||
})),
|
})),
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
|
||||||
|
const errors = error.errors.map((err) => ({
|
||||||
|
path: err.path.join("."),
|
||||||
|
errorCode: `error.validation.${err.code}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
message: "VALIDATION_ERROR",
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const result = querySchema.safeParse(req.query);
|
||||||
|
if (!result.success) {
|
||||||
|
return handleValidationError(res, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uuid: objectKey } = result.data;
|
||||||
|
|
||||||
|
let img;
|
||||||
|
try {
|
||||||
|
const { data } = await prisma.avatar.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
objectKey,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
img = data;
|
||||||
|
} catch (e) {
|
||||||
|
// If anything goes wrong or avatar is not found, use default avatar
|
||||||
|
res.writeHead(302, {
|
||||||
|
Location: AVATAR_FALLBACK,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
|
||||||
|
const imageResp = Buffer.from(decoded, "base64");
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Content-Length": imageResp.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.end(imageResp);
|
||||||
|
}
|
|
@ -9,8 +9,8 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
||||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage";
|
|
||||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||||
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
|
@ -82,19 +82,12 @@ const ProfileView = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { update } = useSession();
|
const { update } = useSession();
|
||||||
|
const { data: user, isLoading } = trpc.viewer.me.useQuery();
|
||||||
|
|
||||||
const [fetchedImgSrc, setFetchedImgSrc] = useState<string>("");
|
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
|
||||||
|
enabled: !isLoading && !user?.avatarUrl,
|
||||||
const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, {
|
|
||||||
onSuccess: async (userData) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(userData.avatar);
|
|
||||||
if (res.url) setFetchedImgSrc(res.url);
|
|
||||||
} catch (err) {
|
|
||||||
setFetchedImgSrc("");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
await update(res);
|
await update(res);
|
||||||
|
@ -226,7 +219,7 @@ const ProfileView = () => {
|
||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
username: user.username || "",
|
username: user.username || "",
|
||||||
avatar: fetchedImgSrc || "",
|
avatar: getUserAvatarUrl(user),
|
||||||
name: user.name || "",
|
name: user.name || "",
|
||||||
email: user.email || "",
|
email: user.email || "",
|
||||||
bio: user.bio || "",
|
bio: user.bio || "",
|
||||||
|
@ -243,6 +236,7 @@ const ProfileView = () => {
|
||||||
key={JSON.stringify(defaultValues)}
|
key={JSON.stringify(defaultValues)}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
isLoading={updateProfileMutation.isLoading}
|
isLoading={updateProfileMutation.isLoading}
|
||||||
|
isFallbackImg={!user.avatarUrl && !avatarData?.avatar}
|
||||||
user={user}
|
user={user}
|
||||||
userOrganization={user.organization}
|
userOrganization={user.organization}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
|
@ -387,6 +381,7 @@ const ProfileForm = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
extraField,
|
extraField,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isFallbackImg,
|
||||||
user,
|
user,
|
||||||
userOrganization,
|
userOrganization,
|
||||||
}: {
|
}: {
|
||||||
|
@ -394,6 +389,7 @@ const ProfileForm = ({
|
||||||
onSubmit: (values: FormValues) => void;
|
onSubmit: (values: FormValues) => void;
|
||||||
extraField?: React.ReactNode;
|
extraField?: React.ReactNode;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isFallbackImg: boolean;
|
||||||
user: RouterOutputs["viewer"]["me"];
|
user: RouterOutputs["viewer"]["me"];
|
||||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -432,7 +428,7 @@ const ProfileForm = ({
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
name="avatar"
|
name="avatar"
|
||||||
render={({ field: { value } }) => {
|
render={({ field: { value } }) => {
|
||||||
const showRemoveAvatarButton = !checkIfItFallbackImage(value);
|
const showRemoveAvatarButton = value === null ? false : !isFallbackImg;
|
||||||
const organization =
|
const organization =
|
||||||
userOrganization && userOrganization.id
|
userOrganization && userOrganization.id
|
||||||
? {
|
? {
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
|
@ -0,0 +1,56 @@
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
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({});
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await test.step("Can upload an initial picture", async () => {
|
||||||
|
await page.goto("/settings/my-account/profile");
|
||||||
|
|
||||||
|
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.locator("input[name='name']").fill(user.email);
|
||||||
|
|
||||||
|
await page.getByText("Update").click();
|
||||||
|
await page.waitForSelector("text=Settings updated successfully");
|
||||||
|
|
||||||
|
const response = await prisma.avatar.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
userId: user.id,
|
||||||
|
teamId: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// todo: remove this; ideally the organization-avatar is updated the moment
|
||||||
|
// 'Settings updated succesfully' is saved.
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey);
|
||||||
|
|
||||||
|
const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, {
|
||||||
|
maxRedirects: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(urlResponse?.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -268,6 +268,7 @@ function getTrpcHandlerData({
|
||||||
user: {
|
user: {
|
||||||
...getSampleUserInSession(),
|
...getSampleUserInSession(),
|
||||||
...user,
|
...user,
|
||||||
|
avatarUrl: user.avatarUrl || null,
|
||||||
} satisfies TrpcSessionUser,
|
} satisfies TrpcSessionUser,
|
||||||
},
|
},
|
||||||
input: input,
|
input: input,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import Shell from "@calcom/features/shell/Shell";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
|
import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
|
@ -145,7 +146,7 @@ const useTabs = () => {
|
||||||
if (tab.href === "/settings/my-account") {
|
if (tab.href === "/settings/my-account") {
|
||||||
tab.name = user?.name || "my_account";
|
tab.name = user?.name || "my_account";
|
||||||
tab.icon = undefined;
|
tab.icon = undefined;
|
||||||
tab.avatar = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${session?.data?.user?.username}/avatar.png`;
|
tab.avatar = getUserAvatarUrl(user);
|
||||||
} else if (tab.href === "/settings/organizations") {
|
} else if (tab.href === "/settings/organizations") {
|
||||||
tab.name = orgBranding?.name || "organization";
|
tab.name = orgBranding?.name || "organization";
|
||||||
tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`;
|
tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`;
|
||||||
|
|
|
@ -6,19 +6,28 @@ import type { User, Team } from "@calcom/prisma/client";
|
||||||
* Gives an organization aware avatar url for a user
|
* 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 ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed
|
||||||
*/
|
*/
|
||||||
export const getUserAvatarUrl = (user: Pick<User, "username" | "organizationId">) => {
|
export const getUserAvatarUrl = (
|
||||||
if (!user.username) return AVATAR_FALLBACK;
|
user: (Pick<User, "username" | "organizationId"> & { avatarUrl?: string | null }) | undefined
|
||||||
|
) => {
|
||||||
|
if (user?.avatarUrl) {
|
||||||
|
return user.avatarUrl;
|
||||||
|
}
|
||||||
|
if (!user?.username) return AVATAR_FALLBACK;
|
||||||
// avatar.png automatically redirects to fallback avatar if user doesn't have one
|
// avatar.png automatically redirects to fallback avatar if user doesn't have one
|
||||||
return `${WEBAPP_URL}/${user.username}/avatar.png${
|
return `${WEBAPP_URL}/${user.username}/avatar.png${
|
||||||
user.organizationId ? `?orgId=${user.organizationId}` : ""
|
user.organizationId ? `?orgId=${user.organizationId}` : ""
|
||||||
}`;
|
}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrgAvatarUrl = (org: {
|
export const getOrgAvatarUrl = (
|
||||||
id: Team["id"];
|
org: Pick<Team, "id" | "slug"> & {
|
||||||
slug: Team["slug"];
|
logoUrl?: string | null;
|
||||||
requestedSlug: string | null;
|
requestedSlug: string | null;
|
||||||
}) => {
|
}
|
||||||
|
) => {
|
||||||
|
if (org.logoUrl) {
|
||||||
|
return org.logoUrl;
|
||||||
|
}
|
||||||
const slug = org.slug ?? org.requestedSlug;
|
const slug = org.slug ?? org.requestedSlug;
|
||||||
return `${WEBAPP_URL}/org/${slug}/avatar.png`;
|
return `${WEBAPP_URL}/org/${slug}/avatar.png`;
|
||||||
};
|
};
|
||||||
|
|
|
@ -191,6 +191,7 @@ export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
availability: [],
|
availability: [],
|
||||||
avatar: "",
|
avatar: "",
|
||||||
|
avatarUrl: "",
|
||||||
away: false,
|
away: false,
|
||||||
backupCodes: null,
|
backupCodes: null,
|
||||||
bio: null,
|
bio: null,
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Team" ADD COLUMN "logoUrl" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "avatarUrl" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "avatars" (
|
||||||
|
"teamId" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"userId" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"data" TEXT NOT NULL,
|
||||||
|
"objectKey" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "avatars_objectKey_key" ON "avatars"("objectKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "avatars_teamId_userId_key" ON "avatars"("teamId", "userId");
|
|
@ -192,6 +192,7 @@ model User {
|
||||||
password String?
|
password String?
|
||||||
bio String?
|
bio String?
|
||||||
avatar String?
|
avatar String?
|
||||||
|
avatarUrl String?
|
||||||
timeZone String @default("Europe/London")
|
timeZone String @default("Europe/London")
|
||||||
weekStart String @default("Sunday")
|
weekStart String @default("Sunday")
|
||||||
// DEPRECATED - TO BE REMOVED
|
// DEPRECATED - TO BE REMOVED
|
||||||
|
@ -279,6 +280,7 @@ model Team {
|
||||||
/// @zod.min(1)
|
/// @zod.min(1)
|
||||||
slug String?
|
slug String?
|
||||||
logo String?
|
logo String?
|
||||||
|
logoUrl String?
|
||||||
appLogo String?
|
appLogo String?
|
||||||
appIconLogo String?
|
appIconLogo String?
|
||||||
bio String?
|
bio String?
|
||||||
|
@ -1011,3 +1013,17 @@ model TempOrgRedirect {
|
||||||
|
|
||||||
@@unique([from, type, fromOrgId])
|
@@unique([from, type, fromOrgId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Avatar {
|
||||||
|
// e.g. NULL(0), organization ID or team logo
|
||||||
|
teamId Int @default(0)
|
||||||
|
// Avatar, NULL(0) if team logo
|
||||||
|
userId Int @default(0)
|
||||||
|
// base64 string
|
||||||
|
data String
|
||||||
|
// different every time to pop the cache.
|
||||||
|
objectKey String @unique
|
||||||
|
|
||||||
|
@@unique([teamId, userId])
|
||||||
|
@@map(name: "avatars")
|
||||||
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
||||||
email: true,
|
email: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
bio: true,
|
bio: true,
|
||||||
|
avatarUrl: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||||
|
|
||||||
type AvatarOptions = {
|
type AvatarOptions = {
|
||||||
|
@ -7,7 +8,15 @@ type AvatarOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const avatarHandler = async ({ ctx }: AvatarOptions) => {
|
export const avatarHandler = async ({ ctx }: AvatarOptions) => {
|
||||||
|
const data = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: ctx.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
avatar: ctx.user.avatar,
|
avatar: data?.avatar,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,6 +25,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
|
||||||
timeFormat: user.timeFormat,
|
timeFormat: user.timeFormat,
|
||||||
timeZone: user.timeZone,
|
timeZone: user.timeZone,
|
||||||
avatar: getUserAvatarUrl(user),
|
avatar: getUserAvatarUrl(user),
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
createdDate: user.createdDate,
|
createdDate: user.createdDate,
|
||||||
trialEndsAt: user.trialEndsAt,
|
trialEndsAt: user.trialEndsAt,
|
||||||
defaultScheduleId: user.defaultScheduleId,
|
defaultScheduleId: user.defaultScheduleId,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { GetServerSidePropsContext, NextApiResponse } from "next";
|
import type { GetServerSidePropsContext, NextApiResponse } from "next";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||||
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
|
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||||
|
@ -31,12 +32,35 @@ type UpdateProfileOptions = {
|
||||||
input: TUpdateProfileInputSchema;
|
input: TUpdateProfileInputSchema;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
|
||||||
|
const objectKey = uuidv4();
|
||||||
|
|
||||||
|
await prisma.avatar.upsert({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
teamId: 0,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: userId,
|
||||||
|
data,
|
||||||
|
objectKey,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
data,
|
||||||
|
objectKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return `/api/avatar/${objectKey}.png`;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
|
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
|
||||||
const { user } = ctx;
|
const { user } = ctx;
|
||||||
const userMetadata = handleUserMetadata({ ctx, input });
|
const userMetadata = handleUserMetadata({ ctx, input });
|
||||||
const data: Prisma.UserUpdateInput = {
|
const data: Prisma.UserUpdateInput = {
|
||||||
...input,
|
...input,
|
||||||
avatar: input.avatar ? await getAvatarToSet(input.avatar) : null,
|
|
||||||
metadata: userMetadata,
|
metadata: userMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -114,6 +138,15 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||||
// when the email changes, the user needs to sign in again.
|
// when the email changes, the user needs to sign in again.
|
||||||
signOutUser = true;
|
signOutUser = true;
|
||||||
}
|
}
|
||||||
|
// don't do anything if avatar is undefined.
|
||||||
|
if (typeof input.avatar !== "undefined") {
|
||||||
|
data.avatarUrl = input.avatar
|
||||||
|
? await uploadAvatar({
|
||||||
|
avatar: await resizeBase64Image(input.avatar),
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -129,6 +162,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||||
metadata: true,
|
metadata: true,
|
||||||
name: true,
|
name: true,
|
||||||
createdDate: true,
|
createdDate: true,
|
||||||
|
avatarUrl: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
schedules: {
|
schedules: {
|
||||||
select: {
|
select: {
|
||||||
|
@ -186,28 +220,11 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Revalidate booking pages
|
|
||||||
// Disabled because the booking pages are currently not using getStaticProps
|
// don't return avatar, we don't need it anymore.
|
||||||
/*const res = ctx.res as NextApiResponse;
|
delete input.avatar;
|
||||||
if (typeof res?.revalidate !== "undefined") {
|
|
||||||
const eventTypes = await prisma.eventType.findMany({
|
return { ...input, signOutUser, passwordReset, avatarUrl: updatedUser.avatarUrl };
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
team: null,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
slug: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// waiting for this isn't needed
|
|
||||||
Promise.all(
|
|
||||||
eventTypes.map((eventType) => res?.revalidate(`/new-booker/${ctx.user.username}/${eventType.slug}`))
|
|
||||||
)
|
|
||||||
.then(() => console.info("Booking pages revalidated"))
|
|
||||||
.catch((e) => console.error(e));
|
|
||||||
}*/
|
|
||||||
return { ...input, signOutUser, passwordReset };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
|
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
|
||||||
|
@ -230,17 +247,3 @@ const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
|
||||||
// Required so we don't override and delete saved values
|
// Required so we don't override and delete saved values
|
||||||
return { ...userMetadata, ...cleanMetadata };
|
return { ...userMetadata, ...cleanMetadata };
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getAvatarToSet(avatar: string | null | undefined) {
|
|
||||||
if (avatar === null || avatar === undefined) {
|
|
||||||
return avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!avatar.startsWith("data:image")) {
|
|
||||||
// Non Base64 avatar currently could only be the dynamic avatar URL(i.e. /{USER}/avatar.png). If we allow setting that URL, we would get infinite redirects on /user/avatar.ts endpoint
|
|
||||||
log.warn("Non Base64 avatar, ignored it", { avatar });
|
|
||||||
// `undefined` would not ignore the avatar, but `null` would remove it. So, we return `undefined` here.
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return await resizeBase64Image(avatar);
|
|
||||||
}
|
|
||||||
|
|
|
@ -177,6 +177,7 @@ export const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
export function DialogClose(
|
export function DialogClose(
|
||||||
props: {
|
props: {
|
||||||
|
"data-testid"?: string;
|
||||||
dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>;
|
dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||||
|
@ -188,7 +189,10 @@ export function DialogClose(
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Close asChild {...props.dialogCloseProps}>
|
<DialogPrimitive.Close asChild {...props.dialogCloseProps}>
|
||||||
{/* This will require the i18n string passed in */}
|
{/* This will require the i18n string passed in */}
|
||||||
<Button data-testid="dialog-rejection" color={props.color || "minimal"} {...props}>
|
<Button
|
||||||
|
data-testid={props["data-testid"] || "dialog-rejection"}
|
||||||
|
color={props.color || "minimal"}
|
||||||
|
{...props}>
|
||||||
{props.children ? props.children : t("Close")}
|
{props.children ? props.children : t("Close")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|
|
@ -170,7 +170,11 @@ export default function ImageUploader({
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button color={triggerButtonColor ?? "secondary"} type="button" className="py-1 text-sm">
|
<Button
|
||||||
|
color={triggerButtonColor ?? "secondary"}
|
||||||
|
type="button"
|
||||||
|
data-testid="open-upload-avatar-dialog"
|
||||||
|
className="py-1 text-sm">
|
||||||
{buttonMsg}
|
{buttonMsg}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
@ -190,7 +194,9 @@ export default function ImageUploader({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||||
<label className="bg-subtle hover:bg-muted hover:text-emphasis border-subtle text-default mt-8 rounded-sm border px-3 py-1 text-xs font-medium leading-4 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
<label
|
||||||
|
data-testid="open-upload-image-filechooser"
|
||||||
|
className="bg-subtle hover:bg-muted hover:text-emphasis border-subtle text-default mt-8 rounded-sm border px-3 py-1 text-xs font-medium leading-4 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||||
<input
|
<input
|
||||||
onInput={onInputFile}
|
onInput={onInputFile}
|
||||||
type="file"
|
type="file"
|
||||||
|
@ -205,8 +211,10 @@ export default function ImageUploader({
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="relative">
|
<DialogFooter className="relative">
|
||||||
<DialogClose color="minimal">{t("cancel")}</DialogClose>
|
<DialogClose color="minimal">{t("cancel")}</DialogClose>
|
||||||
|
<DialogClose
|
||||||
<DialogClose color="primary" onClick={() => showCroppedImage(croppedAreaPixels)}>
|
data-testid="upload-avatar"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => showCroppedImage(croppedAreaPixels)}>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user