diff --git a/apps/web/pages/settings/admin/organizations/index.tsx b/apps/web/pages/settings/admin/organizations/index.tsx index 83fa10880e..9c2d7cc1de 100644 --- a/apps/web/pages/settings/admin/organizations/index.tsx +++ b/apps/web/pages/settings/admin/organizations/index.tsx @@ -1,9 +1,9 @@ -import UnverifiedOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/unverifiedOrgPage"; +import AdminOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage"; import type { CalPageWrapper } from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper"; -const Page = UnverifiedOrgsPage as CalPageWrapper; +const Page = AdminOrgsPage as CalPageWrapper; Page.PageWrapper = PageWrapper; export default Page; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e90f0bc9a5..c7546eab0b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2010,6 +2010,20 @@ "member_removed": "Member removed", "my_availability": "My Availability", "team_availability": "Team Availability", + "admin_org_notification_email_subject": "New organization created: pending action", + "hi_admin": "Hi Administrator", + "admin_org_notification_email_title": "An organization requires DNS setup", + "admin_org_notification_email_body_part1": "An organization with slug \"{{orgSlug}}\" was created.

Please be sure to configure your DNS registry to point the subdomain corresponding to the new organization to where the main app is running. Otherwise the organization will not work.

Here are just the very basic options to configure a subdomain to point to their app so it loads the organization profile page.

You can do it either with the A Record:", + "admin_org_notification_email_body_part2": "Or the CNAME record:", + "admin_org_notification_email_body_part3": "Once you configure the subdomain, please mark the DNS configuration as done in Organizations Admin Settings.", + "admin_org_notification_email_cta": "Go to Organizations Admin Settings", + "org_has_been_processed": "Org has been processed", + "org_error_processing": "There has been an error processing this organization", + "orgs_page_description": "A list of all organizations. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verifciation.", + "unverified": "Unverified", + "dns_missing": "DNS Missing", + "mark_dns_configured": "Mark as DNS configured", + "value": "Value", "your_organization_updated_sucessfully": "Your organization updated successfully", "seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 1608809787..8f05cc8a5b 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -9,6 +9,8 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import type { EmailVerifyLink } from "./templates/account-verify-email"; import AccountVerifyEmail from "./templates/account-verify-email"; +import type { OrganizationNotification } from "./templates/admin-organization-notification"; +import AdminOrganizationNotification from "./templates/admin-organization-notification"; import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email"; import AttendeeCancelledEmail from "./templates/attendee-cancelled-email"; import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email"; @@ -376,3 +378,7 @@ export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, dow export const sendOrganizationEmailVerification = async (sendOrgInput: OrganizationEmailVerify) => { await sendEmail(() => new OrganizationEmailVerification(sendOrgInput)); }; + +export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => { + await sendEmail(() => new AdminOrganizationNotification(input)); +}; diff --git a/packages/emails/src/templates/AdminOrganizationNotificationEmail.tsx b/packages/emails/src/templates/AdminOrganizationNotificationEmail.tsx new file mode 100644 index 0000000000..1449a2ed98 --- /dev/null +++ b/packages/emails/src/templates/AdminOrganizationNotificationEmail.tsx @@ -0,0 +1,118 @@ +import { Trans, type TFunction } from "next-i18next"; + +import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; + +import { BaseEmailHtml, CallToAction } from "../components"; + +type AdminOrganizationNotification = { + language: TFunction; + orgSlug: string; + webappIPAddress: string; +}; + +const dnsTable = (type: string, name: string, value: string, t: TFunction) => ( + + + + + + + + + + + + + + + +
+ {t("type")} + + {t("name")} + + {t("value")} +
+ {type} + + {name} + + {value} +
+); + +export const AdminOrganizationNotificationEmail = ({ + orgSlug, + webappIPAddress, + language, +}: AdminOrganizationNotification) => { + const webAppUrl = WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, ""); + return ( + + }> +

+ <>{language("admin_org_notification_email_title")} +

+

+ <>{language("hi_admin")}! +

+

