diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx index fe90a8ed6c..08a6eff4ce 100644 --- a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; -import { currencyOptions } from "@calcom/app-store/paypal/pages/setup"; +import { currencyOptions } from "@calcom/app-store/paypal/lib/currencyOptions"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/packages/app-store/paypal/lib/Paypal.ts b/packages/app-store/paypal/lib/Paypal.ts index eae742387c..4645061887 100644 --- a/packages/app-store/paypal/lib/Paypal.ts +++ b/packages/app-store/paypal/lib/Paypal.ts @@ -12,12 +12,25 @@ class Paypal { accessToken: string | null = null; expiresAt: number | null = null; - constructor({ clientId, secretKey }: { clientId: string; secretKey: string }) { + constructor(opts: { clientId: string; secretKey: string }) { this.url = IS_PRODUCTION ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"; - this.clientId = clientId; - this.secretKey = secretKey; + this.clientId = opts.clientId; + this.secretKey = opts.secretKey; } + private fetcher = async (endpoint: string, init?: RequestInit | undefined) => { + this.getAccessToken(); + return fetch(`${this.url}${endpoint}`, { + method: "get", + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.accessToken}`, + ...init?.headers, + }, + }); + }; + async getAccessToken(): Promise { if (this.accessToken && this.expiresAt && this.expiresAt > Date.now()) { return; @@ -64,15 +77,6 @@ class Paypal { cancelUrl: string; intent?: "CAPTURE" | "AUTHORIZE"; }): Promise { - // Always get a new access token - await this.getAccessToken(); - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${this.accessToken}`, - "PayPal-Request-Id": uuidv4(), - }; - const createOrderRequestBody: CreateOrderRequestBody = { intent, purchase_units: [ @@ -96,9 +100,11 @@ class Paypal { }; try { - const response = await fetch(`${this.url}/v2/checkout/orders`, { + const response = await this.fetcher("/v2/checkout/orders", { method: "POST", - headers, + headers: { + "PayPal-Request-Id": uuidv4(), + }, body: JSON.stringify(createOrderRequestBody), }); @@ -116,16 +122,8 @@ class Paypal { async captureOrder(orderId: string): Promise { try { - await this.getAccessToken(); - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${this.accessToken}`, - }; - - const captureResult = await fetch(`${this.url}/v2/checkout/orders/${orderId}/capture`, { + const captureResult = await this.fetcher(`/v2/checkout/orders/${orderId}/capture`, { method: "POST", - headers, }); if (captureResult.ok) { const result = await captureResult.json(); @@ -181,14 +179,6 @@ class Paypal { } async createWebhook(): Promise { - // Always get a new access token - await this.getAccessToken(); - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${this.accessToken}`, - }; - const body = { url: `${WEBAPP_URL}/api/integrations/paypal/webhook`, event_types: [ @@ -202,9 +192,8 @@ class Paypal { }; try { - const response = await fetch(`${this.url}/v1/notifications/webhooks`, { + const response = await this.fetcher(`/v1/notifications/webhooks`, { method: "POST", - headers, body: JSON.stringify(body), }); @@ -220,19 +209,8 @@ class Paypal { } async listWebhooks(): Promise { - // Always get a new access token - await this.getAccessToken(); - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${this.accessToken}`, - }; - try { - const response = await fetch(`${this.url}/v1/notifications/webhooks`, { - method: "GET", - headers, - }); + const response = await this.fetcher(`/v1/notifications/webhooks`); if (response.ok) { const { webhooks } = await response.json(); @@ -251,18 +229,9 @@ class Paypal { } async deleteWebhook(webhookId: string): Promise { - // Always get a new access token - await this.getAccessToken(); - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${this.accessToken}`, - }; - try { - const response = await fetch(`${this.url}/v1/notifications/webhooks/${webhookId}`, { + const response = await this.fetcher(`/v1/notifications/webhooks/${webhookId}`, { method: "DELETE", - headers, }); if (response.ok) { @@ -286,7 +255,6 @@ class Paypal { } async verifyWebhook(options: WebhookEventVerifyRequest): Promise { - const url = `${this.url}/v1/notifications/verify-webhook-signature`; const parseRequest = webhookEventVerifyRequestSchema.safeParse(options); // Webhook event should be parsable @@ -307,12 +275,8 @@ class Paypal { const bodyToString = stringy.slice(0, -1) + `,"webhook_event":${options.body.webhook_event}` + "}"; try { - const response = await fetch(url, { + const response = await this.fetcher(`/v1/notifications/verify-webhook-signature`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, body: bodyToString, }); diff --git a/packages/app-store/paypal/lib/currencyOptions.ts b/packages/app-store/paypal/lib/currencyOptions.ts new file mode 100644 index 0000000000..db80dc59f7 --- /dev/null +++ b/packages/app-store/paypal/lib/currencyOptions.ts @@ -0,0 +1,27 @@ +export const currencyOptions = [ + { label: "United States dollar", value: "USD" }, + { label: "Australian dollar", value: "AUD" }, + { label: "Brazilian real 2", value: "BRL" }, + { label: "Canadian dollar", value: "CAD" }, + { label: "Chinese Renmenbi 3", value: "CNY" }, + { label: "Czech koruna", value: "CZK" }, + { label: "Danish krone", value: "DKK" }, + { label: "Euro", value: "EUR" }, + { label: "Hong Kong dollar", value: "HKD" }, + { label: "Hungarian forint 1", value: "HUF" }, + { label: "Israeli new shekel", value: "ILS" }, + { label: "Japanese yen 1", value: "JPY" }, + { label: "Malaysian ringgit 3", value: "MYR" }, + { label: "Mexican peso", value: "MXN" }, + { label: "New Taiwan dollar 1", value: "TWD" }, + { label: "New Zealand dollar", value: "NZD" }, + { label: "Norwegian krone", value: "NOK" }, + { label: "Philippine peso", value: "PHP" }, + { label: "Polish złoty", value: "PLN" }, + { label: "Pound sterling", value: "GBP" }, + { label: "Russian ruble", value: "RUB" }, + { label: "Singapore dollar", value: "SGD" }, + { label: "Swedish krona", value: "SEK" }, + { label: "Swiss franc", value: "CHF" }, + { label: "Thai baht", value: "THB" }, +]; diff --git a/packages/app-store/paypal/lib/updateAppCredentials.validator.ts b/packages/app-store/paypal/lib/updateAppCredentials.validator.ts new file mode 100644 index 0000000000..b9223d46c2 --- /dev/null +++ b/packages/app-store/paypal/lib/updateAppCredentials.validator.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +import type { UpdateAppCredentialsOptions } from "@calcom/trpc/server/routers/viewer/apps/updateAppCredentials.handler"; + +import Paypal from "./Paypal"; + +const schema = z.object({ + credentialId: z.coerce.number(), + key: z.object({ + client_id: z.string(), + secret_key: z.string(), + }), +}); + +const handlePaypalValidations = async ({ input }: UpdateAppCredentialsOptions) => { + const validated = schema.safeParse(input); + if (!validated.success) throw new Error("Invalid input"); + const { key } = validated.data; + + // Test credentials before saving + const paypalClient = new Paypal({ clientId: key.client_id, secretKey: key.secret_key }); + const test = await paypalClient.test(); + if (!test) throw new Error("Provided credentials failed to authenticate"); + + // Delete all existing webhooks + const webhooksToDelete = await paypalClient.listWebhooks(); + if (webhooksToDelete) { + const promises = webhooksToDelete.map((webhook) => paypalClient.deleteWebhook(webhook)); + await Promise.all(promises); + } + + // Create webhook for this installation + const webhookId = await paypalClient.createWebhook(); + if (!webhookId) { + // @TODO: make a button that tries to create the webhook again + console.error("Failed to create webhook", webhookId); + throw new Error("Failed to create webhook"); + } + + return { + client_id: key.client_id, + secret_key: key.secret_key, + webhook_id: webhookId, + }; +}; + +export default handlePaypalValidations; diff --git a/packages/app-store/paypal/pages/setup/index.tsx b/packages/app-store/paypal/pages/setup/index.tsx index cf028616f0..3bcb54fed6 100644 --- a/packages/app-store/paypal/pages/setup/index.tsx +++ b/packages/app-store/paypal/pages/setup/index.tsx @@ -1,57 +1,20 @@ import Link from "next/link"; -import { useRouter } from "next/router"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { Toaster } from "react-hot-toast"; -import Paypal from "@calcom/app-store/paypal/lib/Paypal"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { Button, showToast, TextField } from "@calcom/ui"; -export interface PayPalSetupProps { - public_key: string; - access_token: string; - currency: string; -} - -export const currencyOptions = [ - { label: "United States dollar", value: "USD" }, - { label: "Australian dollar", value: "AUD" }, - { label: "Brazilian real 2", value: "BRL" }, - { label: "Canadian dollar", value: "CAD" }, - { label: "Chinese Renmenbi 3", value: "CNY" }, - { label: "Czech koruna", value: "CZK" }, - { label: "Danish krone", value: "DKK" }, - { label: "Euro", value: "EUR" }, - { label: "Hong Kong dollar", value: "HKD" }, - { label: "Hungarian forint 1", value: "HUF" }, - { label: "Israeli new shekel", value: "ILS" }, - { label: "Japanese yen 1", value: "JPY" }, - { label: "Malaysian ringgit 3", value: "MYR" }, - { label: "Mexican peso", value: "MXN" }, - { label: "New Taiwan dollar 1", value: "TWD" }, - { label: "New Zealand dollar", value: "NZD" }, - { label: "Norwegian krone", value: "NOK" }, - { label: "Philippine peso", value: "PHP" }, - { label: "Polish złoty", value: "PLN" }, - { label: "Pound sterling", value: "GBP" }, - { label: "Russian ruble", value: "RUB" }, - { label: "Singapore dollar", value: "SGD" }, - { label: "Swedish krona", value: "SEK" }, - { label: "Swiss franc", value: "CHF" }, - { label: "Thai baht", value: "THB" }, -]; - -export default function PayPalSetup(props: PayPalSetupProps) { +export default function PayPalSetup() { const [newClientId, setNewClientId] = useState(""); const [newSecretKey, setNewSecretKey] = useState(""); - const router = useRouter(); const { t } = useLocale(); - - const integrations = trpc.viewer.integrations.useQuery({ variant: "payment" }); - const paypalPaymentAppCredentials = integrations.data?.items.find((item) => item.type === "paypal_payment"); - const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [false]; + const integrations = trpc.viewer.integrations.useQuery({ variant: "payment", appId: "paypal" }); + const [paypalPaymentAppCredentials] = integrations.data?.items || []; + const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [-1]; const showContent = !!integrations.data && integrations.isSuccess && !!credentialId; const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({ onSuccess: () => { @@ -63,48 +26,6 @@ export default function PayPalSetup(props: PayPalSetupProps) { }, }); - const saveKeys = async (key: { clientId: string; secretKey: string }) => { - if (!key.clientId || !key.secretKey || key.clientId === key.secretKey) { - return false; - } - if (typeof credentialId !== "number") { - return; - } - - // Test credentials before saving - const paypalClient = new Paypal({ clientId: key.clientId, secretKey: key.secretKey }); - const test = await paypalClient.test(); - if (!test) { - return false; - } - - // Delete all existing webhooks - const webhooksToDelete = await paypalClient.listWebhooks(); - if (webhooksToDelete) { - for (const webhook of webhooksToDelete) { - await paypalClient.deleteWebhook(webhook); - } - } - - // Create webhook for this installation - const webhookId = await paypalClient.createWebhook(); - if (!webhookId) { - // @TODO: make a button that tries to create the webhook again - console.log("Failed to create webhook", webhookId); - return false; - } - saveKeysMutation.mutate({ - credentialId, - key: { - client_id: key.clientId, - secret_key: key.secretKey, - webhook_id: webhookId, - }, - }); - - return true; - }; - if (integrations.isLoading) { return
; } @@ -144,12 +65,15 @@ export default function PayPalSetup(props: PayPalSetupProps) {
diff --git a/packages/app-store/server.ts b/packages/app-store/server.ts index ead279afe2..0d901131ea 100644 --- a/packages/app-store/server.ts +++ b/packages/app-store/server.ts @@ -2,7 +2,7 @@ import type { TFunction } from "next-i18next"; import type { CredentialDataWithTeamName } from "@calcom/app-store/utils"; import { defaultVideoAppCategories } from "@calcom/app-store/utils"; -import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials"; import { prisma } from "@calcom/prisma"; import { AppCategories } from "@calcom/prisma/enums"; @@ -77,7 +77,7 @@ export async function getLocationGroupedOptions( }, }); - const integrations = await getEnabledApps(credentials, true); + const integrations = await getEnabledAppsFromCredentials(credentials, { filterOnCredentials: true }); integrations.forEach((app) => { // All apps that are labeled as a locationOption are video apps. diff --git a/packages/lib/apps/getEnabledApps.ts b/packages/lib/apps/getEnabledAppsFromCredentials.ts similarity index 68% rename from packages/lib/apps/getEnabledApps.ts rename to packages/lib/apps/getEnabledAppsFromCredentials.ts index 4ac0590de3..62ce8c3452 100644 --- a/packages/lib/apps/getEnabledApps.ts +++ b/packages/lib/apps/getEnabledAppsFromCredentials.ts @@ -1,18 +1,27 @@ -import getApps from "@calcom/app-store/utils"; -import type { CredentialDataWithTeamName } from "@calcom/app-store/utils"; -import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@prisma/client"; -import type { Prisma } from ".prisma/client"; +import type { CredentialDataWithTeamName } from "@calcom/app-store/utils"; +import getApps from "@calcom/app-store/utils"; +import { prisma } from "@calcom/prisma"; type EnabledApp = ReturnType[number] & { enabled: boolean }; /** * * @param credentials - Can be user or team credentials - * @param filterOnCredentials - Only include apps where credentials are present + * @param options + * @param options.where Aditional where conditions to filter out apps + * @param options.filterOnCredentials - Only include apps where credentials are present * @returns A list of enabled app metadata & credentials tied to them */ -const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterOnCredentials?: boolean) => { +const getEnabledAppsFromCredentials = async ( + credentials: CredentialDataWithTeamName[], + options?: { + where?: Prisma.AppWhereInput; + filterOnCredentials?: boolean; + } +) => { + const { where: _where = {}, filterOnCredentials = false } = options || {}; const filterOnIds = { credentials: { some: { @@ -33,11 +42,13 @@ const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterO if (teamIds.length) filterOnIds.credentials.some.OR.push({ teamId: { in: teamIds } }); } + const where: Prisma.AppWhereInput = { + enabled: true, + ..._where, + ...(filterOnIds.credentials.some.OR.length && filterOnIds), + }; const enabledApps = await prisma.app.findMany({ - where: { - enabled: true, - ...(filterOnIds.credentials.some.OR.length && filterOnIds), - }, + where, select: { slug: true, enabled: true }, }); const apps = getApps(credentials, filterOnCredentials); @@ -52,4 +63,4 @@ const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterO return filteredApps; }; -export default getEnabledApps; +export default getEnabledAppsFromCredentials; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts index ddf4f8580a..df210591db 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts @@ -1,5 +1,7 @@ +import type { Credential, Prisma } from "@prisma/client"; + import type { CredentialOwner } from "@calcom/app-store/types"; -import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials"; import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; import prisma from "@calcom/prisma"; @@ -7,7 +9,6 @@ import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { TIntegrationsInputSchema } from "./integrations.schema"; -import type { Prisma, Credential } from ".prisma/client"; type IntegrationsOptions = { ctx: { @@ -44,6 +45,8 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) = extendsFeature, teamId, sortByMostPopular, + categories, + appId, } = input; let credentials = await getUsersCredentials(user.id); let userTeams: TeamQuery[] = []; @@ -116,7 +119,10 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) = } } - const enabledApps = await getEnabledApps(credentials, onlyInstalled); + const enabledApps = await getEnabledAppsFromCredentials(credentials, { + filterOnCredentials: onlyInstalled, + ...(appId ? { where: { slug: appId } } : {}), + }); //TODO: Refactor this to pick up only needed fields and prevent more leaking let apps = enabledApps.map( ({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => { diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts index 855f565091..8a0fa0b8b4 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { AppCategories } from "@calcom/prisma/enums"; + export const ZIntegrationsInputSchema = z.object({ variant: z.string().optional(), exclude: z.array(z.string()).optional(), @@ -8,6 +10,8 @@ export const ZIntegrationsInputSchema = z.object({ extendsFeature: z.literal("EventType").optional(), teamId: z.union([z.number(), z.null()]).optional(), sortByMostPopular: z.boolean().optional(), + categories: z.nativeEnum(AppCategories).array().optional(), + appId: z.string().optional(), }); export type TIntegrationsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts index 48bd3cd8ef..f7835f2dce 100644 --- a/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts +++ b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.handler.ts @@ -5,8 +5,9 @@ import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; import type { TUpdateAppCredentialsInputSchema } from "./updateAppCredentials.schema"; +import { handleCustomValidations } from "./updateAppCredentials.validator"; -type UpdateAppCredentialsOptions = { +export type UpdateAppCredentialsOptions = { ctx: { user: NonNullable; }; @@ -16,8 +17,6 @@ type UpdateAppCredentialsOptions = { export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCredentialsOptions) => { const { user } = ctx; - const { key } = input; - // Find user credential const credential = await prisma.credential.findFirst({ where: { @@ -33,6 +32,8 @@ export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCrede }); } + const validatedKeys = await handleCustomValidations({ ctx, input, appId: credential.appId || "" }); + const updated = await prisma.credential.update({ where: { id: credential.id, @@ -40,7 +41,7 @@ export const updateAppCredentialsHandler = async ({ ctx, input }: UpdateAppCrede data: { key: { ...(credential.key as Prisma.JsonObject), - ...(key as Prisma.JsonObject), + ...(validatedKeys as Prisma.JsonObject), }, }, }); diff --git a/packages/trpc/server/routers/viewer/apps/updateAppCredentials.validator.ts b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.validator.ts new file mode 100644 index 0000000000..49567666dd --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/updateAppCredentials.validator.ts @@ -0,0 +1,24 @@ +import { TRPCError } from "@trpc/server"; + +import type { UpdateAppCredentialsOptions } from "./updateAppCredentials.handler"; + +const validators = { + paypal: () => import("@calcom/paypal/lib/updateAppCredentials.validator"), +}; + +export const handleCustomValidations = async ({ + ctx, + input, + appId, +}: UpdateAppCredentialsOptions & { appId: string }) => { + const { key } = input; + const validatorGetter = validators[appId as keyof typeof validators]; + // If no validator is found, return the key as is + if (!validatorGetter) return key; + try { + const validator = (await validatorGetter()).default; + return await validator({ input, ctx }); + } catch (error) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } +};