From 92da689e62e457a90655fc840d9e1657065cf7a2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 31 Oct 2026 08:25:12 -0700 Subject: [PATCH] feat(forms): captcha prop + panel toggle + marker attribute for relay forms captcha=true emits captcha="1", explicit false emits captcha="0", undefined emits nothing (legacy designs byte-identical). New forms default true. Editor shows an inert mock. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0185ukXgKV8v9BUrQWXSUW4P --- craft/src/components/forms/CaptchaMock.tsx | 21 ++++++++ craft/src/components/forms/ContactForm.tsx | 7 +++ craft/src/components/forms/FormContainer.tsx | 8 ++- craft/src/components/forms/SubscribeForm.tsx | 8 ++- .../components/forms/captcha.toHtml.test.ts | 49 +++++++++++++++++++ .../panels/right/styles/FormStylePanel.tsx | 16 ++++++ craft/src/utils/form-relay-wiring.ts | 10 +++- 7 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 craft/src/components/forms/CaptchaMock.tsx create mode 100644 craft/src/components/forms/captcha.toHtml.test.ts diff --git a/craft/src/components/forms/CaptchaMock.tsx b/craft/src/components/forms/CaptchaMock.tsx new file mode 100644 index 0000000..d8dfd49 --- /dev/null +++ b/craft/src/components/forms/CaptchaMock.tsx @@ -0,0 +1,21 @@ +import type { CSSProperties } from 'react'; + +/** + * INERT editor-only stand-in for the ALTCHA widget. It makes no network request and loads + * no script: the real widget is injected at PUBLISH time by the WHP panel + * (FormRelayRewrite.php) once the relay token is known. Never emitted by toHtml. + */ +export const CaptchaMock = ({ style }: { style?: CSSProperties }) => ( + +); diff --git a/craft/src/components/forms/ContactForm.tsx b/craft/src/components/forms/ContactForm.tsx index f70bd59..42a8958 100644 --- a/craft/src/components/forms/ContactForm.tsx +++ b/craft/src/components/forms/ContactForm.tsx @@ -1,6 +1,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; +import { CaptchaMock } from './CaptchaMock'; import { relayFormWiring } from '../../utils/form-relay-wiring'; import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape'; import { tokenRef } from '../../utils/tokens'; @@ -54,6 +55,8 @@ interface ContactFormProps { webhookUrl?: string; webhookSecretId?: string; webhookAuthMode?: 'signature' | 'bearer'; + /** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */ + captcha?: boolean; animation?: string; animationDelay?: string; hideOnDesktop?: boolean; @@ -73,6 +76,7 @@ export const ContactForm: UserComponent = ({ submitText = 'Send Message', submitColor = tokenRef('primary'), formAction = '#', + captcha, successMessage = 'Thank you! We\'ll get back to you soon.', style = {}, labelColor = tokenRef('text'), @@ -150,6 +154,7 @@ export const ContactForm: UserComponent = ({ )} ))} + {captcha === true && } + {captcha === true && } ); }; @@ -133,6 +138,7 @@ SubscribeForm.craft = { style: { backgroundColor: tokenRef('bg') }, recipientEmail: '', thankYouUrl: '', + captcha: true, animation: '', animationDelay: '', hideOnDesktop: false, @@ -202,7 +208,7 @@ SubscribeForm.craft = { // `formAction`-less relay wiring (fallbackAction undefined -> '#') keeps // the old no-recipient case visually identical (action="#") while making // the relay path actually functional once an admin sets an email. - const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId); + const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId, undefined, props.captcha); return { html: `${marker} diff --git a/craft/src/components/forms/captcha.toHtml.test.ts b/craft/src/components/forms/captcha.toHtml.test.ts new file mode 100644 index 0000000..d87d751 --- /dev/null +++ b/craft/src/components/forms/captcha.toHtml.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect } from 'vitest'; +import { ContactForm } from './ContactForm'; +import { SubscribeForm } from './SubscribeForm'; +import { FormContainer } from './FormContainer'; +import { relayFormWiring } from '../../utils/form-relay-wiring'; + +const cases: [string, any, any][] = [ + ['ContactForm', (ContactForm as any).toHtml, { fields: [] }], + ['SubscribeForm', (SubscribeForm as any).toHtml, {}], + ['FormContainer', (FormContainer as any).toHtml, {}], +]; +const base = { recipientEmail: 'a@b.com', thankYouUrl: '/thx' }; + +describe.each(cases)('%s captcha marker attribute', (_n, toHtml, extra) => { + const run = (p: any) => toHtml({ ...extra, ...base, ...p }, '', 'node1').html as string; + test('true emits captcha="1" between id and recipient', () => { + expect(run({ captcha: true })).toMatch(//); + }); + test('explicit false emits captcha="0"', () => { + expect(run({ captcha: false })).toMatch(//); + expect(html).not.toContain('captcha'); + expect(run({ captcha: undefined })).toBe(html); + }); + test('non-boolean junk emits nothing', () => { + expect(run({ captcha: 'yes' })).not.toContain('captcha'); + expect(run({ captcha: 1 })).not.toContain('captcha'); + }); + test('no relay (no recipient): no marker at all, even with captcha true', () => { + expect(toHtml({ ...extra, captcha: true }, '', 'n').html).not.toContain('WHP-FORM'); + }); + test('never emits widget markup (injected at publish)', () => { + expect(run({ captcha: true })).not.toMatch(/altcha-widget|challenge\.php|widget\.php/); + }); +}); + +test('webhook marker keeps captcha attr inside the strict-regex attribute zone', () => { + const m = relayFormWiring('', '', undefined, 'n', { type: 'webhook', url: 'https://x.y/h' }, true).marker; + expect(m).toMatch(/^`, + marker: ``, actionAttr: `__WHP_FORM_ACTION__${fid}__`, honeypot: ``, };