+ + An organization with slug {`"${orgSlug}"`} was created. +
+
+ Please be sure to configure your DNS registry to point the subdomain corresponding to the new + organization to where the main app is running. Otherwise the organization will not work. +
+
+ Here are just the very basic options to configure a subdomain to point to their app so it loads the + organization profile page. +
+
+ You can do it either with the A Record: +
+

+ {dnsTable("A", orgSlug, webappIPAddress, language)} +

+ {language("admin_org_notification_email_body_part2")} +

+ {dnsTable("CNAME", orgSlug, webAppUrl, language)} +

+ {language("admin_org_notification_email_body_part3")} +

+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index c10514f3ec..4571c8df55 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -29,3 +29,4 @@ export * from "@calcom/app-store/routing-forms/emails/components"; export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail"; export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail"; export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail"; +export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail"; diff --git a/packages/emails/templates/admin-organization-notification.ts b/packages/emails/templates/admin-organization-notification.ts new file mode 100644 index 0000000000..680add21d0 --- /dev/null +++ b/packages/emails/templates/admin-organization-notification.ts @@ -0,0 +1,43 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME } from "@calcom/lib/constants"; + +import { renderEmail } from "../"; +import BaseEmail from "./_base-email"; + +export type OrganizationNotification = { + t: TFunction; + instanceAdmins: { email: string }[]; + ownerEmail: string; + orgSlug: string; + webappIPAddress: string; +}; + +export default class AdminOrganizationNotification extends BaseEmail { + input: OrganizationNotification; + + constructor(input: OrganizationNotification) { + super(); + this.name = "SEND_ADMIN_ORG_NOTIFICATION"; + this.input = input; + } + + protected getNodeMailerPayload(): Record { + return { + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + to: this.input.instanceAdmins.map((admin) => admin.email).join(","), + subject: `${this.input.t("admin_org_notification_email_subject")}`, + html: renderEmail("AdminOrganizationNotificationEmail", { + orgSlug: this.input.orgSlug, + webappIPAddress: this.input.webappIPAddress, + language: this.input.t, + }), + text: this.getTextBody(), + }; + } + + protected getTextBody(): string { + return `${this.input.t("hi_admin")}, ${this.input.t("admin_org_notification_email_title").toLowerCase()} + ${this.input.t("admin_org_notification_email_body")}`.trim(); + } +} diff --git a/packages/features/ee/organizations/pages/settings/admin/AdminOrgPage.tsx b/packages/features/ee/organizations/pages/settings/admin/AdminOrgPage.tsx new file mode 100644 index 0000000000..1289151db6 --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/admin/AdminOrgPage.tsx @@ -0,0 +1,145 @@ +import NoSSR from "@calcom/core/components/NoSSR"; +import LicenseRequired from "@calcom/ee/common/components/LicenseRequired"; +import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Meta, DropdownActions, showToast, Table, Badge } from "@calcom/ui"; +import { X, Check, CheckCheck } from "@calcom/ui/components/icon"; + +import { getLayout } from "../../../../../settings/layouts/SettingsLayout"; + +const { Body, Cell, ColumnTitle, Header, Row } = Table; + +function AdminOrgTable() { + const { t } = useLocale(); + const utils = trpc.useContext(); + const [data] = trpc.viewer.organizations.adminGetAll.useSuspenseQuery(); + const verifyMutation = trpc.viewer.organizations.adminVerify.useMutation({ + onSuccess: async () => { + showToast(t("org_has_been_processed"), "success"); + await utils.viewer.organizations.adminGetAll.invalidate(); + }, + onError: (err) => { + console.error(err.message); + showToast(t("org_error_processing"), "error"); + }, + }); + const updateMutation = trpc.viewer.organizations.update.useMutation({ + onSuccess: async () => { + showToast(t("org_has_been_processed"), "success"); + await utils.viewer.organizations.adminGetAll.invalidate(); + }, + onError: (err) => { + console.error(err.message); + showToast(t("org_error_processing"), "error"); + }, + }); + + return ( +
+ +
+ {t("organization")} + {t("owner")} + + {t("edit")} + +
+ + + {data.map((org) => ( + + +
+ {org.name} +
+ + {org.slug}.{extractDomainFromWebsiteUrl} + +
+
+ + {org.members[0].user.email} + + +
+ {!org.metadata?.isOrganizationVerified && {t("unverified")}} + {!org.metadata?.isOrganizationConfigured && {t("dns_missing")}} +
+
+ +
+ {(!org.metadata?.isOrganizationVerified || !org.metadata?.isOrganizationConfigured) && ( + { + verifyMutation.mutate({ + orgId: org.id, + status: "ACCEPT", + }); + }, + icon: Check, + }, + { + id: "reject", + label: t("reject"), + onClick: () => { + verifyMutation.mutate({ + orgId: org.id, + status: "DENY", + }); + }, + icon: X, + }, + ] + : []), + ...(!org.metadata?.isOrganizationConfigured + ? [ + { + id: "dns", + label: t("mark_dns_configured"), + onClick: () => { + updateMutation.mutate({ + orgId: org.id, + metadata: { + isOrganizationConfigured: true, + }, + }); + }, + icon: CheckCheck, + }, + ] + : []), + ]} + /> + )} +
+
+
+ ))} + +
+
+ ); +} + +const AdminOrgList = () => { + const { t } = useLocale(); + return ( + + + + + + + ); +}; + +AdminOrgList.getLayout = getLayout; + +export default AdminOrgList; diff --git a/packages/features/ee/organizations/pages/settings/admin/unverifiedOrgPage.tsx b/packages/features/ee/organizations/pages/settings/admin/unverifiedOrgPage.tsx deleted file mode 100644 index 9b41980113..0000000000 --- a/packages/features/ee/organizations/pages/settings/admin/unverifiedOrgPage.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import NoSSR from "@calcom/core/components/NoSSR"; -import LicenseRequired from "@calcom/ee/common/components/LicenseRequired"; -import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils"; -import { trpc } from "@calcom/trpc/react"; -import { Meta } from "@calcom/ui"; -import { DropdownActions, showToast, Table } from "@calcom/ui"; -import { Check, X } from "@calcom/ui/components/icon"; - -import { getLayout } from "../../../../../settings/layouts/SettingsLayout"; - -const { Body, Cell, ColumnTitle, Header, Row } = Table; - -function UnverifiedOrgTable() { - const utils = trpc.useContext(); - const [data] = trpc.viewer.organizations.adminGetUnverified.useSuspenseQuery(); - const mutation = trpc.viewer.organizations.adminVerify.useMutation({ - onSuccess: async () => { - showToast("Org has been processed", "success"); - await utils.viewer.organizations.adminGetUnverified.invalidate(); - }, - onError: (err) => { - console.error(err.message); - showToast("There has been an error processing this org.", "error"); - }, - }); - - return ( -
- -
- Organization - Owner - - Edit - -
- - - {data.map((org) => ( - - -
- {org.name} -
- - {org.slug}.{extractDomainFromWebsiteUrl} - -
-
- - {org.members[0].user.email} - - - -
- { - mutation.mutate({ - orgId: org.id, - status: "ACCEPT", - }); - }, - icon: Check, - }, - { - id: "reject", - label: "Reject", - onClick: () => { - mutation.mutate({ - orgId: org.id, - status: "DENY", - }); - }, - icon: X, - }, - ]} - /> -
-
-
- ))} - -
-
- ); -} - -const UnverifiedOrgList = () => { - return ( - - - - - - - ); -}; - -UnverifiedOrgList.getLayout = getLayout; - -export default UnverifiedOrgList; diff --git a/packages/lib/getMetadataHelpers.ts b/packages/lib/getMetadataHelpers.ts new file mode 100644 index 0000000000..9c239bb083 --- /dev/null +++ b/packages/lib/getMetadataHelpers.ts @@ -0,0 +1,32 @@ +import type { SomeZodObject, z } from "zod"; + +import objectKeys from "./objectKeys"; + +/** + * The intention behind these helpers is to make it easier to work with metadata. + * @param schema This is the zod schema that you want to use to parse the metadata + * @param rawMetadata This is the metadata that you want to parse + * @returns An object with the parsed metadata, a get function to get a specific key of the metadata, and a mergeMetadata function to merge new metadata with the old one + * @example + * const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema, team.metadata); + * const newMetadata = mergeMetadata({ someKey: "someValue" }); + * prisma.team.update({ ..., data: { metadata: newMetadata } }); + */ +export function getMetadataHelpers(schema: T, rawMetadata: unknown) { + const metadata = schema.parse(rawMetadata) as z.infer; + return { + metadata, + get: (key: keyof z.infer) => metadata[key], + /** This method prevents overwriting the metadata fields that you don't want to change. */ + mergeMetadata: (newMetadata: z.infer) => { + const newMetadataToReturn = { ...metadata, ...newMetadata }; + // We check for each key of newMetadata and if it's explicitly undefined, we delete it from newMetadataToReturn + objectKeys(newMetadata).forEach((key) => { + if (newMetadata[key] === undefined) { + delete newMetadataToReturn[key]; + } + }); + return newMetadataToReturn as z.infer; + }, + }; +} diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 9216701c78..d50f6c904f 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -328,6 +328,7 @@ export const teamMetadataSchema = z subscriptionItemId: z.string().nullable(), isOrganization: z.boolean().nullable(), isOrganizationVerified: z.boolean().nullable(), + isOrganizationConfigured: z.boolean().nullable(), orgAutoAcceptEmail: z.string().nullable(), }) .partial() diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 42c91f5c45..c8ddaebb5b 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -63,11 +63,8 @@ export const viewerOrganizationsRouter = router({ const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler")); return handler(opts); }), - adminGetUnverified: authedAdminProcedure.query(async (opts) => { - const handler = await importHandler( - namespaced("adminGetUnverified"), - () => import("./adminGetUnverified.handler") - ); + adminGetAll: authedAdminProcedure.query(async (opts) => { + const handler = await importHandler(namespaced("adminGetAll"), () => import("./adminGetAll.handler")); return handler(opts); }), adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async (opts) => { diff --git a/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminGetAll.handler.ts similarity index 73% rename from packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts rename to packages/trpc/server/routers/viewer/organizations/adminGetAll.handler.ts index 86c5e0a3f8..1e7bb0c28f 100644 --- a/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/adminGetAll.handler.ts @@ -1,15 +1,16 @@ import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "../../../trpc"; -type AdminGetUnverifiedOptions = { +type AdminGetAllOptions = { ctx: { user: NonNullable; }; }; -export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptions) => { - const unVerifiedTeams = await prisma.team.findMany({ +export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetAllOptions) => { + const allOrgs = await prisma.team.findMany({ where: { AND: [ { @@ -18,18 +19,13 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio equals: true, }, }, - { - metadata: { - path: ["isOrganizationVerified"], - equals: false, - }, - }, ], }, select: { id: true, name: true, slug: true, + metadata: true, members: { where: { role: "OWNER", @@ -47,7 +43,7 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio }, }); - return unVerifiedTeams; + return allOrgs.map((org) => ({ ...org, metadata: teamMetadataSchema.parse(org.metadata) })); }; export default adminGetUnverifiedHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 7736863687..76a37f547a 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -1,19 +1,21 @@ import { createHash } from "crypto"; +import { lookup } from "dns"; import { totp } from "otplib"; import { sendOrganizationEmailVerification } from "@calcom/emails"; +import { sendAdminOrganizationNotification } from "@calcom/emails"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; import { - IS_CALCOM, IS_TEAM_BILLING_ENABLED, RESERVED_SUBDOMAINS, IS_PRODUCTION, + WEBAPP_URL, } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -27,6 +29,15 @@ type CreateOptions = { input: TCreateInputSchema; }; +const getIPAddress = async (url: string): Promise => { + return new Promise((resolve, reject) => { + lookup(url, (err, address) => { + if (err) reject(err); + resolve(address); + }); + }); +}; + const vercelCreateDomain = async (domain: string) => { const response = await fetch( `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`, @@ -43,12 +54,10 @@ const vercelCreateDomain = async (domain: string) => { const data = await response.json(); // Domain is already owned by another team but you can request delegation to access it - if (data.error?.code === "forbidden") - throw new TRPCError({ code: "CONFLICT", message: "domain_taken_team" }); + if (data.error?.code === "forbidden") return false; // Domain is already being used by a different project - if (data.error?.code === "domain_taken") - throw new TRPCError({ code: "CONFLICT", message: "domain_taken_project" }); + if (data.error?.code === "domain_taken") return false; return true; }; @@ -85,9 +94,35 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { const t = await getTranslation(ctx.user.locale ?? "en", "common"); const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); + let isOrganizationConfigured = false; if (check === false) { - if (IS_CALCOM) await vercelCreateDomain(slug); + // eslint-disable-next-line turbo/no-undeclared-env-vars + if (process.env.VERCEL) { + // We only want to proceed to register the subdomain for the org in Vercel + // within a Vercel context + isOrganizationConfigured = await vercelCreateDomain(slug); + } else { + // Otherwise, we proceed to send an administrative email to admins regarding + // the need to configure DNS registry to support the newly created org + const instanceAdmins = await prisma.user.findMany({ + where: { role: UserPermissionRole.ADMIN }, + select: { email: true }, + }); + if (instanceAdmins.length) { + await sendAdminOrganizationNotification({ + instanceAdmins, + orgSlug: slug, + ownerEmail: adminEmail, + webappIPAddress: await getIPAddress( + WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "") + ), + t, + }); + } else { + console.warn("Organization created: subdomain not configured and couldn't notify adminnistrators"); + } + } const createOwnerOrg = await prisma.user.create({ data: { @@ -118,6 +153,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { ...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }), isOrganization: true, isOrganizationVerified: false, + isOrganizationConfigured, orgAutoAcceptEmail: emailDomain, }, }, diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 0573259307..ec8d1fad92 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -1,9 +1,11 @@ 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 { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; import { prisma } from "@calcom/prisma"; +import { UserPermissionRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -20,11 +22,13 @@ type UpdateOptions = { 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; + const currentOrgId = ctx.user?.organization?.id || input.orgId; - if (!currentOrgId) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (!currentOrgId || ctx.user.role !== UserPermissionRole.ADMIN) + throw new TRPCError({ code: "UNAUTHORIZED" }); - if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId))) throw new TRPCError({ code: "UNAUTHORIZED" }); + if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId)) || ctx.user.role !== UserPermissionRole.ADMIN) + throw new TRPCError({ code: "UNAUTHORIZED" }); if (input.slug) { const userConflict = await prisma.team.findMany({ @@ -51,6 +55,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); if (!prevOrganisation) throw new TRPCError({ code: "NOT_FOUND", message: "Organisation not found." }); + const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), prevOrganisation.metadata); const data: Prisma.TeamUpdateArgs["data"] = { name: input.name, @@ -64,6 +69,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { timeZone: input.timeZone, weekStart: input.weekStart, timeFormat: input.timeFormat, + metadata: mergeMetadata({ ...input.metadata }), }; if (input.slug) { @@ -73,20 +79,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { !prevOrganisation.slug ) { // Save it on the metadata so we can use it later - data.metadata = { - requestedSlug: input.slug, - }; + data.metadata = mergeMetadata({ requestedSlug: input.slug }); } else { data.slug = input.slug; - - // If we save slug, we don't need the requestedSlug anymore - const metadataParse = teamMetadataSchema.safeParse(prevOrganisation.metadata); - if (metadataParse.success) { - const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {}; - data.metadata = { - ...cleanMetadata, - }; - } + data.metadata = mergeMetadata({ + // If we save slug, we don't need the requestedSlug anymore + requestedSlug: undefined, + ...input.metadata, + }); } } diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index 5095b56af9..91d1069b31 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -1,11 +1,14 @@ import { z } from "zod"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + export const ZUpdateInputSchema = z.object({ name: z.string().optional(), orgId: z .string() .regex(/^\d+$/) .transform((id) => parseInt(id)) + .or(z.number()) .optional(), bio: z.string().optional(), logo: z @@ -22,6 +25,7 @@ export const ZUpdateInputSchema = z.object({ timeZone: z.string().optional(), weekStart: z.string().optional(), timeFormat: z.number().optional(), + metadata: teamMetadataSchema.unwrap().pick({ isOrganizationConfigured: true }).optional(), }); export type TUpdateInputSchema = z.infer;