@vobs/captcha 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 +56 -0
- package/package.json +28 -0
- package/src/index.test.ts +417 -0
- package/src/index.ts +279 -0
- package/src/slider.ts +566 -0
- package/src/styles/styles.css +437 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addEventListener,
|
|
3
|
+
createElement,
|
|
4
|
+
createFragment,
|
|
5
|
+
createText,
|
|
6
|
+
effect,
|
|
7
|
+
insertBefore,
|
|
8
|
+
insertDynamic,
|
|
9
|
+
setAttribute,
|
|
10
|
+
setProperty,
|
|
11
|
+
type Signal,
|
|
12
|
+
type VobsNode
|
|
13
|
+
} from '@vobs/vobs'
|
|
14
|
+
|
|
15
|
+
export type CaptchaStatus = 'idle' | 'loading' | 'ready' | 'verifying' | 'verified' | 'expired' | 'error'
|
|
16
|
+
export type CaptchaAnswer = string | Readonly<Record<string, unknown>> | null | undefined
|
|
17
|
+
export type CaptchaValue<T> = T | Signal<T> | (() => T)
|
|
18
|
+
|
|
19
|
+
export interface CaptchaChallenge<T = unknown> {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly type?: string
|
|
22
|
+
readonly payload?: T
|
|
23
|
+
readonly expiresAt: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CaptchaSubmitContext<Challenge = unknown> {
|
|
27
|
+
readonly challenge: CaptchaChallenge<Challenge>
|
|
28
|
+
readonly status: CaptchaStatus
|
|
29
|
+
readonly disabled: boolean
|
|
30
|
+
submit(answer: CaptchaAnswer): void | PromiseLike<unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CaptchaChallengeRenderer<Challenge = unknown> =
|
|
34
|
+
(context: CaptchaSubmitContext<Challenge>) => VobsNode | null | undefined
|
|
35
|
+
|
|
36
|
+
export interface CaptchaProps<Challenge = unknown> {
|
|
37
|
+
readonly challenge?: CaptchaValue<CaptchaChallenge<Challenge> | null>
|
|
38
|
+
readonly status?: CaptchaValue<CaptchaStatus>
|
|
39
|
+
readonly error?: CaptchaValue<unknown>
|
|
40
|
+
readonly disabled?: CaptchaValue<boolean>
|
|
41
|
+
readonly renderChallenge?: CaptchaChallengeRenderer<Challenge>
|
|
42
|
+
readonly onSubmit?: (answer: CaptchaAnswer, challenge: CaptchaChallenge<Challenge>) => void | PromiseLike<unknown>
|
|
43
|
+
readonly onRetry?: () => void
|
|
44
|
+
readonly onCancel?: () => void
|
|
45
|
+
readonly retryLabel?: CaptchaValue<string>
|
|
46
|
+
readonly cancelLabel?: CaptchaValue<string>
|
|
47
|
+
readonly loadingLabel?: CaptchaValue<string>
|
|
48
|
+
readonly emptyLabel?: CaptchaValue<string>
|
|
49
|
+
readonly expiredLabel?: CaptchaValue<string>
|
|
50
|
+
readonly errorLabel?: CaptchaValue<string>
|
|
51
|
+
readonly label?: CaptchaValue<string>
|
|
52
|
+
readonly messagePlacement?: 'challenge' | 'footer' | 'none'
|
|
53
|
+
readonly keepChallengeOnError?: boolean
|
|
54
|
+
readonly keepChallengeOnLoading?: boolean
|
|
55
|
+
readonly keepChallengeOnVerifying?: boolean
|
|
56
|
+
readonly showRetry?: boolean
|
|
57
|
+
readonly showRetryWhileLoading?: boolean
|
|
58
|
+
readonly showCancel?: boolean
|
|
59
|
+
readonly retryIcon?: VobsNode | (() => VobsNode | null | undefined)
|
|
60
|
+
readonly class?: CaptchaValue<string>
|
|
61
|
+
readonly className?: CaptchaValue<string>
|
|
62
|
+
readonly id?: CaptchaValue<string>
|
|
63
|
+
readonly title?: CaptchaValue<string>
|
|
64
|
+
readonly role?: CaptchaValue<string>
|
|
65
|
+
readonly [name: `aria-${string}`]: string | number | boolean | undefined
|
|
66
|
+
readonly [name: `data-${string}`]: string | number | boolean | undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function Captcha<Challenge = unknown>(props: CaptchaProps<Challenge> = {}): VobsNode {
|
|
70
|
+
const root = createElement('section')
|
|
71
|
+
const challengeHost = createElement('div')
|
|
72
|
+
const messageHost = createElement('div')
|
|
73
|
+
const actions = createElement('div')
|
|
74
|
+
const header = createElement('div')
|
|
75
|
+
const headerText = createText('')
|
|
76
|
+
|
|
77
|
+
setAttribute(header, 'class', 'vobs-captcha__header')
|
|
78
|
+
setAttribute(challengeHost, 'class', 'vobs-captcha__challenge')
|
|
79
|
+
setAttribute(messageHost, 'class', 'vobs-captcha__message-host')
|
|
80
|
+
setAttribute(actions, 'class', 'vobs-captcha__actions')
|
|
81
|
+
insertBefore(root, header, null)
|
|
82
|
+
insertBefore(root, challengeHost, null)
|
|
83
|
+
insertBefore(root, messageHost, null)
|
|
84
|
+
insertBefore(root, actions, null)
|
|
85
|
+
|
|
86
|
+
insertDynamic(header, null, () => {
|
|
87
|
+
const label = readString(props, 'label')
|
|
88
|
+
if (!label) return null
|
|
89
|
+
setText(headerText, label)
|
|
90
|
+
return headerText
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
effect(() => {
|
|
94
|
+
const status = readValue<CaptchaStatus>(props, 'status', 'idle')
|
|
95
|
+
setAttribute(root, 'class', classNames('vobs-captcha', readString(props, 'class'), readString(props, 'className')))
|
|
96
|
+
setAttribute(root, 'data-status', status)
|
|
97
|
+
setOptionalAttribute(root, 'id', readString(props, 'id'))
|
|
98
|
+
setOptionalAttribute(root, 'title', readString(props, 'title'))
|
|
99
|
+
setOptionalAttribute(root, 'role', readString(props, 'role'))
|
|
100
|
+
setAttribute(root, 'aria-busy', String(status === 'loading' || status === 'verifying'))
|
|
101
|
+
if (readValue(props, 'disabled', false) || status === 'loading' || status === 'verifying') setAttribute(root, 'aria-disabled', 'true')
|
|
102
|
+
else root.removeAttribute('aria-disabled')
|
|
103
|
+
bindDataAndAriaAttributes(root, props)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
insertDynamic(challengeHost, null, () => renderChallenge(props))
|
|
107
|
+
insertDynamic(messageHost, null, () => renderMessage(props))
|
|
108
|
+
insertDynamic(actions, null, () => renderActions(props))
|
|
109
|
+
return root
|
|
110
|
+
|
|
111
|
+
function renderChallenge(input: CaptchaProps<Challenge>): VobsNode | null {
|
|
112
|
+
const status = readValue<CaptchaStatus>(input, 'status', 'idle')
|
|
113
|
+
const challenge = readValue<CaptchaChallenge<Challenge> | null>(input, 'challenge', null)
|
|
114
|
+
const disabled = readValue(input, 'disabled', false) || status === 'loading' || status === 'verifying'
|
|
115
|
+
const renderer = readCallback<CaptchaChallengeRenderer<Challenge> | undefined>(input, 'renderChallenge', undefined)
|
|
116
|
+
|
|
117
|
+
if (status === 'loading' && !readValue(input, 'keepChallengeOnLoading', false)) {
|
|
118
|
+
return createMessage(readValue(input, 'loadingLabel', 'Loading captcha…'))
|
|
119
|
+
}
|
|
120
|
+
if (status === 'verifying' && !readValue(input, 'keepChallengeOnVerifying', false)) {
|
|
121
|
+
return createMessage('Verifying captcha…')
|
|
122
|
+
}
|
|
123
|
+
if (status === 'expired') return createMessage(readValue(input, 'expiredLabel', 'This captcha has expired.'))
|
|
124
|
+
if (status === 'error' && !readValue(input, 'keepChallengeOnError', false)
|
|
125
|
+
&& readValue(input, 'messagePlacement', 'challenge') === 'challenge') {
|
|
126
|
+
return createMessage(readError(input) || readValue(input, 'errorLabel', 'Captcha verification failed.'))
|
|
127
|
+
}
|
|
128
|
+
if (status === 'error' && !readValue(input, 'keepChallengeOnError', false)) return null
|
|
129
|
+
if (!challenge) return createMessage(readValue(input, 'emptyLabel', 'Captcha is not ready.'))
|
|
130
|
+
if (!renderer) return createMessage('Provide renderChallenge to render this captcha challenge.')
|
|
131
|
+
|
|
132
|
+
return renderer({
|
|
133
|
+
challenge,
|
|
134
|
+
status,
|
|
135
|
+
disabled,
|
|
136
|
+
submit(answer) {
|
|
137
|
+
const onSubmit = readCallback<CaptchaProps<Challenge>['onSubmit'] | undefined>(input, 'onSubmit', undefined)
|
|
138
|
+
if (disabled || !onSubmit) return
|
|
139
|
+
return onSubmit(answer, challenge)
|
|
140
|
+
}
|
|
141
|
+
}) ?? createMessage(readValue(input, 'emptyLabel', 'Captcha is not ready.'))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderMessage(input: CaptchaProps<Challenge>): VobsNode | null {
|
|
145
|
+
const status = readValue<CaptchaStatus>(input, 'status', 'idle')
|
|
146
|
+
if (readValue<'challenge' | 'footer' | 'none'>(input, 'messagePlacement', 'challenge') !== 'footer') return null
|
|
147
|
+
if (status !== 'error') return null
|
|
148
|
+
return createMessage(readError(input) || readValue(input, 'errorLabel', 'Captcha verification failed.'))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderActions(input: CaptchaProps<Challenge>): VobsNode | null {
|
|
152
|
+
const status = readValue<CaptchaStatus>(input, 'status', 'idle')
|
|
153
|
+
const disabled = readValue(input, 'disabled', false) || status === 'loading' || status === 'verifying'
|
|
154
|
+
const retry = readCallback<(() => void) | undefined>(input, 'onRetry', undefined)
|
|
155
|
+
const cancel = readCallback<(() => void) | undefined>(input, 'onCancel', undefined)
|
|
156
|
+
if (!retry && !cancel) return null
|
|
157
|
+
return createFragment((parent, anchor) => {
|
|
158
|
+
const canRetry = status === 'idle' || status === 'expired' || status === 'error'
|
|
159
|
+
|| (status === 'loading' && readValue(input, 'showRetryWhileLoading', false))
|
|
160
|
+
|| (readValue(input, 'showRetry', false) && status !== 'loading' && status !== 'verifying')
|
|
161
|
+
if (retry && canRetry) {
|
|
162
|
+
insertBefore(parent, createActionButton(
|
|
163
|
+
readValue(input, 'retryLabel', 'Retry'),
|
|
164
|
+
readCallback(input, 'retryIcon', undefined),
|
|
165
|
+
retry,
|
|
166
|
+
disabled,
|
|
167
|
+
'vobs-captcha__action--retry'
|
|
168
|
+
), anchor)
|
|
169
|
+
}
|
|
170
|
+
const canCancel = status === 'loading' || status === 'verifying' || readValue(input, 'showCancel', false)
|
|
171
|
+
if (cancel && canCancel) {
|
|
172
|
+
insertBefore(parent, createActionButton(
|
|
173
|
+
readValue(input, 'cancelLabel', 'Cancel'),
|
|
174
|
+
undefined,
|
|
175
|
+
cancel,
|
|
176
|
+
disabled,
|
|
177
|
+
'vobs-captcha__action--cancel'
|
|
178
|
+
), anchor)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function createActionButton(
|
|
184
|
+
label: string,
|
|
185
|
+
icon: VobsNode | (() => VobsNode | null | undefined) | undefined,
|
|
186
|
+
handler: () => void,
|
|
187
|
+
disabled: boolean,
|
|
188
|
+
variant: string
|
|
189
|
+
): Element {
|
|
190
|
+
const button = createElement('button')
|
|
191
|
+
setAttribute(button, 'class', `vobs-captcha__action ${variant}`)
|
|
192
|
+
setAttribute(button, 'type', 'button')
|
|
193
|
+
setAttribute(button, 'aria-label', label)
|
|
194
|
+
setProperty(button, 'disabled', disabled)
|
|
195
|
+
if (icon !== undefined) {
|
|
196
|
+
const iconHost = createElement('span')
|
|
197
|
+
setAttribute(iconHost, 'class', 'vobs-captcha__action-icon')
|
|
198
|
+
const value = typeof icon === 'function' ? icon() : icon
|
|
199
|
+
if (value !== null && value !== undefined) insertBefore(iconHost, value, null)
|
|
200
|
+
insertBefore(button, iconHost, null)
|
|
201
|
+
}
|
|
202
|
+
insertBefore(button, createText(label), null)
|
|
203
|
+
addEventListener(button, 'click', handler)
|
|
204
|
+
return button
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createMessage(message: string): VobsNode {
|
|
208
|
+
const node = createElement('p')
|
|
209
|
+
setAttribute(node, 'class', 'vobs-captcha__message')
|
|
210
|
+
insertBefore(node, createText(message), null)
|
|
211
|
+
return node
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function setText(node: Text, value: string): void {
|
|
216
|
+
node.data = value
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { SliderCaptcha, analyzeSliderTrail, collectCaptchaDeviceSignals } from './slider'
|
|
220
|
+
export type {
|
|
221
|
+
CaptchaDeviceSignals,
|
|
222
|
+
SliderCaptchaDecoy,
|
|
223
|
+
SliderCaptchaChallenge,
|
|
224
|
+
SliderCaptchaPayload,
|
|
225
|
+
SliderCaptchaProps,
|
|
226
|
+
SliderCaptchaResult,
|
|
227
|
+
SliderTrailAnalysis,
|
|
228
|
+
SliderCaptchaValue,
|
|
229
|
+
SliderShape,
|
|
230
|
+
SliderTrailPoint
|
|
231
|
+
} from './slider'
|
|
232
|
+
|
|
233
|
+
function classNames(...values: readonly (string | undefined)[]): string {
|
|
234
|
+
return values.filter(Boolean).join(' ')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readValue<T>(props: object, name: string, fallback: T): T {
|
|
238
|
+
const value = Reflect.get(props, name)
|
|
239
|
+
if (value === undefined) return fallback
|
|
240
|
+
if (typeof value === 'function') return value() as T
|
|
241
|
+
if (isSignal<T>(value)) return value.value
|
|
242
|
+
return value as T
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readCallback<T>(props: object, name: string, fallback: T): T {
|
|
246
|
+
const value = Reflect.get(props, name)
|
|
247
|
+
return (value === undefined ? fallback : value) as T
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function readString(props: object, name: string): string | undefined {
|
|
251
|
+
const value = readValue<string | undefined>(props, name, undefined)
|
|
252
|
+
return typeof value === 'string' ? value : undefined
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readError(props: object): string | undefined {
|
|
256
|
+
const error = readValue<unknown>(props, 'error', undefined)
|
|
257
|
+
if (typeof error === 'string') return error
|
|
258
|
+
if (error instanceof Error) return error.message
|
|
259
|
+
return error == null ? undefined : String(error)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function setOptionalAttribute(node: Element, name: string, value: string | undefined): void {
|
|
263
|
+
if (value) setAttribute(node, name, value)
|
|
264
|
+
else node.removeAttribute(name)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function bindDataAndAriaAttributes(node: Element, props: object): void {
|
|
268
|
+
for (const name of Object.keys(props)) {
|
|
269
|
+
if (!name.startsWith('aria-') && !name.startsWith('data-')) continue
|
|
270
|
+
const value = readValue<unknown>(props, name, undefined)
|
|
271
|
+
if (value === undefined || value === null || value === false) node.removeAttribute(name)
|
|
272
|
+
else setAttribute(node, name, String(value))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isSignal<T>(value: unknown): value is Signal<T> {
|
|
277
|
+
return value !== null && typeof value === 'object'
|
|
278
|
+
&& 'value' in value && typeof (value as { dispose?: unknown }).dispose === 'function'
|
|
279
|
+
}
|