data-primals-engine 1.4.0 → 1.4.2

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.
Files changed (40) hide show
  1. package/README.md +856 -797
  2. package/client/src/AssistantChat.jsx +48 -5
  3. package/client/src/AssistantChat.scss +0 -1
  4. package/client/src/ChartConfigModal.jsx +34 -9
  5. package/client/src/Dashboard.jsx +396 -349
  6. package/client/src/DashboardChart.jsx +91 -12
  7. package/client/src/DataTable.jsx +817 -807
  8. package/client/src/Field.jsx +1784 -1782
  9. package/client/src/PackGallery.jsx +391 -290
  10. package/client/src/PackGallery.scss +231 -210
  11. package/client/src/contexts/UIContext.jsx +5 -2
  12. package/client/src/translations.js +188 -0
  13. package/package.json +19 -8
  14. package/src/config.js +2 -2
  15. package/src/core.js +21 -3
  16. package/src/engine.js +2 -1
  17. package/src/events.js +4 -3
  18. package/src/gameObject.js +1 -1
  19. package/src/middlewares/middleware-mongodb.js +3 -1
  20. package/src/modules/assistant/assistant.js +131 -42
  21. package/src/modules/auth-google/index.js +51 -0
  22. package/src/modules/auth-microsoft/index.js +82 -0
  23. package/src/modules/auth-saml/index.js +90 -0
  24. package/src/modules/bucket.js +3 -2
  25. package/src/modules/data/data.backup.js +374 -0
  26. package/src/modules/data/data.history.js +11 -8
  27. package/src/modules/data/data.js +190 -4543
  28. package/src/modules/data/data.operations.js +2790 -0
  29. package/src/modules/data/data.relations.js +687 -0
  30. package/src/modules/data/data.routes.js +1785 -1646
  31. package/src/modules/data/data.scheduling.js +274 -0
  32. package/src/modules/data/data.validation.js +245 -0
  33. package/src/modules/data/index.js +29 -1
  34. package/src/modules/user.js +2 -1
  35. package/src/modules/workflow.js +3 -2
  36. package/src/providers.js +110 -1
  37. package/src/services/stripe.js +3 -2
  38. package/src/sso.js +194 -0
  39. package/test/data.integration.test.js +3 -0
  40. package/test/user.test.js +1 -1
@@ -58,7 +58,7 @@ async function searchModels(query, user) {
58
58
  $or: [{ _user: user.username }, { _user: { $exists: false } }],
59
59
  $and: [{ $or: [{ name: { $regex: searchRegex } }, { description: { $regex: searchRegex } }] }]
60
60
  }, {
61
- projection: { name: 1, description: 1, _id: 0 }
61
+ projection: { name: 1, description: 1, fields: 1, _id: 0 }
62
62
  }).limit(10).toArray();
63
63
  }
64
64
 
