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