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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user