From 7e458a7546e2992d75b6a7210fbf5cad107fa3f2 Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Wed, 9 Apr 2025 14:49:08 -0700 Subject: [PATCH 01/11] init branch --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 19193aa..88ec4ae 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Vercel CLI 33.5.5 - Set the "Base URL" to your deployed project's URL e.g. https://example-marketplace-integration.vercel.app - Set the "Redirect Login URL" to your deployed projects URL with the path `/callback` e.g. https://example-marketplace-integration.vercel.app/callback - Click the "Update" button at the bottom to save your changes. +- 6. In the same Marketplace Integration Settings, create a product for your Vercel Integration using the "Create Product" button. A "product" maps to your own products you want to sell on Vercel. Depending on the product type (e.g. storage), the Vercel dashboard will understand how to interact with your product. From cb5e73984b622e1dddf65289daec206a0f8e81f3 Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Tue, 15 Apr 2025 15:29:05 -0700 Subject: [PATCH 02/11] added 'transfer to vercel' button + requestTransferToMarketplace api --- app/connect/configure/actions.ts | 18 +++++ app/connect/configure/page.tsx | 3 + .../configure/transfer-to-vercel-redirect.tsx | 67 +++++++++++++++++++ app/dashboard/auth.ts | 24 +++++-- .../resources/[resourceId]/actions.ts | 2 +- lib/partner/index.ts | 6 +- lib/vercel/marketplace-api.ts | 42 ++++++++++-- 7 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 app/connect/configure/actions.ts create mode 100644 app/connect/configure/transfer-to-vercel-redirect.tsx diff --git a/app/connect/configure/actions.ts b/app/connect/configure/actions.ts new file mode 100644 index 0000000..47bf510 --- /dev/null +++ b/app/connect/configure/actions.ts @@ -0,0 +1,18 @@ +"use server"; + +import { requestTransferToMarketplace } from "@/lib/vercel/marketplace-api"; +import { billingPlans } from "@/lib/partner"; + +export async function requestTransferToVercelAction(formData: FormData) { + const installationId = formData.get("installationId") as string; + const transferId = Math.random().toString(36).substring(2); + const requester = "Vanessa"; + const billingPlan = billingPlans[0]; + const result = await requestTransferToMarketplace( + installationId, + transferId, + requester, + billingPlan + ); + return result; +} diff --git a/app/connect/configure/page.tsx b/app/connect/configure/page.tsx index 6740fae..debd1e7 100644 --- a/app/connect/configure/page.tsx +++ b/app/connect/configure/page.tsx @@ -1,3 +1,5 @@ +import TransferToVercelRedirect from "./transfer-to-vercel-redirect"; + export default async function Page({ searchParams: { configurationId }, }: { @@ -7,6 +9,7 @@ export default async function Page({

Nothing to configure here. 👀

{configurationId}

