diff --git a/apps/backend/src/app/api/latest/internal/send-test-webhook/route.tsx b/apps/backend/src/app/api/latest/internal/send-test-webhook/route.tsx new file mode 100644 index 000000000..c0b42dc74 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/send-test-webhook/route.tsx @@ -0,0 +1,107 @@ +import { getSvixClient } from "@/lib/webhooks"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { MessageStatus } from "svix"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema, + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + endpoint_id: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().defined(), + error_message: yupString().optional(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const projectId = auth.tenancy.project.id; + const svix = getSvixClient(); + + await svix.application.getOrCreate({ uid: projectId, name: projectId }); + const endpointResult = await Result.fromPromise(svix.endpoint.get(projectId, body.endpoint_id)); + if (endpointResult.status === "error") { + return { + statusCode: 200, + bodyType: "json", + body: { + success: false, + error_message: "Endpoint not found. Make sure it still exists.", + }, + }; + } + const endpoint = endpointResult.data; + + const messageResult = await Result.fromPromise(svix.message.create( + projectId, + { + eventType: "stack.test", + payload: { + type: "stack.test", + data: { + message: "Stack webhook test event triggered from the Stack dashboard.", + endpointUrl: endpoint.url, + }, + }, + }, + )); + if (messageResult.status === "error") { + const errorMessage = messageResult.error instanceof Error ? messageResult.error.message : "Unknown error while sending the test webhook."; + captureError("send-test-webhook", new StackAssertionError("Failed to send test webhook", { + cause: messageResult.error, + project_id: projectId, + endpoint_id: body.endpoint_id, + })); + return { + statusCode: 200, + bodyType: "json", + body: { + success: false, + error_message: errorMessage, + }, + }; + } + + const attemptResult = await Result.retry(async () => { + const attempts = await svix.messageAttempt.listByMsg( + projectId, + messageResult.data.id, + { status: MessageStatus.Success } + ); + const success = attempts.data.some(a => a.endpointId === body.endpoint_id); + return success ? Result.ok(undefined) : Result.error("No successful attempt found"); + }, 3); + + if (attemptResult.status === "error") { + return { + statusCode: 200, + bodyType: "json", + body: { + success: false, + error_message: "Webhook not delivered. Make sure the endpoint is configured correctly.", + }, + }; + } + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + }, + }; + }, +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx index e5a97f249..b34fe2187 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx @@ -1,19 +1,22 @@ "use client"; -import { FormDialog, SmartFormDialog } from "@/components/form-dialog"; +import { SmartFormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; import { useRouter } from "@/components/router"; import { SettingCard } from "@/components/settings"; import { getPublicEnvVar } from '@/lib/env'; import { urlSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { ActionCell, ActionDialog, Alert, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; -import { useState } from "react"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { ActionCell, ActionDialog, Alert, AlertDescription, AlertTitle, Button, Form, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; import { SvixProvider, useEndpoints, useSvix } from "svix-react"; import * as yup from "yup"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; import { getSvixResult } from "./utils"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; type Endpoint = { id: string, @@ -24,46 +27,134 @@ type Endpoint = { function CreateDialog(props: { trigger: React.ReactNode, updateFn: () => void, + onTestRequested?: (endpoint: Endpoint) => void, }) { const { svix, appId } = useSvix(); + const [open, setOpen] = useState(false); + const [createdEndpoint, setCreatedEndpoint] = useState(null); const formSchema = yup.object({ url: urlSchema.defined().label("URL"), description: yup.string().label("Description"), }); - return { - await svix.endpoint.create(appId, { url: values.url, description: values.description }); + const form = useForm>({ + resolver: yupResolver(formSchema), + defaultValues: { + url: "", + description: "", + }, + mode: "onChange", + }); + + const handleOpenChange = (value: boolean) => { + setOpen(value); + if (!value) { props.updateFn(); - }} - render={(form) => ( - <> - - Make sure this is a trusted URL that you control. - - - - {(form.watch('url') as any)?.startsWith('http://') && ( - - Using HTTP endpoints is insecure. This can expose your user data to attackers. Only use HTTP endpoints in development environments. + } + form.reset(); + setCreatedEndpoint(null); + }; + + const handleSubmit = form.handleSubmit(async (values) => { + const created = await svix.endpoint.create(appId, { url: values.url, description: values.description }); + setCreatedEndpoint({ + id: created.id, + url: created.url, + description: created.description, + }); + }); + + const allowInsecureWarning = form.watch("url").startsWith("http://"); + + return ( + + {createdEndpoint ? ( +
+ + Endpoint created + + + URL: {createdEndpoint.url} + + {createdEndpoint.description ? ( + + Description: {createdEndpoint.description} + + ) : null} + + You can now send a test event to verify your integration. + + - )} - - )} - />; +
+ + +
+
+ ) : ( +
+ runAsynchronously(handleSubmit(e))} + className="space-y-4" + > + + Make sure this is a trusted URL that you control. + + + + {allowInsecureWarning && ( + + Using HTTP endpoints is insecure. This can expose your user data to attackers. Only use HTTP endpoints in development environments. + + )} +
+ + +
+ + + )} +
+ ); } export function EndpointEditDialog(props: { @@ -120,7 +211,91 @@ function DeleteDialog(props: { ); } -function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void }) { +function TestEndpointDialog(props: { endpoint: Endpoint, open: boolean, onOpenChange: (open: boolean) => void }) { + const stackAdminApp = useAdminApp(); + const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle'); + const [errorMessage, setErrorMessage] = useState(null); + + const previewPayload = useMemo(() => JSON.stringify({ + type: "stack.test", + data: { + message: "Stack webhook test event triggered from the Stack dashboard.", + endpointUrl: props.endpoint.url, + }, + }, null, 2), [props.endpoint.url]); + + const resetState = () => { + setStatus('idle'); + setErrorMessage(null); + }; + + const sendTestEvent = async () => { + setStatus('sending'); + setErrorMessage(null); + + const result = await stackAdminApp.sendTestWebhook({ endpointId: props.endpoint.id }); + if (result.status === 'ok') { + setStatus('success'); + return; + } + setStatus('error'); + setErrorMessage(result.error.errorMessage); + }; + + return ( + { + props.onOpenChange(value); + if (!value) { + resetState(); + } + }} + title="Send a test webhook" + okButton={{ + label: 'Send test event', + onClick: async () => { + await sendTestEvent(); + return 'prevent-close'; + }, + }} + cancelButton={status === 'sending' ? false : { label: 'Cancel' }} + > +
+ + + We'll send a simple stack.test event to {props.endpoint.url} and check if it was delivered successfully. + + + +
+ Sample payload +
{previewPayload}
+
+ + {status === 'success' && ( + + Event sent + + Test event was sent successfully. +
+ Your endpoint is properly configured and ready to receive events. +
+
+ )} + + {status === 'error' && errorMessage && ( + + Unable to send event + {errorMessage} + + )} +
+
+ ); +} + +function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void, onTestEndpoint: (endpoint: Endpoint) => void }) { const [editDialogOpen, setEditDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const router = useRouter(); @@ -144,6 +319,7 @@ function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void }) { router.push(`/projects/${project.id}/webhooks/${props.endpoint.id}`) }, + { item: "Test", onClick: () => props.onTestEndpoint(props.endpoint) }, { item: "Edit", onClick: () => setEditDialogOpen(true) }, '-', { item: "Delete", onClick: () => setDeleteDialogOpen(true), danger: true } @@ -153,41 +329,43 @@ function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void }) { ); } -function Endpoints(props: { updateFn: () => void }) { +function Endpoints(props: { updateFn: () => void, onTestRequested: (endpoint: Endpoint) => void }) { const endpoints = getSvixResult(useEndpoints({ limit: 100 })); if (!endpoints.loaded) { return endpoints.rendered; } else { return ( - Add new endpoint} updateFn={props.updateFn}/>} - > -
- - - - Endpoint URL - Description - - - - - {endpoints.data.map(endpoint => ( - - {endpoint.url} - {endpoint.description} - - - + <> + Add new endpoint} updateFn={props.updateFn} onTestRequested={props.onTestRequested} />} + > +
+
+ + + Endpoint URL + Description + - ))} - -
-
-
+ + + {endpoints.data.map(endpoint => ( + + {endpoint.url} + {endpoint.description} + + + + + ))} + + + + + ); } } @@ -196,6 +374,7 @@ export default function PageClient() { const stackAdminApp = useAdminApp(); const svixToken = stackAdminApp.useSvixToken(); const [updateCounter, setUpdateCounter] = useState(0); + const [testDialogEndpoint, setTestDialogEndpoint] = useState(null); return ( @@ -209,7 +388,21 @@ export default function PageClient() { appId={stackAdminApp.projectId} options={{ serverUrl: getPublicEnvVar('NEXT_PUBLIC_STACK_SVIX_SERVER_URL') }} > - setUpdateCounter(x => x + 1)} /> + setUpdateCounter(x => x + 1)} + onTestRequested={(endpoint) => setTestDialogEndpoint(endpoint)} + /> + {testDialogEndpoint && ( + { + if (!open) { + setTestDialogEndpoint(null); + } + }} + /> + )} diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 2ed83d1b0..427bd589c 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -354,6 +354,19 @@ export class StackAdminInterface extends StackServerInterface { return await response.json(); } + async sendTestWebhook(data: { + endpoint_id: string, + }): Promise<{ success: boolean, error_message?: string }> { + const response = await this.sendAdminRequest(`/internal/send-test-webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(data), + }, null); + return await response.json(); + } + async listSentEmails(): Promise { const response = await this.sendAdminRequest("/internal/emails", { method: "GET", diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index e4daf1ca0..8f5e71566 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -464,6 +464,18 @@ export class _StackAdminAppImplIncomplete> { + const response = await this._interface.sendTestWebhook({ + endpoint_id: options.endpointId, + }); + + if (response.success) { + return Result.ok(undefined); + } else { + return Result.error({ errorMessage: response.error_message ?? throwErr("Webhook test error not specified") }); + } + } + async listSentEmails(): Promise { const response = await this._interface.listSentEmails(); return response.items.map((email) => ({ diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 051e08c9e..17700220b 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -56,6 +56,8 @@ export type StackAdminApp>, + sendTestWebhook(options: { endpointId: string }): Promise>, + sendSignInInvitationEmail(email: string, callbackUrl: string): Promise, listSentEmails(): Promise,