From e74aca9c903a365fb647f20e5fcd455e66d36e17 Mon Sep 17 00:00:00 2001 From: MananTank Date: Mon, 13 Oct 2025 21:28:49 +0000 Subject: [PATCH] [MNY-239] Dashboard: Show webhook sends for Bridge webhooks (#8213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on enhancing the payment and webhook functionalities in the dashboard application by adding `authToken` support across various components and improving the webhook management interface. ### Detailed summary - Added `authToken` prop to multiple components including `PayAnalytics`, `PaymentHistory`, and `QuickStartSection`. - Enhanced `getWebhooks`, `createWebhook`, `deleteWebhook`, and `updateWebhook` functions to accept `authToken`. - Improved the webhook management UI with `WebhookCard` and related functionalities. - Added dialog components for creating and editing webhooks with validation. - Updated API calls to include `authToken` for authentication in requests. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit - **New Features** - Card-based webhooks UI with create/edit modal, per-card actions, delete confirmation, webhook detail page, and webhook sends viewer (history, filtering, pagination, resend). - Payments area: payment links and payments viewer wired to authenticated CRUD and paginated payments list; resend and webhook-send actions supported. - **Enhancements** - authToken propagated across webhooks, payments, payment links, fees, and related components to enable authenticated operations. - Webhook objects no longer expose secret fields; create/update/delete now return usable payloads. - **Documentation** - Storybook stories added for webhook sends UI (loading, results, error, resend scenarios). --- .../src/@/api/universal-bridge/developer.ts | 327 +++++++--- .../payments/components/PayAnalytics.tsx | 1 + .../components/PaymentHistory.client.tsx | 2 + .../components/QuickstartSection.client.tsx | 2 + .../CreatePaymentLinkButton.client.tsx | 4 + .../components/PaymentLinksTable.client.tsx | 9 + .../(sidebar)/payments/page.tsx | 3 + .../webhooks/components/webhooks.client.tsx | 584 ++++++++++++------ .../(sidebar)/settings/payments/PayConfig.tsx | 2 + .../(sidebar)/settings/payments/page.tsx | 2 + .../payments/[id]/WebhookSendsUI.stories.tsx | 283 +++++++++ .../(sidebar)/webhooks/payments/[id]/page.tsx | 83 +++ .../webhooks/payments/[id]/webhook-sends.tsx | 490 +++++++++++++++ .../(sidebar)/webhooks/payments/page.tsx | 3 + 14 files changed, 1523 insertions(+), 272 deletions(-) create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/WebhookSendsUI.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/webhook-sends.tsx diff --git a/apps/dashboard/src/@/api/universal-bridge/developer.ts b/apps/dashboard/src/@/api/universal-bridge/developer.ts index db86b1bd564..77a6261d074 100644 --- a/apps/dashboard/src/@/api/universal-bridge/developer.ts +++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts @@ -1,28 +1,28 @@ -"use server"; import type { Address } from "thirdweb"; -import { getAuthToken } from "@/api/auth-token"; import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs"; const UB_BASE_URL = NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST; -type Webhook = { +export type Webhook = { url: string; label: string; active: boolean; createdAt: string; id: string; - secret: string; version?: number; // TODO (UB) make this mandatory after migration }; -export async function getWebhooks(props: { clientId: string; teamId: string }) { - const authToken = await getAuthToken(); +export async function getWebhooks(params: { + clientId: string; + teamId: string; + authToken: string; +}) { const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, { headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "GET", }); @@ -36,28 +36,55 @@ export async function getWebhooks(props: { clientId: string; teamId: string }) { return json.data as Array; } -export async function createWebhook(props: { +export async function getWebhookById(params: { + clientId: string; + teamId: string; + authToken: string; + webhookId: string; +}) { + const res = await fetch( + `${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`, + { + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + method: "GET", + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Webhook; +} + +export async function createWebhook(params: { clientId: string; teamId: string; version?: number; url: string; label: string; secret?: string; + authToken: string; }) { - const authToken = await getAuthToken(); - const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, { body: JSON.stringify({ - label: props.label, - secret: props.secret, - url: props.url, - version: props.version, + label: params.label, + secret: params.secret, + url: params.url, + version: params.version, }), headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "POST", }); @@ -67,23 +94,23 @@ export async function createWebhook(props: { throw new Error(text); } - return; + return (await res.json()) as Webhook; } -export async function deleteWebhook(props: { +export async function deleteWebhook(params: { clientId: string; teamId: string; webhookId: string; + authToken: string; }) { - const authToken = await getAuthToken(); const res = await fetch( - `${UB_BASE_URL}/v1/developer/webhooks/${props.webhookId}`, + `${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`, { headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "DELETE", }, @@ -94,7 +121,151 @@ export async function deleteWebhook(props: { throw new Error(text); } - return; + return true; +} + +export async function updateWebhook(params: { + clientId: string; + teamId: string; + webhookId: string; + authToken: string; + body: { + version?: number; + url: string; + label: string; + }; +}) { + const res = await fetch( + `${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`, + { + method: "PUT", + body: JSON.stringify(params.body), + headers: { + Authorization: `Bearer ${params.authToken}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-team-id": params.teamId, + }, + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + + return json.data as Webhook; +} + +export type WebhookSend = { + id: string; + webhookId: string; + webhookUrl: string; + createdAt: string; + onrampId: string | null; + paymentId: string | null; + response: string | null; + responseStatus: number; + status: "PENDING" | "COMPLETED" | "FAILED"; + success: boolean; + transactionId: string | null; + body: unknown; +}; + +type WebhookSendsResponse = { + data: WebhookSend[]; + pagination: { + limit: number; + offset: number; + total: number; + }; +}; + +export async function getWebhookSends(options: { + authToken: string; + projectClientId: string; + teamId: string; + limit?: number; + offset?: number; + webhookId: string; + success?: boolean; +}): Promise { + const { limit, offset, success, webhookId, authToken } = options; + + const url = new URL( + `${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhook-sends`, + ); + + url.searchParams.set("webhookId", webhookId); + + if (limit !== undefined) { + url.searchParams.set("limit", limit.toString()); + } + if (offset !== undefined) { + url.searchParams.set("offset", offset.toString()); + } + if (success !== undefined) { + url.searchParams.set("success", success.toString()); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "x-client-id": options.projectClientId, + "x-team-id": options.teamId, + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(errorJson.message); + } + + return (await response.json()) as WebhookSendsResponse; +} + +export async function resendWebhook( + params: { + authToken: string; + projectClientId: string; + teamId: string; + } & ( + | { + paymentId: string; + } + | { + onrampId: string; + } + ), +) { + const url = new URL( + `${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhooks/retry`, + ); + + const response = await fetch(url.toString(), { + method: "POST", + body: JSON.stringify( + "paymentId" in params + ? { paymentId: params.paymentId } + : { onrampId: params.onrampId }, + ), + headers: { + "Content-Type": "application/json", + "x-client-id": params.projectClientId, + "x-team-id": params.teamId, + Authorization: `Bearer ${params.authToken}`, + }, + }); + + if (!response.ok) { + const errorJson = await response.json(); + throw new Error(errorJson.message); + } + + return true; } type PaymentLink = { @@ -116,17 +287,17 @@ type PaymentLink = { amount: bigint; }; -export async function getPaymentLinks(props: { +export async function getPaymentLinks(params: { clientId: string; teamId: string; + authToken: string; }): Promise> { - const authToken = await getAuthToken(); const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, { headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "GET", }); @@ -159,7 +330,7 @@ export async function getPaymentLinks(props: { })); } -export async function createPaymentLink(props: { +export async function createPaymentLink(params: { clientId: string; teamId: string; title: string; @@ -171,26 +342,25 @@ export async function createPaymentLink(props: { amount: bigint; purchaseData?: unknown; }; + authToken: string; }) { - const authToken = await getAuthToken(); - const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, { body: JSON.stringify({ - title: props.title, - imageUrl: props.imageUrl, + title: params.title, + imageUrl: params.imageUrl, intent: { - destinationChainId: props.intent.destinationChainId, - destinationTokenAddress: props.intent.destinationTokenAddress, - receiver: props.intent.receiver, - amount: props.intent.amount.toString(), - purchaseData: props.intent.purchaseData, + destinationChainId: params.intent.destinationChainId, + destinationTokenAddress: params.intent.destinationTokenAddress, + receiver: params.intent.receiver, + amount: params.intent.amount.toString(), + purchaseData: params.intent.purchaseData, }, }), headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "POST", }); @@ -207,20 +377,20 @@ export async function createPaymentLink(props: { return response.data; } -export async function deletePaymentLink(props: { +export async function deletePaymentLink(params: { clientId: string; teamId: string; paymentLinkId: string; + authToken: string; }) { - const authToken = await getAuthToken(); const res = await fetch( - `${UB_BASE_URL}/v1/developer/links/${props.paymentLinkId}`, + `${UB_BASE_URL}/v1/developer/links/${encodeURIComponent(params.paymentLinkId)}`, { headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "DELETE", }, @@ -231,7 +401,7 @@ export async function deletePaymentLink(props: { throw new Error(text); } - return; + return true; } export type Fee = { @@ -241,14 +411,17 @@ export type Fee = { updatedAt: string; }; -export async function getFees(props: { clientId: string; teamId: string }) { - const authToken = await getAuthToken(); +export async function getFees(params: { + clientId: string; + teamId: string; + authToken: string; +}) { const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, { headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "GET", }); @@ -262,23 +435,23 @@ export async function getFees(props: { clientId: string; teamId: string }) { return json.data as Fee; } -export async function updateFee(props: { +export async function updateFee(params: { clientId: string; teamId: string; feeRecipient: string; feeBps: number; + authToken: string; }) { - const authToken = await getAuthToken(); const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, { body: JSON.stringify({ - feeBps: props.feeBps, - feeRecipient: props.feeRecipient, + feeBps: params.feeBps, + feeRecipient: params.feeRecipient, }), headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "PUT", }); @@ -288,7 +461,7 @@ export async function updateFee(props: { throw new Error(text); } - return; + return true; } export type PaymentsResponse = { @@ -345,36 +518,34 @@ export type Payment = { export type BridgePayment = Extract; -export async function getPayments(props: { +export async function getPayments(params: { clientId: string; teamId: string; paymentLinkId?: string; limit?: number; + authToken: string; offset?: number; }) { - const authToken = await getAuthToken(); - - // Build URL with query parameters if provided const url = new URL(`${UB_BASE_URL}/v1/developer/payments`); - if (props.limit) { - url.searchParams.append("limit", props.limit.toString()); + if (params.limit) { + url.searchParams.append("limit", params.limit.toString()); } - if (props.offset) { - url.searchParams.append("offset", props.offset.toString()); + if (params.offset) { + url.searchParams.append("offset", params.offset.toString()); } - if (props.paymentLinkId) { - url.searchParams.append("paymentLinkId", props.paymentLinkId); + if (params.paymentLinkId) { + url.searchParams.append("paymentLinkId", params.paymentLinkId); } const res = await fetch(url.toString(), { headers: { - Authorization: `Bearer ${authToken}`, + Authorization: `Bearer ${params.authToken}`, "Content-Type": "application/json", - "x-client-id": props.clientId, - "x-team-id": props.teamId, + "x-client-id": params.clientId, + "x-team-id": params.teamId, }, method: "GET", }); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PayAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PayAnalytics.tsx index a66b2024455..c920d2f865f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PayAnalytics.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PayAnalytics.tsx @@ -156,6 +156,7 @@ export async function PayAnalytics(props: { client={props.client} projectClientId={props.projectClientId} teamId={props.teamId} + authToken={props.authToken} /> ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.client.tsx index d6888cea4f2..8edd8bedecd 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.client.tsx @@ -28,6 +28,7 @@ export function PaymentHistory(props: { client: ThirdwebClient; projectClientId: string; teamId: string; + authToken: string; }) { const [page, setPage] = useState(1); const { data: payPurchaseData, isLoading } = useQuery< @@ -40,6 +41,7 @@ export function PaymentHistory(props: { limit: pageSize, offset: (page - 1) * pageSize, teamId: props.teamId, + authToken: props.authToken, }); return res; }, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx index 6bd0d297176..00705b1f108 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx @@ -16,6 +16,7 @@ export function QuickStartSection(props: { clientId: string; teamId: string; projectWalletAddress?: string; + authToken: string; }) { return (
@@ -46,6 +47,7 @@ export function QuickStartSection(props: { clientId={props.clientId} projectWalletAddress={props.projectWalletAddress} teamId={props.teamId} + authToken={props.authToken} > + + + + + + + + +
+
+

+ Created{" "} + {formatDistanceToNow(webhook.createdAt, { addSuffix: true })} +

+ + {webhook.version && ( + <> + +

+ v{webhook.version || "1"} +

+ + )} +
+ +
+ +
+
+ + ); +} + const formSchema = z.object({ label: z.string().min(3, "Label must be at least 3 characters long"), - secret: z.string().optional(), url: z.string().url("Please enter a valid URL."), version: z.string(), }); function CreatePaymentWebhookButton( - props: PropsWithChildren, + props: PropsWithChildren<{ + clientId: string; + teamId: string; + authToken: string; + }>, ) { const [open, setOpen] = useState(false); const [secretStored, setSecretStored] = useState(false); @@ -166,26 +251,135 @@ function CreatePaymentWebhookButton( return randomPrivateKey(); }, [open]); + async function handleSubmit(values: z.infer) { + await createWebhook({ + clientId: props.clientId, + authToken: props.authToken, + label: values.label, + secret, + teamId: props.teamId, + url: values.url, + version: Number(values.version), + }); + } + return ( + + {props.children} + + + Create Webhook + + Receive a webhook notification when a bridge, swap or onramp event + occurs. + + + + + + + ); +} + +function EditPaymentWebhookButton(props: { + webhook: Webhook; + clientId: string; + teamId: string; + authToken: string; +}) { + const [open, setOpen] = useState(false); + + async function handleSubmit(values: z.infer) { + await updateWebhook({ + clientId: props.clientId, + webhookId: props.webhook.id, + teamId: props.teamId, + authToken: props.authToken, + body: { + label: values.label, + url: values.url, + version: Number(values.version), + }, + }); + } + + return ( + + + { + e.preventDefault(); + }} + > + + Edit + + + + + + Edit Webhook + + + + + + ); +} + +function BridgeWebhookModalContent( + props: { + clientId: string; + handleSubmit: (values: z.infer) => Promise; + setOpen: (value: boolean) => void; + } & ( + | { + type: "create"; + secret: string; + secretStored: boolean; + setSecretStored: (value: boolean) => void; + } + | { + type: "edit"; + webhook: Webhook; + } + ), +) { + const queryClient = useQueryClient(); + const form = useForm>({ - defaultValues: { - label: "", - url: "", - version: "2", - }, + defaultValues: + props.type === "create" + ? { + label: "", + url: "", + version: "2", + } + : { + label: props.webhook.label, + url: props.webhook.url, + version: props.webhook.version?.toString() || "2", + }, resolver: zodResolver(formSchema), }); - const queryClient = useQueryClient(); - const createMutation = useMutation({ + + const mutation = useMutation({ mutationFn: async (values: z.infer) => { - await createWebhook({ - clientId: props.clientId, - label: values.label, - secret, - teamId: props.teamId, - url: values.url, - version: Number(values.version), - }); - return null; + await props.handleSubmit(values); }, onSuccess: () => { return queryClient.invalidateQueries({ @@ -193,137 +387,129 @@ function CreatePaymentWebhookButton( }); }, }); + return ( - - {props.children} - -
- - createMutation.mutateAsync(values, { - onError: (err) => { - toast.error("Failed to create webhook", { - description: err instanceof Error ? err.message : undefined, - }); - }, - onSuccess: () => { - setOpen(false); - setSecretStored(false); - toast.success("Webhook created successfully"); - form.reset(); - form.clearErrors(); - form.setValue("url", ""); - form.setValue("label", ""); - form.setValue("version", "2"); - form.setValue("secret", undefined); - }, - }), - )} - > - - Create Webhook - - Receive a webhook notification when a bridge, swap or onramp - event occurs. - - - - ( - - URL - - - This is the URL that will receive the webhook. - - - - )} - /> - ( - - Label - - - A label to help you identify this webhook. - - - - )} - /> + + + mutation.mutateAsync(values, { + onError: (err) => { + toast.error("Failed to create webhook", { + description: err instanceof Error ? err.message : undefined, + }); + }, + onSuccess: () => { + props.setOpen(false); + toast.success( + `Webhook ${props.type === "create" ? "created" : "updated"} successfully`, + ); + }, + }), + )} + > + ( + + URL + + + This is the URL that will receive the webhook. + + + + )} + /> + ( + + Label + + + A label to help you identify this webhook. + + + + )} + /> - ( - - Version - - - Select the data format of the webhook payload (v2 - recommended, v1 for legacy users). - - - - )} - /> + ( + + Version + + + Select the data format of the webhook payload (v2 recommended, + v1 for legacy users). + + + + )} + /> -
- Webhook Secret + {props.type === "create" && ( +
+ Webhook Secret - + + Passed as a bearer token in all webhook requests to verify the + authenticity of the request. + + + + { + props.setSecretStored(!!v); + }} /> - - Passed as a bearer token in all webhook requests to verify the - authenticity of the request. - - - { - setSecretStored(!!v); - }} - /> - I confirm that I've securely stored my webhook secret - -
- - - - - - - -
+ I confirm that I've securely stored my webhook secret + +
+ )} + + + + + + ); } -function DeleteWebhookButton( - props: PropsWithChildren, -) { +function DeleteWebhookButton(props: { + clientId: string; + teamId: string; + webhookId: string; + authToken: string; +}) { const [open, setOpen] = useState(false); const queryClient = useQueryClient(); const deleteMutation = useMutation({ @@ -332,8 +518,8 @@ function DeleteWebhookButton( clientId: props.clientId, teamId: props.teamId, webhookId: id, + authToken: props.authToken, }); - return null; }, onSuccess: () => { return queryClient.invalidateQueries({ @@ -343,7 +529,17 @@ function DeleteWebhookButton( }); return ( - {props.children} + + { + e.preventDefault(); + }} + > + + Delete + + Are you sure? diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/PayConfig.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/PayConfig.tsx index 17781edcf2f..4fb60457fbf 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/PayConfig.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/PayConfig.tsx @@ -27,6 +27,7 @@ interface PayConfigProps { teamSlug: string; fees: Fee; projectWalletAddress?: string; + authToken: string; } export const PayConfig: React.FC = (props) => { @@ -48,6 +49,7 @@ export const PayConfig: React.FC = (props) => { feeBps: values.developerFeeBPS, feeRecipient: values.payoutAddress, teamId: props.teamId, + authToken: props.authToken, }); }, }); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx index 61201dd3204..7a547ea9361 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/payments/page.tsx @@ -37,6 +37,7 @@ export default async function Page(props: { let fees = await getFees({ clientId: project.publishableKey, teamId: team.id, + authToken: authToken, }).catch(() => { return { createdAt: "", @@ -69,6 +70,7 @@ export default async function Page(props: { teamId={team.id} projectWalletAddress={projectWallet?.address} teamSlug={team_slug} + authToken={authToken} /> ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/WebhookSendsUI.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/WebhookSendsUI.stories.tsx new file mode 100644 index 00000000000..40a170be11d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/WebhookSendsUI.stories.tsx @@ -0,0 +1,283 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { + getWebhookSends, + WebhookSend, +} from "@/api/universal-bridge/developer"; +import { WebhookSendsUI } from "./webhook-sends"; + +const meta = { + title: "app/webhooks/WebhookSendsUI", + component: WebhookSendsUI, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type WebhookSendResult = Awaited>; + +function createMockWebhookSendItem(): WebhookSend { + const type = Math.random() > 0.5 ? "onramp" : "bridge"; + const status = + Math.random() > 0.5 + ? "PENDING" + : Math.random() > 0.5 + ? "COMPLETED" + : "FAILED"; + + const responseStatus = + Math.random() > 0.5 ? (Math.random() > 0.5 ? 200 : 500) : 404; + + const responseType = Math.random() > 0.5 ? "json" : "text"; + + return { + id: crypto.randomUUID(), + webhookId: "8fa29fe7-95a9-4b70-97a6-7e80504f5a75", + webhookUrl: "https://example.webhook/foo/bar", + paymentId: + "0x9620fef2c2267b223b8e998b1453b15324acf3ac03e531c9b60c38d6a02843cb", + transactionId: + type === "bridge" + ? "0x79fa097789678db4fcb8712d49133a9447e0039c8a8f93be0cfe3664ac304b80" + : null, + onrampId: type === "onramp" ? crypto.randomUUID() : null, + status: status, + body: { + version: 1, + type: type === "bridge" ? "pay.onchain-transaction" : undefined, + data: + type === "bridge" + ? { + buyWithCryptoStatus: { + quote: { + fromToken: { + chainId: 42161, + tokenAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + decimals: 18, + priceUSDCents: 452169, + name: "Ether", + symbol: "ETH", + }, + toToken: { + chainId: 8453, + tokenAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + decimals: 18, + priceUSDCents: 451824, + name: "Ether", + symbol: "ETH", + }, + fromAmountWei: "101000000000000", + fromAmount: "0.000101", + toAmountWei: "100000000000000", + toAmount: "0.0001", + toAmountMin: "0.0001", + toAmountMinWei: "100000000000000", + estimated: { + fromAmountUSDCents: 46, + toAmountMinUSDCents: 45, + toAmountUSDCents: 45, + slippageBPS: 0, + feesUSDCents: 0, + gasCostUSDCents: 0, + durationSeconds: 60, + }, + createdAt: "2025-09-16T23:20:53.228Z", + }, + status: "PENDING", + subStatus: "NONE", + fromAddress: "0x2a4f24f935eb178e3e7ba9b53a5ee6d8407c0709", + toAddress: "0x2a4f24f935eb178e3e7ba9b53a5ee6d8407c0709", + bridge: "Universal Bridge", + }, + } + : { + buyWithFiatStatus: { + intentId: "9833815f-77b8-4e3e-b0cf-b8741be16ede", + fromAddress: "0x0000000000000000000000000000000000000000", + toAddress: "0xEbE5C4774fd4eEA444094367E01d26f0771e248b", + status: "PENDING", + quote: { + createdAt: "2025-06-19T18:32:42.092Z", + estimatedOnRampAmount: "0.002", + estimatedOnRampAmountWei: "2000000000000000", + estimatedToTokenAmount: "0.002", + estimatedToTokenAmountWei: "2000000000000000", + onRampToken: { + chainId: 42161, + address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + symbol: "ETH", + name: "Ether", + decimals: 18, + priceUsd: 2511.635965, + iconUri: "https://assets.relay.link/icons/1/light.png", + }, + toToken: { + chainId: 42161, + address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + symbol: "ETH", + name: "Ether", + decimals: 18, + priceUsd: 2511.635965, + iconUri: "https://assets.relay.link/icons/1/light.png", + }, + estimatedDurationSeconds: 60, + }, + failureMessage: "", + purchaseData: null, + }, + }, + }, + success: true, + responseStatus: responseStatus, + response: + responseType === "json" + ? '{ "message": "Success" }' + : "This is a plaintext response", + createdAt: "2025-09-16T23:20:53.981Z", + }; +} + +function createMockWebhookSendResult(length: number): WebhookSendResult { + return { + data: Array.from({ length }, createMockWebhookSendItem), + pagination: { + limit: 10, + offset: 0, + total: 100, + }, + }; +} + +const mockEmptyWebhookSendResult: WebhookSendResult = { + data: [], + pagination: { + limit: 10, + offset: 0, + total: 0, + }, +}; + +export const Loading: Story = { + args: { + webhookId: "mock-webhook-id", + authToken: "mock-auth-token", + projectClientId: "mock-project-id", + teamId: "mock-team-id", + getWebhookSends: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000000)); + return createMockWebhookSendResult(1); + }, + resendWebhook: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return true; + }, + }, +}; + +export const TenResults: Story = { + args: { + webhookId: "mock-webhook-id", + authToken: "mock-auth-token", + projectClientId: "mock-project-id", + teamId: "mock-team-id", + getWebhookSends: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return createMockWebhookSendResult(10); + }, + resendWebhook: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return true; + }, + }, +}; + +export const OneResult: Story = { + args: { + webhookId: "mock-webhook-id", + authToken: "mock-auth-token", + projectClientId: "mock-project-id", + teamId: "mock-team-id", + getWebhookSends: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return createMockWebhookSendResult(1); + }, + resendWebhook: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return true; + }, + }, +}; + +export const FewResults: Story = { + args: { + webhookId: "mock-webhook-id", + authToken: "mock-auth-token", + projectClientId: "mock-project-id", + teamId: "mock-team-id", + getWebhookSends: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return createMockWebhookSendResult(3); + }, + resendWebhook: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return true; + }, + }, +}; + +export const NoResults: Story = { + args: { + webhookId: "mock-webhook-id", + authToken: "mock-auth-token", + projectClientId: "mock-project-id", + teamId: "mock-team-id", + getWebhookSends: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return mockEmptyWebhookSendResult; + }, + resendWebhook: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return true; + }, + }, +}; + +export const ErrorState: Story = { + args: { + webhookId: "mock-webhook-id", + authToken: "mock-auth-token", + projectClientId: "mock-project-id", + teamId: "mock-team-id", + getWebhookSends: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + throw new Error("Failed to fetch webhook sends"); + }, + resendWebhook: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return true; + }, + }, +}; + +export const ResendError: Story = { + args: { + webhookId: "mock-webhook-id", + authToken: "mock-auth-token", + projectClientId: "mock-project-id", + teamId: "mock-team-id", + getWebhookSends: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return createMockWebhookSendResult(1); + }, + resendWebhook: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + throw new Error("Failed to resend webhook"); + }, + }, +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/page.tsx new file mode 100644 index 00000000000..a0f1bee8fba --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/page.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { getWebhookById } from "@/api/universal-bridge/developer"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { WebhookSends } from "./webhook-sends"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + id: string; + }>; +}) { + const [authToken, params] = await Promise.all([getAuthToken(), props.params]); + + const project = await getProject(params.team_slug, params.project_slug); + + if (!project || !authToken) { + notFound(); + } + + const webhook = await getWebhookById({ + clientId: project.publishableKey, + teamId: project.teamId, + authToken, + webhookId: params.id, + }); + + if (!webhook) { + notFound(); + } + + return ( +
+ + + + + + Webhooks + + + + + + + +
+ +
+

+ {webhook.label || "Untitled Webhook"} +

+ +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/webhook-sends.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/webhook-sends.tsx new file mode 100644 index 00000000000..cbb531a19bc --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/[id]/webhook-sends.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; +import { PlainTextCodeBlock } from "@workspace/ui/components/code/plaintext-code"; +import { cn } from "@workspace/ui/lib/utils"; +import { formatDate } from "date-fns"; +import { + ChevronRightIcon, + CircleAlertIcon, + CircleCheckIcon, + DotIcon, + RotateCwIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + getWebhookSends, + resendWebhook, + type WebhookSend, +} from "@/api/universal-bridge/developer"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { CodeClient } from "@/components/ui/code/code.client"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/Spinner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useIsMobile } from "@/hooks/use-mobile"; + +const pageSize = 10; + +type WebhookSendsProps = { + webhookId: string; + authToken: string; + projectClientId: string; + teamId: string; +}; + +export function WebhookSends(props: WebhookSendsProps) { + return ( + + ); +} + +export function WebhookSendsUI( + props: WebhookSendsProps & { + getWebhookSends: typeof getWebhookSends; + resendWebhook: typeof resendWebhook; + }, +) { + const [page, setPage] = useState(1); + const [_selectedWebhookSend, setSelectedWebhookSend] = + useState(null); + const [successFilter, _setSuccessFilter] = useState< + "all" | "success" | "failed" + >("all"); + const isMobile = useIsMobile(); + const [_isDialogOpen, setIsDialogOpen] = useState(false); + const isDialogOpen = isMobile && _isDialogOpen; + + const webhookSendsQuery = useQuery({ + queryKey: ["webhook-sends", props.webhookId, page], + retry: false, + refetchOnWindowFocus: false, + queryFn: () => + props.getWebhookSends({ + webhookId: props.webhookId, + authToken: props.authToken, + projectClientId: props.projectClientId, + teamId: props.teamId, + limit: pageSize, + offset: (page - 1) * pageSize, + success: + successFilter === "all" ? undefined : successFilter === "success", + }), + // preserve the previous data when the page changes + placeholderData: keepPreviousData, + }); + + const totalItems = webhookSendsQuery.data?.pagination.total; + const totalPages = totalItems ? Math.ceil(totalItems / pageSize) : 1; + const selectedWebhookSend = + _selectedWebhookSend || (isMobile ? null : webhookSendsQuery.data?.data[0]); + + return ( +
+
+

Events

+ {/* show a filter selector here */} +
+ + {webhookSendsQuery.isError ? ( +
+ Failed to load events +
+ ) : ( +
+ {/* grid */} +
+ {/* left */} +
+ {webhookSendsQuery.isFetching && + Array.from({ length: pageSize }).map(() => ( + // biome-ignore lint/correctness/useJsxKeyInIterable: ok + + ))} + + {!webhookSendsQuery.isFetching && + webhookSendsQuery.data?.data.map((webhookSend) => ( + { + setSelectedWebhookSend(webhookSend); + setIsDialogOpen(true); + }} + /> + ))} + + {!webhookSendsQuery.isFetching && + webhookSendsQuery.data?.data.length === 0 && ( +
+ No events found +
+ )} +
+ + {/* right - desktop */} +
+ {webhookSendsQuery.isFetching ? ( +
+ +
+ ) : selectedWebhookSend ? ( + + ) : null} +
+ + {/* Dialog for mobile */} + { + setIsDialogOpen(open); + if (!open) { + setSelectedWebhookSend(null); + } + }} + > + + {selectedWebhookSend && ( + + )} + + +
+ + {totalPages && totalPages > 1 && ( +
+ { + setPage(page); + setSelectedWebhookSend(null); + }} + /> +
+ )} +
+ )} +
+ ); +} + +function WebhookSendInfo(props: { + webhookSend: WebhookSend; + authToken: string; + projectClientId: string; + teamId: string; + resendWebhook: typeof resendWebhook; +}) { + const bodyString = useMemo(() => { + try { + if (!props.webhookSend.body) { + return undefined; + } + + if ( + typeof props.webhookSend.body === "object" && + props.webhookSend.body !== null + ) { + return JSON.stringify(props.webhookSend.body, null, 2); + } + + return undefined; + } catch { + return undefined; + } + }, [props.webhookSend.body]); + + const responseString: { lang: "json" | "text"; code: string } = + useMemo(() => { + if (!props.webhookSend.response) { + return { + lang: "text", + code: "", + }; + } + + try { + const json = JSON.parse(props.webhookSend.response); + return { + lang: "json", + code: JSON.stringify(json, null, 2), + }; + } catch { + return { + lang: "text", + code: props.webhookSend.response, + }; + } + }, [props.webhookSend.response]); + + const resendMutation = useMutation({ + mutationFn: async () => { + if (props.webhookSend.onrampId) { + const result = await props.resendWebhook({ + onrampId: props.webhookSend.onrampId, + authToken: props.authToken, + projectClientId: props.projectClientId, + teamId: props.teamId, + }); + return result; + } else if (props.webhookSend.paymentId) { + const result = await props.resendWebhook({ + paymentId: props.webhookSend.paymentId, + authToken: props.authToken, + projectClientId: props.projectClientId, + teamId: props.teamId, + }); + return result; + } else { + throw new Error("No transaction or onramp ID found"); + } + }, + onSuccess: () => { + toast.success("Webhook resent"); + }, + onError: (error: unknown) => { + toast.error("Failed to resend webhook", { + description: error instanceof Error ? error.message : undefined, + }); + }, + }); + + return ( +
+
+

+ {getWebhookSendTitle(props.webhookSend)}. + {props.webhookSend.status.toLowerCase()} +

+ +

+ Sent on{" "} + {formatDate( + new Date(props.webhookSend.createdAt), + "MMM d, yyyy HH:mm:ss", + )} +

+
+ + + +
+ +
+ {/* succes */} +
+

Delivery Status

+

+ {" "} + {props.webhookSend.success ? "Delivered" : "Failed"}{" "} +

+
+ + {/* transaction id */} + {props.webhookSend.transactionId && ( +
+

Transaction ID

+ +
+ )} + + {/* Payment ID */} + {props.webhookSend.paymentId && ( + + )} + + {/* onramp id */} + {props.webhookSend.onrampId && ( + + )} + + {/* event id */} + {props.webhookSend.id && ( + + )} + + {/* payload */} +
+

Payload

+ +
+ + {/* response */} +
+
+

Response

+ + +
+ + {responseString.lang === "text" ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function CopyTextField(props: { label: string; value: string }) { + return ( +
+

{props.label}

+ +
+ ); +} + +function WebhookSendCompactInfo(props: { + webhookSend: WebhookSend; + isSelected: boolean; + onSelect: (webhookSend: WebhookSend) => void; +}) { + return ( +
+ +
+ ); +} + +function WebhookSendCompactInfoSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +function getWebhookSendTitle(webhookSend: WebhookSend) { + if (typeof webhookSend.body === "object" && webhookSend.body !== null) { + if ("type" in webhookSend.body) { + const type = webhookSend.body.type; + if (typeof type === "string") { + return type; + } + } + } + + if (webhookSend.onrampId) { + return "onramp"; + } + + return "unknown"; +} + +function StatusBadge(props: { status: number }) { + const variant = + props.status >= 200 && props.status < 300 ? "success" : "destructive"; + + return ( + + {variant === "success" ? ( + + ) : ( + + )} + {props.status} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/page.tsx index 8538df1428a..b0ecd211974 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/payments/page.tsx @@ -21,6 +21,9 @@ export default async function Page(props: { ); }