@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/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
+ }