feat(web): add Cloudflare Turnstile to the public forms

Render the widget on the contact, event registration and both application
forms, and verify the token with Siteverify in each action before anything
is written to the CMS. Verification fails closed. The check is off unless
both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set.

Allow challenges.cloudflare.com in script-src and frame-src.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:05:51 +02:00
co-authored by Claude Opus 5.5
parent 0b2c8d581f
commit 709a978da5
17 changed files with 475 additions and 15 deletions
@@ -37,7 +37,8 @@
an enhancement only: all three steps are in the server-rendered HTML, no
field is hidden behind a step, and the whole thing submits and reports its
errors with JavaScript switched off. `useNavigation()` only swaps the button
into its loading state.
into its loading state. The one exception is the Cloudflare Turnstile check,
which needs scripting whenever it is configured (~/components/ui/Turnstile).
WHAT THE DRAFT PROMISES AND THIS PAGE DOES NOT (CLAUDE.md rule 5 - the
wording lives in ./_events-opportunities/copy.ts, where each omission is
@@ -65,6 +66,7 @@ import { data, Form, Link, useActionData, useNavigation } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { CardLink, CardShell } from "~/components/ui/Card";
import { Input, Textarea } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import {
interpolate,
localizedPath,
@@ -76,6 +78,7 @@ import {
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/applications-and-opportunities";
import { Container } from "./_page-kit/sections";
@@ -192,6 +195,8 @@ type ActionResult =
readonly values: PathwayApplicationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
@@ -219,11 +224,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 },
);
}
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
try {
await submitPathwayApplication({
values,
@@ -235,7 +259,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[opportunities] application from /${locale} was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 },
);
}
@@ -678,13 +708,15 @@ function ApplicationForm({
result: ActionResult | undefined;
route: ApplicationRoute;
}) {
const { t } = useTranslation();
const { locale, t } = useTranslation();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const errors = result && !result.ok ? result.errors : {};
const values = result && !result.ok ? result.values : EMPTY_VALUES;
const hasFailed = result !== undefined && !result.ok && result.failed;
const hasFailedVerification =
result !== undefined && !result.ok && result.verificationFailed;
/* This form has no consent checkbox - only the event registration does - so
the "consent" code cannot occur here; it is narrowed away rather than
@@ -723,7 +755,7 @@ function ApplicationForm({
className="flex flex-col gap-lg"
noValidate
>
{invalidFields.length > 0 || hasFailed ? (
{invalidFields.length > 0 || hasFailed || hasFailedVerification ? (
<div
ref={summaryRef}
tabIndex={-1}
@@ -731,7 +763,11 @@ function ApplicationForm({
className="flex flex-col gap-xs rounded-card border border-border-danger bg-surface-danger-subtle p-md"
>
<p className="body-md-bold text-text-danger">
{hasFailed ? t("form.errorGeneric") : copy.apply.errorSummaryTitle}
{hasFailed
? t("form.errorGeneric")
: hasFailedVerification
? t("form.errorVerification")
: copy.apply.errorSummaryTitle}
</p>
{hasFailed ? (
@@ -887,6 +923,8 @@ function ApplicationForm({
</div>
</div>
<Turnstile action="get-involved" locale={locale} resetKey={result} />
<div className="flex flex-wrap items-center justify-between gap-md border-t border-border-subtle pt-lg">
<p className="body-sm max-w-[26rem] text-text-tertiary">
{copy.apply.acknowledgement}