@labyrinthe/otp-widget 0.0.0-stage → 1.2.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/CHANGELOG.md +80 -0
- package/LICENSE +21 -0
- package/README.fr.md +584 -0
- package/README.md +574 -2
- package/dist/index.cjs +887 -0
- package/dist/index.d.cts +215 -0
- package/dist/index.d.ts +215 -0
- package/dist/index.js +875 -0
- package/dist/labyrinthe-otp.min.js +70 -0
- package/dist/react.cjs +81 -0
- package/dist/react.d.cts +43 -0
- package/dist/react.d.ts +43 -0
- package/dist/react.js +66 -0
- package/dist/server.cjs +613 -0
- package/dist/server.d.cts +264 -0
- package/dist/server.d.ts +264 -0
- package/dist/server.js +610 -0
- package/package.json +131 -4
package/README.md
CHANGED
|
@@ -1,3 +1,575 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @labyrinthe/otp-widget
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@labyrinthe/otp-widget)
|
|
4
|
+
[](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/widget-otp.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
Drop-in phone verification for [Labyrinthe Connect](https://labyrinthe-rdc.com), the SMS and OTP platform for the
|
|
9
|
+
DR Congo and Central Africa: a `<labyrinthe-otp>` web component, a React wrapper, and the two server endpoints it
|
|
10
|
+
needs (Express, Next.js and any Fetch API runtime).
|
|
11
|
+
|
|
12
|
+
*Version française : [README.fr.md](README.fr.md).*
|
|
13
|
+
|
|
14
|
+
- **No API key in the browser.** The widget only calls *your* server; your server calls Labyrinthe with
|
|
15
|
+
[`@labyrinthe/connect`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/tree/main/node). `otp_id` and the
|
|
16
|
+
code never reach the browser.
|
|
17
|
+
- Phone step (DR Congo `+243` by default, same normalisation as the SDK), separate code boxes, paste,
|
|
18
|
+
`autocomplete="one-time-code"`, Web OTP API on Android, auto-submit, resend countdown, attempts left, every error
|
|
19
|
+
explained.
|
|
20
|
+
- Accessible (labels, `role="alert"`, live region, keyboard, focus management), responsive, light / dark, themable
|
|
21
|
+
with CSS custom properties and `::part()`, French (default) and English, every text overridable.
|
|
22
|
+
- Zero dependencies, Shadow DOM, **8.1 kB min+gzip** (standalone script), SSR-safe.
|
|
23
|
+
|
|
24
|
+
Version **1.2.0**, built against the API specification **1.3.0** and `@labyrinthe/connect` 1.2. What changed:
|
|
25
|
+
[CHANGELOG](CHANGELOG.md).
|
|
26
|
+
|
|
27
|
+
## Contents
|
|
28
|
+
|
|
29
|
+
1. [How it works](#how-it-works)
|
|
30
|
+
2. [Requirements](#requirements)
|
|
31
|
+
3. [Installation](#installation)
|
|
32
|
+
4. [Quick start](#quick-start)
|
|
33
|
+
5. [Server handlers](#server-handlers) (Express, Next.js, Hono, Bun, Deno, other languages)
|
|
34
|
+
6. [Server options](#server-options)
|
|
35
|
+
7. [The element](#the-element) (script tag, bundler, attributes, properties, events)
|
|
36
|
+
8. [React](#react)
|
|
37
|
+
9. [Styling and theming](#styling-and-theming)
|
|
38
|
+
10. [Texts and languages](#texts-and-languages)
|
|
39
|
+
11. [Accessibility](#accessibility)
|
|
40
|
+
12. [Errors](#errors) and [maintenance](#maintenance-503-service_unavailable)
|
|
41
|
+
13. [Security](#security)
|
|
42
|
+
14. [Test mode](#test-mode) · [Size](#size)
|
|
43
|
+
15. [Upgrading](#upgrading) · [Contributing](#contributing) · [License](#license)
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
A Labyrinthe API key (`lsk_live_…`) gives full access to your account: whoever holds it can send SMS on your credits.
|
|
48
|
+
It must stay on your server; `@labyrinthe/connect` even refuses to run in a browser. So the widget never talks to
|
|
49
|
+
Labyrinthe: it talks to two endpoints on **your** server, which this package also provides.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Browser Your server Labyrinthe API
|
|
53
|
+
<labyrinthe-otp> @labyrinthe/otp-widget/server (lsk_live_… / lsk_test_…)
|
|
54
|
+
│ POST /api/otp/send │ │
|
|
55
|
+
│ { phone, locale, send_id } │ │
|
|
56
|
+
│────────────────────────────────▶│ beforeSend() (your anti-abuse checks) │
|
|
57
|
+
│ │ client.otp.send({ phone, … }) ─────────▶│ SMS
|
|
58
|
+
│ │◀──────────── { otp_id, expires_at, … } │
|
|
59
|
+
│◀── { sent, phone_masked, … } ───│ Set-Cookie: encrypted { otp_id, phone } │
|
|
60
|
+
│ (no otp_id, no code) │ (HttpOnly, SameSite=Strict, Secure) │
|
|
61
|
+
│ POST /api/otp/verify { code } │ │
|
|
62
|
+
│────────────────────────────────▶│ decrypt cookie → otp_id │
|
|
63
|
+
│ │ client.otp.verify({ otpId, code }) ────▶│
|
|
64
|
+
│ │◀───── { verified, reason, attempts… } │
|
|
65
|
+
│ │ onVerified({ phone }) → your session │
|
|
66
|
+
│◀── { verified: true, data } ────│ cookie cleared │
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The browser only ever sees a masked number, the expiry, the attempts left and the resend delay.
|
|
70
|
+
|
|
71
|
+
## Requirements
|
|
72
|
+
|
|
73
|
+
| Part | Needs |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `<labyrinthe-otp>` | A browser with Custom Elements and Shadow DOM: Chrome / Edge 84+, Firefox 90+, Safari 15+ |
|
|
76
|
+
| `@labyrinthe/otp-widget/react` | React ≥ 18 (18 and 19, Next.js App Router) |
|
|
77
|
+
| `@labyrinthe/otp-widget/server` | Node.js ≥ 18 (`engines`), or any runtime with Fetch and Web Crypto (edge, Deno, Bun), and `@labyrinthe/connect` ^1.2.0 |
|
|
78
|
+
|
|
79
|
+
`@labyrinthe/connect` and `react` are optional peer dependencies: install them only where you use the server helper
|
|
80
|
+
or the React wrapper.
|
|
81
|
+
|
|
82
|
+
## Installation
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install @labyrinthe/otp-widget @labyrinthe/connect
|
|
86
|
+
# or
|
|
87
|
+
pnpm add @labyrinthe/otp-widget @labyrinthe/connect
|
|
88
|
+
# or
|
|
89
|
+
yarn add @labyrinthe/otp-widget @labyrinthe/connect
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Without a bundler, load the standalone script from a CDN (pin the version):
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
|
|
96
|
+
<!-- or https://unpkg.com/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js -->
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
To self-host it, the file is exported as `@labyrinthe/otp-widget/cdn` (see
|
|
100
|
+
[`examples/express/server.ts`](examples/express/server.ts)).
|
|
101
|
+
|
|
102
|
+
## Quick start
|
|
103
|
+
|
|
104
|
+
**1. Server.** Two endpoints that keep the key and `otp_id` (Express here; other frameworks
|
|
105
|
+
[below](#server-handlers)):
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import express from 'express';
|
|
109
|
+
import { Labyrinthe } from '@labyrinthe/connect';
|
|
110
|
+
import { createOtpHandlers } from '@labyrinthe/otp-widget/server';
|
|
111
|
+
|
|
112
|
+
const otp = createOtpHandlers({
|
|
113
|
+
client: new Labyrinthe(), // LABYRINTHE_API_KEY=lsk_test_… (sends nothing, bills nothing)
|
|
114
|
+
secret: process.env.LABYRINTHE_OTP_SECRET, // ≥ 32 random characters: openssl rand -hex 32
|
|
115
|
+
onVerified: async ({ phone, request }) => {
|
|
116
|
+
// The ONLY place where the number is trusted: log the user in here.
|
|
117
|
+
return { redirect: '/dashboard' }; // sent to the browser as `data`
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const app = express();
|
|
122
|
+
app.use(express.json());
|
|
123
|
+
app.post('/api/otp/send', otp.express.send);
|
|
124
|
+
app.post('/api/otp/verify', otp.express.verify);
|
|
125
|
+
app.listen(3000);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**2. Browser.**
|
|
129
|
+
|
|
130
|
+
```html
|
|
131
|
+
<script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
|
|
132
|
+
|
|
133
|
+
<labyrinthe-otp send-url="/api/otp/send" verify-url="/api/otp/verify"></labyrinthe-otp>
|
|
134
|
+
<script>
|
|
135
|
+
document.querySelector('labyrinthe-otp')
|
|
136
|
+
.addEventListener('labyrinthe-otp:verified', (e) => location.assign(e.detail.data.redirect));
|
|
137
|
+
</script>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Complete examples: [`examples/express`](examples/express) (static page + Express server) and
|
|
141
|
+
[`examples/nextjs`](examples/nextjs) (route handlers + client component).
|
|
142
|
+
|
|
143
|
+
## Server handlers
|
|
144
|
+
|
|
145
|
+
`createOtpHandlers(options)` returns:
|
|
146
|
+
|
|
147
|
+
| Member | Type | For |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `send`, `verify` | `(request: Request) => Promise<Response>` | Next.js App Router, Hono, Bun, Deno, edge runtimes |
|
|
150
|
+
| `express.send`, `express.verify` | `(req, res, next) => Promise<void>` | Express, Connect, plain `node:http` (with or without `express.json()`) |
|
|
151
|
+
|
|
152
|
+
Create the handlers **once** and share them between the two routes.
|
|
153
|
+
|
|
154
|
+
### Express
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
app.use(express.json({ limit: '4kb' })); // optional: the handlers also read the raw stream
|
|
158
|
+
app.post('/api/otp/send', otp.express.send);
|
|
159
|
+
app.post('/api/otp/verify', otp.express.verify);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Next.js (App Router)
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// lib/otp.ts — server only
|
|
166
|
+
import { Labyrinthe } from '@labyrinthe/connect';
|
|
167
|
+
import { createOtpHandlers } from '@labyrinthe/otp-widget/server';
|
|
168
|
+
|
|
169
|
+
export const otp = createOtpHandlers({
|
|
170
|
+
client: () => new Labyrinthe(), // created on first use, not at build time
|
|
171
|
+
secret: process.env.LABYRINTHE_OTP_SECRET,
|
|
172
|
+
cookie: { path: '/api/otp' }, // the cookie only goes to the two OTP routes
|
|
173
|
+
onVerified: async ({ phone, setCookie }) => {
|
|
174
|
+
const token = await createAppSession(phone); // your auth system
|
|
175
|
+
setCookie(`app_session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000`);
|
|
176
|
+
return { redirect: '/dashboard' };
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
// app/api/otp/send/route.ts
|
|
183
|
+
import { otp } from '../../../../lib/otp';
|
|
184
|
+
export const POST = otp.send;
|
|
185
|
+
|
|
186
|
+
// app/api/otp/verify/route.ts
|
|
187
|
+
import { otp } from '../../../../lib/otp';
|
|
188
|
+
export const POST = otp.verify;
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Keep `LABYRINTHE_API_KEY` and `LABYRINTHE_OTP_SECRET` in `.env.local`, never in a `NEXT_PUBLIC_…` variable. The
|
|
192
|
+
handlers run on the Node.js runtime and on the Edge runtime.
|
|
193
|
+
|
|
194
|
+
### Hono
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { Hono } from 'hono';
|
|
198
|
+
|
|
199
|
+
const app = new Hono();
|
|
200
|
+
app.post('/api/otp/send', (c) => otp.send(c.req.raw));
|
|
201
|
+
app.post('/api/otp/verify', (c) => otp.verify(c.req.raw));
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Bun and Deno
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
const handle = (request: Request): Promise<Response> | Response => {
|
|
208
|
+
const { pathname } = new URL(request.url);
|
|
209
|
+
if (pathname === '/api/otp/send') return otp.send(request);
|
|
210
|
+
if (pathname === '/api/otp/verify') return otp.verify(request);
|
|
211
|
+
return new Response('Not found', { status: 404 });
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
Bun.serve({ port: 3000, fetch: handle }); // Bun
|
|
215
|
+
// Deno.serve({ port: 3000 }, handle); // Deno
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Another backend language
|
|
219
|
+
|
|
220
|
+
The widget only needs two JSON endpoints, so any backend can serve it (for example with the PHP, Python, .NET or Java
|
|
221
|
+
SDK). The protocol (`ErrorBody`, `SendSuccessBody`, `VerifySuccessBody`, `VerifyFailureBody` types are exported):
|
|
222
|
+
|
|
223
|
+
| Request (from the widget) | Success response (200) |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `POST {send-url}` `{ "phone": "+243812345678", "locale": "fr", "send_id": "<32 hex>" }` | `{ "sent": true, "phone_masked": "+243 81•••••78", "expires_at": "…", "max_attempts": 5, "resend_after_seconds": 60, "code_length"?: 6, "code_type"?: "numeric" }` |
|
|
226
|
+
| `POST {verify-url}` `{ "code": "482913" }` | `{ "verified": true, "data"?: … }` or `{ "verified": false, "reason": "invalid_code", "attempts_left": 2 }` |
|
|
227
|
+
|
|
228
|
+
Errors: any status with `{ "error": { "code", "message", "retry_after_seconds"?, "admin_message"?, "until"? } }` and
|
|
229
|
+
the codes of [Errors](#errors). Requests are sent with `Content-Type: application/json`, the cookies of your origin
|
|
230
|
+
(`credentials`) and your `requestHeaders`. On your side: keep `otp_id` in the server session, derive the
|
|
231
|
+
`Idempotency-Key` of `otp/send` from `send_id` (the helper uses an HMAC with its secret, so the browser never chooses
|
|
232
|
+
the key), and trust the number only after `verified: true`.
|
|
233
|
+
|
|
234
|
+
## Server options
|
|
235
|
+
|
|
236
|
+
| Option | Default | Description |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| `client` | — | **Required.** A `Labyrinthe` instance, or a function returning one (called on first use). |
|
|
239
|
+
| `secret` | `LABYRINTHE_OTP_SECRET` | **Required**, ≥ 32 characters. Encrypts the cookie and derives idempotency keys. Not your API key. |
|
|
240
|
+
| `onVerified(ctx)` | — | **Required.** Called once the code is verified: `ctx.phone` (international, no `+`), `ctx.request` (Fetch `Request` or Express `req`), `ctx.setCookie(cookie)`, `ctx.responseHeaders`. Its return value is sent to the browser as `data` (JSON, non-secret). |
|
|
241
|
+
| `beforeSend(ctx)` | — | Your anti-abuse checks (`ctx.phone`, `ctx.request`): return `false` to refuse (403 `forbidden`). |
|
|
242
|
+
| `onError(error, { action })` | — | Upstream and unexpected errors (`action`: `'send'` or `'verify'`), for your logs. Never contains the key, the code or `otp_id`. |
|
|
243
|
+
| `store` | — (state in the cookie) | `{ get, set, delete }` (Redis, database…): the cookie then only carries a random session id. `memoryStore()` for development. |
|
|
244
|
+
| `cookie` | `{ name: 'labyrinthe_otp', path: '/', sameSite: 'Strict' }` | Also `domain` and `secure` (default: on HTTPS requests and when `NODE_ENV=production`). |
|
|
245
|
+
| `defaultCountryCode` | `'243'` | Added to local numbers. |
|
|
246
|
+
| `template` | account settings | SMS template, must contain `{code}`. |
|
|
247
|
+
| `locale` | widget's `locale`, then account settings | SMS language, `'fr'` or `'en'`. |
|
|
248
|
+
| `webOtpDomain` | — | Your site's host (`app.example.com`): the SMS ends with `@app.example.com #CODE` for the Web OTP API. Validated at start-up. |
|
|
249
|
+
| `stripAccents` | `false` | Remove accents from the SMS (GSM-7, often half the cost). |
|
|
250
|
+
| `resendAfterSeconds` | from the API, then 60 | Override of the countdown before *Resend* (the API still enforces the real delay). |
|
|
251
|
+
| `codeLength`, `codeType` | from the API | Overrides of the code shape. Only needed with an API older than 1.2.0. |
|
|
252
|
+
| `exposeTestCode` | `false` | Development only: sends the `test_code` of a `lsk_test_` key to the browser. Refused when `NODE_ENV=production`. |
|
|
253
|
+
| `allowedOrigins` | the request's own host | Origins allowed to call the endpoints (`Origin` / `Sec-Fetch-Site` checks). |
|
|
254
|
+
|
|
255
|
+
| Environment variable | Read by |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `LABYRINTHE_API_KEY`, `LABYRINTHE_BASE_URL` | `new Labyrinthe()` (`@labyrinthe/connect`) |
|
|
258
|
+
| `LABYRINTHE_OTP_SECRET` | `createOtpHandlers()` when `secret` is not given |
|
|
259
|
+
| `NODE_ENV` | `production` refuses `exposeTestCode` and makes the cookie `Secure` |
|
|
260
|
+
|
|
261
|
+
`createOtpHandlers()` throws at start-up for a missing or short `secret`, a missing `onVerified`, a `template` without
|
|
262
|
+
`{code}`, an invalid `webOtpDomain` or `defaultCountryCode`, and `exposeTestCode` in production.
|
|
263
|
+
|
|
264
|
+
## The element
|
|
265
|
+
|
|
266
|
+
### Script tag or bundler
|
|
267
|
+
|
|
268
|
+
```html
|
|
269
|
+
<!-- Standalone script (CDN or self-hosted): defines <labyrinthe-otp> and the global LabyrintheOtp -->
|
|
270
|
+
<script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// With a bundler (Vite, webpack, Next.js…): importing the package defines the element.
|
|
275
|
+
import '@labyrinthe/otp-widget';
|
|
276
|
+
// Importing it on a server (SSR) does nothing.
|
|
277
|
+
|
|
278
|
+
import { defineLabyrintheOtp } from '@labyrinthe/otp-widget';
|
|
279
|
+
defineLabyrintheOtp('my-otp'); // optional: register another tag name (idempotent)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```html
|
|
283
|
+
<labyrinthe-otp
|
|
284
|
+
send-url="/api/otp/send"
|
|
285
|
+
verify-url="/api/otp/verify"
|
|
286
|
+
locale="en"
|
|
287
|
+
country-code="243"
|
|
288
|
+
theme="dark"
|
|
289
|
+
></labyrinthe-otp>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Attributes
|
|
293
|
+
|
|
294
|
+
| Attribute | Default | Description |
|
|
295
|
+
|---|---|---|
|
|
296
|
+
| `send-url`, `verify-url` | `/api/otp/send`, `/api/otp/verify` | Your endpoints. |
|
|
297
|
+
| `locale` | `fr` | `fr` or `en`: texts of the widget, and SMS language unless the server sets one. |
|
|
298
|
+
| `country-code` | `243` | Default country code, editable by the user. |
|
|
299
|
+
| `phone` | — | Pre-filled number. |
|
|
300
|
+
| `code-length`, `code-type` | `6`, `numeric` | Code shape until the server says otherwise (it always does with the API ≥ 1.2.0). `code-type`: `numeric` or `alphanumeric`. |
|
|
301
|
+
| `theme` | automatic | `light` or `dark`; otherwise follows `prefers-color-scheme`. |
|
|
302
|
+
| `heading` | the `title` text | Title above the form; `""` hides it. |
|
|
303
|
+
| `credentials` | `same-origin` | `include` when your endpoints are on another origin (then `cookie: { sameSite: 'None', secure: true }` and `allowedOrigins` on the server). |
|
|
304
|
+
| `no-auto-submit` | — | Do not submit as soon as every box is filled. |
|
|
305
|
+
| `no-web-otp` | — | Do not use the Web OTP API. |
|
|
306
|
+
|
|
307
|
+
### Properties and methods
|
|
308
|
+
|
|
309
|
+
| Member | Description |
|
|
310
|
+
|---|---|
|
|
311
|
+
| `step` | Read-only: `'phone'`, `'code'` or `'success'`. |
|
|
312
|
+
| `texts` | Text overrides (object, merged with the locale defaults); `null` resets them. |
|
|
313
|
+
| `requestHeaders` | Extra headers sent to your endpoints (e.g. a CSRF token). |
|
|
314
|
+
| `reset()` | Back to the phone step, clearing the code. |
|
|
315
|
+
| `focus()` | Focuses the first focusable control inside (the Shadow DOM delegates focus). |
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
const widget = document.querySelector('labyrinthe-otp');
|
|
319
|
+
widget.requestHeaders = { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content };
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Events
|
|
323
|
+
|
|
324
|
+
Every event bubbles and crosses the Shadow DOM (`bubbles`, `composed`); its `detail` is typed (`OtpSentDetail`,
|
|
325
|
+
`OtpVerifiedDetail`, `OtpErrorDetail`). Constants: `EVENT_SENT`, `EVENT_VERIFIED`, `EVENT_ERROR`.
|
|
326
|
+
|
|
327
|
+
| Event | `detail` |
|
|
328
|
+
|---|---|
|
|
329
|
+
| `labyrinthe-otp:sent` | `{ phone, phoneMasked, expiresAt: Date, maxAttempts, resendAfterSeconds, testCode? }` — first send and every resend |
|
|
330
|
+
| `labyrinthe-otp:verified` | `{ phone, data }` — `data` is what your `onVerified()` returned |
|
|
331
|
+
| `labyrinthe-otp:error` | `{ code, message, status, retryAfterSeconds?, attemptsLeft?, adminMessage?, until?: Date }` — `message` is the text shown to the user, `status` `null` for local and network errors |
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
widget.addEventListener('labyrinthe-otp:error', (e) => analytics.track('otp_error', { code: e.detail.code }));
|
|
335
|
+
widget.addEventListener('labyrinthe-otp:verified', (e) => location.assign(e.detail.data.redirect));
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The `verified` event is for the user experience only (redirect, message): the server already trusted the number in
|
|
339
|
+
`onVerified()`. Never trust the browser event.
|
|
340
|
+
|
|
341
|
+
### Web OTP API (Android Chrome)
|
|
342
|
+
|
|
343
|
+
For the browser to fill the code automatically, the last line of the SMS must be `@your-domain.com #CODE`, where the
|
|
344
|
+
domain is exactly the host of the page showing the widget (top-level page, HTTPS). Set the server option
|
|
345
|
+
`webOtpDomain: 'shop.example.com'`: the API adds that line itself (do not put it in your `template`). The widget then
|
|
346
|
+
calls `navigator.credentials.get({ otp: { transport: ['sms'] } })` while the code boxes are shown (disable with
|
|
347
|
+
`no-web-otp`); typing, pasting and `autocomplete="one-time-code"` keep working everywhere.
|
|
348
|
+
|
|
349
|
+
## React
|
|
350
|
+
|
|
351
|
+
```tsx
|
|
352
|
+
'use client';
|
|
353
|
+
import { useRouter } from 'next/navigation';
|
|
354
|
+
import { LabyrintheOtp } from '@labyrinthe/otp-widget/react';
|
|
355
|
+
|
|
356
|
+
export function VerifyPhone() {
|
|
357
|
+
const router = useRouter();
|
|
358
|
+
return (
|
|
359
|
+
<LabyrintheOtp
|
|
360
|
+
sendUrl="/api/otp/send"
|
|
361
|
+
verifyUrl="/api/otp/verify"
|
|
362
|
+
locale="en"
|
|
363
|
+
texts={{ send: 'Text me a code' }}
|
|
364
|
+
onError={(error) => console.warn(error.code)}
|
|
365
|
+
onVerified={({ data }) => router.push((data as { redirect: string }).redirect)}
|
|
366
|
+
/>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
| Prop | Same as |
|
|
372
|
+
|---|---|
|
|
373
|
+
| `sendUrl`, `verifyUrl`, `locale`, `countryCode`, `phone`, `codeLength`, `codeType`, `theme`, `heading`, `credentials` | the attributes above |
|
|
374
|
+
| `autoSubmit` (default `true`), `webOtp` (default `true`) | `no-auto-submit`, `no-web-otp` when `false` |
|
|
375
|
+
| `texts`, `requestHeaders` | the properties above |
|
|
376
|
+
| `onSent`, `onVerified`, `onError` | the events above (receive the `detail`) |
|
|
377
|
+
| `className`, `style`, `id` | on the host element |
|
|
378
|
+
|
|
379
|
+
The component is a client component (`'use client'`), SSR-safe, works with React 18 and 19, and forwards a `ref` to
|
|
380
|
+
the `<labyrinthe-otp>` element (`ref.current.reset()`). Callbacks always use the latest props without re-subscribing.
|
|
381
|
+
|
|
382
|
+
## Styling and theming
|
|
383
|
+
|
|
384
|
+
The widget renders in a Shadow DOM: style it with CSS custom properties on the host and with `::part()`.
|
|
385
|
+
|
|
386
|
+
```css
|
|
387
|
+
labyrinthe-otp {
|
|
388
|
+
--lab-otp-primary: #008374; /* buttons, links, focus ring */
|
|
389
|
+
--lab-otp-primary-contrast: #fff; /* text on primary */
|
|
390
|
+
--lab-otp-accent: #F85A40; /* test-mode hint */
|
|
391
|
+
--lab-otp-error: #c2381f;
|
|
392
|
+
--lab-otp-success: #008374;
|
|
393
|
+
--lab-otp-bg: #fff;
|
|
394
|
+
--lab-otp-fg: #19201e;
|
|
395
|
+
--lab-otp-muted: #5b6763;
|
|
396
|
+
--lab-otp-border: #d5dcd9;
|
|
397
|
+
--lab-otp-field: #f8faf9; /* input background */
|
|
398
|
+
--lab-otp-radius: 10px;
|
|
399
|
+
--lab-otp-font: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
400
|
+
--lab-otp-cell-size: 44px; /* code boxes (36px under 360px wide) */
|
|
401
|
+
--lab-otp-gap: 8px;
|
|
402
|
+
}
|
|
403
|
+
labyrinthe-otp::part(root) { border: 0; box-shadow: 0 8px 30px rgb(0 0 0 / 0.08); }
|
|
404
|
+
labyrinthe-otp::part(cell) { font-size: 24px; }
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
The values above are the light defaults (teal `#008374`, coral `#F85A40`); `theme="dark"`, or the system dark mode
|
|
408
|
+
without `theme="light"`, switches to a dark palette. The host is `display: block` with `max-width: 420px`.
|
|
409
|
+
|
|
410
|
+
Parts: `root`, `title`, `phone-form`, `country-code`, `phone-input`, `button`, `send-button`, `code-form`, `cells`,
|
|
411
|
+
`cell`, `verify-button`, `link`, `change-button`, `resend-button`, `test-code`, `success`, `error`.
|
|
412
|
+
|
|
413
|
+
## Texts and languages
|
|
414
|
+
|
|
415
|
+
`locale="fr"` (default) or `locale="en"`. Override any text with the `texts` property (or React prop): top-level keys
|
|
416
|
+
and `errors` entries are merged with the locale defaults.
|
|
417
|
+
|
|
418
|
+
```js
|
|
419
|
+
widget.texts = {
|
|
420
|
+
send: 'Text me a code',
|
|
421
|
+
errors: { invalid_code: 'Nope, try again.' },
|
|
422
|
+
};
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
| Keys | Placeholders |
|
|
426
|
+
|---|---|
|
|
427
|
+
| `title`, `phoneLabel`, `countryCodeLabel`, `phonePlaceholder`, `phoneHint`, `send`, `sending`, `codeLabel`, `verify`, `verifying`, `changePhone`, `resend`, `resendAvailable`, `attemptsLeftOne`, `success` | — |
|
|
428
|
+
| `codeSentTo` | `{phone}` |
|
|
429
|
+
| `cellLabel` (accessible name of each box) | `{index}`, `{total}` |
|
|
430
|
+
| `resendIn`, `retryIn` | `{time}` (`1:15`, or `1:30:00` beyond an hour) |
|
|
431
|
+
| `attemptsLeftOther` | `{count}` |
|
|
432
|
+
| `testMode` | `{code}` |
|
|
433
|
+
| `errors.<code>` (see [Errors](#errors)) | `{time}` in `resend_too_soon`, `{count}` in `incomplete_code` |
|
|
434
|
+
|
|
435
|
+
`LOCALES` (the built-in French and English texts), `resolveTexts(locale, override)` and the `OtpWidgetTexts` /
|
|
436
|
+
`OtpWidgetTextsOverride` types are exported, for example to build a third language from the English texts.
|
|
437
|
+
|
|
438
|
+
## Accessibility
|
|
439
|
+
|
|
440
|
+
- Every field has a label; the code boxes are a `role="group"` with an accessible name per box (`cellLabel`).
|
|
441
|
+
- Errors are announced (`role="alert"`, re-announced when the same error repeats); progress (code sent, verified) goes
|
|
442
|
+
to a polite live region.
|
|
443
|
+
- Full keyboard use: arrows, Home, End and Backspace move between boxes, paste fills every box,
|
|
444
|
+
`enterkeyhint="done"`.
|
|
445
|
+
- Focus moves to the first empty box after a send, to *Resend* when a new code is needed, to the success message at
|
|
446
|
+
the end.
|
|
447
|
+
- Visible focus ring, `aria-invalid` on a refused number, `aria-busy` while waiting, transitions disabled with
|
|
448
|
+
`prefers-reduced-motion`.
|
|
449
|
+
|
|
450
|
+
## Errors
|
|
451
|
+
|
|
452
|
+
Error codes are stable, lower case, and shown with the localised text of `errors.<code>`. Upstream (Labyrinthe)
|
|
453
|
+
messages are never forwarded to the browser, except the maintenance message.
|
|
454
|
+
|
|
455
|
+
| `code` | HTTP from your server | When | Labyrinthe API error |
|
|
456
|
+
|---|---|---|---|
|
|
457
|
+
| `invalid_phone` | 422 | The number is invalid, blacklisted or opted out | `INVALID_PHONE`, `RECIPIENT_NOT_SENDABLE`, local validation |
|
|
458
|
+
| `resend_too_soon` | 429 | A code was sent too recently: countdown of `retry_after_seconds` | `OTP_RESEND_TOO_SOON` |
|
|
459
|
+
| `rate_limited` | 429 | Too many requests (number, account, key) or a send still in progress | `OTP_RATE_LIMITED`, `TOO_MANY_REQUESTS`, `IDEMPOTENCY_REQUEST_IN_PROGRESS` |
|
|
460
|
+
| `no_pending_verification` | 400 | No pending code for this browser (cookie missing, expired or tampered with) | `NOT_FOUND` on verify |
|
|
461
|
+
| `forbidden` | 403 | Refused by `beforeSend()` or by the origin check | — |
|
|
462
|
+
| `invalid_request` | 400, 405, 413, 415, 422 | Malformed request (not `POST`, not JSON, too large, bad `send_id` or code) | — |
|
|
463
|
+
| `service_unavailable` | 503 | Labyrinthe unreachable or misconfigured (key, credits, Sender ID), or [maintenance](#maintenance-503-service_unavailable) | `FEATURE_DISABLED`, `OTP_SERVICE_DISABLED`, others |
|
|
464
|
+
| `account_suspended` | 503 | Your Labyrinthe account is suspended | `ACCOUNT_SUSPENDED` |
|
|
465
|
+
| `send_failed` | 502 | The send failed unexpectedly on the Labyrinthe side, a code may have been sent | `IDEMPOTENCY_KEY_UNRESOLVED` |
|
|
466
|
+
| `server_error` | 500 | Unexpected error in your server (e.g. `onVerified()` threw) | — |
|
|
467
|
+
|
|
468
|
+
Refusals of a code are answered `200 { verified: false, reason }`: `invalid_code` (attempts left shown),
|
|
469
|
+
`expired`, `already_used`, `max_attempts_reached` (the last three ask for a new code). Widget-side codes, with
|
|
470
|
+
`status: null`: `network_error` (your server is unreachable) and `incomplete_code`.
|
|
471
|
+
|
|
472
|
+
`onError()` receives every upstream error except the ones caused by the user (`invalid_phone`, `resend_too_soon`,
|
|
473
|
+
`rate_limited`), plus unexpected ones: log the `requestId` of `@labyrinthe/connect` errors.
|
|
474
|
+
|
|
475
|
+
### Maintenance (503 `service_unavailable`)
|
|
476
|
+
|
|
477
|
+
When the Labyrinthe platform temporarily shuts down the API or the OTP functions (`503 FEATURE_DISABLED` /
|
|
478
|
+
`OTP_SERVICE_DISABLED`), nothing is sent and the helper answers:
|
|
479
|
+
|
|
480
|
+
```json
|
|
481
|
+
{ "error": { "code": "service_unavailable", "message": "The verification service is temporarily unavailable.",
|
|
482
|
+
"admin_message": "Maintenance until 14:00.", "until": "2026-11-06T14:00:00+00:00", "retry_after_seconds": 5400 } }
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- `admin_message`: the platform administrator's message, written for end users (trimmed, at most 500 characters),
|
|
486
|
+
when there is one — the only upstream text forwarded;
|
|
487
|
+
- `until` (ISO 8601) and `retry_after_seconds` + a `Retry-After` header: only when the end is scheduled;
|
|
488
|
+
- `onError()` is called.
|
|
489
|
+
|
|
490
|
+
The widget shows the administrator's message (as plain text) or `errors.service_unavailable`, followed by `retryIn`
|
|
491
|
+
("Try again in 1:30:00.") when the time left is known. The `labyrinthe-otp:error` event carries `adminMessage` and
|
|
492
|
+
`until` (`Date`). Nothing is retried automatically; the user can try again later.
|
|
493
|
+
|
|
494
|
+
## Security
|
|
495
|
+
|
|
496
|
+
- **The API key stays on your server.** The browser never sees it, nor `otp_id`, nor the code.
|
|
497
|
+
- `otp_id` and the phone number travel in an **AES-256-GCM encrypted and authenticated** cookie (key derived from
|
|
498
|
+
`secret` with HKDF-SHA-256), `HttpOnly`, `SameSite=Strict`, `Secure` on HTTPS. The browser can neither read nor
|
|
499
|
+
modify it. With `store`, the cookie only carries a random session id (encrypted too).
|
|
500
|
+
- **Trust the number in `onVerified()`**, on the server, never in the browser event.
|
|
501
|
+
- Endpoints accept `POST` + `application/json` only (a cross-site form cannot send it without a CORS preflight), bodies
|
|
502
|
+
up to 4 kB, and reject other origins (`Origin` / `Sec-Fetch-Site`; widen with `allowedOrigins`). Add a CSRF token
|
|
503
|
+
with `requestHeaders` if your app uses one. Responses are `Cache-Control: no-store`.
|
|
504
|
+
- **Every send is a billed SMS**: use `beforeSend()` for rate limits per IP or account, a captcha or an allow-list. The
|
|
505
|
+
API also limits sends per number and per account (`resend_too_soon`, `rate_limited`).
|
|
506
|
+
- **No double SMS.** Each send carries a random `send_id`, kept by the widget until the send succeeds: after a lost
|
|
507
|
+
response, the next click reuses it. The helper turns it into the `Idempotency-Key` with an HMAC of `secret` (the
|
|
508
|
+
browser never chooses the key sent to Labyrinthe), so the API replays the first send instead of sending a new SMS.
|
|
509
|
+
The SDK also retries network errors and 5xx with the same key.
|
|
510
|
+
- **Outcome unknown (`send_failed`).** After `409 IDEMPOTENCY_KEY_UNRESOLVED`, nothing is retried automatically; the
|
|
511
|
+
widget forgets its `send_id`, so a new code requested by the user is sent with a new key.
|
|
512
|
+
- The code is never returned to the browser, except `test_code` with a test key when you explicitly enable
|
|
513
|
+
`exposeTestCode` in development (refused in production; the API never returns it with a live key).
|
|
514
|
+
- The administrator's maintenance message is inserted as text, never as HTML.
|
|
515
|
+
|
|
516
|
+
## Test mode
|
|
517
|
+
|
|
518
|
+
With a `lsk_test_…` key nothing is sent and nothing is billed. In development, `exposeTestCode: true` shows the code
|
|
519
|
+
inside the widget ("Test mode: the code is …") and in the `sent` event, so the whole flow can be tried without a phone:
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
createOtpHandlers({
|
|
523
|
+
client: new Labyrinthe({ apiKey: 'lsk_test_…' }),
|
|
524
|
+
secret: process.env.LABYRINTHE_OTP_SECRET,
|
|
525
|
+
exposeTestCode: process.env.NODE_ENV === 'development',
|
|
526
|
+
onVerified: () => ({ ok: true }),
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
## Size
|
|
531
|
+
|
|
532
|
+
| File | gzip | |
|
|
533
|
+
|---|---|---|
|
|
534
|
+
| `dist/labyrinthe-otp.min.js` | 8.1 kB minified (7.2 kB brotli) | standalone `<script>`; CI budget 10 kB min+gzip (`npm run size`) |
|
|
535
|
+
| `dist/index.js` | 9.6 kB unminified | ESM for bundlers (minified by your build) |
|
|
536
|
+
| `dist/react.js` | 0.7 kB | React wrapper, without the element |
|
|
537
|
+
| `dist/server.js` | 6.8 kB unminified | server only, never shipped to the browser |
|
|
538
|
+
|
|
539
|
+
No runtime dependency. `sideEffects` is declared so bundlers keep the element definition and tree-shake the rest.
|
|
540
|
+
|
|
541
|
+
## Upgrading
|
|
542
|
+
|
|
543
|
+
- **1.1.x → 1.2.0** (API 1.3.0): update the peer dependency `@labyrinthe/connect` to `^1.2.0`. No code change.
|
|
544
|
+
Maintenance is answered as `service_unavailable` with `admin_message`, `until` and `Retry-After`; the error event
|
|
545
|
+
gains `adminMessage` and `until`; new text `retryIn` (add it if you pass a complete `OtpWidgetTexts` object);
|
|
546
|
+
durations of an hour or more are shown as `h:mm:ss`.
|
|
547
|
+
- **1.1.0 → 1.1.1**: requires `@labyrinthe/connect` ≥ 1.1.1 (short foreign numbers keep their country code). New error
|
|
548
|
+
code `send_failed`.
|
|
549
|
+
- **1.0.x → 1.1.0** (API 1.2.0): code length, alphabet and resend delay come from the API: remove `codeLength`,
|
|
550
|
+
`codeType` and `resendAfterSeconds` from the server options unless you need overrides. Real attempts left, new
|
|
551
|
+
`webOtpDomain` and `stripAccents` options, new error code `account_suspended`, and the send request gains `send_id`
|
|
552
|
+
(custom servers must accept it).
|
|
553
|
+
|
|
554
|
+
Details: [CHANGELOG](CHANGELOG.md).
|
|
555
|
+
|
|
556
|
+
## Contributing
|
|
557
|
+
|
|
558
|
+
Issues and pull requests go to the monorepo
|
|
559
|
+
[Labyrinthe-Team/labyrinthe-connect-sdks](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks)
|
|
560
|
+
(see [CONTRIBUTING.md](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/CONTRIBUTING.md)). In
|
|
561
|
+
`widget-otp/`:
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
npm ci
|
|
565
|
+
npm run check # lint + typecheck + tests (happy-dom, React, server) + build + check:dist + size + publint + attw
|
|
566
|
+
|
|
567
|
+
# integration tests against a local backend, with a TEST key only
|
|
568
|
+
LABYRINTHE_API_KEY=lsk_test_… LABYRINTHE_BASE_URL=http://localhost:8000 npm run test:integration
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
Security issues: email connect@labyrinthe-rdc.com rather than opening a public issue.
|
|
572
|
+
|
|
573
|
+
## License
|
|
574
|
+
|
|
575
|
+
MIT — see [LICENSE](LICENSE).
|