mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
webhook testing
This commit is contained in:
parent
b677e3fd74
commit
c9bc00cb50
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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<Endpoint | null>(null);
|
||||
|
||||
const formSchema = yup.object({
|
||||
url: urlSchema.defined().label("URL"),
|
||||
description: yup.string().label("Description"),
|
||||
});
|
||||
|
||||
return <FormDialog
|
||||
trigger={props.trigger}
|
||||
title={"Create new endpoint"}
|
||||
formSchema={formSchema}
|
||||
okButton={{ label: "Create" }}
|
||||
onSubmit={async (values) => {
|
||||
await svix.endpoint.create(appId, { url: values.url, description: values.description });
|
||||
const form = useForm<yup.InferType<typeof formSchema>>({
|
||||
resolver: yupResolver(formSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
description: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value);
|
||||
if (!value) {
|
||||
props.updateFn();
|
||||
}}
|
||||
render={(form) => (
|
||||
<>
|
||||
<Alert>
|
||||
Make sure this is a trusted URL that you control.
|
||||
</Alert>
|
||||
<InputField
|
||||
label="URL"
|
||||
name="url"
|
||||
control={form.control}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
name="description"
|
||||
control={form.control}
|
||||
/>
|
||||
{(form.watch('url') as any)?.startsWith('http://') && (
|
||||
<Alert variant="destructive">
|
||||
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 (
|
||||
<ActionDialog
|
||||
trigger={props.trigger}
|
||||
title={"Create new endpoint"}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
okButton={false}
|
||||
cancelButton={false}
|
||||
>
|
||||
{createdEndpoint ? (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="success">
|
||||
<AlertTitle>Endpoint created</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span className="block">
|
||||
<span className="font-semibold">URL:</span> {createdEndpoint.url}
|
||||
</span>
|
||||
{createdEndpoint.description ? (
|
||||
<span className="block">
|
||||
<span className="font-semibold">Description:</span> {createdEndpoint.description}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="block pt-2">
|
||||
You can now send a test event to verify your integration.
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>;
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
props.onTestRequested?.(createdEndpoint);
|
||||
handleOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Test endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => runAsynchronously(handleSubmit(e))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Alert>
|
||||
Make sure this is a trusted URL that you control.
|
||||
</Alert>
|
||||
<InputField
|
||||
label="URL"
|
||||
name="url"
|
||||
control={form.control}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
name="description"
|
||||
control={form.control}
|
||||
/>
|
||||
{allowInsecureWarning && (
|
||||
<Alert variant="destructive">
|
||||
Using HTTP endpoints is insecure. This can expose your user data to attackers. Only use HTTP endpoints in development environments.
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</ActionDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<ActionDialog
|
||||
open={props.open}
|
||||
onOpenChange={(value) => {
|
||||
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' }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
We'll send a simple <code className="rounded bg-muted px-1 py-0.5">stack.test</code> event to <span >{props.endpoint.url}</span> and check if it was delivered successfully.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Typography type='label'>Sample payload</Typography>
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-muted p-4 text-xs leading-relaxed">{previewPayload}</pre>
|
||||
</div>
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert variant='success'>
|
||||
<AlertTitle>Event sent</AlertTitle>
|
||||
<AlertDescription>
|
||||
Test event was sent successfully.
|
||||
<br />
|
||||
Your endpoint is properly configured and ready to receive events.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'error' && errorMessage && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Unable to send event</AlertTitle>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</ActionDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
<ActionCell
|
||||
items={[
|
||||
{ item: "View Details", onClick: () => 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 (
|
||||
<SettingCard
|
||||
title="Endpoints"
|
||||
description="Endpoints are the URLs that we will send events to. Please make sure you control these endpoints, as they can receive sensitive data."
|
||||
actions={<CreateDialog trigger={<Button>Add new endpoint</Button>} updateFn={props.updateFn}/>}
|
||||
>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[600px]">Endpoint URL</TableHead>
|
||||
<TableHead className="w-[300px]">Description</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{endpoints.data.map(endpoint => (
|
||||
<TableRow key={endpoint.id}>
|
||||
<TableCell>{endpoint.url}</TableCell>
|
||||
<TableCell>{endpoint.description}</TableCell>
|
||||
<TableCell className="flex justify-end gap-4">
|
||||
<ActionMenu endpoint={endpoint} updateFn={props.updateFn} />
|
||||
</TableCell>
|
||||
<>
|
||||
<SettingCard
|
||||
title="Endpoints"
|
||||
description="Endpoints are the URLs that we will send events to. Please make sure you control these endpoints, as they can receive sensitive data."
|
||||
actions={<CreateDialog trigger={<Button>Add new endpoint</Button>} updateFn={props.updateFn} onTestRequested={props.onTestRequested} />}
|
||||
>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[600px]">Endpoint URL</TableHead>
|
||||
<TableHead className="w-[300px]">Description</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{endpoints.data.map(endpoint => (
|
||||
<TableRow key={endpoint.id}>
|
||||
<TableCell>{endpoint.url}</TableCell>
|
||||
<TableCell>{endpoint.description}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionMenu endpoint={endpoint} updateFn={props.updateFn} onTestEndpoint={props.onTestRequested} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -196,6 +374,7 @@ export default function PageClient() {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const svixToken = stackAdminApp.useSvixToken();
|
||||
const [updateCounter, setUpdateCounter] = useState(0);
|
||||
const [testDialogEndpoint, setTestDialogEndpoint] = useState<Endpoint | null>(null);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="webhooks">
|
||||
@ -209,7 +388,21 @@ export default function PageClient() {
|
||||
appId={stackAdminApp.projectId}
|
||||
options={{ serverUrl: getPublicEnvVar('NEXT_PUBLIC_STACK_SVIX_SERVER_URL') }}
|
||||
>
|
||||
<Endpoints updateFn={() => setUpdateCounter(x => x + 1)} />
|
||||
<Endpoints
|
||||
updateFn={() => setUpdateCounter(x => x + 1)}
|
||||
onTestRequested={(endpoint) => setTestDialogEndpoint(endpoint)}
|
||||
/>
|
||||
{testDialogEndpoint && (
|
||||
<TestEndpointDialog
|
||||
endpoint={testDialogEndpoint}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setTestDialogEndpoint(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SvixProvider>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
|
||||
@ -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<InternalEmailsCrud["Admin"]["List"]> {
|
||||
const response = await this.sendAdminRequest("/internal/emails", {
|
||||
method: "GET",
|
||||
|
||||
@ -464,6 +464,18 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestWebhook(options: { endpointId: string }): Promise<Result<undefined, { errorMessage: string }>> {
|
||||
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<AdminSentEmail[]> {
|
||||
const response = await this._interface.listSentEmails();
|
||||
return response.items.map((email) => ({
|
||||
|
||||
@ -56,6 +56,8 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
|
||||
emailConfig: EmailConfig,
|
||||
}): Promise<Result<undefined, { errorMessage: string }>>,
|
||||
|
||||
sendTestWebhook(options: { endpointId: string }): Promise<Result<undefined, { errorMessage: string }>>,
|
||||
|
||||
sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void>,
|
||||
|
||||
listSentEmails(): Promise<AdminSentEmail[]>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user