@qaflo/forms-client 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/client.d.ts +27 -0
- package/client.js +89 -0
- package/form.d.ts +83 -0
- package/form.js +246 -0
- package/index.d.ts +6 -0
- package/index.js +20 -0
- package/messages.d.ts +25 -0
- package/messages.js +76 -0
- package/package.json +59 -0
- package/submit.d.ts +21 -0
- package/submit.js +118 -0
- package/turnstile.d.ts +38 -0
- package/turnstile.js +181 -0
- package/validate.d.ts +37 -0
- package/validate.js +91 -0
package/turnstile.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Turnstile lifecycle, as a React hook.
|
|
3
|
+
*
|
|
4
|
+
* EXPLICIT rendering, with a widget id per hook instance. Implicit rendering
|
|
5
|
+
* (letting the script scan the DOM for `.cf-turnstile`) breaks in two ways this
|
|
6
|
+
* codebase has already met: the script never rescans after a client-side
|
|
7
|
+
* navigation, so a form reached by clicking a link gets no widget at all; and
|
|
8
|
+
* two forms on one page share `turnstile.reset()` with no argument, so
|
|
9
|
+
* submitting one blanks the other's token. royalshadescurtains renders a form
|
|
10
|
+
* in its footer AND on its contact page, on the same page.
|
|
11
|
+
*
|
|
12
|
+
* Everything that touches `window` is inside an effect. These are Gatsby sites
|
|
13
|
+
* and they server-render this component.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
17
|
+
|
|
18
|
+
import { submitTo, DEFAULT_ENDPOINT } from './submit.js'
|
|
19
|
+
import { SECURITY_CHECK_PENDING } from './messages.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `render=explicit` is what stops the script auto-scanning the DOM. The markup
|
|
23
|
+
* below may still carry `class="cf-turnstile"` — inert decoration once the
|
|
24
|
+
* script is in explicit mode, and worth keeping so the widget is recognisable.
|
|
25
|
+
*/
|
|
26
|
+
export const TURNSTILE_SCRIPT =
|
|
27
|
+
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hidden from people, visible to bots. Styled away rather than `type="hidden"`:
|
|
31
|
+
* a bot reads the markup either way, but a password manager will not autofill a
|
|
32
|
+
* positioned-off-screen text input, and an autofilled honeypot is a real
|
|
33
|
+
* visitor's enquiry silently dropped.
|
|
34
|
+
*/
|
|
35
|
+
export const honeypotStyle = {
|
|
36
|
+
position: 'absolute',
|
|
37
|
+
left: '-9999px',
|
|
38
|
+
width: 1,
|
|
39
|
+
height: 1,
|
|
40
|
+
overflow: 'hidden',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One script tag for the whole page, however many forms mount.
|
|
44
|
+
/** @type {Promise<any> | null} */
|
|
45
|
+
let scriptPromise = null
|
|
46
|
+
|
|
47
|
+
/** @returns {Promise<any>} */
|
|
48
|
+
export function loadTurnstile() {
|
|
49
|
+
if (typeof window === 'undefined') return Promise.reject(new Error('turnstile: no window'))
|
|
50
|
+
if (window.turnstile) return Promise.resolve(window.turnstile)
|
|
51
|
+
if (!scriptPromise) {
|
|
52
|
+
scriptPromise = new Promise((resolve, reject) => {
|
|
53
|
+
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT}"]`)
|
|
54
|
+
const script = existing || document.createElement('script')
|
|
55
|
+
script.addEventListener('load', () => resolve(window.turnstile))
|
|
56
|
+
script.addEventListener('error', () => {
|
|
57
|
+
scriptPromise = null // let the next mount try again
|
|
58
|
+
reject(new Error('turnstile failed to load'))
|
|
59
|
+
})
|
|
60
|
+
if (!existing) {
|
|
61
|
+
script.src = TURNSTILE_SCRIPT
|
|
62
|
+
script.async = true
|
|
63
|
+
script.defer = true
|
|
64
|
+
document.head.appendChild(script)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return scriptPromise
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One widget per call.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ sitekey: string, action: string, endpoint?: string, appearance?: string, messages?: Record<string|number, string>, fallbackEmail?: string, theme?: string }} config
|
|
75
|
+
*/
|
|
76
|
+
export function useTurnstile(config) {
|
|
77
|
+
const { sitekey, action, endpoint = DEFAULT_ENDPOINT, appearance = 'interaction-only' } = config
|
|
78
|
+
|
|
79
|
+
const containerRef = useRef(/** @type {HTMLElement | null} */ (null))
|
|
80
|
+
const widgetId = useRef(/** @type {string | null} */ (null))
|
|
81
|
+
const [ready, setReady] = useState(false)
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
let cancelled = false
|
|
85
|
+
loadTurnstile()
|
|
86
|
+
.then((turnstile) => {
|
|
87
|
+
if (cancelled || !containerRef.current || widgetId.current !== null) return
|
|
88
|
+
widgetId.current = turnstile.render(containerRef.current, {
|
|
89
|
+
sitekey,
|
|
90
|
+
// MUST equal the site's site_key in the registry. The service compares
|
|
91
|
+
// it with the action Cloudflare reports, which is what stops a token
|
|
92
|
+
// solved on one of the thirty sites being spent on another.
|
|
93
|
+
action,
|
|
94
|
+
appearance,
|
|
95
|
+
...(config.theme ? { theme: config.theme } : {}),
|
|
96
|
+
})
|
|
97
|
+
if (!cancelled) setReady(true)
|
|
98
|
+
})
|
|
99
|
+
.catch(() => {
|
|
100
|
+
// Blocked script, or an ad blocker. Submitting will say the check has
|
|
101
|
+
// not finished rather than posting a request that can only 403.
|
|
102
|
+
})
|
|
103
|
+
return () => {
|
|
104
|
+
cancelled = true
|
|
105
|
+
if (widgetId.current !== null && typeof window !== 'undefined' && window.turnstile) {
|
|
106
|
+
try {
|
|
107
|
+
window.turnstile.remove(widgetId.current)
|
|
108
|
+
} catch {
|
|
109
|
+
/* already gone */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
widgetId.current = null
|
|
113
|
+
}
|
|
114
|
+
// sitekey/action/appearance are per-site constants; re-rendering the widget
|
|
115
|
+
// because an object literal was rebuilt would throw the token away.
|
|
116
|
+
}, [sitekey, action, appearance, config.theme])
|
|
117
|
+
|
|
118
|
+
/** Read this widget's token. `''` when it has not solved yet. */
|
|
119
|
+
const getToken = useCallback(() => {
|
|
120
|
+
if (widgetId.current === null || typeof window === 'undefined' || !window.turnstile) return ''
|
|
121
|
+
try {
|
|
122
|
+
return window.turnstile.getResponse(widgetId.current) || ''
|
|
123
|
+
} catch {
|
|
124
|
+
return ''
|
|
125
|
+
}
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tokens are single-use and expire in five minutes, so reset whichever way a
|
|
130
|
+
* submit went; a second attempt on the old token is a 403 that looks like a
|
|
131
|
+
* broken captcha. Scoped to THIS widget's id.
|
|
132
|
+
*/
|
|
133
|
+
const reset = useCallback(() => {
|
|
134
|
+
if (widgetId.current === null || typeof window === 'undefined' || !window.turnstile) return
|
|
135
|
+
try {
|
|
136
|
+
window.turnstile.reset(widgetId.current)
|
|
137
|
+
} catch {
|
|
138
|
+
/* widget not mounted; nothing to reset */
|
|
139
|
+
}
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Spread on an empty <div /> for a site that wants the markup handed to it.
|
|
144
|
+
* A site that owns its own markup uses `containerRef` directly instead.
|
|
145
|
+
*/
|
|
146
|
+
const widgetProps = {
|
|
147
|
+
ref: containerRef,
|
|
148
|
+
className: 'cf-turnstile',
|
|
149
|
+
'data-sitekey': sitekey,
|
|
150
|
+
'data-action': action,
|
|
151
|
+
'data-appearance': appearance,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Submit through THIS widget: read its token, refuse without one, post, and
|
|
156
|
+
* reset it afterwards. The refusal deliberately does not reset — the widget is
|
|
157
|
+
* probably still solving, and resetting would discard that.
|
|
158
|
+
*
|
|
159
|
+
* @param {Record<string, unknown>} body
|
|
160
|
+
*/
|
|
161
|
+
const submit = useCallback(
|
|
162
|
+
async (body) => {
|
|
163
|
+
const token = getToken()
|
|
164
|
+
if (!token) {
|
|
165
|
+
return { ok: false, status: 0, message: SECURITY_CHECK_PENDING }
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return await submitTo(endpoint, body, {
|
|
169
|
+
token,
|
|
170
|
+
messages: config.messages,
|
|
171
|
+
fallbackEmail: config.fallbackEmail,
|
|
172
|
+
})
|
|
173
|
+
} finally {
|
|
174
|
+
reset()
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
[endpoint, getToken, reset, config.messages, config.fallbackEmail],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return { containerRef, widgetProps, getToken, reset, submit, ready }
|
|
181
|
+
}
|
package/validate.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface FieldRule {
|
|
2
|
+
required?: boolean
|
|
3
|
+
min?: number
|
|
4
|
+
max?: number
|
|
5
|
+
email?: boolean
|
|
6
|
+
/** Used in the default messages; defaults to the field's own name. */
|
|
7
|
+
label?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type FieldMessage =
|
|
11
|
+
| string
|
|
12
|
+
| ((ctx: { value: string; limit: number; label: string; field: string }) => string)
|
|
13
|
+
|
|
14
|
+
export interface FieldMessages {
|
|
15
|
+
required?: FieldMessage
|
|
16
|
+
email?: FieldMessage
|
|
17
|
+
min?: FieldMessage
|
|
18
|
+
max?: FieldMessage
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `true` means "the rule the service applies to a field of this name". */
|
|
22
|
+
export type FieldSpec = Record<string, FieldRule | true>
|
|
23
|
+
|
|
24
|
+
/** A bare string overrides the `required` message. */
|
|
25
|
+
export type MessageSpec = Record<string, FieldMessages | string>
|
|
26
|
+
|
|
27
|
+
export declare const SERVER_RULES: Record<
|
|
28
|
+
'name' | 'email' | 'phone' | 'subject' | 'message',
|
|
29
|
+
FieldRule
|
|
30
|
+
>
|
|
31
|
+
export declare const DEFAULT_FIELDS: FieldSpec
|
|
32
|
+
|
|
33
|
+
export declare function validateFields(
|
|
34
|
+
values: Record<string, string>,
|
|
35
|
+
fields?: FieldSpec,
|
|
36
|
+
messages?: MessageSpec,
|
|
37
|
+
): Record<string, string>
|
package/validate.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side validation, mirroring the service's own rules.
|
|
3
|
+
*
|
|
4
|
+
* **The server is the authority** (`internal/api/validate.go`) and every rule
|
|
5
|
+
* here is duplicated there. This exists so that nothing the service would
|
|
6
|
+
* reject costs a network round trip — because a rejection there comes back as
|
|
7
|
+
* one banner at the bottom of the form with no indication of which field caused
|
|
8
|
+
* it. Nothing here is a security control; a browser can always be bypassed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The service's own limits, copied from internal/api/validate.go. A site that
|
|
13
|
+
* names no fields gets exactly these.
|
|
14
|
+
*/
|
|
15
|
+
export const SERVER_RULES = {
|
|
16
|
+
name: { required: true, min: 2, max: 100 },
|
|
17
|
+
email: { required: true, email: true, max: 254 },
|
|
18
|
+
phone: { max: 32 },
|
|
19
|
+
subject: { max: 150 },
|
|
20
|
+
message: { required: true, min: 10, max: 5000 },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The four a contact form almost always has. */
|
|
24
|
+
export const DEFAULT_FIELDS = {
|
|
25
|
+
name: SERVER_RULES.name,
|
|
26
|
+
email: SERVER_RULES.email,
|
|
27
|
+
phone: SERVER_RULES.phone,
|
|
28
|
+
message: SERVER_RULES.message,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Deliberately loose. Anything stricter starts rejecting addresses that exist,
|
|
32
|
+
// and the service parses the address properly anyway.
|
|
33
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
34
|
+
|
|
35
|
+
const DEFAULTS = {
|
|
36
|
+
required: (label) => `Please enter your ${label}.`,
|
|
37
|
+
email: () => 'That does not look like a valid email address.',
|
|
38
|
+
min: (label, n) => `That looks too short — please enter at least ${n} characters.`,
|
|
39
|
+
max: (label, n) => `That is too long — ${n} characters at most.`,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve one message. A site may override with a string or a function, so the
|
|
44
|
+
* dynamic ones ("three more characters") stay possible.
|
|
45
|
+
*
|
|
46
|
+
* @param {Record<string, any>|undefined} overrides
|
|
47
|
+
* @param {string} field
|
|
48
|
+
* @param {'required'|'email'|'min'|'max'} rule
|
|
49
|
+
*/
|
|
50
|
+
function message(overrides, field, rule, label, limit, value) {
|
|
51
|
+
const own = overrides && overrides[field]
|
|
52
|
+
const candidate = own && typeof own === 'object' ? own[rule] : rule === 'required' ? own : undefined
|
|
53
|
+
if (typeof candidate === 'function') return candidate({ value, limit, label, field })
|
|
54
|
+
if (typeof candidate === 'string') return candidate
|
|
55
|
+
return DEFAULTS[rule](label, limit)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Record<string, string>} values
|
|
60
|
+
* @param {Record<string, any>} fields
|
|
61
|
+
* @param {Record<string, any>} [messages]
|
|
62
|
+
* @returns {Record<string, string>} one entry per INVALID field
|
|
63
|
+
*/
|
|
64
|
+
export function validateFields(values, fields = DEFAULT_FIELDS, messages) {
|
|
65
|
+
/** @type {Record<string, string>} */
|
|
66
|
+
const errors = {}
|
|
67
|
+
|
|
68
|
+
for (const [field, raw] of Object.entries(fields)) {
|
|
69
|
+
// `true` means "use the service's own rule for a field of this name".
|
|
70
|
+
const rule = raw === true ? SERVER_RULES[field] || { required: true } : raw
|
|
71
|
+
if (!rule) continue
|
|
72
|
+
|
|
73
|
+
const label = rule.label || field
|
|
74
|
+
const value = (values[field] ?? '').trim()
|
|
75
|
+
|
|
76
|
+
if (!value) {
|
|
77
|
+
if (rule.required) errors[field] = message(messages, field, 'required', label, 0, value)
|
|
78
|
+
// An empty optional field is not short, and not a bad address. Saying so
|
|
79
|
+
// is how a form ends up scolding someone for leaving Company blank.
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (rule.email && !EMAIL_RE.test(value)) {
|
|
83
|
+
errors[field] = message(messages, field, 'email', label, 0, value)
|
|
84
|
+
} else if (rule.min && value.length < rule.min) {
|
|
85
|
+
errors[field] = message(messages, field, 'min', label, rule.min, value)
|
|
86
|
+
} else if (rule.max && value.length > rule.max) {
|
|
87
|
+
errors[field] = message(messages, field, 'max', label, rule.max, value)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return errors
|
|
91
|
+
}
|