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:
@@ -109,6 +109,11 @@ DOCKER_STRAPI_URL=http://cms:1337
|
|||||||
# Settings -> API Tokens, then paste the value here (it is shown once).
|
# Settings -> API Tokens, then paste the value here (it is shown once).
|
||||||
STRAPI_TOKEN=
|
STRAPI_TOKEN=
|
||||||
|
|
||||||
|
# Cloudflare Turnstile on the public forms. Both keys or neither: with either
|
||||||
|
# one empty, the forms are not checked. See apps/web/.env.example.
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CMS RUNTIME
|
# CMS RUNTIME
|
||||||
|
|||||||
@@ -929,6 +929,7 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly
|
|||||||
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
|
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
|
||||||
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
|
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
|
||||||
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
|
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
|
||||||
|
| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | web | Cloudflare Turnstile on the public forms. Set both or neither — with either empty, no widget renders and submissions are not checked |
|
||||||
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
|
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
|
||||||
| `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins |
|
| `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins |
|
||||||
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
|
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
|
||||||
|
|||||||
@@ -45,3 +45,15 @@ STRAPI_URL=http://localhost:1337
|
|||||||
# Optional Strapi API token. Only needed to read draft or otherwise protected
|
# Optional Strapi API token. Only needed to read draft or otherwise protected
|
||||||
# content; published content is public. Settings -> API Tokens in the admin.
|
# content; published content is public. Settings -> API Tokens in the admin.
|
||||||
STRAPI_TOKEN=
|
STRAPI_TOKEN=
|
||||||
|
|
||||||
|
# --- Bot protection --------------------------------------------------------
|
||||||
|
|
||||||
|
# Cloudflare Turnstile on the public forms (contact, event registration and
|
||||||
|
# both application forms). Both keys or neither: with either one empty, no
|
||||||
|
# widget renders and submissions are not checked. The site key is public and
|
||||||
|
# reaches the browser; the secret key never does.
|
||||||
|
# Cloudflare dashboard -> Turnstile -> your widget. For local testing,
|
||||||
|
# Cloudflare's always-pass pair is 1x00000000000000000000AA (site) and
|
||||||
|
# 1x0000000000000000000000000000000AA (secret).
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
TURNSTILE - Cloudflare's bot check on the public forms
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Rendered INSIDE a <Form>: the widget injects a hidden `cf-turnstile-response`
|
||||||
|
input into its container, so the token travels with the other fields and
|
||||||
|
the route's action checks it with `verifyTurnstile` (~/lib/turnstile.server).
|
||||||
|
|
||||||
|
The site key is read off the root loader. Without one the component renders
|
||||||
|
nothing and the server skips the check, so an unconfigured deploy keeps
|
||||||
|
working exactly as before.
|
||||||
|
|
||||||
|
EXPLICIT RENDERING, NOT THE `cf-turnstile` CLASS
|
||||||
|
Implicit rendering scans the DOM once, when api.js loads - a form reached by
|
||||||
|
a client-side navigation would never get a widget. Rendering from an effect
|
||||||
|
ties the widget to the component instead, and `remove` on unmount keeps
|
||||||
|
Cloudflare's iframes from piling up across navigations.
|
||||||
|
|
||||||
|
THE SCRIPT AND THE CSP
|
||||||
|
api.js is appended from here, once per page, the first time a form mounts -
|
||||||
|
so pages without a form never load it. The CSP (entry.server.tsx) admits it
|
||||||
|
through 'strict-dynamic', because the bundle that appends it is nonced; the
|
||||||
|
widget's iframe needs `frame-src https://challenges.cloudflare.com`.
|
||||||
|
|
||||||
|
SCRIPTING OFF
|
||||||
|
The check cannot run without JavaScript, so the forms that otherwise work
|
||||||
|
as plain posts cannot pass it. The <noscript> line says so up front instead
|
||||||
|
of letting the reader fill everything in first.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouteLoaderData } from "react-router";
|
||||||
|
|
||||||
|
import { createTranslator } from "~/i18n/t";
|
||||||
|
import type { AppTheme } from "~/lib/theme";
|
||||||
|
|
||||||
|
import type { Locale } from "../LanguageSwitcher";
|
||||||
|
|
||||||
|
/** Cloudflare requires this exact URL - api.js must not be proxied or cached. */
|
||||||
|
const SCRIPT_URL =
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which form a token was issued for. Reported per action in the Cloudflare
|
||||||
|
* dashboard. Up to 32 characters of [A-Za-z0-9_-].
|
||||||
|
*/
|
||||||
|
export type TurnstileAction =
|
||||||
|
| "contact"
|
||||||
|
| "event-registration"
|
||||||
|
| "opportunity-application"
|
||||||
|
| "get-involved";
|
||||||
|
|
||||||
|
/** The slice of the root loader this component reads. */
|
||||||
|
interface RootLoaderData {
|
||||||
|
theme?: AppTheme;
|
||||||
|
turnstileSiteKey?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slice of `window.turnstile` used here. */
|
||||||
|
interface TurnstileApi {
|
||||||
|
render(
|
||||||
|
container: HTMLElement,
|
||||||
|
options: {
|
||||||
|
sitekey: string;
|
||||||
|
action: string;
|
||||||
|
theme: AppTheme;
|
||||||
|
language: Locale;
|
||||||
|
size: "flexible";
|
||||||
|
},
|
||||||
|
): string | undefined;
|
||||||
|
reset(widgetId: string): void;
|
||||||
|
remove(widgetId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: TurnstileApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scriptLoad: Promise<TurnstileApi> | undefined;
|
||||||
|
|
||||||
|
/** Appends api.js once and resolves with the API. A failed load may retry. */
|
||||||
|
function loadTurnstile(): Promise<TurnstileApi> {
|
||||||
|
if (window.turnstile) return Promise.resolve(window.turnstile);
|
||||||
|
|
||||||
|
scriptLoad ??= new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = SCRIPT_URL;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () =>
|
||||||
|
window.turnstile
|
||||||
|
? resolve(window.turnstile)
|
||||||
|
: reject(new Error("api.js loaded without defining window.turnstile"));
|
||||||
|
script.onerror = () => {
|
||||||
|
script.remove();
|
||||||
|
scriptLoad = undefined;
|
||||||
|
reject(new Error("api.js could not be loaded"));
|
||||||
|
};
|
||||||
|
document.head.append(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return scriptLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnstileProps {
|
||||||
|
/** Which form this is. */
|
||||||
|
action: TurnstileAction;
|
||||||
|
/** Reading locale, for the widget's own language and the fallback line. */
|
||||||
|
locale: Locale;
|
||||||
|
/**
|
||||||
|
* Anything that changes once per submission - pass the route's action data.
|
||||||
|
* Siteverify accepts a token only once, so every new result asks the widget
|
||||||
|
* for a fresh one.
|
||||||
|
*/
|
||||||
|
resetKey?: unknown;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Turnstile({ action, locale, resetKey, className }: TurnstileProps) {
|
||||||
|
const rootData = useRouteLoaderData<RootLoaderData>("root");
|
||||||
|
const t = useMemo(() => createTranslator(locale), [locale]);
|
||||||
|
|
||||||
|
const siteKey = rootData?.turnstileSiteKey ?? null;
|
||||||
|
const theme: AppTheme = rootData?.theme ?? "dark";
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widgetIdRef = useRef<string | undefined>(undefined);
|
||||||
|
const [unavailable, setUnavailable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!siteKey) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadTurnstile().then(
|
||||||
|
(turnstile) => {
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
widgetIdRef.current = turnstile.render(containerRef.current, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
action,
|
||||||
|
theme,
|
||||||
|
language: locale,
|
||||||
|
size: "flexible",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) setUnavailable(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (widgetIdRef.current) window.turnstile?.remove(widgetIdRef.current);
|
||||||
|
widgetIdRef.current = undefined;
|
||||||
|
};
|
||||||
|
}, [siteKey, action, theme, locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
if (!siteKey) return null;
|
||||||
|
|
||||||
|
const fallback = (
|
||||||
|
<p className="body-sm text-[var(--text-danger)]">
|
||||||
|
{t("form.verificationUnavailable")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{/* Cloudflare owns everything inside this element; React renders no
|
||||||
|
children into it, so the two never fight over the same nodes. */}
|
||||||
|
{unavailable ? fallback : <div ref={containerRef} />}
|
||||||
|
<noscript>{fallback}</noscript>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Turnstile;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
USAGE
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
// Inside the <Form>, just above the submit button. In the action, call
|
||||||
|
// `verifyTurnstile(form)` from ~/lib/turnstile.server after validation.
|
||||||
|
<Turnstile action="contact" locale={locale} resetKey={actionData} />
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { Turnstile, default } from "./Turnstile";
|
||||||
|
export type { TurnstileAction, TurnstileProps } from "./Turnstile";
|
||||||
@@ -45,8 +45,10 @@ function contentSecurityPolicy(nonce: string): string {
|
|||||||
const mediaOrigin = new URL(STRAPI_URL).origin;
|
const mediaOrigin = new URL(STRAPI_URL).origin;
|
||||||
const directives = [
|
const directives = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com`,
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://challenges.cloudflare.com`,
|
||||||
"script-src-attr 'none'",
|
"script-src-attr 'none'",
|
||||||
|
// The Turnstile widget on the public forms renders in Cloudflare's iframe.
|
||||||
|
"frame-src https://challenges.cloudflare.com",
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
`img-src 'self' ${mediaOrigin} https://www.google-analytics.com data: blob:`,
|
`img-src 'self' ${mediaOrigin} https://www.google-analytics.com data: blob:`,
|
||||||
`media-src 'self' ${mediaOrigin} data: blob:`,
|
`media-src 'self' ${mediaOrigin} data: blob:`,
|
||||||
|
|||||||
@@ -168,6 +168,8 @@
|
|||||||
"errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.",
|
"errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.",
|
||||||
"errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.",
|
"errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.",
|
||||||
"errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
|
"errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
|
||||||
|
"errorVerification": "تعذّر إكمال التحقق الأمني. يرجى المحاولة مرة أخرى.",
|
||||||
|
"verificationUnavailable": "تعذّر تحميل التحقق الأمني. يرجى التأكد من تفعيل JavaScript ثم إعادة تحميل الصفحة.",
|
||||||
"successTitle": "شكرًا لك",
|
"successTitle": "شكرًا لك",
|
||||||
"successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
|
"successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -168,6 +168,8 @@
|
|||||||
"errorEmail": "Enter a valid email address.",
|
"errorEmail": "Enter a valid email address.",
|
||||||
"errorConsent": "Please confirm this to complete your registration.",
|
"errorConsent": "Please confirm this to complete your registration.",
|
||||||
"errorGeneric": "Something went wrong. Please try again.",
|
"errorGeneric": "Something went wrong. Please try again.",
|
||||||
|
"errorVerification": "The security check could not be completed. Please try again.",
|
||||||
|
"verificationUnavailable": "The security check could not load. Please make sure JavaScript is enabled, then reload the page.",
|
||||||
"successTitle": "Thank you",
|
"successTitle": "Thank you",
|
||||||
"successBody": "Your message has been sent. We will get back to you as soon as we can."
|
"successBody": "Your message has been sent. We will get back to you as soon as we can."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export interface ServerEnv {
|
|||||||
readonly STRAPI_TOKEN?: string;
|
readonly STRAPI_TOKEN?: string;
|
||||||
/** Public origin of this site, no trailing slash. Used for canonical URLs. */
|
/** Public origin of this site, no trailing slash. Used for canonical URLs. */
|
||||||
readonly SITE_URL: string;
|
readonly SITE_URL: string;
|
||||||
|
/**
|
||||||
|
* Cloudflare Turnstile keys. Set together or not at all: the check on the
|
||||||
|
* public forms is on only when BOTH are present, so a half-configured deploy
|
||||||
|
* leaves the forms unprotected rather than rejecting every submission.
|
||||||
|
*/
|
||||||
|
readonly TURNSTILE_SITE_KEY?: string;
|
||||||
|
readonly TURNSTILE_SECRET_KEY?: string;
|
||||||
readonly isProduction: boolean;
|
readonly isProduction: boolean;
|
||||||
readonly isDevelopment: boolean;
|
readonly isDevelopment: boolean;
|
||||||
}
|
}
|
||||||
@@ -92,14 +99,30 @@ export function getServerEnv(): ServerEnv {
|
|||||||
issues,
|
issues,
|
||||||
);
|
);
|
||||||
const token = source.STRAPI_TOKEN?.trim();
|
const token = source.STRAPI_TOKEN?.trim();
|
||||||
|
const turnstileSiteKey = source.TURNSTILE_SITE_KEY?.trim();
|
||||||
|
const turnstileSecretKey = source.TURNSTILE_SECRET_KEY?.trim();
|
||||||
|
|
||||||
if (issues.length > 0) throw new EnvError(issues);
|
if (issues.length > 0) throw new EnvError(issues);
|
||||||
|
|
||||||
|
/* Not an EnvError: a missing bot check is no reason to take every page
|
||||||
|
down. It is said out loud once, because the forms are now unguarded. */
|
||||||
|
if (Boolean(turnstileSiteKey) !== Boolean(turnstileSecretKey)) {
|
||||||
|
console.warn(
|
||||||
|
"[env] Only one of TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY is set; Turnstile is off.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
cached = {
|
cached = {
|
||||||
NODE_ENV,
|
NODE_ENV,
|
||||||
STRAPI_URL,
|
STRAPI_URL,
|
||||||
SITE_URL,
|
SITE_URL,
|
||||||
...(token ? { STRAPI_TOKEN: token } : {}),
|
...(token ? { STRAPI_TOKEN: token } : {}),
|
||||||
|
...(turnstileSiteKey && turnstileSecretKey
|
||||||
|
? {
|
||||||
|
TURNSTILE_SITE_KEY: turnstileSiteKey,
|
||||||
|
TURNSTILE_SECRET_KEY: turnstileSecretKey,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
isProduction: NODE_ENV === "production",
|
isProduction: NODE_ENV === "production",
|
||||||
isDevelopment: NODE_ENV === "development",
|
isDevelopment: NODE_ENV === "development",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
CLOUDFLARE TURNSTILE - SERVER-SIDE VERIFICATION. SERVER ONLY.
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The widget (~/components/ui/Turnstile) only PRODUCES a token; nothing is
|
||||||
|
proven until Siteverify accepts it. Every public form action calls
|
||||||
|
`verifyTurnstile` after its own field validation and before anything is
|
||||||
|
written to the CMS.
|
||||||
|
|
||||||
|
FAILS CLOSED
|
||||||
|
A missing token, a rejected one and an unreachable Siteverify all answer
|
||||||
|
`false`. A bot cannot make Cloudflare unreachable, so failing open would only
|
||||||
|
ever help an attacker; the reader gets a "try again" either way.
|
||||||
|
|
||||||
|
SINGLE USE
|
||||||
|
Siteverify accepts a token exactly once, so each form resets its widget
|
||||||
|
after every action result (`resetKey`) - a corrected resubmit would
|
||||||
|
otherwise carry a spent token and fail with `timeout-or-duplicate`.
|
||||||
|
|
||||||
|
OFF WHEN UNCONFIGURED
|
||||||
|
With TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY unset (see env.server.ts) no
|
||||||
|
widget renders and every submission passes, so local development and a
|
||||||
|
deploy that has not been given keys yet behave exactly as before.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
import { getServerEnv } from "./env.server";
|
||||||
|
|
||||||
|
const SITEVERIFY_URL =
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||||
|
|
||||||
|
/** The hidden input the widget injects into its form. Cloudflare's default. */
|
||||||
|
const RESPONSE_FIELD = "cf-turnstile-response";
|
||||||
|
|
||||||
|
/** Cloudflare's documented ceiling - anything longer is not a token. */
|
||||||
|
const MAX_TOKEN_LENGTH = 2048;
|
||||||
|
|
||||||
|
/** Error codes that mean the KEY is wrong, not the visitor. */
|
||||||
|
const SECRET_ERRORS = new Set(["missing-input-secret", "invalid-input-secret"]);
|
||||||
|
|
||||||
|
interface SiteverifyResult {
|
||||||
|
readonly success?: unknown;
|
||||||
|
readonly "error-codes"?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when Turnstile is off, or Siteverify accepted the form's token. */
|
||||||
|
export async function verifyTurnstile(form: FormData): Promise<boolean> {
|
||||||
|
const { TURNSTILE_SECRET_KEY } = getServerEnv();
|
||||||
|
if (!TURNSTILE_SECRET_KEY) return true;
|
||||||
|
|
||||||
|
const token = form.get(RESPONSE_FIELD);
|
||||||
|
if (
|
||||||
|
typeof token !== "string" ||
|
||||||
|
token.length === 0 ||
|
||||||
|
token.length > MAX_TOKEN_LENGTH
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(SITEVERIFY_URL, {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
const result = (await response.json()) as SiteverifyResult;
|
||||||
|
|
||||||
|
if (result.success === true) return true;
|
||||||
|
|
||||||
|
/* A visitor who fails the check is routine and is not logged. A key
|
||||||
|
Cloudflare refuses fails EVERY submission, so that one is. */
|
||||||
|
const codes = Array.isArray(result["error-codes"]) ? result["error-codes"] : [];
|
||||||
|
if (codes.some((code) => SECRET_ERRORS.has(code))) {
|
||||||
|
console.error(
|
||||||
|
`[turnstile] Siteverify rejected TURNSTILE_SECRET_KEY: ${codes.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[turnstile] Siteverify could not be reached", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ import { useLocale, useRouteHandleValue } from "~/i18n";
|
|||||||
import { DEFAULT_LOCALE, dirFor, type Locale } from "~/i18n/config";
|
import { DEFAULT_LOCALE, dirFor, type Locale } from "~/i18n/config";
|
||||||
import { localizedPath } from "~/i18n/paths";
|
import { localizedPath } from "~/i18n/paths";
|
||||||
import { createTranslator, getMessages } from "~/i18n/t";
|
import { createTranslator, getMessages } from "~/i18n/t";
|
||||||
import { getSiteUrl } from "~/lib/env.server";
|
import { getServerEnv, getSiteUrl } from "~/lib/env.server";
|
||||||
import {
|
import {
|
||||||
readCanonicalPath,
|
readCanonicalPath,
|
||||||
readFooterSurface,
|
readFooterSurface,
|
||||||
@@ -94,6 +94,9 @@ export function loader({ request }: Route.LoaderArgs) {
|
|||||||
`:root` is dark, so the absence of a cookie is a dark first visit. */
|
`:root` is dark, so the absence of a cookie is a dark first visit. */
|
||||||
theme: readTheme(request.headers.get("Cookie")),
|
theme: readTheme(request.headers.get("Cookie")),
|
||||||
nonce: createCspNonce(),
|
nonce: createCspNonce(),
|
||||||
|
/* Public by design - the widget needs it in the browser. Null when
|
||||||
|
Turnstile is not configured, and then no form renders the widget. */
|
||||||
|
turnstileSiteKey: getServerEnv().TURNSTILE_SITE_KEY ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
Both forms work without JavaScript: they are plain <Form method="post">
|
Both forms work without JavaScript: they are plain <Form method="post">
|
||||||
submissions, so validation has to be complete on the server. There is no
|
submissions, so validation has to be complete on the server. There is no
|
||||||
client mirror of these rules to drift out of sync.
|
client mirror of these rules to drift out of sync. (The Cloudflare
|
||||||
|
Turnstile check, when configured, is the one part that needs scripting.)
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
import type { TranslationKey } from "~/i18n";
|
import type { TranslationKey } from "~/i18n";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
|
|
||||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||||
import { Input, Textarea } from "~/components/ui/Input";
|
import { Input, Textarea } from "~/components/ui/Input";
|
||||||
|
import { Turnstile } from "~/components/ui/Turnstile";
|
||||||
import {
|
import {
|
||||||
interpolate,
|
interpolate,
|
||||||
localizedPath,
|
localizedPath,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
import { safeHref } from "~/lib/safe-href";
|
import { safeHref } from "~/lib/safe-href";
|
||||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||||
import { isStrapiError, requireEntity } from "~/lib/strapi";
|
import { isStrapiError, requireEntity } from "~/lib/strapi";
|
||||||
|
import { verifyTurnstile } from "~/lib/turnstile.server";
|
||||||
|
|
||||||
import type { Route } from "./+types/applications-and-opportunities.$slug";
|
import type { Route } from "./+types/applications-and-opportunities.$slug";
|
||||||
import { getRouteCopy } from "./_events-opportunities/copy";
|
import { getRouteCopy } from "./_events-opportunities/copy";
|
||||||
@@ -98,6 +100,8 @@ type ActionResult =
|
|||||||
readonly values: ApplicationValues;
|
readonly values: ApplicationValues;
|
||||||
/** True when validation passed but the CMS refused the write. */
|
/** True when validation passed but the CMS refused the write. */
|
||||||
readonly failed: boolean;
|
readonly failed: boolean;
|
||||||
|
/** True when validation passed but the Turnstile check did not. */
|
||||||
|
readonly verificationFailed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
@@ -121,11 +125,30 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return data(
|
return data(
|
||||||
{ ok: false, errors, values, failed: false } satisfies ActionResult,
|
{
|
||||||
|
ok: false,
|
||||||
|
errors,
|
||||||
|
values,
|
||||||
|
failed: false,
|
||||||
|
verificationFailed: false,
|
||||||
|
} satisfies ActionResult,
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await verifyTurnstile(form))) {
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
errors: {},
|
||||||
|
values,
|
||||||
|
failed: false,
|
||||||
|
verificationFailed: true,
|
||||||
|
} satisfies ActionResult,
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitApplication({
|
await submitApplication({
|
||||||
values,
|
values,
|
||||||
@@ -138,7 +161,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
`[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`,
|
`[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`,
|
||||||
);
|
);
|
||||||
return data(
|
return data(
|
||||||
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
|
{
|
||||||
|
ok: false,
|
||||||
|
errors: {},
|
||||||
|
values,
|
||||||
|
failed: true,
|
||||||
|
verificationFailed: false,
|
||||||
|
} satisfies ActionResult,
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,6 +521,10 @@ function ApplicationSection({
|
|||||||
</Notice>
|
</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{result && !result.ok && result.verificationFailed ? (
|
||||||
|
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Form method="post" action={detailPath} className="flex flex-col gap-md">
|
<Form method="post" action={detailPath} className="flex flex-col gap-md">
|
||||||
<div className="grid gap-md sm:grid-cols-2">
|
<div className="grid gap-md sm:grid-cols-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -544,6 +577,12 @@ function ApplicationSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
action="opportunity-application"
|
||||||
|
locale={locale}
|
||||||
|
resetKey={result}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-sm">
|
<div className="flex flex-wrap items-center gap-sm">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
an enhancement only: all three steps are in the server-rendered HTML, no
|
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
|
field is hidden behind a step, and the whole thing submits and reports its
|
||||||
errors with JavaScript switched off. `useNavigation()` only swaps the button
|
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
|
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
|
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 { ArrowIcon, Button } from "~/components/ui/Button";
|
||||||
import { CardLink, CardShell } from "~/components/ui/Card";
|
import { CardLink, CardShell } from "~/components/ui/Card";
|
||||||
import { Input, Textarea } from "~/components/ui/Input";
|
import { Input, Textarea } from "~/components/ui/Input";
|
||||||
|
import { Turnstile } from "~/components/ui/Turnstile";
|
||||||
import {
|
import {
|
||||||
interpolate,
|
interpolate,
|
||||||
localizedPath,
|
localizedPath,
|
||||||
@@ -76,6 +78,7 @@ import {
|
|||||||
} from "~/i18n";
|
} from "~/i18n";
|
||||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||||
import { isStrapiError } from "~/lib/strapi";
|
import { isStrapiError } from "~/lib/strapi";
|
||||||
|
import { verifyTurnstile } from "~/lib/turnstile.server";
|
||||||
|
|
||||||
import type { Route } from "./+types/applications-and-opportunities";
|
import type { Route } from "./+types/applications-and-opportunities";
|
||||||
import { Container } from "./_page-kit/sections";
|
import { Container } from "./_page-kit/sections";
|
||||||
@@ -192,6 +195,8 @@ type ActionResult =
|
|||||||
readonly values: PathwayApplicationValues;
|
readonly values: PathwayApplicationValues;
|
||||||
/** True when validation passed but the CMS refused the write. */
|
/** True when validation passed but the CMS refused the write. */
|
||||||
readonly failed: boolean;
|
readonly failed: boolean;
|
||||||
|
/** True when validation passed but the Turnstile check did not. */
|
||||||
|
readonly verificationFailed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
@@ -219,11 +224,30 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return data(
|
return data(
|
||||||
{ ok: false, errors, values, failed: false } satisfies ActionResult,
|
{
|
||||||
|
ok: false,
|
||||||
|
errors,
|
||||||
|
values,
|
||||||
|
failed: false,
|
||||||
|
verificationFailed: false,
|
||||||
|
} satisfies ActionResult,
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await verifyTurnstile(form))) {
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
errors: {},
|
||||||
|
values,
|
||||||
|
failed: false,
|
||||||
|
verificationFailed: true,
|
||||||
|
} satisfies ActionResult,
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitPathwayApplication({
|
await submitPathwayApplication({
|
||||||
values,
|
values,
|
||||||
@@ -235,7 +259,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
`[opportunities] application from /${locale} was rejected: ${error.status} ${error.message}`,
|
`[opportunities] application from /${locale} was rejected: ${error.status} ${error.message}`,
|
||||||
);
|
);
|
||||||
return data(
|
return data(
|
||||||
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
|
{
|
||||||
|
ok: false,
|
||||||
|
errors: {},
|
||||||
|
values,
|
||||||
|
failed: true,
|
||||||
|
verificationFailed: false,
|
||||||
|
} satisfies ActionResult,
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -678,13 +708,15 @@ function ApplicationForm({
|
|||||||
result: ActionResult | undefined;
|
result: ActionResult | undefined;
|
||||||
route: ApplicationRoute;
|
route: ApplicationRoute;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { locale, t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const isSubmitting = navigation.state === "submitting";
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
|
||||||
const errors = result && !result.ok ? result.errors : {};
|
const errors = result && !result.ok ? result.errors : {};
|
||||||
const values = result && !result.ok ? result.values : EMPTY_VALUES;
|
const values = result && !result.ok ? result.values : EMPTY_VALUES;
|
||||||
const hasFailed = result !== undefined && !result.ok && result.failed;
|
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
|
/* This form has no consent checkbox - only the event registration does - so
|
||||||
the "consent" code cannot occur here; it is narrowed away rather than
|
the "consent" code cannot occur here; it is narrowed away rather than
|
||||||
@@ -723,7 +755,7 @@ function ApplicationForm({
|
|||||||
className="flex flex-col gap-lg"
|
className="flex flex-col gap-lg"
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
{invalidFields.length > 0 || hasFailed ? (
|
{invalidFields.length > 0 || hasFailed || hasFailedVerification ? (
|
||||||
<div
|
<div
|
||||||
ref={summaryRef}
|
ref={summaryRef}
|
||||||
tabIndex={-1}
|
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"
|
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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{hasFailed ? (
|
{hasFailed ? (
|
||||||
@@ -887,6 +923,8 @@ function ApplicationForm({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<p className="body-sm max-w-[26rem] text-text-tertiary">
|
||||||
{copy.apply.acknowledgement}
|
{copy.apply.acknowledgement}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
Client-side JS is an enhancement only - the page works as a plain HTML form
|
Client-side JS is an enhancement only - the page works as a plain HTML form
|
||||||
post, and `useNavigation()` merely swaps the button into its loading state.
|
post, and `useNavigation()` merely swaps the button into its loading state.
|
||||||
|
The one exception is the Cloudflare Turnstile check, which needs scripting
|
||||||
|
whenever it is configured (see ~/components/ui/Turnstile).
|
||||||
|
|
||||||
Every message the reader can see is localised: field labels and the generic
|
Every message the reader can see is localised: field labels and the generic
|
||||||
errors come from the i18n catalogue, the page prose from the approved draft.
|
errors come from the i18n catalogue, the page prose from the approved draft.
|
||||||
@@ -41,9 +43,11 @@ import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from
|
|||||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||||
import { EuBadge } from "~/components/ui/EuBadge";
|
import { EuBadge } from "~/components/ui/EuBadge";
|
||||||
import { Input, Textarea } from "~/components/ui/Input";
|
import { Input, Textarea } from "~/components/ui/Input";
|
||||||
|
import { Turnstile } from "~/components/ui/Turnstile";
|
||||||
import { createTranslator, sectionPath, type Locale } from "~/i18n";
|
import { createTranslator, sectionPath, type Locale } from "~/i18n";
|
||||||
import { getServerEnv } from "~/lib/env.server";
|
import { getServerEnv } from "~/lib/env.server";
|
||||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||||
|
import { verifyTurnstile } from "~/lib/turnstile.server";
|
||||||
|
|
||||||
import type { Route } from "./+types/contact";
|
import type { Route } from "./+types/contact";
|
||||||
import type { CmsContactPage } from "./_page-kit/cms-types";
|
import type { CmsContactPage } from "./_page-kit/cms-types";
|
||||||
@@ -274,6 +278,20 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await verifyTurnstile(form))) {
|
||||||
|
const t = createTranslator(locale);
|
||||||
|
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
status: "failed" as const,
|
||||||
|
errors: {} as ContactErrors,
|
||||||
|
values,
|
||||||
|
formError: t("form.errorVerification"),
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createContactSubmission(
|
await createContactSubmission(
|
||||||
values,
|
values,
|
||||||
@@ -548,6 +566,8 @@ export default function Contact({
|
|||||||
{...(errors.message ? { error: errors.message } : {})}
|
{...(errors.message ? { error: errors.message } : {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Turnstile action="contact" locale={locale} resetKey={actionData} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
|
|
||||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||||
import { Input } from "~/components/ui/Input";
|
import { Input } from "~/components/ui/Input";
|
||||||
|
import { Turnstile } from "~/components/ui/Turnstile";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
import { safeHref } from "~/lib/safe-href";
|
import { safeHref } from "~/lib/safe-href";
|
||||||
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
|
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
|
||||||
import { isStrapiError, requireEntity } from "~/lib/strapi";
|
import { isStrapiError, requireEntity } from "~/lib/strapi";
|
||||||
|
import { verifyTurnstile } from "~/lib/turnstile.server";
|
||||||
|
|
||||||
import type { Route } from "./+types/events-calendar.$slug";
|
import type { Route } from "./+types/events-calendar.$slug";
|
||||||
import { getRouteCopy } from "./_events-opportunities/copy";
|
import { getRouteCopy } from "./_events-opportunities/copy";
|
||||||
@@ -161,6 +163,8 @@ type ActionResult =
|
|||||||
readonly values: RegistrationValues;
|
readonly values: RegistrationValues;
|
||||||
/** True when validation passed but the CMS refused the write. */
|
/** True when validation passed but the CMS refused the write. */
|
||||||
readonly failed: boolean;
|
readonly failed: boolean;
|
||||||
|
/** True when validation passed but the Turnstile check did not. */
|
||||||
|
readonly verificationFailed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
@@ -185,11 +189,30 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return data(
|
return data(
|
||||||
{ ok: false, errors, values, failed: false } satisfies ActionResult,
|
{
|
||||||
|
ok: false,
|
||||||
|
errors,
|
||||||
|
values,
|
||||||
|
failed: false,
|
||||||
|
verificationFailed: false,
|
||||||
|
} satisfies ActionResult,
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await verifyTurnstile(form))) {
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
errors: {},
|
||||||
|
values,
|
||||||
|
failed: false,
|
||||||
|
verificationFailed: true,
|
||||||
|
} satisfies ActionResult,
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let registrationState: "confirmed" | "waitlisted";
|
let registrationState: "confirmed" | "waitlisted";
|
||||||
try {
|
try {
|
||||||
/* Only the fields the CMS whitelist accepts are sent - see
|
/* Only the fields the CMS whitelist accepts are sent - see
|
||||||
@@ -215,7 +238,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
`[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`,
|
`[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`,
|
||||||
);
|
);
|
||||||
return data(
|
return data(
|
||||||
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
|
{
|
||||||
|
ok: false,
|
||||||
|
errors: {},
|
||||||
|
values,
|
||||||
|
failed: true,
|
||||||
|
verificationFailed: false,
|
||||||
|
} satisfies ActionResult,
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -630,8 +659,12 @@ function RegistrationSection({
|
|||||||
</Notice>
|
</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* A plain POST to this same URL: no fetcher, no client validation, and
|
{result && !result.ok && result.verificationFailed ? (
|
||||||
therefore nothing that stops working with scripting off. */}
|
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* A plain POST to this same URL: no fetcher and no client validation.
|
||||||
|
Only the Turnstile check, when configured, needs scripting. */}
|
||||||
<Form method="post" action={detailPath} className="flex flex-col gap-md">
|
<Form method="post" action={detailPath} className="flex flex-col gap-md">
|
||||||
<div className="grid gap-md sm:grid-cols-2">
|
<div className="grid gap-md sm:grid-cols-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -693,6 +726,12 @@ function RegistrationSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Turnstile
|
||||||
|
action="event-registration"
|
||||||
|
locale={locale}
|
||||||
|
resetKey={result}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-sm">
|
<div className="flex flex-wrap items-center gap-sm">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ services:
|
|||||||
STRAPI_TOKEN: ${STRAPI_TOKEN:-}
|
STRAPI_TOKEN: ${STRAPI_TOKEN:-}
|
||||||
# Canonical URLs, hreflang alternates and JSON-LD are built from this.
|
# Canonical URLs, hreflang alternates and JSON-LD are built from this.
|
||||||
SITE_URL: ${SITE_URL:-http://localhost:3000}
|
SITE_URL: ${SITE_URL:-http://localhost:3000}
|
||||||
|
# Cloudflare Turnstile on the public forms - off unless both are set.
|
||||||
|
TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
|
||||||
|
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Panton and the Arabic faces are licensed and deliberately excluded from
|
# Panton and the Arabic faces are licensed and deliberately excluded from
|
||||||
# the image (.dockerignore). Mounting the host folder read-only lets a
|
# the image (.dockerignore). Mounting the host folder read-only lets a
|
||||||
|
|||||||
Reference in New Issue
Block a user