@liorian/sdk 0.28.4-alpha.0a2443c → 0.30.0-alpha.ed4249e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/package.json +1 -1
- package/schemas/module.schema.json +78 -161
- package/src/domain/config/app.config.ts +9 -2
- package/src/domain/config/auth.config.ts +7 -0
- package/src/infrastructure/hooks/index.ts +2 -1
- package/src/infrastructure/hooks/use-sso-login.ts +126 -0
- package/src/infrastructure/providers/auth.provider.tsx +1 -1
- package/src/infrastructure/utilities/activity-lock.service.ts +22 -7
- package/src/infrastructure/utilities/sso-auth.util.ts +92 -31
- package/src/presentation/components/auth/sso-callback.view.tsx +5 -4
- package/src/presentation/components/auth/sso-sign-in.view.tsx +20 -10
- package/src/presentation/components/auth-lock-screen.tsx +8 -48
- package/src/presentation/components/index.tsx +2 -0
- package/src/presentation/components/navigation-progress.tsx +135 -0
- package/src/presentation/components/redirect.tsx +14 -0
package/README.md
CHANGED
|
@@ -322,7 +322,9 @@ import {motionVariants, transitionEasing} from "@liorian/sdk/infrastructure/libr
|
|
|
322
322
|
### Hooks (`infrastructure/hooks/`)
|
|
323
323
|
|
|
324
324
|
`use-auth`, `use-notifications`, `use-mobile`, `use-is-desktop`, `use-is-tauri`,
|
|
325
|
-
`use-activity-lock-draft`, `use-elastic-interaction`, `use-recent-colors`, `use-recent-modules
|
|
325
|
+
`use-activity-lock-draft`, `use-elastic-interaction`, `use-recent-colors`, `use-recent-modules`,
|
|
326
|
+
`use-sso-login` (déclenche le flux SSO selon `AuthConfig.sso.mode` — `redirect` par défaut,
|
|
327
|
+
`popup` sur option — et reçoit le résultat via `postMessage` + repli `storage`).
|
|
326
328
|
|
|
327
329
|
### Stores zustand (`infrastructure/stores/`)
|
|
328
330
|
|
|
@@ -361,7 +363,10 @@ manager. Reprendre la même source de vérité côté SDK et côté manager.
|
|
|
361
363
|
- `permission.util`, `format.util`, `strings.util`, `classname.util` (`cn`/`CommonClassName`) ;
|
|
362
364
|
- `encryption.service`, `activity-tracker.service`, `activity-lock.service` (verrouillage d'écran),
|
|
363
365
|
`form-snapshot.service` (brouillons de formulaire au verrouillage) ;
|
|
364
|
-
- `sso-auth.util
|
|
366
|
+
- `sso-auth.util` — client SSO OAuth 2.0 + PKCE vers Liorian Auth : `startSsoLogin`
|
|
367
|
+
(redirection, mode par défaut), `startSsoLoginPopup` (optionnel), `handleSsoCallback`,
|
|
368
|
+
`isSsoCallbackInPopup`, `SSO_MODE`, `SSO_RESULT_STORAGE_KEY` ;
|
|
369
|
+
- `auth-logout.util`, `route-access` (accès par route), `media-label.service`,
|
|
365
370
|
`push-permission.util`, `tauri.util`, `modules.ts`.
|
|
366
371
|
|
|
367
372
|
---
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "module-manifest.schema.json",
|
|
4
4
|
"$comment": "Schéma canonique du manifest.json des modules Liorian (@liorian/sdk). Référencé par les modules du socle (frontend/liorian-socle/src/modules/*) et les modules externes (frontend/liorian-socle/library/modules/*).",
|
|
5
5
|
"title": "Module Manifest (Liorian)",
|
|
6
|
-
"description": "Manifeste d'installation et de déclaration d'un module Liorian. Ce fichier décrit l'identité du module, sa compatibilité (plateformes, versions
|
|
6
|
+
"description": "Manifeste d'installation et de déclaration d'un module Liorian. Ce fichier décrit l'identité du module, sa compatibilité (plateformes, versions socle/API), ses permissions, ses scopes OAuth, ses capacités Tauri et ses prérequis fonctionnels (requirements/optionalRequirements). Les dépendances npm d'exécution et de développement sont portées par le `package.json` du module. Il est la source de vérité des métadonnées techniques d'un module, consommée par le Module Store et l'outillage de distribution.",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"default": {},
|
|
9
9
|
"examples": [
|
|
@@ -19,25 +19,18 @@
|
|
|
19
19
|
"type": "INTERNAL",
|
|
20
20
|
"entry": "index.tsx",
|
|
21
21
|
"uri": "/hello-world",
|
|
22
|
-
"publisher": {
|
|
23
|
-
"id": "liorian",
|
|
24
|
-
"name": "Liorian Workspace"
|
|
25
|
-
},
|
|
26
22
|
"platforms": {
|
|
27
23
|
"web": {"supported": true, "modes": ["web"]},
|
|
28
24
|
"desktop": {"supported": true, "modes": ["local-webview"], "os": ["windows", "macos", "linux"]},
|
|
29
|
-
"mobile": {"supported": true, "modes": ["local-webview"], "os": ["android"]
|
|
25
|
+
"mobile": {"supported": true, "modes": ["local-webview"], "os": ["android"]}
|
|
30
26
|
},
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"apiScopes": ["hello-world.read", "hello-world.write"],
|
|
35
|
-
"capabilities": {
|
|
36
|
-
"needsNetwork": true,
|
|
37
|
-
"supportsOffline": false,
|
|
38
|
-
"requiresOrganization": true,
|
|
39
|
-
"requiresAuthenticatedUser": true
|
|
27
|
+
"compatibility": {
|
|
28
|
+
"socle": {"min": "0.17.1", "max": "0.17.x"},
|
|
29
|
+
"api": {"min": "0.27.0", "max": "0.27.x"}
|
|
40
30
|
},
|
|
31
|
+
"permissions": ["User:Get", "Editor:Post", "Editor:Put", "Admin:Delete"],
|
|
32
|
+
"oauth": {"scopes": ["openid", "profile", "email", "organizations"]},
|
|
33
|
+
"capabilities": ["core:default"],
|
|
41
34
|
"isEnabled": true,
|
|
42
35
|
"isDefault": false,
|
|
43
36
|
"requirements": {
|
|
@@ -62,12 +55,10 @@
|
|
|
62
55
|
"type",
|
|
63
56
|
"entry",
|
|
64
57
|
"uri",
|
|
65
|
-
"publisher",
|
|
66
58
|
"platforms",
|
|
67
|
-
"
|
|
68
|
-
"apiCompatibility",
|
|
59
|
+
"compatibility",
|
|
69
60
|
"permissions",
|
|
70
|
-
"
|
|
61
|
+
"oauth",
|
|
71
62
|
"capabilities",
|
|
72
63
|
"isEnabled",
|
|
73
64
|
"isDefault",
|
|
@@ -181,52 +172,71 @@
|
|
|
181
172
|
"description": "Catégorie du module utilisée pour le filtrage et le regroupement dans le Module Store (alignée sur l'enum `ModuleCategory` du SDK).",
|
|
182
173
|
"examples": ["FINANCE", "COMMERCIAL", "SYSTEM"]
|
|
183
174
|
},
|
|
184
|
-
"publisher": {
|
|
185
|
-
"$ref": "#/definitions/publisher",
|
|
186
|
-
"title": "Éditeur",
|
|
187
|
-
"description": "Identité de l'éditeur du module, affichée dans le Module Store et utilisée pour la vérification de signature."
|
|
188
|
-
},
|
|
189
175
|
"platforms": {
|
|
190
176
|
"$ref": "#/definitions/platforms",
|
|
191
177
|
"title": "Plateformes supportées",
|
|
192
178
|
"description": "Matrice de support du module par plateforme (web, desktop, mobile) et par modes d'exécution."
|
|
193
179
|
},
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
180
|
+
"compatibility": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"required": ["socle", "api"],
|
|
183
|
+
"properties": {
|
|
184
|
+
"socle": {
|
|
185
|
+
"$ref": "#/definitions/version-range",
|
|
186
|
+
"title": "Compatibilité socle (manager)",
|
|
187
|
+
"description": "Plage de versions du socle (manager, frontend liorian-socle) acceptées par le module. Hors de cette plage, le module est marqué `incompatible`."
|
|
188
|
+
},
|
|
189
|
+
"api": {
|
|
190
|
+
"$ref": "#/definitions/version-range",
|
|
191
|
+
"title": "Compatibilité API",
|
|
192
|
+
"description": "Plage de versions de l'API backend attendues par le module. Hors de cette plage, les appels de service peuvent ne pas être conformes."
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
"additionalProperties": false,
|
|
196
|
+
"title": "Compatibilité",
|
|
197
|
+
"description": "Plages de versions attendues du socle/manager (`socle`) et de l'API backend (`api`). Chaque plage est un objet `version-range` (`min` requis, `max` optionnel).",
|
|
198
|
+
"examples": [
|
|
199
|
+
{
|
|
200
|
+
"socle": {"min": "0.17.1", "max": "0.17.x"},
|
|
201
|
+
"api": {"min": "0.27.0", "max": "0.27.x"}
|
|
202
|
+
}
|
|
203
|
+
]
|
|
203
204
|
},
|
|
204
205
|
"permissions": {
|
|
205
206
|
"type": "array",
|
|
206
207
|
"items": {
|
|
207
|
-
"$ref": "#/definitions/permission-code"
|
|
208
|
+
"$ref": "#/definitions/role-permission-code"
|
|
208
209
|
},
|
|
209
210
|
"minItems": 1,
|
|
210
211
|
"uniqueItems": true,
|
|
211
212
|
"title": "Permissions",
|
|
212
|
-
"description": "
|
|
213
|
-
"examples": [["
|
|
213
|
+
"description": "Accès accordés au module par rôle et par verbe HTTP, au format `<ROLE>:<VERBE>` (ex. `User:Get`, `Editor:Post`, `Editor:Put`, `Admin:Delete`). Rôles et actions alignés sur les rôles/permissions du backend (PermissionsConfig) et sur les verbes HTTP des contrôleurs Raiton.",
|
|
214
|
+
"examples": [["User:Get", "Editor:Post", "Editor:Put", "Admin:Delete"]]
|
|
214
215
|
},
|
|
215
|
-
"
|
|
216
|
-
"type": "
|
|
217
|
-
"
|
|
218
|
-
|
|
216
|
+
"oauth": {
|
|
217
|
+
"type": "object",
|
|
218
|
+
"required": ["scopes"],
|
|
219
|
+
"properties": {
|
|
220
|
+
"scopes": {
|
|
221
|
+
"type": "array",
|
|
222
|
+
"items": {
|
|
223
|
+
"$ref": "#/definitions/oauth-scope"
|
|
224
|
+
},
|
|
225
|
+
"minItems": 1,
|
|
226
|
+
"uniqueItems": true,
|
|
227
|
+
"title": "Scopes OAuth",
|
|
228
|
+
"description": "Scopes OAuth demandés par le module lors de la négociation d'accès. Doivent appartenir au catalogue OAuth autorisé (`openid`, `profile`, `email`, `organizations`, `roles`, `permissions`).",
|
|
229
|
+
"examples": [["openid", "profile", "email", "organizations"]]
|
|
230
|
+
}
|
|
219
231
|
},
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"description": "Scopes OAuth/API recensés par le module, généralement un sous-ensemble des permissions demandées (lecture/écriture). Utilisés lors de la négociation des accès avec l'API backend.",
|
|
224
|
-
"examples": [["accounting.read", "accounting.write"]]
|
|
232
|
+
"additionalProperties": false,
|
|
233
|
+
"title": "Configuration OAuth",
|
|
234
|
+
"description": "Scopes OAuth requis par le module, alignés sur le catalogue de scopes autorisés par le serveur OAuth."
|
|
225
235
|
},
|
|
226
236
|
"capabilities": {
|
|
227
237
|
"$ref": "#/definitions/capabilities",
|
|
228
238
|
"title": "Capacités",
|
|
229
|
-
"description": "
|
|
239
|
+
"description": "Identifiants de permissions/capacités Tauri requis par le module sur le client desktop, alignés sur les capacités déclarées dans `src-tauri/capabilities/`."
|
|
230
240
|
},
|
|
231
241
|
"isEnabled": {
|
|
232
242
|
"type": "boolean",
|
|
@@ -372,7 +382,7 @@
|
|
|
372
382
|
},
|
|
373
383
|
"additionalProperties": false,
|
|
374
384
|
"title": "Plage de compatibilité",
|
|
375
|
-
"description": "Contrainte de compatibilité avec le
|
|
385
|
+
"description": "Contrainte de compatibilité avec le socle (`compatibility.socle`) ou l'API backend (`compatibility.api`) : borne minimale requise, borne maximale et sévérité.",
|
|
376
386
|
"examples": [{"min": "0.17.1", "max": "0.17.x"}]
|
|
377
387
|
},
|
|
378
388
|
"module-version-map": {
|
|
@@ -390,52 +400,6 @@
|
|
|
390
400
|
"description": "Association d'identifiants de modules (`requirements` / `optionalRequirements`) à des plages de versions acceptées.",
|
|
391
401
|
"examples": [{"organization": ">=1.0.0", "media-library": ">=1.0.0"}]
|
|
392
402
|
},
|
|
393
|
-
"publisher": {
|
|
394
|
-
"type": "object",
|
|
395
|
-
"required": ["id", "name"],
|
|
396
|
-
"properties": {
|
|
397
|
-
"id": {
|
|
398
|
-
"type": "string",
|
|
399
|
-
"pattern": "^[a-z0-9]+([-_][a-z0-9]+)*$",
|
|
400
|
-
"title": "Identifiant de l'éditeur",
|
|
401
|
-
"description": "Identifiant technique de l'éditeur (ex. `liorian`)."
|
|
402
|
-
},
|
|
403
|
-
"name": {
|
|
404
|
-
"type": "string",
|
|
405
|
-
"minLength": 1,
|
|
406
|
-
"title": "Nom de l'éditeur",
|
|
407
|
-
"description": "Nom affiché de l'éditeur (ex. `Liorian Workspace`)."
|
|
408
|
-
},
|
|
409
|
-
"url": {
|
|
410
|
-
"type": "string",
|
|
411
|
-
"format": "uri",
|
|
412
|
-
"title": "Site de l'éditeur",
|
|
413
|
-
"description": "URL du site officiel de l'éditeur."
|
|
414
|
-
},
|
|
415
|
-
"email": {
|
|
416
|
-
"type": "string",
|
|
417
|
-
"format": "email",
|
|
418
|
-
"title": "Contact",
|
|
419
|
-
"description": "Adresse de contact du support."
|
|
420
|
-
},
|
|
421
|
-
"description": {
|
|
422
|
-
"type": "string",
|
|
423
|
-
"maxLength": 256,
|
|
424
|
-
"title": "Description",
|
|
425
|
-
"description": "Courte présentation de l'éditeur."
|
|
426
|
-
},
|
|
427
|
-
"avatar": {
|
|
428
|
-
"type": "string",
|
|
429
|
-
"format": "uri",
|
|
430
|
-
"title": "Avatar",
|
|
431
|
-
"description": "URL de l'avatar logo de l'éditeur."
|
|
432
|
-
}
|
|
433
|
-
},
|
|
434
|
-
"additionalProperties": false,
|
|
435
|
-
"title": "Éditeur",
|
|
436
|
-
"description": "Identité de l'entité publiant le module.",
|
|
437
|
-
"examples": [{"id": "liorian", "name": "Liorian Workspace"}]
|
|
438
|
-
},
|
|
439
403
|
"platforms": {
|
|
440
404
|
"type": "object",
|
|
441
405
|
"required": ["web", "desktop", "mobile"],
|
|
@@ -494,14 +458,9 @@
|
|
|
494
458
|
},
|
|
495
459
|
"uniqueItems": true,
|
|
496
460
|
"title": "Systèmes d'exploitation",
|
|
497
|
-
"description": "Systèmes d'exploitation supportés (pertinent pour desktop/mobile).",
|
|
461
|
+
"description": "Systèmes d'exploitation supportés (pertinent pour desktop/mobile, où `ios` signifie support iOS).",
|
|
498
462
|
"examples": [["windows", "macos", "linux"]]
|
|
499
463
|
},
|
|
500
|
-
"iosSupported": {
|
|
501
|
-
"type": "boolean",
|
|
502
|
-
"title": "Support iOS",
|
|
503
|
-
"description": "Indique si iOS est supporté sur la plateforme mobile (obsolète si `os` inclut déjà `ios`)."
|
|
504
|
-
},
|
|
505
464
|
"minOsVersion": {
|
|
506
465
|
"type": "string",
|
|
507
466
|
"title": "Version OS minimale",
|
|
@@ -529,71 +488,29 @@
|
|
|
529
488
|
"description": "Définition du support d'un module sur une plateforme donnée."
|
|
530
489
|
},
|
|
531
490
|
"capabilities": {
|
|
532
|
-
"type": "
|
|
533
|
-
"
|
|
534
|
-
"
|
|
535
|
-
|
|
536
|
-
"default": true,
|
|
537
|
-
"title": "Réseau requis",
|
|
538
|
-
"description": "Si `true`, le module requiert une connexion réseau pour fonctionner (appels API)."
|
|
539
|
-
},
|
|
540
|
-
"supportsOffline": {
|
|
541
|
-
"type": "boolean",
|
|
542
|
-
"default": false,
|
|
543
|
-
"title": "Mode hors-ligne",
|
|
544
|
-
"description": "Si `true`, le module conserve un fonctionnement dégradé hors connexion (cache local, file d'attente)."
|
|
545
|
-
},
|
|
546
|
-
"requiresOrganization": {
|
|
547
|
-
"type": "boolean",
|
|
548
|
-
"default": true,
|
|
549
|
-
"title": "Organisation requise",
|
|
550
|
-
"description": "Si `true`, le module exige une organisation active (le Module Guard bloque sans organisation)."
|
|
551
|
-
},
|
|
552
|
-
"requiresAuthenticatedUser": {
|
|
553
|
-
"type": "boolean",
|
|
554
|
-
"default": true,
|
|
555
|
-
"title": "Utilisateur authentifié requis",
|
|
556
|
-
"description": "Si `true`, le module exige une session utilisateur authentifiée."
|
|
557
|
-
},
|
|
558
|
-
"requiresAdmin": {
|
|
559
|
-
"type": "boolean",
|
|
560
|
-
"default": false,
|
|
561
|
-
"title": "Administrateur requis",
|
|
562
|
-
"description": "Si `true`, seules les sessions avec le rôle administrateur peuvent accéder au module."
|
|
563
|
-
},
|
|
564
|
-
"supportsRealtime": {
|
|
565
|
-
"type": "boolean",
|
|
566
|
-
"default": false,
|
|
567
|
-
"title": "Temps réel",
|
|
568
|
-
"description": "Si `true`, le module s'appuie sur des routines et/ou événements temps réel (push, websocket)."
|
|
569
|
-
},
|
|
570
|
-
"processesLocalData": {
|
|
571
|
-
"type": "boolean",
|
|
572
|
-
"default": false,
|
|
573
|
-
"title": "Traitement local des données",
|
|
574
|
-
"description": "Si `true`, le module traite des données sur l'appareil (chiffrement local, médias locaux)."
|
|
575
|
-
}
|
|
491
|
+
"type": "array",
|
|
492
|
+
"items": {
|
|
493
|
+
"type": "string",
|
|
494
|
+
"pattern": "^[a-z][a-z0-9_]*:[a-z0-9_.:-]+$"
|
|
576
495
|
},
|
|
577
|
-
"
|
|
578
|
-
"title": "Capacités
|
|
579
|
-
"description": "
|
|
580
|
-
"examples": [
|
|
581
|
-
{
|
|
582
|
-
"needsNetwork": true,
|
|
583
|
-
"supportsOffline": false,
|
|
584
|
-
"requiresOrganization": true,
|
|
585
|
-
"requiresAuthenticatedUser": true
|
|
586
|
-
}
|
|
587
|
-
]
|
|
496
|
+
"uniqueItems": true,
|
|
497
|
+
"title": "Capacités Tauri",
|
|
498
|
+
"description": "Identifiants de permissions/capacités Tauri requis par le module sur le client desktop. Valeurs alignées sur les identifiants déclarés dans `src-tauri/capabilities/*.json` (ex. `core:default`, `notification:default`, `core:window:allow-start-dragging`).",
|
|
499
|
+
"examples": [["core:default", "notification:default"]]
|
|
588
500
|
},
|
|
589
|
-
"permission-code": {
|
|
501
|
+
"role-permission-code": {
|
|
590
502
|
"type": "string",
|
|
591
|
-
"pattern": "^
|
|
592
|
-
"
|
|
593
|
-
"
|
|
594
|
-
"
|
|
595
|
-
|
|
596
|
-
|
|
503
|
+
"pattern": "^(Root|SuperAdmin|Admin|Manager|Editor|Moderator|Analyst|Commercial|Operator|Contributor|Caissier|User|Guest|Viewer):(Get|Post|Put|Delete)$",
|
|
504
|
+
"title": "Permission rôle:verbe",
|
|
505
|
+
"description": "Accès accordé au format `<ROLE>:<VERBE>`. `ROLE` est un nom de rôle existant du backend (`Root`, `SuperAdmin`, `Admin`, `Manager`, `Editor`, `Moderator`, `Analyst`, `Commercial`, `Operator`, `Contributor`, `Caissier`, `User`, `Guest`, `Viewer`) et `VERBE` un verbe HTTP (`Get`, `Post`, `Put`, `Delete`).",
|
|
506
|
+
"examples": ["User:Get", "Editor:Post", "Editor:Put", "Admin:Delete"]
|
|
507
|
+
},
|
|
508
|
+
"oauth-scope": {
|
|
509
|
+
"type": "string",
|
|
510
|
+
"enum": ["openid", "profile", "email", "organizations", "roles", "permissions"],
|
|
511
|
+
"title": "Scope OAuth",
|
|
512
|
+
"description": "Scope OAuth du catalogue autorisé, aligné sur les scopes pré-enregistrés du serveur OAuth (définis par `packages/oauth-kit` et les seeds).",
|
|
513
|
+
"examples": ["openid", "profile", "email", "organizations"]
|
|
597
514
|
},
|
|
598
515
|
"icon-reference": {
|
|
599
516
|
"type": "string",
|
|
@@ -11,9 +11,16 @@ export const AppConfig = {
|
|
|
11
11
|
APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION as unknown as string,
|
|
12
12
|
APP_VERSION_NUMBER: process.env.NEXT_PUBLIC_APP_VERSION_NUMBER as unknown as number,
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** Alias conservé pour compatibilité : renvoie la même cadence que `APP_REFRESH_UI`. */
|
|
15
|
+
UI_REFRESH_TIMEOUT: toPositiveMilliseconds(
|
|
16
|
+
process.env.NEXT_PUBLIC_APP_REFRESH_UI ?? process.env.NEXT_PUBLIC_UI_REFRESH_TIMEOUT,
|
|
17
|
+
30 * 1000
|
|
18
|
+
),
|
|
15
19
|
UI_ACTIVITY_TRACKER_TIMEOUT: toPositiveMilliseconds(process.env.NEXT_PUBLIC_UI_ACTIVITY_TRACKER_TIMEOUT, 3 * 60 * 1000),
|
|
16
|
-
APP_REFRESH_UI: toPositiveMilliseconds(
|
|
20
|
+
APP_REFRESH_UI: toPositiveMilliseconds(
|
|
21
|
+
process.env.NEXT_PUBLIC_APP_REFRESH_UI ?? process.env.NEXT_PUBLIC_UI_REFRESH_TIMEOUT,
|
|
22
|
+
30 * 1000
|
|
23
|
+
),
|
|
17
24
|
|
|
18
25
|
AUTH_HOST: process.env.NEXT_PUBLIC_AUTH_HOST as unknown as string,
|
|
19
26
|
CONNECT_HOST: process.env.NEXT_PUBLIC_CONNECT_HOST as unknown as string,
|
|
@@ -21,6 +21,13 @@ export const AuthConfig = {
|
|
|
21
21
|
clientId: process.env.NEXT_PUBLIC_AUTH_CLIENT_ID || 'liorian-socle',
|
|
22
22
|
/** Chemin de rappel SSO de l'application (code + state). */
|
|
23
23
|
redirectPath: '/auth/callback',
|
|
24
|
+
/**
|
|
25
|
+
* Mode de déclenchement du SSO :
|
|
26
|
+
* - `redirect` (défaut) : redirection pleine page vers Liorian Auth ;
|
|
27
|
+
* - `popup` : fenêtre popup, avec repli automatique en redirection si bloquée.
|
|
28
|
+
* Configurable par application via `NEXT_PUBLIC_AUTH_SSO_MODE`.
|
|
29
|
+
*/
|
|
30
|
+
mode: (process.env.NEXT_PUBLIC_AUTH_SSO_MODE === 'popup' ? 'popup' : 'redirect') as 'popup' | 'redirect',
|
|
24
31
|
},
|
|
25
32
|
excludes: [
|
|
26
33
|
'/auth',
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {useCallback, useEffect, useRef, useState} from "react"
|
|
4
|
+
import {
|
|
5
|
+
SSO_CALLBACK_MESSAGE,
|
|
6
|
+
SSO_MODE,
|
|
7
|
+
SSO_RESULT_STORAGE_KEY,
|
|
8
|
+
startSsoLogin,
|
|
9
|
+
startSsoLoginPopup,
|
|
10
|
+
} from "../utilities/sso-auth.util"
|
|
11
|
+
|
|
12
|
+
export interface UseSsoLoginOptions {
|
|
13
|
+
/** Destination après une authentification réussie (navigation complète). Par défaut, recharge la page courante. */
|
|
14
|
+
redirectUrl?: string
|
|
15
|
+
/** Callback invoqué après une authentification réussie. */
|
|
16
|
+
onAuthenticated?: () => void
|
|
17
|
+
/** Callback invoqué en cas d'échec (popup fermé/refusé, redirection impossible). */
|
|
18
|
+
onError?: (error?: unknown) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseSsoLoginResult {
|
|
22
|
+
/** Vrai pendant le lancement et l'attente du flux SSO. */
|
|
23
|
+
connecting: boolean
|
|
24
|
+
/** Démarre le flux SSO selon le mode configuré (`redirect` par défaut, `popup` sur option). */
|
|
25
|
+
start: () => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Gère le déclenchement du SSO Liorian et la réception de son résultat.
|
|
30
|
+
*
|
|
31
|
+
* Le mode est lu depuis `AuthConfig.sso.mode` (`NEXT_PUBLIC_AUTH_SSO_MODE`) :
|
|
32
|
+
* - `redirect` (défaut) : redirection pleine page, aucun écouteur requis ;
|
|
33
|
+
* - `popup` : fenêtre popup (repli automatique en redirection si bloquée).
|
|
34
|
+
*
|
|
35
|
+
* En mode popup, le résultat est reçu via `postMessage` et, en repli lorsque
|
|
36
|
+
* `window.opener` a été neutralisé (COOP), via l'événement `storage`.
|
|
37
|
+
*/
|
|
38
|
+
export function useSsoLogin({redirectUrl, onAuthenticated, onError}: UseSsoLoginOptions = {}): UseSsoLoginResult {
|
|
39
|
+
const [connecting, setConnecting] = useState(false)
|
|
40
|
+
const settled = useRef(false)
|
|
41
|
+
const watcher = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
42
|
+
|
|
43
|
+
const stopWatcher = useCallback(() => {
|
|
44
|
+
if (watcher.current) {
|
|
45
|
+
clearInterval(watcher.current)
|
|
46
|
+
watcher.current = null
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const finish = useCallback((success: boolean, error?: unknown) => {
|
|
51
|
+
if (settled.current) return
|
|
52
|
+
settled.current = true
|
|
53
|
+
stopWatcher()
|
|
54
|
+
setConnecting(false)
|
|
55
|
+
|
|
56
|
+
if (!success) {
|
|
57
|
+
onError?.(error)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onAuthenticated?.()
|
|
62
|
+
if (redirectUrl) {
|
|
63
|
+
window.location.assign(redirectUrl)
|
|
64
|
+
} else {
|
|
65
|
+
window.location.reload()
|
|
66
|
+
}
|
|
67
|
+
}, [redirectUrl, onAuthenticated, onError, stopWatcher])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
// En mode redirection, la navigation quitte la page : aucun écouteur requis.
|
|
71
|
+
if (SSO_MODE !== 'popup') return
|
|
72
|
+
|
|
73
|
+
const onMessage = (event: MessageEvent) => {
|
|
74
|
+
if (event.origin !== window.location.origin) return
|
|
75
|
+
if (event.data?.source !== 'liorian-auth') return
|
|
76
|
+
if (event.data?.type !== SSO_CALLBACK_MESSAGE) return
|
|
77
|
+
finish(Boolean(event.data?.success))
|
|
78
|
+
}
|
|
79
|
+
const onStorage = (event: StorageEvent) => {
|
|
80
|
+
if (event.key !== SSO_RESULT_STORAGE_KEY || !event.newValue) return
|
|
81
|
+
try {
|
|
82
|
+
finish(Boolean(JSON.parse(event.newValue)?.success))
|
|
83
|
+
} catch {
|
|
84
|
+
// Valeur illisible : ignorer.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
window.addEventListener('message', onMessage)
|
|
89
|
+
window.addEventListener('storage', onStorage)
|
|
90
|
+
return () => {
|
|
91
|
+
window.removeEventListener('message', onMessage)
|
|
92
|
+
window.removeEventListener('storage', onStorage)
|
|
93
|
+
stopWatcher()
|
|
94
|
+
}
|
|
95
|
+
}, [finish, stopWatcher])
|
|
96
|
+
|
|
97
|
+
const start = useCallback(async () => {
|
|
98
|
+
if (connecting) return
|
|
99
|
+
settled.current = false
|
|
100
|
+
setConnecting(true)
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (SSO_MODE !== 'popup') {
|
|
104
|
+
await startSsoLogin()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const popup = await startSsoLoginPopup()
|
|
109
|
+
if (!popup) {
|
|
110
|
+
await startSsoLogin()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Popup fermé sans résultat : réactiver le bouton côté onglet d'origine.
|
|
115
|
+
watcher.current = setInterval(() => {
|
|
116
|
+
if (popup.closed) {
|
|
117
|
+
finish(false, new Error('Fenêtre d’authentification fermée.'))
|
|
118
|
+
}
|
|
119
|
+
}, 500)
|
|
120
|
+
} catch (error) {
|
|
121
|
+
finish(false, error)
|
|
122
|
+
}
|
|
123
|
+
}, [connecting, finish])
|
|
124
|
+
|
|
125
|
+
return {connecting, start}
|
|
126
|
+
}
|
|
@@ -46,7 +46,7 @@ export function AuthProvider({children, requireOrganization = true}: AuthProvide
|
|
|
46
46
|
if (!armed()) return
|
|
47
47
|
ActivityLockService.start({
|
|
48
48
|
userId,
|
|
49
|
-
timeout:
|
|
49
|
+
timeout: AppConfig.UI_ACTIVITY_TRACKER_TIMEOUT,
|
|
50
50
|
onLocked: () => setLocked(true),
|
|
51
51
|
})
|
|
52
52
|
})
|
|
@@ -12,9 +12,6 @@ export const ACTIVITY_LOCK_PREFERENCE_LABEL = "ui_activity_tracker_timeout";
|
|
|
12
12
|
/** Clé localStorage du verrou : persiste l'état verrouillé même après un rafraîchissement. */
|
|
13
13
|
const ACTIVITY_LOCK_STORAGE_KEY = "@liorian-activity-lock";
|
|
14
14
|
|
|
15
|
-
/** Délai d'inactivité par défaut (repli) : 3 minutes. */
|
|
16
|
-
const DEFAULT_TIMEOUT = 3 * 60 * 1000;
|
|
17
|
-
|
|
18
15
|
export interface ActivityLockState {
|
|
19
16
|
locked: boolean;
|
|
20
17
|
userId?: string;
|
|
@@ -115,6 +112,11 @@ export class ActivityLockService {
|
|
|
115
112
|
/**
|
|
116
113
|
* Résout le délai d'inactivité : préférence utilisateur persistée (si définie),
|
|
117
114
|
* sinon `AppConfig.UI_ACTIVITY_TRACKER_TIMEOUT`, sinon valeur par défaut.
|
|
115
|
+
*
|
|
116
|
+
* La préférence utilisateur prime volontairement sur la configuration
|
|
117
|
+
* `NEXT_PUBLIC_UI_ACTIVITY_TRACKER_TIMEOUT` : c'est le comportement attendu,
|
|
118
|
+
* mais une trace (mode développement) indique la source réellement appliquée
|
|
119
|
+
* pour lever toute ambiguïté.
|
|
118
120
|
*/
|
|
119
121
|
static async getTimeout(userId?: string): Promise<number> {
|
|
120
122
|
if (userId) {
|
|
@@ -122,10 +124,23 @@ export class ActivityLockService {
|
|
|
122
124
|
.getValue(userId, ACTIVITY_LOCK_PREFERENCE_LABEL)
|
|
123
125
|
.catch(() => undefined);
|
|
124
126
|
const parsed = raw ? Number(raw) : NaN;
|
|
125
|
-
if (Number.isFinite(parsed) && parsed > 0)
|
|
127
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
128
|
+
if (process.env.NODE_ENV === "development") {
|
|
129
|
+
console.info(
|
|
130
|
+
`[ActivityLock] Préférence utilisateur "${ACTIVITY_LOCK_PREFERENCE_LABEL}" = ${parsed} ms —` +
|
|
131
|
+
"elle prime sur NEXT_PUBLIC_UI_ACTIVITY_TRACKER_TIMEOUT."
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (process.env.NODE_ENV === "development") {
|
|
138
|
+
console.info(
|
|
139
|
+
`[ActivityLock] Délai d'inactivité = ${AppConfig.UI_ACTIVITY_TRACKER_TIMEOUT} ms` +
|
|
140
|
+
"(NEXT_PUBLIC_UI_ACTIVITY_TRACKER_TIMEOUT)."
|
|
141
|
+
);
|
|
126
142
|
}
|
|
127
|
-
|
|
128
|
-
return Number.isFinite(config) && config > 0 ? config : DEFAULT_TIMEOUT;
|
|
143
|
+
return AppConfig.UI_ACTIVITY_TRACKER_TIMEOUT;
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
/** Persiste le délai d'inactivité choisi par l'utilisateur (via UserPreference). */
|
|
@@ -149,7 +164,7 @@ export class ActivityLockService {
|
|
|
149
164
|
|
|
150
165
|
this.enabled = true;
|
|
151
166
|
this.userId = options.userId;
|
|
152
|
-
this.timeout = options.timeout ??
|
|
167
|
+
this.timeout = options.timeout ?? AppConfig.UI_ACTIVITY_TRACKER_TIMEOUT;
|
|
153
168
|
this.onLocked = options.onLocked;
|
|
154
169
|
|
|
155
170
|
// S'assure que le tracker d'activité tourne (partagé avec les notifications push) :
|
|
@@ -10,28 +10,47 @@ import {authUserConnectedStore} from "../stores/auth-user-connected.store";
|
|
|
10
10
|
* Client OAuth 2.0 (Authorization Code + PKCE) vers Liorian Auth.
|
|
11
11
|
*
|
|
12
12
|
* Utilisé par les applications consommatrices (ex. liorian-socle) pour
|
|
13
|
-
* s'authentifier via le SSO de `liorian-auth`. Deux modes de déclenchement
|
|
14
|
-
*
|
|
15
|
-
* - redirection complète (`startSsoLogin`) —
|
|
13
|
+
* s'authentifier via le SSO de `liorian-auth`. Deux modes de déclenchement,
|
|
14
|
+
* choisis par `NEXT_PUBLIC_AUTH_SSO_MODE` (défaut : redirection) :
|
|
15
|
+
* - redirection complète (`startSsoLogin`) — mode par défaut ;
|
|
16
|
+
* - fenêtre popup (`startSsoLoginPopup`) — optionnel, repli en redirection si bloquée.
|
|
16
17
|
*
|
|
17
18
|
* Les secrets PKCE (`code_verifier`/`state`) sont stockés en `localStorage`
|
|
18
19
|
* (et non en `sessionStorage`) afin d'être partagés entre l'onglet principal
|
|
19
|
-
* et le popup de la même origine.
|
|
20
|
+
* et le popup de la même origine. Le mode courant y est également mémorisé afin
|
|
21
|
+
* de fermer le popup de façon fiable, même lorsque `window.opener` est neutralisé
|
|
22
|
+
* (Cross-Origin-Opener-Policy). Le résultat est alors aussi publié via
|
|
23
|
+
* `localStorage` pour prévenir l'onglet d'origine.
|
|
20
24
|
*/
|
|
21
25
|
|
|
26
|
+
/** Mode de déclenchement du SSO (voir `AuthConfig.sso.mode`). */
|
|
27
|
+
export type SsoMode = 'redirect' | 'popup';
|
|
28
|
+
|
|
22
29
|
/** Identifiant OAuth de l'application consommatrice (configurable par application). */
|
|
23
30
|
export const SSO_CLIENT_ID = AuthConfig.sso.clientId;
|
|
24
31
|
export const SSO_SCOPE = 'openid profile email organizations';
|
|
25
32
|
export const SSO_REDIRECT_PATH = AuthConfig.sso.redirectPath;
|
|
26
33
|
|
|
34
|
+
/** Mode SSO effectif de l'application (`redirect` par défaut, `popup` sur option). */
|
|
35
|
+
export const SSO_MODE: SsoMode = AuthConfig.sso.mode;
|
|
36
|
+
|
|
27
37
|
const SSO_STORAGE = {
|
|
28
38
|
codeVerifier: 'sso.code_verifier',
|
|
29
39
|
state: 'sso.state',
|
|
40
|
+
mode: 'sso.mode',
|
|
41
|
+
result: 'sso.result',
|
|
30
42
|
} as const;
|
|
31
43
|
|
|
32
44
|
/** Type de message posté par le popup de callback vers l'onglet d'origine. */
|
|
33
45
|
export const SSO_CALLBACK_MESSAGE = 'SSO_CALLBACK';
|
|
34
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Clé `localStorage` publiée par le popup à la fin du callback. Sert de repli
|
|
49
|
+
* à `postMessage` : l'onglet d'origine écoute l'événement `storage` pour savoir
|
|
50
|
+
* si l'authentification a réussi.
|
|
51
|
+
*/
|
|
52
|
+
export const SSO_RESULT_STORAGE_KEY = SSO_STORAGE.result;
|
|
53
|
+
|
|
35
54
|
function ssoOrigin(): string {
|
|
36
55
|
return (AppConfig.AUTH_HOST || 'https://localhost:5050').replace(/\/$/, '');
|
|
37
56
|
}
|
|
@@ -54,6 +73,10 @@ async function sha256Base64url(input: string): Promise<string> {
|
|
|
54
73
|
return base64url(digest);
|
|
55
74
|
}
|
|
56
75
|
|
|
76
|
+
function randomId(): string {
|
|
77
|
+
return Math.random().toString(36).slice(2);
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
/** Construit l'URL d'autorisation vers Liorian Auth (PKCE S256). */
|
|
58
81
|
export async function buildSsoLoginUrl(clientId: string = AuthConfig.sso.clientId): Promise<string> {
|
|
59
82
|
const codeVerifier = randomBase64url(64);
|
|
@@ -75,19 +98,22 @@ export async function buildSsoLoginUrl(clientId: string = AuthConfig.sso.clientI
|
|
|
75
98
|
return url.toString();
|
|
76
99
|
}
|
|
77
100
|
|
|
78
|
-
/** Redirige toute la fenêtre vers Liorian Auth. */
|
|
101
|
+
/** Redirige toute la fenêtre vers Liorian Auth (mode redirection, par défaut). */
|
|
79
102
|
export async function startSsoLogin(clientId?: string): Promise<void> {
|
|
103
|
+
localStorage.removeItem(SSO_STORAGE.mode);
|
|
80
104
|
window.location.assign(await buildSsoLoginUrl(clientId));
|
|
81
105
|
}
|
|
82
106
|
|
|
83
107
|
/**
|
|
84
|
-
* Ouvre un popup Liorian Auth pour se connecter.
|
|
108
|
+
* Ouvre un popup Liorian Auth pour se connecter (mode popup, optionnel).
|
|
85
109
|
* Retourne la référence de la fenêtre, ou `null` si le popup a été bloqué
|
|
86
|
-
* (appelant doit alors basculer sur une redirection).
|
|
110
|
+
* (l'appelant doit alors basculer sur une redirection).
|
|
87
111
|
*/
|
|
88
112
|
export async function startSsoLoginPopup(clientId?: string): Promise<Window | null> {
|
|
89
113
|
const url = await buildSsoLoginUrl(clientId);
|
|
90
114
|
|
|
115
|
+
localStorage.setItem(SSO_STORAGE.mode, 'popup');
|
|
116
|
+
|
|
91
117
|
const width = 480;
|
|
92
118
|
const height = 720;
|
|
93
119
|
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
@@ -100,16 +126,33 @@ export async function startSsoLoginPopup(clientId?: string): Promise<Window | nu
|
|
|
100
126
|
);
|
|
101
127
|
}
|
|
102
128
|
|
|
103
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Vrai si la page de callback s'exécute dans un popup ouvert par `startSsoLoginPopup`.
|
|
131
|
+
*
|
|
132
|
+
* Seul le marqueur mémorisé par `startSsoLoginPopup` fait foi : une redirection
|
|
133
|
+
* pleine page (`startSsoLogin`) doit toujours ramener l'utilisateur dans
|
|
134
|
+
* l'onglet courant, même si ce dernier possède un `window.opener` (onglet ouvert
|
|
135
|
+
* par un lien `_blank`, webview Tauri, etc.). Se fier à `window.opener` ferait
|
|
136
|
+
* passer ce callback pour un popup : la session serait installée mais la
|
|
137
|
+
* navigation jamais déclenchée, laissant l'utilisateur bloqué sur le callback.
|
|
138
|
+
*/
|
|
104
139
|
export function isSsoCallbackInPopup(): boolean {
|
|
105
|
-
|
|
140
|
+
if (typeof window === 'undefined') return false;
|
|
141
|
+
try {
|
|
142
|
+
return window.localStorage.getItem(SSO_STORAGE.mode) === 'popup';
|
|
143
|
+
} catch {
|
|
144
|
+
// Stockage indisponible : on retombe sur la détection par `window.opener`.
|
|
145
|
+
return !!window.opener && window.opener !== window;
|
|
146
|
+
}
|
|
106
147
|
}
|
|
107
148
|
|
|
108
149
|
/**
|
|
109
150
|
* Échange le code d'autorisation (callback) contre une session et l'installe.
|
|
110
151
|
*
|
|
111
|
-
* En mode popup, le résultat est renvoyé à l'onglet d'origine
|
|
112
|
-
* puis le popup est refermé
|
|
152
|
+
* En mode popup, le résultat est renvoyé à l'onglet d'origine (`postMessage` +
|
|
153
|
+
* repli `localStorage`) puis le popup est refermé : la méthode retourne `false`
|
|
154
|
+
* pour empêcher la navigation. En mode redirection, elle retourne `true` en cas
|
|
155
|
+
* de succès et lève une erreur en cas d'échec pour laisser l'appelant rediriger.
|
|
113
156
|
*/
|
|
114
157
|
export async function handleSsoCallback(): Promise<boolean> {
|
|
115
158
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -121,10 +164,10 @@ export async function handleSsoCallback(): Promise<boolean> {
|
|
|
121
164
|
|
|
122
165
|
if (error || !code) {
|
|
123
166
|
if (isPopup) {
|
|
124
|
-
|
|
125
|
-
|
|
167
|
+
finishPopup(false);
|
|
168
|
+
return false;
|
|
126
169
|
}
|
|
127
|
-
|
|
170
|
+
throw new Error(params.get('error_description') || error || 'Code d’autorisation manquant.');
|
|
128
171
|
}
|
|
129
172
|
|
|
130
173
|
try {
|
|
@@ -158,31 +201,49 @@ export async function handleSsoCallback(): Promise<boolean> {
|
|
|
158
201
|
authUserConnectedStore.getState().setCurrentUser(authData.user);
|
|
159
202
|
authUserConnectedStore.getState().setOrganizations(authData.organizations || []);
|
|
160
203
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (isPopup && window.opener) {
|
|
165
|
-
postResult(window.opener, true);
|
|
166
|
-
window.close();
|
|
204
|
+
if (isPopup) {
|
|
205
|
+
finishPopup(true);
|
|
167
206
|
return false;
|
|
168
207
|
}
|
|
169
208
|
|
|
209
|
+
localStorage.removeItem(SSO_STORAGE.codeVerifier);
|
|
210
|
+
localStorage.removeItem(SSO_STORAGE.state);
|
|
170
211
|
return true;
|
|
171
212
|
} catch (e) {
|
|
172
|
-
// En mode popup : signaler l'échec
|
|
173
|
-
//
|
|
174
|
-
if (isPopup
|
|
175
|
-
|
|
176
|
-
window.close();
|
|
213
|
+
// En mode popup : signaler l'échec puis refermer, sinon l'écran reste
|
|
214
|
+
// bloqué sur « Finalisation de la connexion… ».
|
|
215
|
+
if (isPopup) {
|
|
216
|
+
finishPopup(false);
|
|
177
217
|
return false;
|
|
178
218
|
}
|
|
179
219
|
throw e;
|
|
180
220
|
}
|
|
181
221
|
}
|
|
182
222
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Clôt le popup : notifie l'onglet d'origine puis referme la fenêtre.
|
|
225
|
+
*
|
|
226
|
+
* Le résultat est publié à la fois par `postMessage` (si `window.opener` est
|
|
227
|
+
* disponible) et via `localStorage`, dont l'événement `storage` est reçu par
|
|
228
|
+
* l'onglet d'origine même lorsque COOP a neutralisé l'opener.
|
|
229
|
+
*/
|
|
230
|
+
function finishPopup(success: boolean): void {
|
|
231
|
+
localStorage.removeItem(SSO_STORAGE.codeVerifier);
|
|
232
|
+
localStorage.removeItem(SSO_STORAGE.state);
|
|
233
|
+
localStorage.removeItem(SSO_STORAGE.mode);
|
|
234
|
+
localStorage.setItem(SSO_STORAGE.result, JSON.stringify({success, id: randomId()}));
|
|
235
|
+
|
|
236
|
+
const opener = window.opener;
|
|
237
|
+
if (opener) {
|
|
238
|
+
try {
|
|
239
|
+
opener.postMessage(
|
|
240
|
+
{source: 'liorian-auth', type: SSO_CALLBACK_MESSAGE, success},
|
|
241
|
+
window.location.origin,
|
|
242
|
+
);
|
|
243
|
+
} catch {
|
|
244
|
+
// Opener cross-origin : le résultat reste disponible via `localStorage`.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
window.close();
|
|
249
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {useEffect, useRef} from "react";
|
|
4
4
|
import {useRouter} from "next/navigation";
|
|
5
|
-
import {handleSsoCallback
|
|
5
|
+
import {handleSsoCallback} from "../../../infrastructure/utilities/sso-auth.util";
|
|
6
6
|
import {Activity} from "../activity";
|
|
7
7
|
|
|
8
8
|
export interface SsoCallbackViewProps {
|
|
@@ -27,11 +27,12 @@ export function SsoCallbackView({successRedirect = '/', errorRedirect = '/auth/s
|
|
|
27
27
|
}
|
|
28
28
|
done.current = true;
|
|
29
29
|
handleSsoCallback()
|
|
30
|
-
.then(() => {
|
|
31
|
-
|
|
30
|
+
.then((handledInPage) => {
|
|
31
|
+
// `false` : le résultat a été renvoyé au popup appelant, la fenêtre se ferme.
|
|
32
|
+
if (handledInPage) router.replace(successRedirect);
|
|
32
33
|
})
|
|
33
34
|
.catch(() => {
|
|
34
|
-
|
|
35
|
+
router.replace(errorRedirect);
|
|
35
36
|
});
|
|
36
37
|
}, [router, successRedirect, errorRedirect]);
|
|
37
38
|
|
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {useEffect} from "react";
|
|
4
|
-
import {
|
|
3
|
+
import {useEffect, useRef} from "react";
|
|
4
|
+
import {useSsoLogin} from "../../../infrastructure/hooks/use-sso-login";
|
|
5
5
|
import {Activity} from "../activity";
|
|
6
6
|
|
|
7
7
|
export interface SsoSignInViewProps {
|
|
8
|
+
/** Destination après une authentification réussie en mode popup. */
|
|
9
|
+
successRedirect?: string
|
|
8
10
|
/** Destination si le SSO ne peut pas être démarré (popup/redirection bloquée). */
|
|
9
11
|
fallbackRedirect?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* Écran de connexion SSO :
|
|
14
|
-
*
|
|
15
|
+
* Écran de connexion SSO : lance le flux Liorian Auth (Authorization Code + PKCE)
|
|
16
|
+
* dès le montage, selon le mode configuré (`NEXT_PUBLIC_AUTH_SSO_MODE` :
|
|
17
|
+
* `redirect` par défaut, `popup` sur option). Réutilisable par toutes les
|
|
18
|
+
* applications consommatrices.
|
|
15
19
|
*/
|
|
16
|
-
export function SsoSignInView({fallbackRedirect = '/auth/sign-up'}: SsoSignInViewProps) {
|
|
20
|
+
export function SsoSignInView({successRedirect = '/', fallbackRedirect = '/auth/sign-up'}: SsoSignInViewProps) {
|
|
21
|
+
const {start} = useSsoLogin({
|
|
22
|
+
redirectUrl: successRedirect,
|
|
23
|
+
onError: () => window.location.assign(fallbackRedirect),
|
|
24
|
+
});
|
|
25
|
+
const started = useRef(false);
|
|
26
|
+
|
|
17
27
|
useEffect(() => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}, [
|
|
28
|
+
if (started.current) return;
|
|
29
|
+
started.current = true;
|
|
30
|
+
void start();
|
|
31
|
+
}, [start]);
|
|
22
32
|
|
|
23
33
|
return (
|
|
24
34
|
<div className="flex min-h-svh flex-col items-center justify-center gap-4 bg-background">
|
|
@@ -26,4 +36,4 @@ export function SsoSignInView({fallbackRedirect = '/auth/sign-up'}: SsoSignInVie
|
|
|
26
36
|
<p className="text-sm text-muted-foreground">Connexion…</p>
|
|
27
37
|
</div>
|
|
28
38
|
);
|
|
29
|
-
}
|
|
39
|
+
}
|
|
@@ -7,7 +7,7 @@ import {AppConfig} from "../../domain/config/app.config"
|
|
|
7
7
|
import {AuthUserService} from "../../application/service/auth-user.service"
|
|
8
8
|
import {AuthApiService, webauthnBytes} from "../../application/service/auth-api-service"
|
|
9
9
|
import {ActivityLockService} from "../../infrastructure/utilities/activity-lock.service"
|
|
10
|
-
import {
|
|
10
|
+
import {useSsoLogin} from "../../infrastructure/hooks/use-sso-login"
|
|
11
11
|
import {Avatar, AvatarFallback} from "../ui/avatar"
|
|
12
12
|
import {Button} from "../ui/button"
|
|
13
13
|
import {Input} from "../ui/input"
|
|
@@ -49,7 +49,6 @@ export function AuthLockScreen(
|
|
|
49
49
|
onUnlocked,
|
|
50
50
|
actionLabel = 'Se connecter avec Liorian Auth',
|
|
51
51
|
}: AuthLockScreenProps) {
|
|
52
|
-
const [connecting, setConnecting] = useState(false)
|
|
53
52
|
const [user, setUser] = useState<UserInterface | null>(null)
|
|
54
53
|
const [mode, setMode] = useState<'sso' | 'unlock'>(() => AuthUserService.isAuthenticated() ? 'unlock' : 'sso')
|
|
55
54
|
const [password, setPassword] = useState('')
|
|
@@ -57,6 +56,11 @@ export function AuthLockScreen(
|
|
|
57
56
|
const [unlocking, setUnlocking] = useState(false)
|
|
58
57
|
const [error, setError] = useState<string | null>(null)
|
|
59
58
|
|
|
59
|
+
// Connexion SSO Liorian : déléguée au hook partagé (redirection par défaut,
|
|
60
|
+
// popup sur option). Le hook gère le repli, la fermeture du popup et la
|
|
61
|
+
// réception du résultat, puis déclenche `onAuthenticated` / la navigation.
|
|
62
|
+
const {connecting, start: startSsoLoginFlow} = useSsoLogin({redirectUrl, onAuthenticated})
|
|
63
|
+
|
|
60
64
|
useEffect(() => {
|
|
61
65
|
setUser(AuthUserService.getUser())
|
|
62
66
|
}, [])
|
|
@@ -81,52 +85,8 @@ export function AuthLockScreen(
|
|
|
81
85
|
return {name, avatarId, initials};
|
|
82
86
|
}, [user]);
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (event.origin !== window.location.origin) return;
|
|
87
|
-
if (event.data?.source !== 'liorian-auth') return;
|
|
88
|
-
if (event.data?.type !== SSO_CALLBACK_MESSAGE) return;
|
|
89
|
-
|
|
90
|
-
if (event.data?.success) {
|
|
91
|
-
setConnecting(false);
|
|
92
|
-
onAuthenticated?.();
|
|
93
|
-
// La session a été installée dans le `localStorage` partagé : on force une
|
|
94
|
-
// navigation complète pour que `AuthProvider` ré-hydrate le store depuis le stockage.
|
|
95
|
-
if (redirectUrl) {
|
|
96
|
-
window.location.assign(redirectUrl);
|
|
97
|
-
} else {
|
|
98
|
-
window.location.reload();
|
|
99
|
-
}
|
|
100
|
-
} else {
|
|
101
|
-
setConnecting(false);
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
window.addEventListener('message', onMessage);
|
|
105
|
-
return () => window.removeEventListener('message', onMessage);
|
|
106
|
-
}, [redirectUrl, onAuthenticated]);
|
|
107
|
-
|
|
108
|
-
const handleAuthenticate = async () => {
|
|
109
|
-
if (connecting) return;
|
|
110
|
-
setConnecting(true);
|
|
111
|
-
try {
|
|
112
|
-
const popup = await startSsoLoginPopup();
|
|
113
|
-
if (!popup) {
|
|
114
|
-
await startSsoLogin();
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Si le popup est fermé sans message de succès, réactiver le bouton.
|
|
119
|
-
const watchPopup = setInterval(() => {
|
|
120
|
-
if (popup.closed) {
|
|
121
|
-
clearInterval(watchPopup);
|
|
122
|
-
setConnecting(false);
|
|
123
|
-
}
|
|
124
|
-
}, 500);
|
|
125
|
-
window.addEventListener('beforeunload', () => clearInterval(watchPopup));
|
|
126
|
-
} catch (e) {
|
|
127
|
-
console.error('Impossible de démarrer l’authentification SSO', e);
|
|
128
|
-
setConnecting(false);
|
|
129
|
-
}
|
|
88
|
+
const handleAuthenticate = () => {
|
|
89
|
+
void startSsoLoginFlow();
|
|
130
90
|
};
|
|
131
91
|
|
|
132
92
|
const handleUnlockWithPassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
@@ -16,6 +16,7 @@ export * from "./local-time";
|
|
|
16
16
|
export * from "./markdown-parser";
|
|
17
17
|
export * from "./message-scroller";
|
|
18
18
|
export * from "./module-first-access-config.view";
|
|
19
|
+
export * from "./navigation-progress";
|
|
19
20
|
export * from "./module-first-access-pending.view";
|
|
20
21
|
export * from "./module-guard";
|
|
21
22
|
export * from "./modules-first-access.guard";
|
|
@@ -24,6 +25,7 @@ export * from "./notification-details.modal";
|
|
|
24
25
|
export * from "./notification-item";
|
|
25
26
|
export * from "./notifications-popover";
|
|
26
27
|
export * from "./push-notifications.provider";
|
|
28
|
+
export * from "./redirect";
|
|
27
29
|
export * from "./upload-progress-dialog";
|
|
28
30
|
export * from "./waiting";
|
|
29
31
|
export * from "./waiting-bar";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {usePathname} from "next/navigation";
|
|
5
|
+
import {useIsFetching} from "@tanstack/react-query";
|
|
6
|
+
import {WaitingBar} from "./waiting-bar";
|
|
7
|
+
import {cn} from "../../infrastructure/utilities/utils";
|
|
8
|
+
|
|
9
|
+
// Délai avant affichage : évite tout flash sur les navigations instantanées
|
|
10
|
+
const SHOW_DELAY_MS = 150;
|
|
11
|
+
// Filet de sécurité : la barre ne doit jamais rester bloquée à l'écran
|
|
12
|
+
const SAFETY_TIMEOUT_MS = 20_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
Barre de progression de navigation globale, visible :
|
|
16
|
+
- dès que l'utilisateur entame un changement de page (clic sur un lien
|
|
17
|
+
interne ou navigation programmatique),
|
|
18
|
+
- pendant que le serveur compile la page demandée (dev, compilation à la
|
|
19
|
+
demande : le clic bloque jusqu'à la fin de compilation),
|
|
20
|
+
puis masquée dès que la page cible est affichée.
|
|
21
|
+
S'affiche aussi pendant les requêtes react-query en cours (comportement
|
|
22
|
+
historique).
|
|
23
|
+
*/
|
|
24
|
+
export function NavigationProgress() {
|
|
25
|
+
const isFetching = useIsFetching() > 0;
|
|
26
|
+
|
|
27
|
+
const [navigating, setNavigating] = React.useState(false);
|
|
28
|
+
const pathname = usePathname();
|
|
29
|
+
|
|
30
|
+
const targetRef = React.useRef<string | null>(null);
|
|
31
|
+
const showTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
32
|
+
const safetyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
33
|
+
|
|
34
|
+
const finish = React.useCallback(() => {
|
|
35
|
+
if (showTimerRef.current) clearTimeout(showTimerRef.current);
|
|
36
|
+
if (safetyTimerRef.current) clearTimeout(safetyTimerRef.current);
|
|
37
|
+
showTimerRef.current = null;
|
|
38
|
+
safetyTimerRef.current = null;
|
|
39
|
+
targetRef.current = null;
|
|
40
|
+
setNavigating(false);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
const currentPath = () => window.location.pathname + window.location.search;
|
|
45
|
+
|
|
46
|
+
const startNavigation = (path: string) => {
|
|
47
|
+
if (path === currentPath()) return;
|
|
48
|
+
targetRef.current = path;
|
|
49
|
+
if (showTimerRef.current) clearTimeout(showTimerRef.current);
|
|
50
|
+
showTimerRef.current = setTimeout(() => setNavigating(true), SHOW_DELAY_MS);
|
|
51
|
+
if (safetyTimerRef.current) clearTimeout(safetyTimerRef.current);
|
|
52
|
+
safetyTimerRef.current = setTimeout(finish, SAFETY_TIMEOUT_MS);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// 1. Clic sur un lien interne : signal immédiat, y compris pendant que
|
|
56
|
+
// le serveur compile la page suivante (aucun événement routeur tant
|
|
57
|
+
// que la compilation/RSC n'est pas prête).
|
|
58
|
+
const onClickCapture = (event: MouseEvent) => {
|
|
59
|
+
if (event.defaultPrevented || event.button !== 0 ||
|
|
60
|
+
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
61
|
+
const anchor = (event.target as Element | null)?.closest?.("a");
|
|
62
|
+
if (!anchor) return;
|
|
63
|
+
const href = anchor.getAttribute("href");
|
|
64
|
+
if (!href || href.startsWith("#")) return;
|
|
65
|
+
if (anchor.target === "_blank" || anchor.hasAttribute("download")) return;
|
|
66
|
+
let url: URL;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(anchor.href, window.location.href);
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (url.origin !== window.location.origin) return;
|
|
73
|
+
startNavigation(url.pathname + url.search);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 2. Navigations programmatiques (router.push…) : le routeur passe par
|
|
77
|
+
// l'History API au moment où il confirme la navigation.
|
|
78
|
+
const wrap = (key: "pushState" | "replaceState") => {
|
|
79
|
+
const original = window.history[key].bind(window.history);
|
|
80
|
+
(window.history as any)[key] = (data: unknown, unused: string, url?: string | URL | null) => {
|
|
81
|
+
try {
|
|
82
|
+
if (url != null) {
|
|
83
|
+
const parsed = new URL(String(url), window.location.href);
|
|
84
|
+
if (parsed.origin === window.location.origin) {
|
|
85
|
+
startNavigation(parsed.pathname + parsed.search);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// URL non parsable : ignorer silencieusement
|
|
90
|
+
}
|
|
91
|
+
const result = original(data, unused, url as never);
|
|
92
|
+
// Navigation confirmée : masque dès que la cible est atteinte
|
|
93
|
+
if (targetRef.current && currentPath() === targetRef.current) {
|
|
94
|
+
setTimeout(finish, 0);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
};
|
|
98
|
+
return original;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const originalPush = wrap("pushState");
|
|
102
|
+
const originalReplace = wrap("replaceState");
|
|
103
|
+
|
|
104
|
+
document.addEventListener("click", onClickCapture, true);
|
|
105
|
+
return () => {
|
|
106
|
+
document.removeEventListener("click", onClickCapture, true);
|
|
107
|
+
window.history.pushState = originalPush;
|
|
108
|
+
window.history.replaceState = originalReplace;
|
|
109
|
+
if (showTimerRef.current) clearTimeout(showTimerRef.current);
|
|
110
|
+
if (safetyTimerRef.current) clearTimeout(safetyTimerRef.current);
|
|
111
|
+
};
|
|
112
|
+
}, [finish]);
|
|
113
|
+
|
|
114
|
+
// Filet complémentaire : la page cible est rendue (changement de route
|
|
115
|
+
// détecté par le routeur) alors que le push n'aurait pas suffi.
|
|
116
|
+
React.useEffect(() => {
|
|
117
|
+
if (targetRef.current && window.location.pathname + window.location.search === targetRef.current) {
|
|
118
|
+
finish();
|
|
119
|
+
}
|
|
120
|
+
}, [pathname, finish]);
|
|
121
|
+
|
|
122
|
+
const visible = navigating || isFetching;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
aria-hidden={!visible}
|
|
127
|
+
className={cn(
|
|
128
|
+
"fixed top-0 left-0 right-0 z-9999 transition-opacity duration-300",
|
|
129
|
+
visible ? "opacity-100" : "opacity-0 pointer-events-none",
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
<WaitingBar/>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {useEffect} from "react";
|
|
4
|
+
import {useRouter} from "next/navigation";
|
|
5
|
+
|
|
6
|
+
export function Redirect({to}: { to: string }) {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
router.replace(to);
|
|
11
|
+
}, [router, to]);
|
|
12
|
+
|
|
13
|
+
return null;
|
|
14
|
+
}
|