@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.fr.md
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
# @labyrinthe/otp-widget
|
|
2
|
+
|
|
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
|
+
Vérification de numéro de téléphone prête à l'emploi pour [Labyrinthe Connect](https://labyrinthe-rdc.com), la
|
|
9
|
+
plateforme SMS et OTP pour la RD Congo et l'Afrique centrale : un composant web `<labyrinthe-otp>`, un wrapper React,
|
|
10
|
+
et les deux points de terminaison serveur dont il a besoin (Express, Next.js et tout runtime Fetch API).
|
|
11
|
+
|
|
12
|
+
*English version: [README.md](README.md).*
|
|
13
|
+
|
|
14
|
+
- **Aucune clé API dans le navigateur.** Le widget n'appelle que *votre* serveur ; votre serveur appelle Labyrinthe
|
|
15
|
+
avec [`@labyrinthe/connect`](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/tree/main/node). `otp_id` et
|
|
16
|
+
le code n'arrivent jamais dans le navigateur.
|
|
17
|
+
- Étape du numéro (RD Congo `+243` par défaut, même normalisation que le SDK), cases de code séparées, collage,
|
|
18
|
+
`autocomplete="one-time-code"`, Web OTP API sur Android, envoi automatique, compte à rebours de renvoi, essais
|
|
19
|
+
restants, chaque erreur expliquée.
|
|
20
|
+
- Accessible (libellés, `role="alert"`, région live, clavier, gestion du focus), responsive, clair / sombre,
|
|
21
|
+
personnalisable par propriétés CSS et `::part()`, français (par défaut) et anglais, chaque texte remplaçable.
|
|
22
|
+
- Aucune dépendance, Shadow DOM, **8,1 ko min+gzip** (script autonome), compatible SSR.
|
|
23
|
+
|
|
24
|
+
Version **1.2.0**, construite sur la spécification de l'API **1.3.0** et `@labyrinthe/connect` 1.2. Nouveautés :
|
|
25
|
+
[CHANGELOG](CHANGELOG.md) (en anglais).
|
|
26
|
+
|
|
27
|
+
## Sommaire
|
|
28
|
+
|
|
29
|
+
1. [Fonctionnement](#fonctionnement)
|
|
30
|
+
2. [Prérequis](#prérequis)
|
|
31
|
+
3. [Installation](#installation)
|
|
32
|
+
4. [Démarrage rapide](#démarrage-rapide)
|
|
33
|
+
5. [Handlers serveur](#handlers-serveur) (Express, Next.js, Hono, Bun, Deno, autres langages)
|
|
34
|
+
6. [Options serveur](#options-serveur)
|
|
35
|
+
7. [Élément web](#élément-web) (balise script, bundler, attributs, propriétés, événements)
|
|
36
|
+
8. [React](#react)
|
|
37
|
+
9. [Styles et thèmes](#styles-et-thèmes)
|
|
38
|
+
10. [Textes et langues](#textes-et-langues)
|
|
39
|
+
11. [Accessibilité](#accessibilité)
|
|
40
|
+
12. [Erreurs](#erreurs) et [maintenance](#maintenance-503-service_unavailable)
|
|
41
|
+
13. [Sécurité](#sécurité)
|
|
42
|
+
14. [Mode test](#mode-test) · [Taille](#taille)
|
|
43
|
+
15. [Mise à jour](#mise-à-jour) · [Contribuer](#contribuer) · [Licence](#licence)
|
|
44
|
+
|
|
45
|
+
## Fonctionnement
|
|
46
|
+
|
|
47
|
+
Une clé API Labyrinthe (`lsk_live_…`) donne un accès complet à votre compte : qui la détient peut envoyer des SMS sur
|
|
48
|
+
vos crédits. Elle doit rester sur votre serveur ; `@labyrinthe/connect` refuse même de s'exécuter dans un navigateur.
|
|
49
|
+
Le widget ne parle donc jamais à Labyrinthe : il parle à deux points de terminaison de **votre** serveur, que ce
|
|
50
|
+
package fournit aussi.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Navigateur Votre serveur API Labyrinthe
|
|
54
|
+
<labyrinthe-otp> @labyrinthe/otp-widget/server (lsk_live_… / lsk_test_…)
|
|
55
|
+
│ POST /api/otp/send │ │
|
|
56
|
+
│ { phone, locale, send_id } │ │
|
|
57
|
+
│────────────────────────────────▶│ beforeSend() (vos contrôles anti-abus) │
|
|
58
|
+
│ │ client.otp.send({ phone, … }) ─────────▶│ SMS
|
|
59
|
+
│ │◀──────────── { otp_id, expires_at, … } │
|
|
60
|
+
│◀── { sent, phone_masked, … } ───│ Set-Cookie : { otp_id, phone } chiffré │
|
|
61
|
+
│ (ni otp_id, ni code) │ (HttpOnly, SameSite=Strict, Secure) │
|
|
62
|
+
│ POST /api/otp/verify { code } │ │
|
|
63
|
+
│────────────────────────────────▶│ cookie déchiffré → otp_id │
|
|
64
|
+
│ │ client.otp.verify({ otpId, code }) ────▶│
|
|
65
|
+
│ │◀───── { verified, reason, attempts… } │
|
|
66
|
+
│ │ onVerified({ phone }) → votre session │
|
|
67
|
+
│◀── { verified: true, data } ────│ cookie effacé │
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Le navigateur ne voit jamais qu'un numéro masqué, l'expiration, les essais restants et le délai de renvoi.
|
|
71
|
+
|
|
72
|
+
## Prérequis
|
|
73
|
+
|
|
74
|
+
| Partie | Nécessite |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `<labyrinthe-otp>` | Un navigateur avec Custom Elements et Shadow DOM : Chrome / Edge 84+, Firefox 90+, Safari 15+ |
|
|
77
|
+
| `@labyrinthe/otp-widget/react` | React ≥ 18 (18 et 19, App Router de Next.js) |
|
|
78
|
+
| `@labyrinthe/otp-widget/server` | Node.js ≥ 18 (`engines`), ou tout runtime avec Fetch et Web Crypto (edge, Deno, Bun), et `@labyrinthe/connect` ^1.2.0 |
|
|
79
|
+
|
|
80
|
+
`@labyrinthe/connect` et `react` sont des dépendances pairs facultatives : installez-les seulement là où vous
|
|
81
|
+
utilisez le helper serveur ou le wrapper React.
|
|
82
|
+
|
|
83
|
+
## Installation
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm install @labyrinthe/otp-widget @labyrinthe/connect
|
|
87
|
+
# ou
|
|
88
|
+
pnpm add @labyrinthe/otp-widget @labyrinthe/connect
|
|
89
|
+
# ou
|
|
90
|
+
yarn add @labyrinthe/otp-widget @labyrinthe/connect
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Sans bundler, chargez le script autonome depuis un CDN (fixez la version) :
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
|
|
97
|
+
<!-- ou https://unpkg.com/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js -->
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Pour l'héberger vous-même, le fichier est exporté sous `@labyrinthe/otp-widget/cdn` (voir
|
|
101
|
+
[`examples/express/server.ts`](examples/express/server.ts)).
|
|
102
|
+
|
|
103
|
+
## Démarrage rapide
|
|
104
|
+
|
|
105
|
+
**1. Serveur.** Deux points de terminaison qui gardent la clé et `otp_id` (ici Express ; autres frameworks
|
|
106
|
+
[plus bas](#handlers-serveur)) :
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import express from 'express';
|
|
110
|
+
import { Labyrinthe } from '@labyrinthe/connect';
|
|
111
|
+
import { createOtpHandlers } from '@labyrinthe/otp-widget/server';
|
|
112
|
+
|
|
113
|
+
const otp = createOtpHandlers({
|
|
114
|
+
client: new Labyrinthe(), // LABYRINTHE_API_KEY=lsk_test_… (n'envoie rien, ne facture rien)
|
|
115
|
+
secret: process.env.LABYRINTHE_OTP_SECRET, // ≥ 32 caractères aléatoires : openssl rand -hex 32
|
|
116
|
+
onVerified: async ({ phone, request }) => {
|
|
117
|
+
// Le SEUL endroit où le numéro est digne de confiance : connectez l'utilisateur ici.
|
|
118
|
+
return { redirect: '/dashboard' }; // envoyé au navigateur dans `data`
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const app = express();
|
|
123
|
+
app.use(express.json());
|
|
124
|
+
app.post('/api/otp/send', otp.express.send);
|
|
125
|
+
app.post('/api/otp/verify', otp.express.verify);
|
|
126
|
+
app.listen(3000);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**2. Navigateur.**
|
|
130
|
+
|
|
131
|
+
```html
|
|
132
|
+
<script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
|
|
133
|
+
|
|
134
|
+
<labyrinthe-otp send-url="/api/otp/send" verify-url="/api/otp/verify"></labyrinthe-otp>
|
|
135
|
+
<script>
|
|
136
|
+
document.querySelector('labyrinthe-otp')
|
|
137
|
+
.addEventListener('labyrinthe-otp:verified', (e) => location.assign(e.detail.data.redirect));
|
|
138
|
+
</script>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Exemples complets : [`examples/express`](examples/express) (page statique + serveur Express) et
|
|
142
|
+
[`examples/nextjs`](examples/nextjs) (route handlers + composant client).
|
|
143
|
+
|
|
144
|
+
## Handlers serveur
|
|
145
|
+
|
|
146
|
+
`createOtpHandlers(options)` renvoie :
|
|
147
|
+
|
|
148
|
+
| Membre | Type | Pour |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `send`, `verify` | `(request: Request) => Promise<Response>` | App Router de Next.js, Hono, Bun, Deno, runtimes edge |
|
|
151
|
+
| `express.send`, `express.verify` | `(req, res, next) => Promise<void>` | Express, Connect, `node:http` (avec ou sans `express.json()`) |
|
|
152
|
+
|
|
153
|
+
Créez les handlers **une seule fois** et partagez-les entre les deux routes.
|
|
154
|
+
|
|
155
|
+
### Express
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
app.use(express.json({ limit: '4kb' })); // facultatif : les handlers lisent aussi le flux brut
|
|
159
|
+
app.post('/api/otp/send', otp.express.send);
|
|
160
|
+
app.post('/api/otp/verify', otp.express.verify);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Next.js (App Router)
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// lib/otp.ts — serveur uniquement
|
|
167
|
+
import { Labyrinthe } from '@labyrinthe/connect';
|
|
168
|
+
import { createOtpHandlers } from '@labyrinthe/otp-widget/server';
|
|
169
|
+
|
|
170
|
+
export const otp = createOtpHandlers({
|
|
171
|
+
client: () => new Labyrinthe(), // créé au premier appel, pas au build
|
|
172
|
+
secret: process.env.LABYRINTHE_OTP_SECRET,
|
|
173
|
+
cookie: { path: '/api/otp' }, // le cookie n'est envoyé qu'aux deux routes OTP
|
|
174
|
+
onVerified: async ({ phone, setCookie }) => {
|
|
175
|
+
const token = await createAppSession(phone); // votre système d'authentification
|
|
176
|
+
setCookie(`app_session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000`);
|
|
177
|
+
return { redirect: '/dashboard' };
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// app/api/otp/send/route.ts
|
|
184
|
+
import { otp } from '../../../../lib/otp';
|
|
185
|
+
export const POST = otp.send;
|
|
186
|
+
|
|
187
|
+
// app/api/otp/verify/route.ts
|
|
188
|
+
import { otp } from '../../../../lib/otp';
|
|
189
|
+
export const POST = otp.verify;
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Gardez `LABYRINTHE_API_KEY` et `LABYRINTHE_OTP_SECRET` dans `.env.local`, jamais dans une variable `NEXT_PUBLIC_…`.
|
|
193
|
+
Les handlers fonctionnent sur le runtime Node.js comme sur le runtime Edge.
|
|
194
|
+
|
|
195
|
+
### Hono
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { Hono } from 'hono';
|
|
199
|
+
|
|
200
|
+
const app = new Hono();
|
|
201
|
+
app.post('/api/otp/send', (c) => otp.send(c.req.raw));
|
|
202
|
+
app.post('/api/otp/verify', (c) => otp.verify(c.req.raw));
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Bun et Deno
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const handle = (request: Request): Promise<Response> | Response => {
|
|
209
|
+
const { pathname } = new URL(request.url);
|
|
210
|
+
if (pathname === '/api/otp/send') return otp.send(request);
|
|
211
|
+
if (pathname === '/api/otp/verify') return otp.verify(request);
|
|
212
|
+
return new Response('Not found', { status: 404 });
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
Bun.serve({ port: 3000, fetch: handle }); // Bun
|
|
216
|
+
// Deno.serve({ port: 3000 }, handle); // Deno
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Autre langage côté serveur
|
|
220
|
+
|
|
221
|
+
Le widget n'a besoin que de deux points de terminaison JSON : n'importe quel backend peut le servir (par exemple avec
|
|
222
|
+
le SDK PHP, Python, .NET ou Java). Le protocole (les types `ErrorBody`, `SendSuccessBody`, `VerifySuccessBody`,
|
|
223
|
+
`VerifyFailureBody` sont exportés) :
|
|
224
|
+
|
|
225
|
+
| Requête (du widget) | Réponse de succès (200) |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `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" }` |
|
|
228
|
+
| `POST {verify-url}` `{ "code": "482913" }` | `{ "verified": true, "data"?: … }` ou `{ "verified": false, "reason": "invalid_code", "attempts_left": 2 }` |
|
|
229
|
+
|
|
230
|
+
Erreurs : tout statut avec `{ "error": { "code", "message", "retry_after_seconds"?, "admin_message"?, "until"? } }` et
|
|
231
|
+
les codes de la section [Erreurs](#erreurs). Les requêtes partent avec `Content-Type: application/json`, les cookies de
|
|
232
|
+
votre origine (`credentials`) et vos `requestHeaders`. De votre côté : gardez `otp_id` dans la session serveur,
|
|
233
|
+
dérivez l'`Idempotency-Key` de `otp/send` à partir de `send_id` (le helper utilise un HMAC avec son secret : le
|
|
234
|
+
navigateur ne choisit jamais la clé) et ne faites confiance au numéro qu'après `verified: true`.
|
|
235
|
+
|
|
236
|
+
## Options serveur
|
|
237
|
+
|
|
238
|
+
| Option | Défaut | Description |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `client` | — | **Obligatoire.** Une instance `Labyrinthe`, ou une fonction qui en renvoie une (appelée au premier usage). |
|
|
241
|
+
| `secret` | `LABYRINTHE_OTP_SECRET` | **Obligatoire**, ≥ 32 caractères. Chiffre le cookie et dérive les clés d'idempotence. Ce n'est pas votre clé API. |
|
|
242
|
+
| `onVerified(ctx)` | — | **Obligatoire.** Appelée une fois le code vérifié : `ctx.phone` (international, sans `+`), `ctx.request` (`Request` Fetch ou `req` Express), `ctx.setCookie(cookie)`, `ctx.responseHeaders`. Sa valeur de retour part au navigateur dans `data` (JSON, non secret). |
|
|
243
|
+
| `beforeSend(ctx)` | — | Vos contrôles anti-abus (`ctx.phone`, `ctx.request`) : renvoyez `false` pour refuser (403 `forbidden`). |
|
|
244
|
+
| `onError(error, { action })` | — | Erreurs de Labyrinthe et inattendues (`action` : `'send'` ou `'verify'`), pour vos logs. Ne contient jamais la clé, le code ni `otp_id`. |
|
|
245
|
+
| `store` | — (état dans le cookie) | `{ get, set, delete }` (Redis, base de données…) : le cookie ne porte alors qu'un identifiant de session aléatoire. `memoryStore()` pour le développement. |
|
|
246
|
+
| `cookie` | `{ name: 'labyrinthe_otp', path: '/', sameSite: 'Strict' }` | Aussi `domain` et `secure` (par défaut : sur les requêtes HTTPS et quand `NODE_ENV=production`). |
|
|
247
|
+
| `defaultCountryCode` | `'243'` | Ajouté aux numéros locaux. |
|
|
248
|
+
| `template` | paramètres du compte | Modèle du SMS, doit contenir `{code}`. |
|
|
249
|
+
| `locale` | `locale` du widget, puis paramètres du compte | Langue du SMS, `'fr'` ou `'en'`. |
|
|
250
|
+
| `webOtpDomain` | — | Hôte de votre site (`app.example.com`) : le SMS se termine par `@app.example.com #CODE` pour la Web OTP API. Validé au démarrage. |
|
|
251
|
+
| `stripAccents` | `false` | Retire les accents du SMS (GSM-7, souvent moitié prix). |
|
|
252
|
+
| `resendAfterSeconds` | celui de l'API, puis 60 | Remplace le compte à rebours avant *Renvoyer* (l'API impose toujours le vrai délai). |
|
|
253
|
+
| `codeLength`, `codeType` | ceux de l'API | Remplacent la forme du code. Utiles seulement avec une API antérieure à 1.2.0. |
|
|
254
|
+
| `exposeTestCode` | `false` | Développement seulement : transmet au navigateur le `test_code` d'une clé `lsk_test_`. Refusé quand `NODE_ENV=production`. |
|
|
255
|
+
| `allowedOrigins` | l'hôte de la requête | Origines autorisées à appeler les points de terminaison (contrôles `Origin` / `Sec-Fetch-Site`). |
|
|
256
|
+
|
|
257
|
+
| Variable d'environnement | Lue par |
|
|
258
|
+
|---|---|
|
|
259
|
+
| `LABYRINTHE_API_KEY`, `LABYRINTHE_BASE_URL` | `new Labyrinthe()` (`@labyrinthe/connect`) |
|
|
260
|
+
| `LABYRINTHE_OTP_SECRET` | `createOtpHandlers()` sans `secret` |
|
|
261
|
+
| `NODE_ENV` | `production` refuse `exposeTestCode` et rend le cookie `Secure` |
|
|
262
|
+
|
|
263
|
+
`createOtpHandlers()` lève une erreur au démarrage pour un `secret` absent ou trop court, un `onVerified` absent, un
|
|
264
|
+
`template` sans `{code}`, un `webOtpDomain` ou un `defaultCountryCode` invalide, et `exposeTestCode` en production.
|
|
265
|
+
|
|
266
|
+
## Élément web
|
|
267
|
+
|
|
268
|
+
### Balise script ou bundler
|
|
269
|
+
|
|
270
|
+
```html
|
|
271
|
+
<!-- Script autonome (CDN ou auto-hébergé) : définit <labyrinthe-otp> et la variable globale LabyrintheOtp -->
|
|
272
|
+
<script src="https://cdn.jsdelivr.net/npm/@labyrinthe/otp-widget@1.2.0/dist/labyrinthe-otp.min.js" defer></script>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
// Avec un bundler (Vite, webpack, Next.js…) : importer le package définit l'élément.
|
|
277
|
+
import '@labyrinthe/otp-widget';
|
|
278
|
+
// L'importer côté serveur (SSR) ne fait rien.
|
|
279
|
+
|
|
280
|
+
import { defineLabyrintheOtp } from '@labyrinthe/otp-widget';
|
|
281
|
+
defineLabyrintheOtp('my-otp'); // facultatif : enregistrer un autre nom de balise (idempotent)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```html
|
|
285
|
+
<labyrinthe-otp
|
|
286
|
+
send-url="/api/otp/send"
|
|
287
|
+
verify-url="/api/otp/verify"
|
|
288
|
+
locale="en"
|
|
289
|
+
country-code="243"
|
|
290
|
+
theme="dark"
|
|
291
|
+
></labyrinthe-otp>
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Attributs
|
|
295
|
+
|
|
296
|
+
| Attribut | Défaut | Description |
|
|
297
|
+
|---|---|---|
|
|
298
|
+
| `send-url`, `verify-url` | `/api/otp/send`, `/api/otp/verify` | Vos points de terminaison. |
|
|
299
|
+
| `locale` | `fr` | `fr` ou `en` : textes du widget, et langue du SMS sauf si le serveur en impose une. |
|
|
300
|
+
| `country-code` | `243` | Indicatif par défaut, modifiable par l'utilisateur. |
|
|
301
|
+
| `phone` | — | Numéro pré-rempli. |
|
|
302
|
+
| `code-length`, `code-type` | `6`, `numeric` | Forme du code en attendant celle du serveur (toujours fournie avec l'API ≥ 1.2.0). `code-type` : `numeric` ou `alphanumeric`. |
|
|
303
|
+
| `theme` | automatique | `light` ou `dark` ; sinon suit `prefers-color-scheme`. |
|
|
304
|
+
| `heading` | le texte `title` | Titre au-dessus du formulaire ; `""` le masque. |
|
|
305
|
+
| `credentials` | `same-origin` | `include` si vos points de terminaison sont sur une autre origine (alors `cookie: { sameSite: 'None', secure: true }` et `allowedOrigins` côté serveur). |
|
|
306
|
+
| `no-auto-submit` | — | Ne pas envoyer dès que toutes les cases sont remplies. |
|
|
307
|
+
| `no-web-otp` | — | Ne pas utiliser la Web OTP API. |
|
|
308
|
+
|
|
309
|
+
### Propriétés et méthodes
|
|
310
|
+
|
|
311
|
+
| Membre | Description |
|
|
312
|
+
|---|---|
|
|
313
|
+
| `step` | Lecture seule : `'phone'`, `'code'` ou `'success'`. |
|
|
314
|
+
| `texts` | Textes remplacés (objet, fusionné avec ceux de la langue) ; `null` les réinitialise. |
|
|
315
|
+
| `requestHeaders` | En-têtes ajoutés aux appels vers vos points de terminaison (ex. jeton CSRF). |
|
|
316
|
+
| `reset()` | Retour à l'étape du numéro, code effacé. |
|
|
317
|
+
| `focus()` | Donne le focus au premier contrôle focalisable (le Shadow DOM délègue le focus). |
|
|
318
|
+
|
|
319
|
+
```js
|
|
320
|
+
const widget = document.querySelector('labyrinthe-otp');
|
|
321
|
+
widget.requestHeaders = { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content };
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Événements
|
|
325
|
+
|
|
326
|
+
Chaque événement remonte et traverse le Shadow DOM (`bubbles`, `composed`) ; son `detail` est typé (`OtpSentDetail`,
|
|
327
|
+
`OtpVerifiedDetail`, `OtpErrorDetail`). Constantes : `EVENT_SENT`, `EVENT_VERIFIED`, `EVENT_ERROR`.
|
|
328
|
+
|
|
329
|
+
| Événement | `detail` |
|
|
330
|
+
|---|---|
|
|
331
|
+
| `labyrinthe-otp:sent` | `{ phone, phoneMasked, expiresAt: Date, maxAttempts, resendAfterSeconds, testCode? }` — premier envoi et chaque renvoi |
|
|
332
|
+
| `labyrinthe-otp:verified` | `{ phone, data }` — `data` est ce qu'a renvoyé votre `onVerified()` |
|
|
333
|
+
| `labyrinthe-otp:error` | `{ code, message, status, retryAfterSeconds?, attemptsLeft?, adminMessage?, until?: Date }` — `message` est le texte affiché, `status` vaut `null` pour les erreurs locales et réseau |
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
widget.addEventListener('labyrinthe-otp:error', (e) => analytics.track('otp_error', { code: e.detail.code }));
|
|
337
|
+
widget.addEventListener('labyrinthe-otp:verified', (e) => location.assign(e.detail.data.redirect));
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
L'événement `verified` ne sert qu'à l'expérience utilisateur (redirection, message) : le serveur a déjà fait confiance
|
|
341
|
+
au numéro dans `onVerified()`. Ne faites jamais confiance à l'événement du navigateur.
|
|
342
|
+
|
|
343
|
+
### Web OTP API (Chrome Android)
|
|
344
|
+
|
|
345
|
+
Pour que le navigateur remplisse le code automatiquement, la dernière ligne du SMS doit être `@votre-domaine.com #CODE`,
|
|
346
|
+
où le domaine est exactement l'hôte de la page qui affiche le widget (page de premier niveau, HTTPS). Passez l'option
|
|
347
|
+
serveur `webOtpDomain: 'boutique.example.com'` : l'API ajoute cette ligne elle-même (ne la mettez pas dans votre
|
|
348
|
+
`template`). Le widget appelle alors `navigator.credentials.get({ otp: { transport: ['sms'] } })` tant que les cases du
|
|
349
|
+
code sont affichées (désactivable avec `no-web-otp`) ; la saisie, le collage et `autocomplete="one-time-code"`
|
|
350
|
+
continuent de fonctionner partout.
|
|
351
|
+
|
|
352
|
+
## React
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
'use client';
|
|
356
|
+
import { useRouter } from 'next/navigation';
|
|
357
|
+
import { LabyrintheOtp } from '@labyrinthe/otp-widget/react';
|
|
358
|
+
|
|
359
|
+
export function VerifyPhone() {
|
|
360
|
+
const router = useRouter();
|
|
361
|
+
return (
|
|
362
|
+
<LabyrintheOtp
|
|
363
|
+
sendUrl="/api/otp/send"
|
|
364
|
+
verifyUrl="/api/otp/verify"
|
|
365
|
+
locale="fr"
|
|
366
|
+
texts={{ send: 'Recevoir mon code' }}
|
|
367
|
+
onError={(error) => console.warn(error.code)}
|
|
368
|
+
onVerified={({ data }) => router.push((data as { redirect: string }).redirect)}
|
|
369
|
+
/>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
| Prop | Équivaut à |
|
|
375
|
+
|---|---|
|
|
376
|
+
| `sendUrl`, `verifyUrl`, `locale`, `countryCode`, `phone`, `codeLength`, `codeType`, `theme`, `heading`, `credentials` | les attributs ci-dessus |
|
|
377
|
+
| `autoSubmit` (`true` par défaut), `webOtp` (`true` par défaut) | `no-auto-submit`, `no-web-otp` quand `false` |
|
|
378
|
+
| `texts`, `requestHeaders` | les propriétés ci-dessus |
|
|
379
|
+
| `onSent`, `onVerified`, `onError` | les événements ci-dessus (reçoivent le `detail`) |
|
|
380
|
+
| `className`, `style`, `id` | sur l'élément hôte |
|
|
381
|
+
|
|
382
|
+
Le composant est un composant client (`'use client'`), compatible SSR, fonctionne avec React 18 et 19, et transmet une
|
|
383
|
+
`ref` vers l'élément `<labyrinthe-otp>` (`ref.current.reset()`). Les callbacks utilisent toujours les dernières props,
|
|
384
|
+
sans réabonnement.
|
|
385
|
+
|
|
386
|
+
## Styles et thèmes
|
|
387
|
+
|
|
388
|
+
Le widget s'affiche dans un Shadow DOM : personnalisez-le par propriétés CSS posées sur l'hôte et par `::part()`.
|
|
389
|
+
|
|
390
|
+
```css
|
|
391
|
+
labyrinthe-otp {
|
|
392
|
+
--lab-otp-primary: #008374; /* boutons, liens, anneau de focus */
|
|
393
|
+
--lab-otp-primary-contrast: #fff; /* texte sur la couleur principale */
|
|
394
|
+
--lab-otp-accent: #F85A40; /* encart du mode test */
|
|
395
|
+
--lab-otp-error: #c2381f;
|
|
396
|
+
--lab-otp-success: #008374;
|
|
397
|
+
--lab-otp-bg: #fff;
|
|
398
|
+
--lab-otp-fg: #19201e;
|
|
399
|
+
--lab-otp-muted: #5b6763;
|
|
400
|
+
--lab-otp-border: #d5dcd9;
|
|
401
|
+
--lab-otp-field: #f8faf9; /* fond des champs */
|
|
402
|
+
--lab-otp-radius: 10px;
|
|
403
|
+
--lab-otp-font: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
404
|
+
--lab-otp-cell-size: 44px; /* cases du code (36px sous 360px de large) */
|
|
405
|
+
--lab-otp-gap: 8px;
|
|
406
|
+
}
|
|
407
|
+
labyrinthe-otp::part(root) { border: 0; box-shadow: 0 8px 30px rgb(0 0 0 / 0.08); }
|
|
408
|
+
labyrinthe-otp::part(cell) { font-size: 24px; }
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Ces valeurs sont celles du thème clair (sarcelle `#008374`, corail `#F85A40`) ; `theme="dark"`, ou le mode sombre du
|
|
412
|
+
système sans `theme="light"`, bascule sur une palette sombre. L'hôte est en `display: block` avec `max-width: 420px`.
|
|
413
|
+
|
|
414
|
+
Parts : `root`, `title`, `phone-form`, `country-code`, `phone-input`, `button`, `send-button`, `code-form`, `cells`,
|
|
415
|
+
`cell`, `verify-button`, `link`, `change-button`, `resend-button`, `test-code`, `success`, `error`.
|
|
416
|
+
|
|
417
|
+
## Textes et langues
|
|
418
|
+
|
|
419
|
+
`locale="fr"` (par défaut) ou `locale="en"`. Remplacez n'importe quel texte avec la propriété `texts` (ou la prop
|
|
420
|
+
React) : les clés de premier niveau et les entrées de `errors` sont fusionnées avec les textes de la langue.
|
|
421
|
+
|
|
422
|
+
```js
|
|
423
|
+
widget.texts = {
|
|
424
|
+
send: 'Recevoir mon code',
|
|
425
|
+
errors: { invalid_code: 'Raté, réessayez.' },
|
|
426
|
+
};
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
| Clés | Variables |
|
|
430
|
+
|---|---|
|
|
431
|
+
| `title`, `phoneLabel`, `countryCodeLabel`, `phonePlaceholder`, `phoneHint`, `send`, `sending`, `codeLabel`, `verify`, `verifying`, `changePhone`, `resend`, `resendAvailable`, `attemptsLeftOne`, `success` | — |
|
|
432
|
+
| `codeSentTo` | `{phone}` |
|
|
433
|
+
| `cellLabel` (nom accessible de chaque case) | `{index}`, `{total}` |
|
|
434
|
+
| `resendIn`, `retryIn` | `{time}` (`1:15`, ou `1:30:00` au-delà d'une heure) |
|
|
435
|
+
| `attemptsLeftOther` | `{count}` |
|
|
436
|
+
| `testMode` | `{code}` |
|
|
437
|
+
| `errors.<code>` (voir [Erreurs](#erreurs)) | `{time}` dans `resend_too_soon`, `{count}` dans `incomplete_code` |
|
|
438
|
+
|
|
439
|
+
`LOCALES` (les textes français et anglais intégrés), `resolveTexts(locale, override)` et les types `OtpWidgetTexts` /
|
|
440
|
+
`OtpWidgetTextsOverride` sont exportés, par exemple pour construire une troisième langue à partir des textes anglais.
|
|
441
|
+
|
|
442
|
+
## Accessibilité
|
|
443
|
+
|
|
444
|
+
- Chaque champ a un libellé ; les cases du code forment un `role="group"` avec un nom accessible par case
|
|
445
|
+
(`cellLabel`).
|
|
446
|
+
- Les erreurs sont annoncées (`role="alert"`, de nouveau annoncées si la même erreur se répète) ; la progression (code
|
|
447
|
+
envoyé, vérifié) passe par une région live polie.
|
|
448
|
+
- Utilisation complète au clavier : flèches, Début, Fin et Retour arrière déplacent entre les cases, le collage remplit
|
|
449
|
+
toutes les cases, `enterkeyhint="done"`.
|
|
450
|
+
- Le focus va à la première case vide après un envoi, sur *Renvoyer* quand un nouveau code est nécessaire, sur le
|
|
451
|
+
message de succès à la fin.
|
|
452
|
+
- Anneau de focus visible, `aria-invalid` sur un numéro refusé, `aria-busy` pendant l'attente, transitions désactivées
|
|
453
|
+
avec `prefers-reduced-motion`.
|
|
454
|
+
|
|
455
|
+
## Erreurs
|
|
456
|
+
|
|
457
|
+
Les codes d'erreur sont stables, en minuscules, et affichés avec le texte localisé `errors.<code>`. Les messages de
|
|
458
|
+
Labyrinthe ne sont jamais transmis au navigateur, sauf le message de maintenance.
|
|
459
|
+
|
|
460
|
+
| `code` | HTTP de votre serveur | Quand | Erreur de l'API Labyrinthe |
|
|
461
|
+
|---|---|---|---|
|
|
462
|
+
| `invalid_phone` | 422 | Numéro invalide, en liste noire ou désinscrit | `INVALID_PHONE`, `RECIPIENT_NOT_SENDABLE`, validation locale |
|
|
463
|
+
| `resend_too_soon` | 429 | Code envoyé trop récemment : compte à rebours de `retry_after_seconds` | `OTP_RESEND_TOO_SOON` |
|
|
464
|
+
| `rate_limited` | 429 | Trop de demandes (numéro, compte, clé) ou envoi encore en cours | `OTP_RATE_LIMITED`, `TOO_MANY_REQUESTS`, `IDEMPOTENCY_REQUEST_IN_PROGRESS` |
|
|
465
|
+
| `no_pending_verification` | 400 | Aucun code en attente pour ce navigateur (cookie absent, expiré ou modifié) | `NOT_FOUND` à la vérification |
|
|
466
|
+
| `forbidden` | 403 | Refusé par `beforeSend()` ou par le contrôle d'origine | — |
|
|
467
|
+
| `invalid_request` | 400, 405, 413, 415, 422 | Requête malformée (pas `POST`, pas du JSON, trop grande, `send_id` ou code invalide) | — |
|
|
468
|
+
| `service_unavailable` | 503 | Labyrinthe injoignable ou mal configuré (clé, crédits, Sender ID), ou [maintenance](#maintenance-503-service_unavailable) | `FEATURE_DISABLED`, `OTP_SERVICE_DISABLED`, autres |
|
|
469
|
+
| `account_suspended` | 503 | Votre compte Labyrinthe est suspendu | `ACCOUNT_SUSPENDED` |
|
|
470
|
+
| `send_failed` | 502 | L'envoi a échoué de façon inattendue côté Labyrinthe, un code a pu partir | `IDEMPOTENCY_KEY_UNRESOLVED` |
|
|
471
|
+
| `server_error` | 500 | Erreur inattendue de votre serveur (ex. `onVerified()` a levé une erreur) | — |
|
|
472
|
+
|
|
473
|
+
Les refus d'un code sont répondus en `200 { verified: false, reason }` : `invalid_code` (essais restants affichés),
|
|
474
|
+
`expired`, `already_used`, `max_attempts_reached` (les trois derniers demandent un nouveau code). Codes propres au
|
|
475
|
+
widget, avec `status: null` : `network_error` (votre serveur est injoignable) et `incomplete_code`.
|
|
476
|
+
|
|
477
|
+
`onError()` reçoit toutes les erreurs de Labyrinthe sauf celles causées par l'utilisateur (`invalid_phone`,
|
|
478
|
+
`resend_too_soon`, `rate_limited`), plus les erreurs inattendues : journalisez le `requestId` des erreurs de
|
|
479
|
+
`@labyrinthe/connect`.
|
|
480
|
+
|
|
481
|
+
### Maintenance (503 `service_unavailable`)
|
|
482
|
+
|
|
483
|
+
Quand la plateforme Labyrinthe coupe temporairement l'API ou les fonctions OTP (`503 FEATURE_DISABLED` /
|
|
484
|
+
`OTP_SERVICE_DISABLED`), rien n'est envoyé et le helper répond :
|
|
485
|
+
|
|
486
|
+
```json
|
|
487
|
+
{ "error": { "code": "service_unavailable", "message": "The verification service is temporarily unavailable.",
|
|
488
|
+
"admin_message": "Maintenance jusqu'à 14 h.", "until": "2026-11-06T14:00:00+00:00", "retry_after_seconds": 5400 } }
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
- `admin_message` : le message de l'administrateur de la plateforme, écrit pour les utilisateurs finaux (nettoyé,
|
|
492
|
+
500 caractères au maximum), quand il y en a un — le seul texte de Labyrinthe transmis ;
|
|
493
|
+
- `until` (ISO 8601) et `retry_after_seconds` + un en-tête `Retry-After` : seulement quand la fin est programmée ;
|
|
494
|
+
- `onError()` est appelée.
|
|
495
|
+
|
|
496
|
+
Le widget affiche le message de l'administrateur (en texte brut) ou `errors.service_unavailable`, suivi de `retryIn`
|
|
497
|
+
(« Réessayez dans 1:30:00. ») quand le temps restant est connu. L'événement `labyrinthe-otp:error` porte `adminMessage`
|
|
498
|
+
et `until` (`Date`). Rien n'est relancé automatiquement ; l'utilisateur peut réessayer plus tard.
|
|
499
|
+
|
|
500
|
+
## Sécurité
|
|
501
|
+
|
|
502
|
+
- **La clé API reste sur votre serveur.** Le navigateur ne la voit jamais, ni `otp_id`, ni le code.
|
|
503
|
+
- `otp_id` et le numéro voyagent dans un cookie **chiffré et authentifié en AES-256-GCM** (clé dérivée de `secret` par
|
|
504
|
+
HKDF-SHA-256), `HttpOnly`, `SameSite=Strict`, `Secure` en HTTPS. Le navigateur ne peut ni le lire ni le modifier.
|
|
505
|
+
Avec `store`, le cookie ne porte qu'un identifiant de session aléatoire (lui aussi chiffré).
|
|
506
|
+
- **Faites confiance au numéro dans `onVerified()`**, côté serveur, jamais dans l'événement du navigateur.
|
|
507
|
+
- Les points de terminaison n'acceptent que `POST` + `application/json` (un formulaire d'un autre site ne peut pas
|
|
508
|
+
l'envoyer sans pré-vol CORS), des corps de 4 ko au plus, et refusent les autres origines (`Origin` /
|
|
509
|
+
`Sec-Fetch-Site` ; élargir avec `allowedOrigins`). Ajoutez un jeton CSRF avec `requestHeaders` si votre application
|
|
510
|
+
en utilise un. Les réponses sont en `Cache-Control: no-store`.
|
|
511
|
+
- **Chaque envoi est un SMS facturé** : utilisez `beforeSend()` pour limiter par IP ou par compte, ajouter un captcha
|
|
512
|
+
ou une liste d'autorisation. L'API limite aussi les envois par numéro et par compte (`resend_too_soon`,
|
|
513
|
+
`rate_limited`).
|
|
514
|
+
- **Jamais de double SMS.** Chaque envoi porte un `send_id` aléatoire, conservé par le widget jusqu'à la réussite de
|
|
515
|
+
l'envoi : après une réponse perdue, le clic suivant le réutilise. Le helper en dérive l'`Idempotency-Key` par un HMAC
|
|
516
|
+
de `secret` (le navigateur ne choisit jamais la clé envoyée à Labyrinthe) : l'API rejoue le premier envoi au lieu
|
|
517
|
+
d'envoyer un nouveau SMS. Le SDK relance aussi les erreurs réseau et les 5xx avec la même clé.
|
|
518
|
+
- **Issue inconnue (`send_failed`).** Après `409 IDEMPOTENCY_KEY_UNRESOLVED`, rien n'est relancé automatiquement ; le
|
|
519
|
+
widget oublie son `send_id`, si bien qu'un nouveau code demandé par l'utilisateur part avec une nouvelle clé.
|
|
520
|
+
- Le code n'est jamais renvoyé au navigateur, sauf `test_code` avec une clé de test quand vous activez explicitement
|
|
521
|
+
`exposeTestCode` en développement (refusé en production ; l'API ne le renvoie jamais avec une clé live).
|
|
522
|
+
- Le message de maintenance de l'administrateur est inséré comme texte, jamais comme HTML.
|
|
523
|
+
|
|
524
|
+
## Mode test
|
|
525
|
+
|
|
526
|
+
Avec une clé `lsk_test_…`, rien n'est envoyé ni facturé. En développement, `exposeTestCode: true` affiche le code dans
|
|
527
|
+
le widget (« Mode test : le code est … ») et dans l'événement `sent`, pour essayer tout le parcours sans téléphone :
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
createOtpHandlers({
|
|
531
|
+
client: new Labyrinthe({ apiKey: 'lsk_test_…' }),
|
|
532
|
+
secret: process.env.LABYRINTHE_OTP_SECRET,
|
|
533
|
+
exposeTestCode: process.env.NODE_ENV === 'development',
|
|
534
|
+
onVerified: () => ({ ok: true }),
|
|
535
|
+
});
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
## Taille
|
|
539
|
+
|
|
540
|
+
| Fichier | gzip | |
|
|
541
|
+
|---|---|---|
|
|
542
|
+
| `dist/labyrinthe-otp.min.js` | 8,1 ko minifié (7,2 ko brotli) | `<script>` autonome ; budget CI de 10 ko min+gzip (`npm run size`) |
|
|
543
|
+
| `dist/index.js` | 9,6 ko non minifié | ESM pour bundlers (minifié par votre build) |
|
|
544
|
+
| `dist/react.js` | 0,7 ko | wrapper React, sans l'élément |
|
|
545
|
+
| `dist/server.js` | 6,8 ko non minifié | serveur uniquement, jamais envoyé au navigateur |
|
|
546
|
+
|
|
547
|
+
Aucune dépendance à l'exécution. `sideEffects` est déclaré : les bundlers conservent la définition de l'élément et
|
|
548
|
+
éliminent le reste.
|
|
549
|
+
|
|
550
|
+
## Mise à jour
|
|
551
|
+
|
|
552
|
+
- **1.1.x → 1.2.0** (API 1.3.0) : passez la dépendance pair `@labyrinthe/connect` à `^1.2.0`. Aucun changement de
|
|
553
|
+
code. La maintenance est répondue en `service_unavailable` avec `admin_message`, `until` et `Retry-After` ;
|
|
554
|
+
l'événement d'erreur gagne `adminMessage` et `until` ; nouveau texte `retryIn` (à ajouter si vous passez un objet
|
|
555
|
+
`OtpWidgetTexts` complet) ; les durées d'une heure ou plus s'affichent en `h:mm:ss`.
|
|
556
|
+
- **1.1.0 → 1.1.1** : nécessite `@labyrinthe/connect` ≥ 1.1.1 (les numéros étrangers courts gardent leur indicatif).
|
|
557
|
+
Nouveau code d'erreur `send_failed`.
|
|
558
|
+
- **1.0.x → 1.1.0** (API 1.2.0) : longueur, alphabet du code et délai de renvoi viennent de l'API : retirez
|
|
559
|
+
`codeLength`, `codeType` et `resendAfterSeconds` des options serveur sauf besoin de les forcer. Essais restants réels,
|
|
560
|
+
nouvelles options `webOtpDomain` et `stripAccents`, nouveau code d'erreur `account_suspended`, et la requête d'envoi
|
|
561
|
+
gagne `send_id` (un serveur maison doit l'accepter).
|
|
562
|
+
|
|
563
|
+
Détails : [CHANGELOG](CHANGELOG.md).
|
|
564
|
+
|
|
565
|
+
## Contribuer
|
|
566
|
+
|
|
567
|
+
Tickets et pull requests vont dans le monorepo
|
|
568
|
+
[Labyrinthe-Team/labyrinthe-connect-sdks](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks)
|
|
569
|
+
(voir [CONTRIBUTING.md](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/blob/main/CONTRIBUTING.md)). Dans
|
|
570
|
+
`widget-otp/` :
|
|
571
|
+
|
|
572
|
+
```bash
|
|
573
|
+
npm ci
|
|
574
|
+
npm run check # lint + types + tests (happy-dom, React, serveur) + build + check:dist + size + publint + attw
|
|
575
|
+
|
|
576
|
+
# tests d'intégration contre un backend local, avec une clé de TEST uniquement
|
|
577
|
+
LABYRINTHE_API_KEY=lsk_test_… LABYRINTHE_BASE_URL=http://localhost:8000 npm run test:integration
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Failles de sécurité : écrivez à connect@labyrinthe-rdc.com plutôt que d'ouvrir un ticket public.
|
|
581
|
+
|
|
582
|
+
## Licence
|
|
583
|
+
|
|
584
|
+
MIT — voir [LICENSE](LICENSE).
|