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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgKV8v9BUrQWXSUW4P
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-31 08:25:12 -07:00
1 parent 76f0ba9ca1
commit 92da689e62
7 files changed
+116 -3

No files matched your search

@@ -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 }) => (
<div
data-testid="captcha-mock"
aria-hidden="true"
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', maxWidth: 260,
border: '1px solid rgba(128,128,128,0.4)', borderRadius: 6, fontSize: 13,
fontFamily: 'Inter, sans-serif', opacity: 0.85, pointerEvents: 'none', ...style,
}}
>
<span style={{ width: 16, height: 16, border: '2px solid currentColor', borderRadius: 3, display: 'inline-block' }} />
I&apos;m not a robot <span style={{ marginLeft: 'auto', fontSize: 10, opacity: 0.6 }}>ALTCHA</span>
</div>
);
@@ -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<ContactFormProps> = ({
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<ContactFormProps> = ({
)}
</div>
))}
{captcha === true && <CaptchaMock />}
<button
type="submit"
style={{
@@ -197,6 +202,7 @@ ContactForm.craft = {
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
captcha: true,
animation: '',
animationDelay: '',
hideOnDesktop: false,
@@ -269,6 +275,7 @@ ContactForm.craft = {
secretId: props.webhookSecretId,
authMode: props.webhookAuthMode,
},
props.captcha,
);
// The form-sender relay delivers success via a full-page 303 redirect
+7 -1
View File
@@ -3,6 +3,7 @@ import { tokenRef } from '../../utils/tokens';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { CaptchaMock } from './CaptchaMock';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { sanitizeFormMethod } from '../../utils/escape';
@@ -10,6 +11,8 @@ interface FormContainerProps {
action?: string;
method?: 'GET' | 'POST';
recipientEmail?: string;
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
captcha?: boolean;
thankYouUrl?: string;
style?: CSSProperties;
children?: React.ReactNode;
@@ -24,6 +27,7 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
action = '#',
method = 'POST',
style = {},
captcha,
}) => {
const { connectors: { connect, drag } } = useNode();
@@ -46,6 +50,7 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
tag="div"
/>
{captcha === true && <CaptchaMock style={{ marginTop: 12 }} />}
</form>
);
};
@@ -59,6 +64,7 @@ FormContainer.craft = {
method: 'POST',
recipientEmail: '',
thankYouUrl: '',
captcha: true,
style: {
padding: '24px',
backgroundColor: tokenRef('bg'),
@@ -85,7 +91,7 @@ FormContainer.craft = {
padding: '24px',
...props.style,
});
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId, undefined, props.captcha);
const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
const body = honeypot + childrenHtml; // honeypot as first child
return {
+7 -1
View File
@@ -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 } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
@@ -25,6 +26,8 @@ interface SubscribeFormProps {
* published form then has no working action at all, which is the bug
* this prop exists to fix. */
recipientEmail?: string;
/** ALTCHA captcha (relay path). undefined = legacy design: no marker attr, stored flag untouched. */
captcha?: boolean;
thankYouUrl?: string;
animation?: string;
animationDelay?: string;
@@ -40,6 +43,7 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
buttonColor = tokenRef('primary'),
layout = 'inline',
style = {},
captcha,
}) => {
const {
connectors: { connect, drag },
@@ -116,6 +120,7 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
{buttonText}
</button>
</form>
{captcha === true && <CaptchaMock style={{ margin: '12px auto 0' }} />}
</div>
);
};
@@ -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}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
@@ -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(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="1" recipient="a@b.com" thankyou="\/thx"-->/);
});
test('explicit false emits captcha="0"', () => {
expect(run({ captcha: false })).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" captcha="0" recipient=/);
});
test('undefined (legacy design) is byte-identical to the legacy marker', () => {
const html = run({});
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
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(/^<!--WHP-FORM id="[^"]+"(?: [a-z]+="[^"]*")* captcha="1" recipient=/);
});
describe('new forms default to captcha on', () => {
test.each([ContactForm, SubscribeForm, FormContainer])('%#', (C: any) => {
expect(C.craft.props.captcha).toBe(true);
});
});
@@ -369,6 +369,22 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
</p>
</div>
)}
{nodeProps.captcha !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
data-testid="form-captcha"
checked={nodeProps.captcha === true}
onChange={(e) => setProp('captcha', e.target.checked)}
/>
Spam protection (captcha)
</label>
<p style={hintStyle}>
Adds a privacy-friendly ALTCHA check to the published form (served by the contact-form relay; visitors need JavaScript). Only applies when submissions are sent through the relay.
</p>
</div>
)}
{nodeProps.thankYouUrl !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Thank-you page URL (optional)</label>
+9 -1
View File
@@ -82,6 +82,10 @@ export interface RelayWiring {
* the marker/placeholder id deterministically and uniquely --
* see `scopeId` in ./escape. Falls back to a stable hash of the
* recipient/thankYouUrl/fallbackAction when omitted (never random).
* @param captcha ALTCHA captcha intent for the relay (whp: FormRelayRewrite). `true` emits
* `captcha="1"`, EXPLICIT `false` emits `captcha="0"` (user un-ticked: the
* relay must clear a stored flag), anything else (undefined/legacy) emits NO
* attribute, so legacy saved designs stay byte-identical and keep their stored flag.
* @param destination optional destination descriptor. Omitted, or `type: 'email'`,
* yields the LEGACY narrow marker byte for byte -- every
* already-published site depends on that shape continuing to
@@ -107,6 +111,7 @@ export function relayFormWiring(
fallbackAction: string | undefined,
nodeId?: string,
destination?: FormDestination,
captcha?: boolean,
): RelayWiring {
const destType = allowlist(destination?.type, DESTINATION_TYPES, 'email');
const isWebhook = destType === 'webhook';
@@ -136,9 +141,12 @@ export function relayFormWiring(
+ markerAttr('authmode', allowlist(destination?.authMode, AUTH_MODES, 'signature'))
: '';
const captchaAttr = captcha === true ? markerAttr('captcha', '1')
: captcha === false ? markerAttr('captcha', '0') : '';
return {
useRelay: true,
marker: `<!--WHP-FORM id="${fid}"${extraAttrs} recipient="${escapeAttr(recipientEmail || '')}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
marker: `<!--WHP-FORM id="${fid}"${extraAttrs}${captchaAttr} recipient="${escapeAttr(recipientEmail || '')}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
};