@liorian/sdk 0.28.3-alpha.ed82e18 → 0.29.0-alpha.bdbe320

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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`, `auth-logout.util`, `route-access` (accès par route), `media-label.service`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liorian/sdk",
3
- "version": "0.28.3-alpha.ed82e18",
3
+ "version": "0.29.0-alpha.bdbe320",
4
4
  "description": "Core package for the Liorian (application shell, domain, infrastructure, UI design system).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 manager/API), ses permissions, ses prérequis fonctionnels (requirements/optionalRequirements) et ses dépendances runtime (dependencies/devDependencies). 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.",
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 manager/API), ses permissions 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": [
@@ -45,15 +45,6 @@
45
45
  "identity": ">=1.0.0"
46
46
  },
47
47
  "optionalRequirements": {},
48
- "dependencies": {
49
- "@liorian/sdk": "workspace:*",
50
- "react": "^19.0.0",
51
- "react-dom": "^19.0.0"
52
- },
53
- "devDependencies": {
54
- "@types/react": "^19.2.17",
55
- "typescript": "^6.0.3"
56
- },
57
48
  "widgets": ["analytics"],
58
49
  "routines": ["helloWorldAnalyticsRoutine"],
59
50
  "providers": ["layout"]
@@ -81,9 +72,7 @@
81
72
  "isEnabled",
82
73
  "isDefault",
83
74
  "requirements",
84
- "optionalRequirements",
85
- "dependencies",
86
- "devDependencies"
75
+ "optionalRequirements"
87
76
  ],
88
77
  "additionalProperties": true,
89
78
  "properties": {
@@ -263,18 +252,6 @@
263
252
  "description": "Modules optionnels dont le module enrichit le comportement s'ils sont présents (intégrations facultatives). La valeur `{}` indique aucun prérequis optionnel.",
264
253
  "examples": [{"messenger": ">=1.0.0"}]
265
254
  },
266
- "dependencies": {
267
- "$ref": "#/definitions/dependency-map",
268
- "title": "Dépendances npm",
269
- "description": "Dépendances de packages npm nécessaires à l'exécution du module (nom de package -> plage de versions ou `workspace:*` pour les packages du monorepo).",
270
- "examples": [{"@liorian/sdk": "workspace:*", "@tanstack/react-query": "^5.101.2"}]
271
- },
272
- "devDependencies": {
273
- "$ref": "#/definitions/dependency-map",
274
- "title": "Dépendances de développement",
275
- "description": "Dépendances npm nécessaires uniquement au développement et à la compilation du module (types, toolchain).",
276
- "examples": [{"typescript": "^6.0.3", "@types/react": "^19.2.17"}]
277
- },
278
255
  "widgets": {
279
256
  "type": "array",
280
257
  "items": {
@@ -413,21 +390,6 @@
413
390
  "description": "Association d'identifiants de modules (`requirements` / `optionalRequirements`) à des plages de versions acceptées.",
414
391
  "examples": [{"organization": ">=1.0.0", "media-library": ">=1.0.0"}]
415
392
  },
416
- "dependency-map": {
417
- "type": "object",
418
- "propertyNames": {
419
- "pattern": "^(?:@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$"
420
- },
421
- "patternProperties": {
422
- "^(?:@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$": {
423
- "$ref": "#/definitions/semver-range"
424
- }
425
- },
426
- "additionalProperties": false,
427
- "title": "Carte package npm -> version",
428
- "description": "Association de noms de packages npm (avec ou sans scope `@scope/package`) à des plages de versions acceptées.",
429
- "examples": [{"@liorian/sdk": "workspace:*", "react": "^19.0.0"}]
430
- },
431
393
  "publisher": {
432
394
  "type": "object",
433
395
  "required": ["id", "name"],
@@ -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',
@@ -7,4 +7,5 @@ export * from "./use-is-tauri";
7
7
  export * from "./use-mobile";
8
8
  export * from "./use-notifications";
9
9
  export * from "./use-recent-colors";
10
- export * from "./use-recent-modules";
10
+ export * from "./use-recent-modules";
11
+ export * from "./use-sso-login";
@@ -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
+ }
@@ -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
- * - fenêtre popup (`startSsoLoginPopup`) privilégié ;
15
- * - redirection complète (`startSsoLogin`) — solution de repli si le popup est bloqué.
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
- /** Vrai si la page de callback s'exécute dans un popup ouvert par `startSsoLoginPopup`. */
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
- return typeof window !== 'undefined' && !!window.opener && window.location !== window.opener.location;
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 via `postMessage`
112
- * puis le popup est refermé (retourne `false` pour empêcher la navigation).
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
- postResult(window.opener, false);
125
- window.close();
167
+ finishPopup(false);
168
+ return false;
126
169
  }
127
- return false;
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
- localStorage.removeItem(SSO_STORAGE.codeVerifier);
162
- localStorage.removeItem(SSO_STORAGE.state);
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 à l'onglet d'origine et refermer le popup,
173
- // sinon l'écran reste bloqué sur « Finalisation de la connexion… ».
174
- if (isPopup && window.opener) {
175
- postResult(window.opener, false);
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
- function postResult(opener: Window, success: boolean): void {
184
- opener.postMessage(
185
- {source: 'liorian-auth', type: SSO_CALLBACK_MESSAGE, success},
186
- window.location.origin,
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, isSsoCallbackInPopup} from "../../../infrastructure/utilities/sso-auth.util";
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
- if (!isSsoCallbackInPopup()) router.replace(successRedirect);
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
- if (!isSsoCallbackInPopup()) router.replace(errorRedirect);
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 {startSsoLogin} from "../../../infrastructure/utilities/sso-auth.util";
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 : redirige vers Liorian Auth (flux Authorization Code
14
- * + PKCE) dès le montage. Réutilisable par toutes les applications consommatrices.
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
- startSsoLogin().catch(() => {
19
- window.location.assign(fallbackRedirect);
20
- });
21
- }, [fallbackRedirect]);
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 {startSsoLogin, startSsoLoginPopup, SSO_CALLBACK_MESSAGE} from "../../infrastructure/utilities/sso-auth.util"
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
- useEffect(() => {
85
- const onMessage = (event: MessageEvent) => {
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>) => {