@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/README.fr.md ADDED
@@ -0,0 +1,584 @@
1
+ # @labyrinthe/otp-widget
2
+
3
+ [![npm](https://img.shields.io/npm/v/@labyrinthe/otp-widget.svg)](https://www.npmjs.com/package/@labyrinthe/otp-widget)
4
+ [![CI](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/widget-otp.yml/badge.svg)](https://github.com/Labyrinthe-Team/labyrinthe-connect-sdks/actions/workflows/widget-otp.yml)
5
+ [![Licence : MIT](https://img.shields.io/badge/licence-MIT-blue.svg)](LICENSE)
6
+ ![min+gzip](https://img.shields.io/badge/min%2Bgzip-8.1%20kB-008374)
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).