feat: Implement orgs/teams logoUrls (#13699)
* feat: Implement orgs/teams logoUrls * Use getAvatarUrl in team/[slug] * Extract uploadLogo to standalone file for reuse * Allow removing the team/organisation logo * fix: API build fail due to server-only, removed
This commit is contained in:
@@ -425,7 +425,7 @@ export const EventTypeList = ({
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.team && !isManagedEventType && (
|
||||
{!!type.teamId && !isManagedEventType && (
|
||||
<UserAvatarGroup
|
||||
className="relative right-3"
|
||||
size="sm"
|
||||
|
||||
@@ -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 { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getOrgAvatarUrl, getTeamAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -157,6 +157,11 @@ function TeamPage({
|
||||
</div>
|
||||
);
|
||||
|
||||
const profileImageSrc =
|
||||
isValidOrgDomain || team.metadata?.isOrganization
|
||||
? getOrgAvatarUrl({ slug: currentOrgDomain, logoUrl: team.logoUrl })
|
||||
: getTeamAvatarUrl({ slug: team.slug, logoUrl: team.logoUrl, organizationId: team.parent?.id });
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
@@ -166,22 +171,14 @@ function TeamPage({
|
||||
title: markdownStrippedBio,
|
||||
profile: {
|
||||
name: `${team.name}`,
|
||||
image: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`,
|
||||
image: profileImageSrc,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pb-12 pt-12">
|
||||
<div className="mx-auto mb-8 max-w-3xl text-center">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
alt={teamName}
|
||||
imageSrc={
|
||||
isValidOrgDomain
|
||||
? `/org/${currentOrgDomain}/avatar.png`
|
||||
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
<Avatar alt={teamName} imageSrc={profileImageSrc} size="lg" />
|
||||
</div>
|
||||
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider" data-testid="team-name">
|
||||
{team.parent && `${team.parent.name} `}
|
||||
|
||||
@@ -325,7 +325,7 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => {
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const showRemoveLogoButton = !!value;
|
||||
|
||||
return (
|
||||
@@ -340,9 +340,7 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("upload_logo")}
|
||||
handleAvatarChange={(newLogo) => {
|
||||
form.setValue("logo", newLogo, { shouldDirty: true });
|
||||
}}
|
||||
handleAvatarChange={onChange}
|
||||
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
|
||||
imageSrc={value ?? undefined}
|
||||
/>
|
||||
@@ -350,7 +348,7 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
form.setValue("logo", null, { shouldDirty: true });
|
||||
onChange(null);
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function getTeamAvatarUrl(
|
||||
team: Pick<Team, "slug"> & {
|
||||
organizationId?: number | null;
|
||||
logoUrl?: string | null;
|
||||
requestedSlug: string | null;
|
||||
requestedSlug?: string | null;
|
||||
}
|
||||
) {
|
||||
if (team.logoUrl) {
|
||||
@@ -42,7 +42,7 @@ export function getTeamAvatarUrl(
|
||||
export const getOrgAvatarUrl = (
|
||||
org: Pick<Team, "slug"> & {
|
||||
logoUrl?: string | null;
|
||||
requestedSlug: string | null;
|
||||
requestedSlug?: string | null;
|
||||
}
|
||||
) => {
|
||||
if (org.logoUrl) {
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function getTeamWithMembers(args: {
|
||||
name: true,
|
||||
slug: true,
|
||||
...(!!includeTeamLogo ? { logo: true } : {}),
|
||||
logoUrl: true,
|
||||
bio: true,
|
||||
hideBranding: true,
|
||||
hideBookATeamMember: true,
|
||||
@@ -232,6 +233,7 @@ export async function getTeamWithMembers(args: {
|
||||
|
||||
return {
|
||||
...teamWithoutInviteTokens,
|
||||
...(teamWithoutInviteTokens.logoUrl ? { logo: teamWithoutInviteTokens.logoUrl } : {}),
|
||||
/** To prevent breaking we only return non-email attached token here, if we have one */
|
||||
inviteToken: inviteTokens.find(
|
||||
(token) =>
|
||||
|
||||
@@ -99,12 +99,6 @@ export class EventTypeRepository {
|
||||
const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id;
|
||||
const select = {
|
||||
...eventTypeSelect,
|
||||
// TODO: As required by getByViewHandler - Make it configurable
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hashedLink: true,
|
||||
users: { select: userSelect },
|
||||
children: {
|
||||
|
||||
@@ -103,11 +103,6 @@ export class MembershipRepository {
|
||||
eventTypes: {
|
||||
select: {
|
||||
...eventTypeSelect,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hashedLink: true,
|
||||
users: { select: userSelect },
|
||||
children: {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export const uploadLogo = async ({
|
||||
teamId,
|
||||
logo: data,
|
||||
}: {
|
||||
teamId: number;
|
||||
logo: string;
|
||||
}): Promise<string> => {
|
||||
const objectKey = uuidv4();
|
||||
|
||||
await prisma.avatar.upsert({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId: 0,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
data,
|
||||
objectKey,
|
||||
},
|
||||
update: {
|
||||
data,
|
||||
objectKey,
|
||||
},
|
||||
});
|
||||
|
||||
return `/api/avatar/${objectKey}.png`;
|
||||
};
|
||||
@@ -207,7 +207,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
|
||||
if (!input?.filters || !hasFilter(input?.filters)) {
|
||||
return true;
|
||||
}
|
||||
return input?.filters?.teamIds?.includes(eventType?.team?.id || 0) ?? false;
|
||||
return input?.filters?.teamIds?.includes(eventType?.teamId || 0) ?? false;
|
||||
};
|
||||
eventTypeGroups = ([] as EventTypeGroup[]).concat(
|
||||
eventTypeGroups,
|
||||
@@ -262,6 +262,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
|
||||
})
|
||||
: getTeamAvatarUrl({
|
||||
slug: team.slug,
|
||||
logoUrl: team.logoUrl,
|
||||
requestedSlug: team.metadata?.requestedSlug ?? null,
|
||||
organizationId: team.parentId,
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
|
||||
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
|
||||
import { uploadLogo } from "@calcom/lib/server/uploadLogo";
|
||||
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
@@ -60,7 +61,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
|
||||
const data: Prisma.TeamUpdateArgs["data"] = {
|
||||
name: input.name,
|
||||
logo: input.logo,
|
||||
calVideoLogo: input.calVideoLogo,
|
||||
bio: input.bio,
|
||||
hideBranding: input.hideBranding,
|
||||
@@ -74,6 +74,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
metadata: mergeMetadata({ ...input.metadata }),
|
||||
};
|
||||
|
||||
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
|
||||
data.logo = input.logo;
|
||||
data.logoUrl = await uploadLogo({
|
||||
logo: input.logo,
|
||||
teamId: currentOrgId,
|
||||
});
|
||||
} else if (typeof input.logo !== "undefined" && !input.logo) {
|
||||
data.logo = data.logoUrl = null;
|
||||
}
|
||||
|
||||
if (input.slug) {
|
||||
if (
|
||||
IS_TEAM_BILLING_ENABLED &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const ZUpdateInputSchema = z.object({
|
||||
@@ -13,9 +14,9 @@ export const ZUpdateInputSchema = z.object({
|
||||
bio: z.string().optional(),
|
||||
logo: z
|
||||
.string()
|
||||
.transform(async (val) => await resizeBase64Image(val))
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => v || null),
|
||||
.nullable(),
|
||||
calVideoLogo: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -37,10 +37,11 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
|
||||
const metadata = teamMetadataSchema.parse(mmship.team.metadata);
|
||||
return !metadata?.isOrganization;
|
||||
})
|
||||
.map(({ team: { inviteTokens, ..._team }, ...membership }) => ({
|
||||
.map(({ team: { inviteTokens, logo, logoUrl, ..._team }, ...membership }) => ({
|
||||
role: membership.role,
|
||||
accepted: membership.accepted,
|
||||
..._team,
|
||||
logo: logoUrl || logo,
|
||||
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}`),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
|
||||
import { uploadLogo } from "@calcom/lib/server/uploadLogo";
|
||||
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -46,7 +47,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
|
||||
const data: Prisma.TeamUpdateArgs["data"] = {
|
||||
name: input.name,
|
||||
logo: input.logo,
|
||||
bio: input.bio,
|
||||
hideBranding: input.hideBranding,
|
||||
isPrivate: input.isPrivate,
|
||||
@@ -56,6 +56,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
theme: input.theme,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
input.slug &&
|
||||
IS_TEAM_BILLING_ENABLED &&
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
export const ZUpdateInputSchema = z.object({
|
||||
id: z.number(),
|
||||
bio: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
logo: z.string().nullable().optional(),
|
||||
logo: z
|
||||
.string()
|
||||
.transform(async (val) => await resizeBase64Image(val))
|
||||
.nullable()
|
||||
.optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.transform((val) => slugify(val.trim()))
|
||||
|
||||
Reference in New Issue
Block a user