@@ -74,39 +74,49 @@ const createSystemPrompt = (modelDefs, lang) => {
74
74
  Tu es "Prior", un assistant expert en analyse de données pour le moteur data-primals-engine..
75
75
  Ta mission est d'aider l'utilisateur en répondant à ses questions sur ses données.
76
76
 
77
+ REGLE FONDATRICE : suis les règles et ne dévie pas du chemin.
78
+ STYLE UTILISE : apporte l'information au plus rapide, sans détours, ni sollicitation à l'utilisateur, ou à des tiers.
77
79
 
78
80
  FORMAT DE RÉPONSE OBLIGATOIRE :
79
81
  Un SEUL objet JSON valide contenant exactement 2 champs :
80
82
  1. "action" (string)
81
83
  2. "params" (object)
82
- 2.1 : query (string) : Les mots clés de la recherche
83
- 2.2 : filter (object) : Les filtres de la recherche
84
- 2.3 : limit (number) : Le nombre de résultats à afficher
85
- 2.4 : sort (string) : La clé de tri de la recherche
86
- 2.6 : model (string) : Le nom du modèle à utiliser
87
- 2.7 : data (object) : Les données à ajouter à la base de données
88
-
89
- Tu as accès aux outils suivants.
90
-
91
- NOUVEL OUTIL PRIORITAIRE:
92
- 1. **search_models**: À utiliser EN PREMIER SI TU NE SAIS PAS QUEL MODÈLE UTILISER pour répondre à la question de l'utilisateur. Si tu l'as déjà recherché, garde la définition initiale et n'effectue pas de recherche.
93
- - Utilisation: { "action": "search_models", "params": { "query": "mots-clés de la recherche" } }
94
-
95
- 1. **search**: Pour chercher des informations dans les données de l'utilisateur.
84
+
85
+ Tu as accès aux outils et actions suivants.
86
+
87
+ OUTILS DE RAISONNEMENT INTERNE: (Utilisés pour collecter de l'information avant de décider de l'action finale)
88
+ 1. **search_models**: Pour rechercher les modèles de données disponibles.
89
+ - Utilisation: { "action": "search_models", "params": { "query": "^regexToSearchFor$" } }
90
+ ====
91
+
92
+ ACTIONS FINALES: (Actions qui terminent ta réflexion et renvoient un résultat à l'utilisateur)
93
+ 1. **search**: Pour chercher des informations et les AFFICHER à l'utilisateur sous forme de tableau.
96
94
  - Utilisation: { "action": "search", "params": { "model": "nomDuModele", "filter": {}, "limit": 10 } }
97
95
 
98
- 2. **post**: Pour créer une nouvelle donnée.
96
+ 2. **post**: Pour créer une nouvelle donnée (nécessite confirmation).
99
97
  - Utilisation: { "action": "post", "params": { "model": "nomDuModele", "data": {} } }
100
98
 
101
- 3. **update**: Pour mettre à jour des données existantes.
99
+ 3. **update**: Pour mettre à jour des données existantes (nécessite confirmation).
102
100
  - Utilisation: { "action": "update", "params": { "model": "nomDuModele", "filter": {}, "data": {} } }
103
101
  - filter est très pratique pour mettre à jour des données ciblées en une seule passe.
104
102
 
105
- 4. **delete**: Pour supprimer des données.
103
+ 4. **delete**: Pour supprimer des données (nécessite confirmation).
106
104
  - Utilisation: { "action": "delete", "params": { "model": "nomDuModele", "filter": {} } }
107
105
 
108
- 5. **displayMessage**: Pour répondre directement à l'utilisateur. N'utilise cette action QUE lorsque tu as toutes les informations nécessaires pour formuler une réponse finale.
106
+ 5. **displayMessage**: Pour répondre avec un simple message texte. N'utilise cette action QUE lorsque tu as toutes les informations nécessaires pour formuler une réponse finale.
109
107
  - Utilisation: { "action": "displayMessage", "params": { "message": "Ta réponse textuelle." } }
108
+
109
+ 6. **generateChart**: Pour créer et afficher un graphique.
110
+ - Utilisation: { "action": "generateChart", "params": { ...config } }
111
+ - Le paramètre \`config\` doit contenir :
112
+ - \`title\` (string): Un titre clair pour le graphique.
113
+ - \`model\` (string): Le nom du modèle de données à utiliser.
114
+ - \`type\` (string): Le type de graphique. Peut être 'bar', 'line', 'pie', 'doughnut'.
115
+ - \`aggregationType\` (string): Comment agréger les données. 'count' (par défaut), 'sum', 'avg', 'min', 'max'.
116
+ - \`xAxis\` (string): (Pour bar/line) Le champ pour l'axe des X. Un champ de type date, enum ou string du modèle.
117
+ - \`groupBy\` (string): (Pour pie/doughnut) Le champ sur lequel grouper les données (un champ enum ou relation du modèle).
118
+ - \`yAxis\` (string): (Optionnel, sauf pour sum/avg/min/max) Le champ numérique à agréger du modèle.
119
+ - \`filter\` (object): (Optionnel, filtre de la recherche, même écriture stricte que pour les filtres de recherche (voir plus bas pour les exemples)
110
120
 
111
121
  Voici le mémo pour assigner des valeurs aux champs des modèles,avec ces types de données :
112
122
  utilise une chaine de caractère convertible en ObjectId (mongodb) lorsque le nom du champ est _id
@@ -125,15 +135,14 @@ utilise la valeur en héxadecimal, ex: '#FF0000' pour les champs de type : color
125
135
  utilise les valeurs de cron standard '* * * * * *' pour : cronSchedule
126
136
 
127
137
  PROCESSUS DE RAISONNEMENT:
128
- 1. L'utilisateur pose une question.
129
- 2. Si le nom du modèle n'est pas évident, utilise d'abord l'outil "search_models" pour trouver le bon modèle.
130
- 3. Une fois le modèle identifié, utilise les autres outils (search, post, etc.) pour effectuer l'action.
131
- 4. Si tu as assez d'informations, réponds à l'utilisateur avec "displayMessage".
132
-
133
- Chacune de tes réponses doit être une étape en soi, ou au plus près d'une étape.
138
+ a- L'utilisateur pose une question, ou demande une action de ta part.
139
+ b- Utilise l'outil **search_models** pour trouver le(s) modèle(s) qui correspondent à la question.
140
+ Si tu as déjà fait la recherche dans la conversation, garde la définition initiale et n'effectue pas de recherche, va directement à l'étape c
141
+ c- Une fois la réponse retournée et intégrée, tu devras utiliser les autres outils (search, post, etc.) dans la conversation pour satisfaire la question initiale, en utilisant les informations des modèles précédents (COMMANDE FINALE)
142
+ Si tu n'as aucune commande à exécuter directement, réponds simplement à l'utilisateur avec "displayMessage".
134
143
 
135
144
  CONTEXTE ACTUEL:
136
- - Date du jour de ton conversateur : ${dt}
145
+ - Date du jour de la conversation : ${dt}
137
146
  - La langue ISO à utiliser dans la conversation : ${lang}
138
147
  - L'utilisateur a accès aux modèles de données suivants et ne peut utiliser les filtres que sur les champs associés:
139
148
  Si tu as besoin de savoir lesquels, utilise l'outil "search_models".
@@ -143,38 +152,91 @@ Si tu as besoin de savoir lesquels, utilise l'outil "search_models".
143
152
 
144
153
  Par exemple :
145
154
  Question : Je voudrais les événements non terminés, qui ne sont pas des festivals ou des salons :
146
- Réponse : { "action" : "search", "params" : ${cond1} }
155
+ Ta réponse: { "action" : "search_models", "params": { "query": "event|événement" } }
156
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond1} }
147
157
 
148
158
  Question : Donne moi les 5 nouvelles entreprises
149
- Réponse : { "action" : "search", "params" : ${cond2} }
159
+ Ta réponse: { "action" : "search_models", "params": { "query": "company|entreprise" } }
160
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond2} }
150
161
 
151
162
  Question : Je veux les 10 dernières traductions ajoutées dans la langue française.
152
- Réponse : { "action" : "search", "params" : ${cond3} }
163
+ Ta réponse: { "action" : "search_models", "params": { "query": "translation|traduction" } }
164
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond3} }
153
165
 
154
166
  Question : Je veux les commandes qui ont été faites par un admin ou un modérateur
155
- Réponse : { "action" : "search", "params" : ${cond4} }
156
- ====
167
+ Ta réponse: { "action" : "search_models", "params": { "query": "order|commande" } }
168
+ COMMANDE FINALE : { "action" : "search", "params" : ${cond4} }
169
+
170
+ Question : Fais-moi un camembert des produits par catégorie.
171
+ Ta réponse: { "action" : "search_models", "params": { "query": "product|produit" } }
172
+ COMMANDE FINALE :
173
+ {
174
+ "action": "generateChart",
175
+ "params": {
176
+ "title": "Répartition des produits par catégorie",
177
+ "model": "product",
178
+ "type": "pie",
179
+ "groupBy": "category",
180
+ "aggregationType": "count",
181
+ "filter": { "$and": [{"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"]}, {"$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} ]}
182
+ }
183
+ }
157
184
 
185
+ Absolute rules:
186
+ ====
158
187
  NEVER use the non-aggregated syntax of the MongoDB operators :
159
- Use { '$gt': ["$publishedAt", "2023-10-05T20:12:00Z" } } } and NOT { publishedAt: { '$gt': '2023-10-05T20:12:00Z' } } }
188
+ Use { "$gt": ["$publishedAt", "2023-10-05T20:12:00Z" ] } and NOT { "publishedAt": { "$gt": "2023-10-05T20:12:00Z" } }
189
+ =====
190
+ If several filters must be combined or juxtaposed, the operators $and, $or, $nor, $not MUST be used to make the logic.
191
+ You need at least one of these operators.
192
+ Example :
193
+ Use filters like this: { "$and": [{"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"]}, {"$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} ]}
194
+ You MUST NOT write filters like this : {"$gt": ["$publishedAt", "2023-10-05T20:12:00Z"], "$lte": ["$publishedAt", "2024-10-05T20:12:00Z"]} without the $and operator
160
195
  =====
161
196
  Et si tu dois utiliser une date :
162
197
  "2025-08-05T20:12:00Z" au lieu de { "$date": "2025-08-05T20:12:00Z" }
163
198
  =====
164
- Si plusieurs filtres doivent être combinés, utilise OBLIGATOIREMENT les opérateurs $and, $or, $nor, $not
165
- =====
166
-
167
- Règles ABSOLUES:
168
199
  - UNE SEULE COMMANDE JSON PAR RÉPONSE
169
200
  - AUCUN TEXTE HORS DU JSON
201
+ - PAS de MARKDOWN pour formatter le JSON, juste le JSON {...} brut.
202
+ =====
170
203
 
171
204
  Exemple d'échange correct :
172
205
 
173
206
  Ma question: Bonjour, je voudrais les requêtes effectuées aujourd'hui sur le modèle "content".
174
- Ta réponse:
207
+ Ta réponse: { "action" : "search_models", "params": { "query": "request" } }
208
+ COMMANDE FINALE :
175
209
  { "action" : "search", "params" : { "model": "request", "filter": { "$and": [{"$gte": "${dt}"}, {"$regexMatch": { "input": "$url", "regex": "content"}}] }, "limit" : 10, "sort" : "_id:DESC" }}`;
176
210
  }
177
211
 
212
+ /**
213
+ * Corrige un filtre généré par l'IA qui pourrait avoir plusieurs opérateurs
214
+ * au premier niveau sans les encapsuler dans un "$and".
215
+ * @param {object} filter - Le filtre potentiellement incorrect.
216
+ * @returns {object} Le filtre corrigé.
217
+ */
218
+ function correctAIFilter(filter) {
219
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
220
+ return filter; // Pas un objet à corriger
221
+ }
222
+
223
+ const keys = Object.keys(filter);
224
+ if (keys.length <= 1) {
225
+ return filter; // Rien à corriger
226
+ }
227
+
228
+ // Vérifie si toutes les clés sont des opérateurs (commencent par '$')
229
+ const allKeysAreOperators = keys.every(key => key.startsWith('$'));
230
+
231
+ if (allKeysAreOperators) {
232
+ logger.warn(`[Assistant] Correction d'un filtre malformé généré par l'IA. Original: ${JSON.stringify(filter)}`);
233
+ const andConditions = keys.map(key => ({ [key]: filter[key] }));
234
+ return { '$and': andConditions };
235
+ }
236
+
237
+ return filter; // Aucune correction nécessaire
238
+ }
239
+
178
240
  /**
179
241
  * Exécute un outil (tool) non-modifiant et retourne le résultat sous forme de chaîne.
180
242
  * @param {string} action - L'action à exécuter ('search', 'search_models').
@@ -216,7 +278,7 @@ async function executeTool(action, params, user, allModels) {
216
278
 
217
279
  if (foundModels.length > 0) {
218
280
  return "J'ai trouvé les modèles suivants qui pourraient correspondre : " +
219
- foundModels.map(m => `\n- Modèle "${m.name}": ${m.description || 'Pas de description.'}`).join('');
281
+ foundModels.map(m => `\n- Modèle "${m.name}": ${m.description || 'Pas de description.'}\n- Champs: ${m.fields.map(f => JSON.stringify(f, null, 2))}`).join('');
220
282
  } else {
221
283
  return "Je n'ai trouvé aucun modèle correspondant à votre recherche.";
222
284
  }
@@ -286,7 +348,7 @@ async function handleChatRequest(message, history, provider, context, user, conf
286
348
  // --- PRÉPARATION DE L'HISTORIQUE DE CONVERSATION ---
287
349
  const systemPrompt = createSystemPrompt([], user.lang || 'en');
288
350
  const conversationHistory = history
289
- .filter(msg => !(msg.from === 'bot' && msg.text.startsWith(i18n.t('assistant.welcome'))))
351
+ .filter(msg => msg.text && !(msg.from === 'bot' && msg.text.startsWith(i18n.t('assistant.welcome'))))
290
352
  .map(msg => new (msg.from === 'user' ? HumanMessage : SystemMessage)(msg.text));
291
353
 
292
354
  conversationHistory.unshift(new SystemMessage(systemPrompt));
@@ -317,11 +379,38 @@ async function handleChatRequest(message, history, provider, context, user, conf
317
379
  };
318
380
  }
319
381
 
320
- logger.debug(`[Assistant] Action décidée par l'IA: ${parsedResponse.action}`);
382
+ logger.debug(`[Assistant] Action décidée par l'IA: ${parsedResponse.action}`, parsedResponse);
321
383
  conversationHistory.push(new SystemMessage(JSON.stringify(parsedResponse)));
322
384
 
323
385
  const { action, params } = parsedResponse;
324
386
 
387
+ // Correction automatique du filtre généré par l'IA, qui hallucine parfois
388
+ if (params && params.filter) {
389
+ logger.debug(`[Assistant] Filtre original de l'IA: ${JSON.stringify(params.filter)}`);
390
+ params.filter = correctAIFilter(params.filter);
391
+ }
392
+
393
+ // Action de génération de graphique, gérée par le front-end
394
+ if (action === 'generateChart') {
395
+ // On retourne directement la configuration du graphique au client.
396
+ return { success: true, chartConfig: params };
397
+ }
398
+
399
+ // NOUVEAU: Action de recherche à afficher, gérée par le front-end
400
+ if (action === 'search') {
401
+ const searchResult = await searchData({
402
+ model: params.model,
403
+ filter: params.filter,
404
+ limit: params.limit || 10,
405
+ sort: params.sort
406
+ }, user);
407
+ if (searchResult.data?.length > 0) {
408
+ return { success: true, dataResult: { model: params.model, data: searchResult.data } };
409
+ } else {
410
+ return { success: true, displayMessage: i18n.t('assistant.search.noResults', "Je n'ai trouvé aucun résultat.") };
411
+ }
412
+ }
413
+
325
414
  // Actions nécessitant une confirmation de l'utilisateur
326
415
  if (['post', 'update', 'delete'].includes(action)) {
327
416
  const confirmationMessage = i18n.t('assistant.confirmActionPrompt', "Veuillez confirmer l'action suivante :");
@@ -337,8 +426,8 @@ async function handleChatRequest(message, history, provider, context, user, conf
337
426
  return { success: true, displayMessage: params.message };
338
427
  }
339
428
 
340
- // Actions à exécuter immédiatement (outils)
341
- if (['search', 'search_models'].includes(action)) {
429
+ // Outils pour le raisonnement interne de l'IA
430
+ if (['search_models'].includes(action)) { // On a enlevé 'search' d'ici
342
431
  const toolResult = await executeTool(action, params, user, allModels);
343
432
  conversationHistory.push(new SystemMessage(`Résultat de l'outil '${action}':\n${toolResult}`));
344
433
  continue; // On continue la boucle pour que l'IA puisse raisonner avec ce nouveau résultat
@@ -0,0 +1,51 @@
1
+ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
2
+ import { Sso, SSOUserProvider } from '../../sso.js';
3
+ import {Logger} from "../../gameObject.js";
4
+
5
+ let logger;
6
+
7
+ export async function onInit(engine) {
8
+ logger = engine.getComponent(Logger);
9
+
10
+ // Le SSOUserProvider est nécessaire pour l'initialisation et pour la stratégie.
11
+ // On le crée donc en premier.
12
+ const ssoUserProvider = new SSOUserProvider(engine);
13
+
14
+ // 1. Récupérer le composant PassportAuth central.
15
+ let passportAuth = engine.getComponent(Sso);
16
+ if (!passportAuth) {
17
+ passportAuth = engine.addComponent(Sso);
18
+ // C'EST ICI QUE L'INITIALISATION DOIT AVOIR LIEU, UNE SEULE FOIS.
19
+ passportAuth.initialize({ ssoUserProvider });
20
+ passportAuth.addLogoutRoute();
21
+ logger.info("[auth-google] Sso component was not found, created and initialized a new one.");
22
+ }
23
+
24
+ // 2. Vérifier que les variables d'environnement nécessaires sont présentes.
25
+ if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
26
+ logger.warn("[auth-google] GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET are not set. Google SSO will be disabled.");
27
+ return;
28
+ }
29
+
30
+ // 3. Créer l'instance de la stratégie Google.
31
+ const googleStrategy = new GoogleStrategy({
32
+ clientID: process.env.GOOGLE_CLIENT_ID,
33
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
34
+ callbackURL: "/api/auth/google/callback"
35
+ },
36
+ async (accessToken, refreshToken, profile, done) => {
37
+ try {
38
+ // On utilise le SSOUserProvider pour trouver ou créer l'utilisateur.
39
+ const user = await ssoUserProvider.findOrCreate(profile);
40
+ return done(null, user);
41
+ } catch (err) {
42
+ return done(err);
43
+ }
44
+ });
45
+
46
+ // 4. Enregistrer la stratégie auprès du composant PassportAuth.
47
+ passportAuth.addStrategy('google', googleStrategy,
48
+ { authPath: '/api/auth/google', callbackPath: '/api/auth/google/callback' },
49
+ { scope: ['profile', 'email'] }
50
+ );
51
+ }
@@ -0,0 +1,82 @@
1
+ import { Sso, SSOUserProvider } from '../../sso.js';
2
+ import { Logger } from "../../gameObject.js";
3
+
4
+ let logger;
5
+
6
+ export async function onInit(engine) {
7
+ logger = engine.getComponent(Logger);
8
+
9
+ try {
10
+ // 1. Tenter d'importer dynamiquement la stratégie Azure AD.
11
+ // L'utilisateur devra exécuter `npm install passport-azure-ad`.
12
+ const { OIDCStrategy } = await import('passport-azure-ad');
13
+
14
+ const ssoUserProvider = new SSOUserProvider(engine);
15
+
16
+ // 2. Récupérer ou créer le composant SSO central.
17
+ let ssoComponent = engine.getComponent(Sso);
18
+ if (!ssoComponent) {
19
+ ssoComponent = engine.addComponent(Sso);
20
+ ssoComponent.initialize({ ssoUserProvider });
21
+ ssoComponent.addLogoutRoute();
22
+ logger.info("[auth-microsoft] Sso component was not found, created and initialized a new one.");
23
+ }
24
+
25
+ // 3. Vérifier les variables d'environnement critiques pour Azure AD.
26
+ const { MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, MICROSOFT_TENANT_ID } = process.env;
27
+ if (!MICROSOFT_CLIENT_ID || !MICROSOFT_CLIENT_SECRET || !MICROSOFT_TENANT_ID) {
28
+ logger.warn("[auth-microsoft] Microsoft environment variables are not fully set. Microsoft SSO will be disabled.");
29
+ return;
30
+ }
31
+
32
+ const callbackUrl = process.env.MICROSOFT_CALLBACK_URL || '/api/auth/microsoft/callback';
33
+
34
+ // 4. Créer l'instance de la stratégie OIDC pour Microsoft.
35
+ const microsoftStrategy = new OIDCStrategy({
36
+ identityMetadata: `https://login.microsoftonline.com/${MICROSOFT_TENANT_ID}/v2.0/.well-known/openid-configuration`,
37
+ clientID: MICROSOFT_CLIENT_ID,
38
+ responseType: 'code id_token',
39
+ responseMode: 'form_post',
40
+ redirectUrl: callbackUrl,
41
+ allowHttpForRedirectUrl: process.env.NODE_ENV !== 'production',
42
+ clientSecret: MICROSOFT_CLIENT_SECRET,
43
+ validateIssuer: false, // Important pour les tenants multiples
44
+ passReqToCallback: false,
45
+ scope: ['profile', 'email', 'openid']
46
+ },
47
+ async (iss, sub, profile, accessToken, refreshToken, done) => {
48
+ try {
49
+ // Le profil Azure AD est différent. On l'adapte à notre format standard.
50
+ const email = profile.upn || profile._json.email;
51
+ if (!email) {
52
+ return done(new Error("Microsoft profile is missing an email (upn)."));
53
+ }
54
+
55
+ const adaptedProfile = {
56
+ provider: 'azuread',
57
+ id: profile.oid, // Object ID de l'utilisateur dans Azure
58
+ displayName: profile.displayName,
59
+ emails: [{ value: email }]
60
+ };
61
+
62
+ const user = await ssoUserProvider.findOrCreate(adaptedProfile);
63
+ return done(null, user);
64
+ } catch (err) {
65
+ return done(err);
66
+ }
67
+ });
68
+
69
+ // 5. Enregistrer la stratégie auprès du composant SSO.
70
+ ssoComponent.addStrategy('azuread-openidconnect', microsoftStrategy, {
71
+ authPath: '/api/auth/microsoft',
72
+ callbackPath: callbackUrl
73
+ });
74
+
75
+ } catch (e) {
76
+ if (e.code === 'ERR_MODULE_NOT_FOUND') {
77
+ logger.info("[auth-microsoft] Module désactivé. Pour l'activer, exécutez 'npm install passport-azure-ad'");
78
+ } else {
79
+ logger.error("[auth-microsoft] Une erreur inattendue a empêché le chargement du module :", e);
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,90 @@
1
+ import { Sso, SSOUserProvider } from '../../sso.js';
2
+ import { Logger } from "../../gameObject.js";
3
+
4
+ let logger;
5
+
6
+ export async function onInit(engine) {
7
+ logger = engine.getComponent(Logger);
8
+
9
+ try {
10
+ // 1. Tenter d'importer dynamiquement la stratégie SAML.
11
+ // Cela ne fonctionnera que si l'utilisateur a exécuté `npm install passport-saml-encrypted`.
12
+ const { Strategy: SamlStrategy } = await import('passport-saml-encrypted');
13
+
14
+ const ssoUserProvider = new SSOUserProvider(engine);
15
+
16
+ // 2. Récupérer le composant SSO central.
17
+ let ssoComponent = engine.getComponent(Sso);
18
+ if (!ssoComponent) {
19
+ // S'il n'existe pas, on l'ajoute. C'est le premier module SSO à se charger.
20
+ ssoComponent = engine.addComponent(Sso);
21
+ ssoComponent.initialize({ ssoUserProvider });
22
+ ssoComponent.addLogoutRoute();
23
+ logger.info("[auth-saml] Sso component was not found, created and initialized a new one.");
24
+ }
25
+
26
+ // 3. Vérifier les variables d'environnement critiques pour SAML.
27
+ const { SAML_ENTRY_POINT, SAML_ISSUER, SAML_CERT, SAML_CALLBACK_URL, SAML_DECRYPTION_KEY } = process.env;
28
+ if (!SAML_ENTRY_POINT || !SAML_ISSUER || !SAML_CERT || !SAML_DECRYPTION_KEY) {
29
+ logger.warn("[auth-saml] SAML environment variables (SAML_ENTRY_POINT, SAML_ISSUER, SAML_CERT, SAML_DECRYPTION_KEY) are not fully set. SAML SSO will be disabled.");
30
+ return;
31
+ }
32
+
33
+ const callbackUrl = SAML_CALLBACK_URL || '/api/auth/saml/callback';
34
+
35
+ // 4. Créer l'instance de la stratégie SAML.
36
+ // La configuration SAML est plus complexe que OAuth2.
37
+ const samlStrategy = new SamlStrategy(
38
+ {
39
+ path: callbackUrl, // Le chemin où l'IdP (Identity Provider) enverra la réponse POST.
40
+ entryPoint: SAML_ENTRY_POINT, // L'URL de connexion de l'IdP.
41
+ issuer: SAML_ISSUER, // L'identifiant de votre application (Service Provider).
42
+ cert: SAML_CERT, // Le certificat public de l'IdP pour vérifier la signature.
43
+ decryptionPvk: SAML_DECRYPTION_KEY, // Clé privée pour déchiffrer les assertions.
44
+ encryptedSAML: true, // Indique que nous attendons des assertions chiffrées.
45
+ acceptedClockSkewMs: -1 // Optionnel mais recommandé: pour les problèmes de synchronisation d'horloge.
46
+ },
47
+ async (profile, done) => {
48
+ try {
49
+ // Normaliser les clés du profil en minuscules pour une correspondance insensible à la casse.
50
+ const normalizedProfile = Object.keys(profile).reduce((acc, key) => {
51
+ acc[key.toLowerCase()] = profile[key];
52
+ return acc;
53
+ }, {});
54
+
55
+ // Le profil SAML est différent du profil Google. Nous devons l'adapter.
56
+ // `nameID` est souvent l'email. Les autres attributs dépendent de la configuration de l'IdP.
57
+ const email = normalizedProfile.email || normalizedProfile.nameid;
58
+ if (!email) {
59
+ return done(new Error("SAML profile is missing an email or nameID."));
60
+ }
61
+
62
+ const adaptedProfile = {
63
+ provider: 'saml',
64
+ id: normalizedProfile.nameid,
65
+ displayName: normalizedProfile.displayname || normalizedProfile.cn || `${normalizedProfile.firstname || ''} ${normalizedProfile.lastname || ''}`.trim() || email,
66
+ emails: [{ value: email }]
67
+ };
68
+
69
+ const user = await ssoUserProvider.findOrCreate(adaptedProfile);
70
+ return done(null, user);
71
+ } catch (err) {
72
+ return done(err);
73
+ }
74
+ }
75
+ );
76
+
77
+ // 5. Enregistrer la stratégie auprès du composant SSO.
78
+ ssoComponent.addStrategy('saml', samlStrategy, {
79
+ authPath: '/api/auth/saml',
80
+ callbackPath: callbackUrl
81
+ });
82
+
83
+ } catch (e) {
84
+ if (e.code === 'ERR_MODULE_NOT_FOUND') {
85
+ logger.info("[auth-saml] Module désactivé. Pour l'activer, exécutez 'npm install passport-saml-encrypted'");
86
+ } else {
87
+ logger.error("[auth-saml] Une erreur inattendue a empêché le chargement du module :", e);
88
+ }
89
+ }
90
+ }
@@ -12,6 +12,7 @@ import i18n from "../../src/i18n.js";
12
12
  import {sendEmail} from "../email.js";
13
13
  import {throttleMiddleware} from "../middlewares/throttle.js";
14
14
  import {getCollectionForUser} from "./mongodb.js";
15
+ import {safeAssignObject} from "../core.js";
15
16
 
16
17
  const restoreRequests = {};
17
18
 
@@ -21,11 +22,11 @@ export const requestRestore = async (user, lang) => {
21
22
  const modelsRestoreToken = crypto.randomBytes(32).toString('hex');
22
23
  const expiration = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
23
24
 
24
- restoreRequests[user?.username] = {
25
+ safeAssignObject(restoreRequests, user?.username, {
25
26
  fullToken: fullRestoreToken,
26
27
  modelsToken: modelsRestoreToken,
27
28
  expiresAt: expiration
28
- };
29
+ });
29
30
 
30
31
  i18n.changeLanguage(lang);
31
32