+
); } diff --git a/app/connect/configure/transfer-to-vercel-redirect.tsx b/app/connect/configure/transfer-to-vercel-redirect.tsx new file mode 100644 index 0000000..29dded8 --- /dev/null +++ b/app/connect/configure/transfer-to-vercel-redirect.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { requestTransferToVercelAction } from "./actions"; + +interface TransferToVercelRedirectProps { + configurationId: string; +} + +export default function TransferToVercelRedirect({ + configurationId, +}: TransferToVercelRedirectProps) { + const [continueUrl, setContinueUrl] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleTransfer() { + setLoading(true); + const formData = new FormData(); + formData.append("installationId", configurationId); + + try { + const result = await requestTransferToVercelAction(formData); + setContinueUrl(result.continueUrl); + } catch (error: any) { + console.error("Transfer failed:", error.message); + } + setLoading(false); + } + + function handleCancel() { + setContinueUrl(null); + } + + return ( +
+ {continueUrl ? ( +
+

Please go to Vercel to complete the transfer.

+
+ + Proceed to Vercel + + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/app/dashboard/auth.ts b/app/dashboard/auth.ts index 925c0cb..2894bb6 100644 --- a/app/dashboard/auth.ts +++ b/app/dashboard/auth.ts @@ -2,13 +2,27 @@ import { OidcClaims, verifyToken } from "@/lib/vercel/auth"; import { cookies } from "next/headers"; export async function getSession(): Promise { - const idToken = cookies().get("id-token"); + // const idToken = cookies().get("id-token"); - if (!idToken) { - throw new Error("ID Token not set"); - } + // if (!idToken) { + // throw new Error("ID Token not set"); + // } - return await verifyToken(idToken.value); + // return await verifyToken(idToken.value); + return { + iss: "https://marketplace.vercel.com", + sub: "account:bbab27ce0645afd2c628cf5432ed30a7bf16506c9f55e156a84443d1df17d147:user:105cd395279d04025d3e3ab92124b0c51257ee2107da24a75344ec023d469e81", + aud: "oac_lwzCwu4BrkUh332AG7gVZ63k", + installation_id: "icfg_MYtOwkOajEA9ONYlcUe3Yyqt", + account_id: + "bbab27ce0645afd2c628cf5432ed30a7bf16506c9f55e156a84443d1df17d147", + user_id: "105cd395279d04025d3e3ab92124b0c51257ee2107da24a75344ec023d469e81", + user_role: "ADMIN", + user_name: "Vanessa Teo", + nbf: 1744240557, + iat: 1744240557, + exp: 17442441570, + }; } export async function createSession(token: string) { diff --git a/app/dashboard/resources/[resourceId]/actions.ts b/app/dashboard/resources/[resourceId]/actions.ts index 679bf83..7d84ad9 100644 --- a/app/dashboard/resources/[resourceId]/actions.ts +++ b/app/dashboard/resources/[resourceId]/actions.ts @@ -9,7 +9,7 @@ import { updateResource, updateResourceNotification, } from "@/lib/partner"; -import { Notification, Resource } from "@/lib/vercel/schemas"; +import type { Notification, Resource } from "@/lib/vercel/schemas"; import { dispatchEvent, updateSecrets } from "@/lib/vercel/marketplace-api"; import { getSession } from "../../auth"; import { revalidatePath } from "next/cache"; diff --git a/lib/partner/index.ts b/lib/partner/index.ts index ff58c48..da245ff 100644 --- a/lib/partner/index.ts +++ b/lib/partner/index.ts @@ -1,5 +1,5 @@ import { nanoid } from "nanoid"; -import { +import type { BillingPlan, GetBillingPlansResponse, GetResourceResponse, @@ -24,7 +24,7 @@ import { importResource as importResourceToVercelApi, } from "../vercel/marketplace-api"; -const billingPlans: BillingPlan[] = [ +export const billingPlans: BillingPlan[] = [ { id: "default", scope: "resource", @@ -342,7 +342,7 @@ export async function provisionPurchase( const balances: Record = {}; for (const item of invoice.items ?? []) { - const amountInCents = Math.floor(parseFloat(item.total) * 100); + const amountInCents = Math.floor(Number.parseFloat(item.total) * 100); if (item.resourceId) { const balance = await addResourceBalanceInternal( installationId, diff --git a/lib/vercel/marketplace-api.ts b/lib/vercel/marketplace-api.ts index bfcc188..be0ae91 100644 --- a/lib/vercel/marketplace-api.ts +++ b/lib/vercel/marketplace-api.ts @@ -1,9 +1,10 @@ import { getInstallation, getResource } from "../partner"; import { env } from "../env"; import { z } from "zod"; -import { +import type { Balance, BillingData, + BillingPlan, CreateInvoiceRequest, DeploymentActionOutcome, ImportResourceRequest, @@ -52,7 +53,7 @@ export async function getAccountInfo( })) as AccountInfo; } -export async function updateSecrets( +export async function updateSecrets( // vercel fetch api installationId: string, resourceId: string, secrets: { name: string; value: string }[] @@ -159,20 +160,26 @@ export async function submitInvoice( let items = billingData.billing.filter((item) => Boolean(item.resourceId)); if (maxAmount !== undefined) { - const total = items.reduce((acc, item) => acc + parseFloat(item.total), 0); + const total = items.reduce( + (acc, item) => acc + Number.parseFloat(item.total), + 0 + ); if (total > maxAmount) { const ratio = maxAmount / total; items = items.map((item) => ({ ...item, quantity: item.quantity * ratio, - total: (parseFloat(item.total) * ratio).toFixed(2), + total: (Number.parseFloat(item.total) * ratio).toFixed(2), })); } } const discounts: InvoiceDiscount[] = []; if (opts?.discountPercent !== undefined && opts.discountPercent > 0) { - const total = items.reduce((acc, item) => acc + parseFloat(item.total), 0); + const total = items.reduce( + (acc, item) => acc + Number.parseFloat(item.total), + 0 + ); if (total > 0) { const discount = total * opts.discountPercent; discounts.push({ @@ -292,3 +299,28 @@ export async function getDeployment( } ); } + +export async function requestTransferToMarketplace( + installationId: string, + transferId: string, + requester: string, + billingPlan: BillingPlan +): Promise<{ continueUrl: string }> { + const result = (await fetchVercelApi( + `/v1/installations/${installationId}/transfers/to-marketplace`, + { + installationId, + method: "POST", + data: { + transferId, + requester: { name: requester }, + billingPlan, + } satisfies { + transferId: string; + requester: { name: string }; + billingPlan: BillingPlan; + }, + } + )) as { continueUrl: string }; + return result; +} From 5dc7940e3a62c5a034617faf05f4c9c51aec8187 Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Tue, 15 Apr 2025 16:25:53 -0700 Subject: [PATCH 03/11] responded to comments --- app/connect/configure/actions.ts | 5 ++++- app/dashboard/auth.ts | 26 ++++++---------------- lib/vercel/marketplace-api.ts | 37 ++++++++++++++++---------------- lib/vercel/schemas.ts | 18 +++++++++++++--- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/app/connect/configure/actions.ts b/app/connect/configure/actions.ts index 47bf510..d1cec2f 100644 --- a/app/connect/configure/actions.ts +++ b/app/connect/configure/actions.ts @@ -7,7 +7,10 @@ export async function requestTransferToVercelAction(formData: FormData) { const installationId = formData.get("installationId") as string; const transferId = Math.random().toString(36).substring(2); const requester = "Vanessa"; - const billingPlan = billingPlans[0]; + const billingPlan = billingPlans.find((plan) => plan.paymentMethodRequired); + if (!billingPlan) { + throw new Error("No billing plan found."); + } const result = await requestTransferToMarketplace( installationId, transferId, diff --git a/app/dashboard/auth.ts b/app/dashboard/auth.ts index 2894bb6..b083f34 100644 --- a/app/dashboard/auth.ts +++ b/app/dashboard/auth.ts @@ -1,28 +1,14 @@ -import { OidcClaims, verifyToken } from "@/lib/vercel/auth"; +import { type OidcClaims, verifyToken } from "@/lib/vercel/auth"; import { cookies } from "next/headers"; export async function getSession(): Promise { - // const idToken = cookies().get("id-token"); + const idToken = cookies().get("id-token"); - // if (!idToken) { - // throw new Error("ID Token not set"); - // } + if (!idToken) { + throw new Error("ID Token not set"); + } - // return await verifyToken(idToken.value); - return { - iss: "https://marketplace.vercel.com", - sub: "account:bbab27ce0645afd2c628cf5432ed30a7bf16506c9f55e156a84443d1df17d147:user:105cd395279d04025d3e3ab92124b0c51257ee2107da24a75344ec023d469e81", - aud: "oac_lwzCwu4BrkUh332AG7gVZ63k", - installation_id: "icfg_MYtOwkOajEA9ONYlcUe3Yyqt", - account_id: - "bbab27ce0645afd2c628cf5432ed30a7bf16506c9f55e156a84443d1df17d147", - user_id: "105cd395279d04025d3e3ab92124b0c51257ee2107da24a75344ec023d469e81", - user_role: "ADMIN", - user_name: "Vanessa Teo", - nbf: 1744240557, - iat: 1744240557, - exp: 17442441570, - }; + return await verifyToken(idToken.value); } export async function createSession(token: string) { diff --git a/lib/vercel/marketplace-api.ts b/lib/vercel/marketplace-api.ts index be0ae91..0abbbcb 100644 --- a/lib/vercel/marketplace-api.ts +++ b/lib/vercel/marketplace-api.ts @@ -1,19 +1,22 @@ import { getInstallation, getResource } from "../partner"; import { env } from "../env"; import { z } from "zod"; -import type { - Balance, - BillingData, - BillingPlan, - CreateInvoiceRequest, - DeploymentActionOutcome, - ImportResourceRequest, - ImportResourceResponse, - Invoice, - InvoiceDiscount, - RefundInvoiceRequest, - SubmitPrepaymentBalanceRequest, - UpdateDeploymentActionRequest, +import { + type RequestTransferToMarketplace, + RequestTransferToMarketplaceSchema, + type Balance, + type BillingData, + type BillingPlan, + type CreateInvoiceRequest, + type DeploymentActionOutcome, + type ImportResourceRequest, + type ImportResourceResponse, + type Invoice, + type InvoiceDiscount, + type RefundInvoiceRequest, + type RequestTransferToMarketplaceRequest, + type SubmitPrepaymentBalanceRequest, + type UpdateDeploymentActionRequest, } from "./schemas"; import { mockBillingData } from "@/data/mock-billing-data"; import { fetchVercelApi } from "./api"; @@ -53,7 +56,7 @@ export async function getAccountInfo( })) as AccountInfo; } -export async function updateSecrets( // vercel fetch api +export async function updateSecrets( installationId: string, resourceId: string, secrets: { name: string; value: string }[] @@ -315,12 +318,8 @@ export async function requestTransferToMarketplace( transferId, requester: { name: requester }, billingPlan, - } satisfies { - transferId: string; - requester: { name: string }; - billingPlan: BillingPlan; }, - } + } satisfies RequestTransferToMarketplaceRequest )) as { continueUrl: string }; return result; } diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index b9bb90b..3b93693 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -646,10 +646,11 @@ const deploymentIntegrationActionStartEventSchema = payload: deploymentWebhookPayloadEventSchema.extend({ installationId: z.string(), action: z.string(), - resourceId: z.string(), configuration: z.object({ + resourceId: z.string(), + configuration: z.object({ id: z.string(), }), - }) + }), }); const deploymentEvent = (eventType: T) => { @@ -657,7 +658,7 @@ const deploymentEvent = (eventType: T) => { type: z.literal(eventType), payload: deploymentWebhookPayloadEventSchema, }); -} +}; export type WebhookEvent = z.infer; export const webhookEventSchema = z.discriminatedUnion("type", [ @@ -681,3 +682,14 @@ export const unknownWebhookEventSchema = webhookEventBaseSchema.extend({ payload: z.unknown(), unknown: z.boolean().optional().default(true), }); + +export type RequestTransferToMarketplace = z.infer< + typeof requestTransferToMarketplaceSchema +>; +export const requestTransferToMarketplaceSchema = z.object({ + transferId: z.string(), + requester: z.object({ + name: z.string(), + }), + billingPlan: billingPlanSchema, +}); From 346d008a28adc1841d86efca42dcb40fbaae8fda Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Tue, 15 Apr 2025 16:52:59 -0700 Subject: [PATCH 04/11] made request zod schema --- lib/vercel/marketplace-api.ts | 39 +++++++++++++++++------------------ lib/vercel/schemas.ts | 13 ++++++++++-- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/lib/vercel/marketplace-api.ts b/lib/vercel/marketplace-api.ts index 0abbbcb..78380b2 100644 --- a/lib/vercel/marketplace-api.ts +++ b/lib/vercel/marketplace-api.ts @@ -1,22 +1,21 @@ import { getInstallation, getResource } from "../partner"; import { env } from "../env"; import { z } from "zod"; -import { - type RequestTransferToMarketplace, - RequestTransferToMarketplaceSchema, - type Balance, - type BillingData, - type BillingPlan, - type CreateInvoiceRequest, - type DeploymentActionOutcome, - type ImportResourceRequest, - type ImportResourceResponse, - type Invoice, - type InvoiceDiscount, - type RefundInvoiceRequest, - type RequestTransferToMarketplaceRequest, - type SubmitPrepaymentBalanceRequest, - type UpdateDeploymentActionRequest, +import type { + Balance, + BillingData, + BillingPlan, + CreateInvoiceRequest, + DeploymentActionOutcome, + ImportResourceRequest, + ImportResourceResponse, + Invoice, + InvoiceDiscount, + RefundInvoiceRequest, + RequestTransferToMarketplace, + RequestTransferToMarketplaceResponse, + SubmitPrepaymentBalanceRequest, + UpdateDeploymentActionRequest, } from "./schemas"; import { mockBillingData } from "@/data/mock-billing-data"; import { fetchVercelApi } from "./api"; @@ -308,7 +307,7 @@ export async function requestTransferToMarketplace( transferId: string, requester: string, billingPlan: BillingPlan -): Promise<{ continueUrl: string }> { +): Promise { const result = (await fetchVercelApi( `/v1/installations/${installationId}/transfers/to-marketplace`, { @@ -318,8 +317,8 @@ export async function requestTransferToMarketplace( transferId, requester: { name: requester }, billingPlan, - }, - } satisfies RequestTransferToMarketplaceRequest - )) as { continueUrl: string }; + } satisfies RequestTransferToMarketplace, + } + )) as RequestTransferToMarketplaceResponse; return result; } diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index 3b93693..6daae07 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -683,13 +683,22 @@ export const unknownWebhookEventSchema = webhookEventBaseSchema.extend({ unknown: z.boolean().optional().default(true), }); +// Transfer to/from Marketplacce + export type RequestTransferToMarketplace = z.infer< typeof requestTransferToMarketplaceSchema >; export const requestTransferToMarketplaceSchema = z.object({ - transferId: z.string(), + transferId: z.string().min(1), requester: z.object({ - name: z.string(), + name: z.string().min(1), }), billingPlan: billingPlanSchema, }); + +export type RequestTransferToMarketplaceResponse = z.infer< + typeof requestTransferToMarketplaceResponseSchema +>; +export const requestTransferToMarketplaceResponseSchema = z.object({ + continueUrl: z.string().url(), +}); From af0c141dd49026e602f01e2da3055998ea9dd3e2 Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Wed, 23 Apr 2025 16:30:29 -0700 Subject: [PATCH 05/11] added schemas --- .../transfers/to-marketplace/route.ts | 22 ++++++++++ lib/vercel/schemas.ts | 44 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 app/v1/installations/[installationId]/transfers/to-marketplace/route.ts diff --git a/app/v1/installations/[installationId]/transfers/to-marketplace/route.ts b/app/v1/installations/[installationId]/transfers/to-marketplace/route.ts new file mode 100644 index 0000000..e26b071 --- /dev/null +++ b/app/v1/installations/[installationId]/transfers/to-marketplace/route.ts @@ -0,0 +1,22 @@ +import { installIntegration } from "@/lib/partner"; +import { readRequestBodyWithSchema } from "@/lib/utils"; +import { withAuth } from "@/lib/vercel/auth"; +import { TransferInstallationToMarketplaceRequestSchema } from "@/lib/vercel/schemas"; + +export const POST = withAuth(async (claims, request) => { + const requestBody = await readRequestBodyWithSchema( + request, + TransferInstallationToMarketplaceRequestSchema + ); + + if (!requestBody.success) { + return new Response(null, { status: 400 }); + } + + await installIntegration(claims.installation_id, { + type: "marketplace", + ...requestBody.data, + }); + + return Response.json({}); +}); diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index 6daae07..32a6e74 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -702,3 +702,47 @@ export type RequestTransferToMarketplaceResponse = z.infer< export const requestTransferToMarketplaceResponseSchema = z.object({ continueUrl: z.string().url(), }); + +export type TransferInstallationToMarketplaceRequest = z.infer< + typeof TransferInstallationToMarketplaceRequestSchema +>; + +export const TransferInstallationToMarketplaceRequestSchema = z.object({ + transferId: z + .string() + .describe('Provided in the "request-transfer-to-marketplace".'), + billingPlanId: z + .string() + .optional() + .describe( + 'Transfer billing plan, if one was provided in the "request-transfer-to-marketplace".' + ), + metadata: metadataSchema + .optional() + .describe( + 'Installation-level metadata, if one was provided in the "request-transfer-to-marketplace".' + ), + scopes: z + .array(z.string().min(1)) + .describe("Scopes for the new installation post transfer."), + acceptedPolicies: z.record(datetimeSchema), + credentials: z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), + }), + account: z + .object({ + name: z.string().optional(), + url: z.string().url(), + contact: z + .object({ + email: z.string().email(), + name: z.string().optional(), + }) + .nullable() + .optional(), + }) + .describe( + "The account information for this installation. Use Get Account Info API to re-fetch this data post transfer." + ), +}); From 043f3b264b24112304b96eede5218fb9fa646b30 Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Wed, 23 Apr 2025 17:06:58 -0700 Subject: [PATCH 06/11] added FORCE_FINALIZE_INSTALLATION --- lib/partner/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/partner/index.ts b/lib/partner/index.ts index da245ff..373eb60 100644 --- a/lib/partner/index.ts +++ b/lib/partner/index.ts @@ -125,7 +125,11 @@ export async function uninstallInstallation( // Installation is finalized immediately if it's on a free plan. const billingPlan = billingPlanMap.get(installation.billingPlanId); - return { finalized: billingPlan?.paymentMethodRequired === false }; + return { + finalized: + billingPlan?.paymentMethodRequired === false || + process.env.FORCE_FINALIZE_INSTALLATION === "true", + }; } export async function listInstallations(): Promise { From 17b1078c1f6e19bcabc2aed04b8277b8435017a7 Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Mon, 28 Apr 2025 15:49:02 -0700 Subject: [PATCH 07/11] updated configure page to look better for demo --- app/connect/configure/page.tsx | 84 ++++++++++++++++++- .../configure/transfer-to-vercel-redirect.tsx | 4 +- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/app/connect/configure/page.tsx b/app/connect/configure/page.tsx index debd1e7..7e4fc46 100644 --- a/app/connect/configure/page.tsx +++ b/app/connect/configure/page.tsx @@ -6,10 +6,86 @@ export default async function Page({ searchParams: { configurationId: string }; }) { return ( -
-

Nothing to configure here. 👀

-

{configurationId}

- +
+
+
+
+

+ ACME Marketplace Team +

+
+
ACME
+
+ +
+
+

Billing

+

+ Manage your billing information and payment methods +

+
+ +
+
+ + +
+
+ + {/* Payment Methods */} +
+
+

Payment Methods

+

+ Manage your payment methods for ACME services +

+
+ +
+
+
+
+
Visa ending in 4242
+
Expires 04/2025
+
+
+ +
+ + +
+
+ + {/* Account Ownership */} +
+
+

Account Ownership

+

+ Transfer your account ownership and billing to a Vercel team +

+
+ +
+

+ Transferring ownership will move all billing responsibilities to + the selected Vercel team. This action cannot be undone without + contacting support. +

+ +
+ Configuration ID: {configurationId} +
+ + +
+
+
); } diff --git a/app/connect/configure/transfer-to-vercel-redirect.tsx b/app/connect/configure/transfer-to-vercel-redirect.tsx index 29dded8..54606da 100644 --- a/app/connect/configure/transfer-to-vercel-redirect.tsx +++ b/app/connect/configure/transfer-to-vercel-redirect.tsx @@ -35,7 +35,9 @@ export default function TransferToVercelRedirect({ - {/* Payment Methods */}

Payment Methods

@@ -51,37 +51,31 @@ export default async function Page({
Expires 04/2025
-
- - {/* Account Ownership */}

Account Ownership

- Transfer your account ownership and billing to a Vercel team + Transfer your account ownership and billing to your Vercel team

Transferring ownership will move all billing responsibilities to - the selected Vercel team. This action cannot be undone without - contacting support. + the selected Vercel team.

-
- Configuration ID: {configurationId} -
-
From 07827b21e212d2da0e26f66574b5557c240a09ab Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Wed, 30 Apr 2025 14:59:07 -0700 Subject: [PATCH 09/11] added request transfer from marketplace --- app/dashboard/installation/actions.ts | 15 +++- app/dashboard/installation/page.tsx | 4 ++ .../transfer-from-vercel-redirect.tsx | 69 +++++++++++++++++++ lib/vercel/marketplace-api.ts | 21 ++++++ lib/vercel/schemas.ts | 29 ++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 app/dashboard/installation/transfer-from-vercel-redirect.tsx diff --git a/app/dashboard/installation/actions.ts b/app/dashboard/installation/actions.ts index 34fa860..9e73a86 100644 --- a/app/dashboard/installation/actions.ts +++ b/app/dashboard/installation/actions.ts @@ -9,8 +9,9 @@ import { getResourceBalance, listResources, } from "@/lib/partner"; -import { Balance } from "@/lib/vercel/schemas"; +import type { Balance } from "@/lib/vercel/schemas"; import { + requestTransferFromMarketplace, sendBillingData, submitPrepaymentBalances, } from "@/lib/vercel/marketplace-api"; @@ -49,3 +50,15 @@ export async function sendBillingDataAction() { await sendBillingData(installationId, billingData); await submitPrepaymentBalances(installationId, balances); } + +export async function requestTransferFromVercelAction(formData: FormData) { + const installationId = formData.get("installationId") as string; + const transferId = Math.random().toString(36).substring(2); + const requester = "Vanessa"; + const result = await requestTransferFromMarketplace( + installationId, + transferId, + requester + ); + return result; +} diff --git a/app/dashboard/installation/page.tsx b/app/dashboard/installation/page.tsx index c2cce47..776db48 100644 --- a/app/dashboard/installation/page.tsx +++ b/app/dashboard/installation/page.tsx @@ -4,6 +4,7 @@ import { getAccountInfo } from "@/lib/vercel/marketplace-api"; import { Section } from "../components/section"; import { addInstallationBalance, sendBillingDataAction } from "./actions"; import { FormButton } from "../components/form-button"; +import TransferFromVercelRedirect from "./transfer-from-vercel-redirect"; export const dynamic = "force-dynamic"; @@ -74,6 +75,9 @@ export default async function IntallationPage() { +
+ +
); } diff --git a/app/dashboard/installation/transfer-from-vercel-redirect.tsx b/app/dashboard/installation/transfer-from-vercel-redirect.tsx new file mode 100644 index 0000000..f33d312 --- /dev/null +++ b/app/dashboard/installation/transfer-from-vercel-redirect.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { requestTransferFromVercelAction } from "./actions"; + +interface TransferFromVercelRedirectProps { + configurationId: string; +} + +export default function TransferFromVercelRedirect({ + configurationId, +}: TransferFromVercelRedirectProps) { + const [continueUrl, setContinueUrl] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleTransfer() { + setLoading(true); + const formData = new FormData(); + formData.append("installationId", configurationId); + + try { + const result = await requestTransferFromVercelAction(formData); + setContinueUrl(result.continueUrl); + } catch (error: any) { + console.error("Transfer failed:", error.message); + } + setLoading(false); + } + + function handleCancel() { + setContinueUrl(null); + } + + return ( +
+ {continueUrl ? ( +
+

+ Please go to Vercel Marketplace to complete the transfer. +

+
+
+ ) : ( + + )} +
+ ); +} diff --git a/lib/vercel/marketplace-api.ts b/lib/vercel/marketplace-api.ts index 78380b2..fd534d5 100644 --- a/lib/vercel/marketplace-api.ts +++ b/lib/vercel/marketplace-api.ts @@ -12,6 +12,8 @@ import type { Invoice, InvoiceDiscount, RefundInvoiceRequest, + RequestTransferFromMarketplace, + RequestTransferFromMarketplaceResponse, RequestTransferToMarketplace, RequestTransferToMarketplaceResponse, SubmitPrepaymentBalanceRequest, @@ -322,3 +324,22 @@ export async function requestTransferToMarketplace( )) as RequestTransferToMarketplaceResponse; return result; } + +export async function requestTransferFromMarketplace( + installationId: string, + transferId: string, + requester: string +): Promise { + const result = (await fetchVercelApi( + `/v1/installations/${installationId}/transfers/from-marketplace`, + { + installationId, + method: "POST", + data: { + transferId, + requester: { name: requester }, + } satisfies RequestTransferFromMarketplace, + } + )) as RequestTransferFromMarketplaceResponse; + return result; +} diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index 32a6e74..b9d6ed8 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -746,3 +746,32 @@ export const TransferInstallationToMarketplaceRequestSchema = z.object({ "The account information for this installation. Use Get Account Info API to re-fetch this data post transfer." ), }); + +export type RequestTransferFromMarketplace = z.infer< + typeof requestTransferFromMarketplaceSchema +>; +export const requestTransferFromMarketplaceSchema = z.object({ + transferId: z.string().min(1), + requester: z.object({ + name: z.string().min(1), + }), +}); + +export type RequestTransferFromMarketplaceResponse = z.infer< + typeof requestTransferToMarketplaceResponseSchema +>; +export const requestTransferFromMarketplaceResponseSchema = z.object({ + continueUrl: z.string().url(), +}); + +export type TransferInstallationFromMarketplaceRequest = z.infer< + typeof TransferInstallationFromMarketplaceRequestSchema +>; + +export const TransferInstallationFromMarketplaceRequestSchema = z.object({ + transferId: z.string(), + requester: z.object({ + name: z.string().min(1), + }), + scopes: z.array(z.string().min(1)), +}); From 788901621094de1f50777599d03632f2f1186cb9 Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Wed, 30 Apr 2025 15:12:05 -0700 Subject: [PATCH 10/11] updated schemas --- lib/vercel/schemas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index b9d6ed8..2924115 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -765,10 +765,10 @@ export const requestTransferFromMarketplaceResponseSchema = z.object({ }); export type TransferInstallationFromMarketplaceRequest = z.infer< - typeof TransferInstallationFromMarketplaceRequestSchema + typeof transferInstallationFromMarketplaceRequestSchema >; -export const TransferInstallationFromMarketplaceRequestSchema = z.object({ +export const transferInstallationFromMarketplaceRequestSchema = z.object({ transferId: z.string(), requester: z.object({ name: z.string().min(1), From a60d7b005cef5e9ad3ac8e24c4ba1034b156829d Mon Sep 17 00:00:00 2001 From: Vanessa Teo Date: Thu, 1 May 2025 13:29:02 -0700 Subject: [PATCH 11/11] added from-marketplace route --- .../transfers/from-marketplace/route.ts | 24 +++++++++++++++++++ lib/vercel/schemas.ts | 7 +++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 app/v1/installations/[installationId]/transfers/from-marketplace/route.ts diff --git a/app/v1/installations/[installationId]/transfers/from-marketplace/route.ts b/app/v1/installations/[installationId]/transfers/from-marketplace/route.ts new file mode 100644 index 0000000..c703855 --- /dev/null +++ b/app/v1/installations/[installationId]/transfers/from-marketplace/route.ts @@ -0,0 +1,24 @@ +import { installIntegration } from "@/lib/partner"; +import { readRequestBodyWithSchema } from "@/lib/utils"; +import { withAuth } from "@/lib/vercel/auth"; +import { transferInstallationFromMarketplaceRequestSchema } from "@/lib/vercel/schemas"; + +export const POST = withAuth(async (claims, request) => { + const requestBody = await readRequestBodyWithSchema( + request, + transferInstallationFromMarketplaceRequestSchema + ); + + if (!requestBody.success) { + return new Response(null, { status: 400 }); + } + + await installIntegration(claims.installation_id, { + type: "external", + scopes: requestBody.data.scopes, + credentials: requestBody.data.credentials, + acceptedPolicies: {}, + }); + + return Response.json({}); +}); diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts index 2924115..4a46099 100644 --- a/lib/vercel/schemas.ts +++ b/lib/vercel/schemas.ts @@ -770,8 +770,9 @@ export type TransferInstallationFromMarketplaceRequest = z.infer< export const transferInstallationFromMarketplaceRequestSchema = z.object({ transferId: z.string(), - requester: z.object({ - name: z.string().min(1), - }), scopes: z.array(z.string().min(1)), + credentials: z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), + }), });