Skip to content
Open
Show file tree
Hide file tree
Changes from all 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this untyped getCloudflareContext().env.WEBHOOK_QUEUE path is causing the lint job to fail here and in the tRPC context helpers. Can we type the Cloudflare env binding instead of suppressing it so CI is green and queue access stays checked?

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