@fabrika/ui 0.7.0 → 0.8.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/README.md +2 -0
- package/package.json +12 -1
- package/src/Challenge.tsx +129 -0
package/README.md
CHANGED
|
@@ -21,3 +21,5 @@ const { Router, useRoute, href } = createRouter({
|
|
|
21
21
|
- `styles.css` — the theme tokens plus every component rule; the one file to import.
|
|
22
22
|
- `theme.css` — the tokens alone (`--fui-*`), light on `:root`, dark on `:root[data-theme='dark']`.
|
|
23
23
|
- Every plain `<a href>` inside `<Router>` navigates in-app; add `data-router="native"` to opt a link out.
|
|
24
|
+
- `@fabrika/ui/challenge`: `<Challenge url={challengeUrl(api.visitors.register)} onToken={…} />`, the ALTCHA widget of
|
|
25
|
+
a public procedure's challenge. It needs the optional peer `altcha`, and fills the form's `challengeToken` field.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "React component kit for the paved road: accessible admin components styled through CSS custom properties.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
"types": "./src/index.ts",
|
|
23
23
|
"default": "./src/index.ts"
|
|
24
24
|
},
|
|
25
|
+
"./challenge": {
|
|
26
|
+
"bun": "./src/Challenge.tsx",
|
|
27
|
+
"types": "./src/Challenge.tsx",
|
|
28
|
+
"default": "./src/Challenge.tsx"
|
|
29
|
+
},
|
|
25
30
|
"./styles.css": "./src/styles.css",
|
|
26
31
|
"./theme.css": "./src/theme.css"
|
|
27
32
|
},
|
|
@@ -30,7 +35,13 @@
|
|
|
30
35
|
"test": "bun test --isolate"
|
|
31
36
|
},
|
|
32
37
|
"peerDependencies": {
|
|
38
|
+
"altcha": "^3.4.0",
|
|
33
39
|
"react": "^19.0.0",
|
|
34
40
|
"react-dom": "^19.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"altcha": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
35
46
|
}
|
|
36
47
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The widget of a public procedure's ALTCHA challenge (`@fabrika/ui/challenge`): the visitor's browser fetches the
|
|
2
|
+
// challenge the application issued, solves its proof of work in workers, and the page sends the payload as the
|
|
3
|
+
// procedure's `challengeToken`. Nothing is loaded from a third party: the widget is bundled with the page.
|
|
4
|
+
//
|
|
5
|
+
// The one module of the kit with a runtime dependency, the optional peer `altcha`, which registers `<altcha-widget>`
|
|
6
|
+
// when this module loads; the kit's root never imports it.
|
|
7
|
+
|
|
8
|
+
import 'altcha'
|
|
9
|
+
// `<altcha-widget>` in React's JSX, with its attributes.
|
|
10
|
+
import type {} from 'altcha/types/react'
|
|
11
|
+
import { useEffect, useRef } from 'react'
|
|
12
|
+
import { classNames } from './class-names'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The texts the widget renders as text, each with the widget's English default. Its footer is not among them: the
|
|
16
|
+
* widget renders that one as HTML, so the kit keeps the widget's own.
|
|
17
|
+
*/
|
|
18
|
+
export interface ChallengeStrings {
|
|
19
|
+
readonly label: string
|
|
20
|
+
readonly verifying: string
|
|
21
|
+
readonly verified: string
|
|
22
|
+
readonly verificationRequired: string
|
|
23
|
+
readonly expired: string
|
|
24
|
+
readonly error: string
|
|
25
|
+
readonly reload: string
|
|
26
|
+
readonly waitAlert: string
|
|
27
|
+
readonly ariaLinkLabel: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChallengeProps {
|
|
31
|
+
/** Where the widget fetches its challenge: `challengeUrl(api.<procedure>)` of `@fabrika/app/client`. */
|
|
32
|
+
readonly url: string
|
|
33
|
+
/** The solved payload, or `null` once there is none again: the challenge expired or failed. */
|
|
34
|
+
readonly onToken?: (token: string | null) => void
|
|
35
|
+
/**
|
|
36
|
+
* The hidden input the widget adds to its form, which a native form posts: `challengeToken`, the field a procedure
|
|
37
|
+
* with a challenge reads.
|
|
38
|
+
*/
|
|
39
|
+
readonly name?: string
|
|
40
|
+
/** When the widget solves by itself; `off` (the default) waits for the visitor to tick it. */
|
|
41
|
+
readonly auto?: 'off' | 'onfocus' | 'onload' | 'onsubmit'
|
|
42
|
+
readonly strings?: Partial<ChallengeStrings>
|
|
43
|
+
readonly className?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The input a procedure with a challenge reads its token from; `@fabrika/app`'s `CHALLENGE_FIELD`. */
|
|
47
|
+
const CHALLENGE_FIELD = 'challengeToken'
|
|
48
|
+
|
|
49
|
+
/** Each set of strings is registered once with the widget, under a language code of its own. */
|
|
50
|
+
const registered = new Map<string, string>()
|
|
51
|
+
|
|
52
|
+
/** The fields of `ChallengeStrings`: only these reach the widget, whatever else an object passed as one carries. */
|
|
53
|
+
const TEXT_FIELDS: readonly (keyof ChallengeStrings)[] = [
|
|
54
|
+
'label',
|
|
55
|
+
'verifying',
|
|
56
|
+
'verified',
|
|
57
|
+
'verificationRequired',
|
|
58
|
+
'expired',
|
|
59
|
+
'error',
|
|
60
|
+
'reload',
|
|
61
|
+
'waitAlert',
|
|
62
|
+
'ariaLinkLabel',
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
function languageOf(given: Partial<ChallengeStrings> | undefined): string | undefined {
|
|
66
|
+
const strings = Object.fromEntries(TEXT_FIELDS.flatMap((field) => {
|
|
67
|
+
const value = given?.[field]
|
|
68
|
+
return typeof value === 'string' ? [[field, value]] : []
|
|
69
|
+
}))
|
|
70
|
+
if (Object.keys(strings).length === 0) {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
const key = JSON.stringify(Object.entries(strings).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
74
|
+
const known = registered.get(key)
|
|
75
|
+
if (known !== undefined) {
|
|
76
|
+
return known
|
|
77
|
+
}
|
|
78
|
+
const code = `x-fui-${registered.size + 1}`
|
|
79
|
+
const english = globalThis.$altcha.i18n.get('en') ?? {}
|
|
80
|
+
globalThis.$altcha.i18n.set(code, { ...english, ...strings })
|
|
81
|
+
registered.set(key, code)
|
|
82
|
+
return code
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A payload the widget reports, or `null` for every other state. */
|
|
86
|
+
function tokenOf(event: Event): string | null | undefined {
|
|
87
|
+
const detail: unknown = event instanceof CustomEvent ? event.detail : undefined
|
|
88
|
+
if (typeof detail !== 'object' || detail === null) {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
const state: unknown = Reflect.get(detail, 'state')
|
|
92
|
+
const payload: unknown = Reflect.get(detail, 'payload')
|
|
93
|
+
if (state === 'verified' && typeof payload === 'string') {
|
|
94
|
+
return payload
|
|
95
|
+
}
|
|
96
|
+
return state === 'expired' || state === 'error' || state === 'unverified' ? null : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The ALTCHA widget for one procedure's challenge. A challenge passes once: after a refused submission, give the
|
|
101
|
+
* component a new `key` so the visitor solves a fresh one.
|
|
102
|
+
*/
|
|
103
|
+
export function Challenge({ url, onToken, name = CHALLENGE_FIELD, auto = 'off', strings, className }: ChallengeProps) {
|
|
104
|
+
const host = useRef<HTMLElement>(null)
|
|
105
|
+
const report = useRef(onToken)
|
|
106
|
+
report.current = onToken
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const element = host.current
|
|
109
|
+
if (element === null) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const changed = (event: Event) => {
|
|
113
|
+
const token = tokenOf(event)
|
|
114
|
+
if (token !== undefined) report.current?.(token)
|
|
115
|
+
}
|
|
116
|
+
element.addEventListener('statechange', changed)
|
|
117
|
+
return () => element.removeEventListener('statechange', changed)
|
|
118
|
+
}, [])
|
|
119
|
+
return (
|
|
120
|
+
<altcha-widget
|
|
121
|
+
ref={host}
|
|
122
|
+
className={classNames('fui-challenge', className)}
|
|
123
|
+
challenge={url}
|
|
124
|
+
name={name}
|
|
125
|
+
auto={auto}
|
|
126
|
+
language={languageOf(strings)}
|
|
127
|
+
/>
|
|
128
|
+
)
|
|
129
|
+
}
|