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:
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'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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">`,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user