Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ web_modules/
.next
.open-next
next-env.d.ts
cloudflare-env.d.ts
out

# Nuxt.js build / generate output
Expand Down
12 changes: 12 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';

initOpenNextCloudflareForDev();

const monorepoRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
outputFileTracingRoot: monorepoRoot,
turbopack: {
root: monorepoRoot,
},
images: {
remotePatterns: [
{
Expand Down
259 changes: 259 additions & 0 deletions apps/web/src/app/(main)/form/[id]/form-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@
import { useRouter } from 'next/navigation';

import { zodResolver } from '@hookform/resolvers/zod';
import { formatDistanceToNow } from 'date-fns';
import {
BellRing,
ExternalLink,
FolderPen,
FolderX,
Shield,
Webhook,
} from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';

import { type RouterOutputs } from '@formbase/api';
import { type User } from '@formbase/auth';
import { Badge } from '@formbase/ui/primitives/badge';
import {
Form,
FormControl,
Expand All @@ -27,7 +30,9 @@ import {
import { Input } from '@formbase/ui/primitives/input';
import { Label } from '@formbase/ui/primitives/label';
import { Switch } from '@formbase/ui/primitives/switch';
import { isValidWebhookUrl } from '@formbase/utils/webhook';

import { CopyButton } from '~/components/copy-button';
import { LoadingButton } from '~/components/loading-button';
import { api } from '~/lib/trpc/react';

Expand Down Expand Up @@ -58,6 +63,18 @@ const honeypotFieldSchema = z.object({
honeypotField: z.string().min(1).optional(),
});

const webhookSettingsSchema = z.object({
enableWebhook: z.boolean().default(false).optional(),
webhookUrl: z
.string()
.url()
.refine(isValidWebhookUrl, {
message: 'URL must use HTTPS (localhost allowed for development)',
})
.optional()
.or(z.literal('')),
});

type FormNameSchema = z.infer<typeof formNameSchema>;
type EnableFormSubmissionsSchema = z.infer<typeof enableFormSubmissionsSchema>;
type EnableFormNotificationsSchema = z.infer<typeof enableNotificationsSchema>;
Expand Down Expand Up @@ -113,6 +130,13 @@ export function FormSettings({ form, user }: FormSettingsProps) {
honeypotField={form.honeypotField}
/>

<WebhookSettings
formId={form.id}
enableWebhook={form.enableWebhook}
webhookUrl={form.webhookUrl ?? ''}
webhookSecret={form.webhookSecret ?? null}
/>

<div className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">Delete Form</Label>
Expand Down Expand Up @@ -597,3 +621,238 @@ const HoneypotFieldSetting = ({
</Form>
);
};

type WebhookSettingsSchema = z.infer<typeof webhookSettingsSchema>;

const WebhookSettings = ({
formId,
enableWebhook,
webhookUrl,
webhookSecret,
}: {
formId: string;
enableWebhook: boolean;
webhookUrl: string;
webhookSecret: string | null;
}) => {
const router = useRouter();

const webhookForm = useForm<WebhookSettingsSchema>({
resolver: zodResolver(webhookSettingsSchema),
defaultValues: {
enableWebhook,
webhookUrl,
},
});

const watchEnableWebhook = webhookForm.watch('enableWebhook');
const watchWebhookUrl = webhookForm.watch('webhookUrl');

const { mutateAsync: updateWebhookSettings, isPending: isUpdating } =
api.form.update.useMutation();

const { mutateAsync: testWebhook, isPending: isTesting } =
api.form.testWebhook.useMutation();

const { data: deliveries } = api.form.listDeliveries.useQuery(
{ formId },
{ enabled: watchEnableWebhook ?? false },
);

async function handleEnableWebhookChange(isChecked: boolean) {
webhookForm.setValue('enableWebhook', isChecked);

try {
await updateWebhookSettings({
id: formId,
enableWebhook: isChecked,
});

toast(
isChecked ? 'Webhook has been enabled' : 'Webhook has been disabled',
{ icon: <Webhook className="h-4 w-4" /> },
);

router.refresh();
} catch {
toast('Failed to update webhook settings', {
description: 'Please try again later',
icon: <FolderX className="h-4 w-4" />,
});
}
}

async function handleWebhookUrlSubmit(data: WebhookSettingsSchema) {
try {
await updateWebhookSettings({
id: formId,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
webhookUrl: data.webhookUrl || null,
});

toast('Webhook URL has been updated', {
icon: <Webhook className="h-4 w-4" />,
});

webhookForm.reset({
enableWebhook: webhookForm.getValues('enableWebhook'),
webhookUrl: data.webhookUrl ?? '',
});
router.refresh();
} catch {
toast('Failed to update webhook URL', {
description: 'URL must use HTTPS (localhost allowed for development)',
icon: <FolderX className="h-4 w-4" />,
});
}
}

async function handleTestWebhook() {
try {
await testWebhook({ formId });
toast('Test webhook has been queued', {
description: 'Check your webhook endpoint for the delivery',
icon: <Webhook className="h-4 w-4" />,
});
} catch {
toast('Failed to send test webhook', {
description: 'Make sure webhook is enabled and URL is configured',
icon: <FolderX className="h-4 w-4" />,
});
}
}

return (
<div className="space-y-4 rounded-lg border p-4">
<Form {...webhookForm}>
<FormField
control={webhookForm.control}
name="enableWebhook"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-base">Enable Webhook</FormLabel>
<FormDescription>
Send an HTTP POST request on every form submission
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? false}
onCheckedChange={handleEnableWebhookChange}
disabled={isUpdating}
/>
</FormControl>
</FormItem>
)}
/>
</Form>

{watchEnableWebhook && (
<>
<Form {...webhookForm}>
<form onSubmit={webhookForm.handleSubmit(handleWebhookUrlSubmit)}>
<FormField
control={webhookForm.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<div className="flex gap-2">
<Input
className="flex-1"
{...field}
placeholder="https://example.com/webhook"
type="url"
/>
<LoadingButton
loading={isUpdating}
type="submit"
variant="default"
>
Save
</LoadingButton>
<LoadingButton
loading={isTesting}
type="button"
variant="outline"
onClick={handleTestWebhook}
disabled={!watchWebhookUrl || webhookForm.formState.isDirty}
>
Test
</LoadingButton>
</div>
</FormControl>
<FormDescription>
Must use HTTPS (localhost allowed for development)
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>

{webhookSecret && (
<div className="space-y-2">
<Label>Signing secret</Label>
<div className="flex items-center gap-2 rounded-md border bg-muted px-3 py-2">
<code className="flex-1 truncate font-mono text-sm">
{webhookSecret}
</code>
<CopyButton text={webhookSecret} />
</div>
<p className="text-sm text-muted-foreground">
Used to verify the X-Formbase-Signature header on each request
</p>
</div>
)}

<div className="space-y-2">
<Label>Recent deliveries</Label>
{deliveries && deliveries.length > 0 ? (
<div className="divide-y rounded-md border">
{deliveries.map((delivery) => (
<div
key={delivery.id}
className="flex items-center justify-between gap-2 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<Badge
variant={
delivery.status === 'success'
? 'secondary'
: delivery.status === 'failed'
? 'destructive'
: 'outline'
}
>
{delivery.status}
</Badge>
{delivery.statusCode !== null && (
<span className="text-muted-foreground">
HTTP {delivery.statusCode}
</span>
)}
<span className="text-muted-foreground">
{delivery.attempts} attempt
{delivery.attempts === 1 ? '' : 's'}
</span>
</div>
<span className="text-muted-foreground">
{formatDistanceToNow(delivery.createdAt, {
addSuffix: true,
})}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No deliveries yet</p>
)}
</div>
</>
)}
</div>
);
};
19 changes: 18 additions & 1 deletion apps/web/src/app/api/s/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { after, userAgent } from 'next/server';

import { type RouterOutputs } from '@formbase/api';
Expand All @@ -7,6 +8,7 @@ import { renderNewSubmissionEmail } from '~/lib/email/templates/new-submission';
import { checkForSpam, stripHoneypotField } from '~/lib/spam-detection';
import { api } from '~/lib/trpc/server';
import { assignFileOrImage, uploadFileFromBlob } from '~/lib/upload-file';
import { enqueueWebhook } from '~/lib/webhooks/producer';

type FormDataResult =
| {
Expand Down Expand Up @@ -133,7 +135,7 @@ export async function POST(
const formKeys = form.keys;
const updatedKeys = [...new Set([...formKeys, ...formDataKeys])];

await api.formData.setFormData({
const { id: formDataId } = await api.formData.setFormData({
data: cleanedFormData,
formId,
keys: updatedKeys,
Expand All @@ -148,6 +150,21 @@ export async function POST(
}),
);
}

if (!spamResult.isSpam && form.enableWebhook && form.webhookUrl) {
const { env } = getCloudflareContext();
const queue = env.WEBHOOK_QUEUE;
Comment thread
ephraimduncan marked this conversation as resolved.
Outdated
const webhookUrl = form.webhookUrl;
after(() =>
enqueueWebhook(queue, {
formId,
formDataId,
webhookUrl,
}).catch((error: unknown) => {
console.error('Failed to enqueue webhook', error);
}),
);
}
const { browser } = userAgent(request);

if (!browser.name) {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { type NextRequest } from 'next/server';

import { fetchRequestHandler } from '@trpc/server/adapters/fetch';

import { getCloudflareContext } from '@opennextjs/cloudflare';

import { appRouter, createTRPCContext } from '@formbase/api';
import { env } from '@formbase/env';

const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
webhookQueue: getCloudflareContext().env.WEBHOOK_QUEUE,
});
};

Expand Down
Loading
Loading