feat(site-builder): webhook destination controls on the contact form
ContactForm gains four craft props (destinationType, webhookUrl, webhookSecretId, webhookAuthMode), all present in craft.props defaults so FormStylePanel's `nodeProps.X !== undefined` gates actually render their controls. The controls live in FormStylePanel (RightPanel renders only GuidedStyles, so related.settings would be dead UI). relayFormWiring widens the marker to optionally carry type/url/secret/ authmode BETWEEN `id` and `recipient`, which is where FormRelayRewrite.php's parser looks. A marker with no type is byte-identical to what shipped before -- pinned by a test that diffs an explicit-email form against one with no destination props at all, since every already-published site depends on that shape continuing to provision an email endpoint. Every optional attribute value goes through one escaping site (markerAttr -> escapeAttr); type and authmode are additionally allowlisted, so a case-drifted "Bearer" reaches the relay as the exact literal it compares against instead of being silently downgraded to unsigned. The raw shared secret is never a prop: it is held in WebhookSecretField's local state, POSTed to /api/form-webhook-secret.php on blur, and only the returned opaque id is persisted. The field is write-only (set / replace / remove, never view) because the endpoint has no read route, and the endpoint's 429 cap message is surfaced verbatim so a customer can act on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
071f3447fd
commit
d1c57db967
8 files changed
+831
-9
No files matched your search
@@ -196,3 +196,83 @@ describe('ContactForm.craft.props includes animation/visibility defaults', () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Webhook destination (Task 10) ----------
|
||||
This file builds props inline rather than spreading a shared object, so
|
||||
`defaultProps` is introduced here for the destination cases only; every
|
||||
pre-existing test above is untouched. */
|
||||
const defaultProps = { fields: [] as any[], formAction: '#' };
|
||||
|
||||
describe('ContactForm.craft.props includes the destination defaults', () => {
|
||||
// The trap this pins: FormStylePanel renders each destination control behind
|
||||
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
|
||||
// invisible control and the whole feature looks like it does nothing.
|
||||
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
|
||||
expect(ContactForm.craft!.props).toMatchObject({
|
||||
destinationType: 'email',
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
});
|
||||
});
|
||||
|
||||
test('no craft prop holds a raw secret -- only an id', () => {
|
||||
const keys = Object.keys(ContactForm.craft!.props as object);
|
||||
expect(keys).toContain('webhookSecretId');
|
||||
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml destination marker', () => {
|
||||
test('email destination emits the legacy marker unchanged', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
|
||||
expect(out.html).toContain('<!--WHP-FORM');
|
||||
expect(out.html).toContain('recipient="a@example.com"');
|
||||
expect(out.html).not.toContain('type="webhook"');
|
||||
});
|
||||
|
||||
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
|
||||
// The guarantee every already-published site depends on: a marker with no
|
||||
// `type` still provisions an email endpoint, so its bytes must not drift by
|
||||
// so much as a space. Compared against a form that has no destination props
|
||||
// at all (a page saved before this feature existed).
|
||||
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
const explicit = toHtml(
|
||||
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
|
||||
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(explicit.html).toBe(legacy.html);
|
||||
// ...and the marker itself is the exact narrow shape, anchored.
|
||||
expect(legacy.html).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@example\.com" thankyou="\/thx"--><form /);
|
||||
});
|
||||
|
||||
test('webhook destination emits type, url, secret id and auth mode', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
|
||||
expect(out.html).toContain('type="webhook"');
|
||||
expect(out.html).toContain('url="https://hooks.example.com/x"');
|
||||
expect(out.html).toContain('secret="sec-1"');
|
||||
expect(out.html).toContain('authmode="bearer"');
|
||||
expect(out.html).toContain('recipient="fb@example.com"');
|
||||
});
|
||||
|
||||
test('a raw secret value is never emitted, only its id', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
|
||||
// Non-vacuous: the marker IS emitted (so there is something that could have
|
||||
// carried the secret) and carries the id, but not the value.
|
||||
expect(out.html).toContain('secret="sec-1"');
|
||||
expect(out.html).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
|
||||
expect(out.html).toContain('type="webhook"');
|
||||
expect(out.html).toContain('recipient=""');
|
||||
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(out.html).toContain('name="_gotcha"');
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,16 @@ interface ContactFormProps {
|
||||
inputBorder?: string;
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
|
||||
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
|
||||
with the url / secret id / auth mode below.
|
||||
There is deliberately NO raw-secret prop: craft props are serialised into
|
||||
the saved project and into published output, so the secret is POSTed to
|
||||
/api/form-webhook-secret.php and only the returned opaque id is kept. */
|
||||
destinationType?: 'email' | 'webhook';
|
||||
webhookUrl?: string;
|
||||
webhookSecretId?: string;
|
||||
webhookAuthMode?: 'signature' | 'bearer';
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
@@ -171,6 +181,12 @@ ContactForm.craft = {
|
||||
inputBorder: '#d1d5db',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
|
||||
// gates actually render the destination controls.
|
||||
destinationType: 'email',
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
@@ -233,7 +249,17 @@ ContactForm.craft = {
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
|
||||
// Only the webhook SECRET ID travels here -- there is no prop holding the raw
|
||||
// secret, by construction (see ContactFormProps).
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(
|
||||
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
|
||||
{
|
||||
type: props.destinationType,
|
||||
url: props.webhookUrl,
|
||||
secretId: props.webhookSecretId,
|
||||
authMode: props.webhookAuthMode,
|
||||
},
|
||||
);
|
||||
|
||||
// The form-sender relay delivers success via a full-page 303 redirect
|
||||
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
||||
|
||||
Reference in new issue
Block a user