codevdesign 1.0.62 → 1.0.65
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/composants/csqcEditeurTexteRiche.vue +34 -36
- package/composants/csqcTable/csqcTable.vue +147 -13
- package/outils/appAxios.ts +4 -7
- package/outils/rafraichisseurToken.ts +188 -187
- package/package.json +1 -1
|
@@ -17,32 +17,34 @@
|
|
|
17
17
|
<script setup lang="ts">
|
|
18
18
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
19
19
|
|
|
20
|
-
import { computed, ref, watch, nextTick } from 'vue'
|
|
21
|
-
import Editor from '@tinymce/tinymce-vue'
|
|
20
|
+
import { computed, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
|
22
21
|
import type { Editor as TinyMCEEditor } from 'tinymce'
|
|
23
22
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
23
|
+
// TinyMCE chargé en chunk séparé (code-splitting)
|
|
24
|
+
const Editor = defineAsyncComponent(async () => {
|
|
25
|
+
// Le core doit s'initialiser en premier
|
|
26
|
+
await import('tinymce/tinymce.js')
|
|
27
|
+
// Tout le reste en parallèle
|
|
28
|
+
await Promise.all([
|
|
29
|
+
import('tinymce/icons/default/icons.min.js'),
|
|
30
|
+
import('tinymce/themes/silver/theme.min.js'),
|
|
31
|
+
import('tinymce/models/dom/model.min.js'),
|
|
32
|
+
import('tinymce/skins/ui/oxide/skin.js'),
|
|
33
|
+
import('tinymce/skins/ui/oxide/content.js'),
|
|
34
|
+
import('tinymce/skins/content/default/content.js'),
|
|
35
|
+
import('tinymce-i18n/langs7/fr_FR'),
|
|
36
|
+
import('tinymce/plugins/autoresize/plugin.min.js'),
|
|
37
|
+
import('tinymce/plugins/advlist/plugin.min.js'),
|
|
38
|
+
import('tinymce/plugins/lists/plugin.min.js'),
|
|
39
|
+
import('tinymce/plugins/link/plugin.min.js'),
|
|
40
|
+
import('tinymce/plugins/autolink/plugin.min.js'),
|
|
41
|
+
import('tinymce/plugins/fullscreen/plugin.min.js'),
|
|
42
|
+
import('tinymce/plugins/table/plugin.min.js'),
|
|
43
|
+
import('tinymce/plugins/image/plugin.min.js'),
|
|
44
|
+
import('tinymce/plugins/code/plugin.min.js'),
|
|
45
|
+
])
|
|
46
|
+
return (await import('@tinymce/tinymce-vue')).default
|
|
47
|
+
})
|
|
46
48
|
|
|
47
49
|
const openLink = (url?: string | null, target?: string | null) => {
|
|
48
50
|
if (!url) return
|
|
@@ -95,8 +97,8 @@
|
|
|
95
97
|
const editorReady = ref(true)
|
|
96
98
|
const editorValue = ref<string>(props.modelValue)
|
|
97
99
|
const _editor = ref<TinyMCEEditor | null>(null)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
let _reinitPending = false
|
|
101
|
+
let _reinitLock = false
|
|
100
102
|
|
|
101
103
|
const imageTailleMaxMo = computed<number>(() => {
|
|
102
104
|
const n = Number(props.imageTailleMaximale)
|
|
@@ -166,10 +168,6 @@
|
|
|
166
168
|
setup: (editor: TinyMCEEditor) => {
|
|
167
169
|
_editor.value = editor
|
|
168
170
|
|
|
169
|
-
editor.on('init', () => {
|
|
170
|
-
// init ok
|
|
171
|
-
})
|
|
172
|
-
|
|
173
171
|
// liens cliquables
|
|
174
172
|
editor.on('click', (e: any) => {
|
|
175
173
|
const a = e.target?.closest?.('a')
|
|
@@ -306,17 +304,17 @@
|
|
|
306
304
|
|
|
307
305
|
// --- Re-montage TinyMCE si props importantes changent ---
|
|
308
306
|
const scheduleReinit = () => {
|
|
309
|
-
if (_reinitPending
|
|
310
|
-
_reinitPending
|
|
307
|
+
if (_reinitPending) return
|
|
308
|
+
_reinitPending = true
|
|
311
309
|
queueMicrotask(async () => {
|
|
312
|
-
_reinitPending
|
|
310
|
+
_reinitPending = false
|
|
313
311
|
await reinitEditor()
|
|
314
312
|
})
|
|
315
313
|
}
|
|
316
314
|
|
|
317
315
|
const reinitEditor = async () => {
|
|
318
|
-
if (_reinitLock
|
|
319
|
-
_reinitLock
|
|
316
|
+
if (_reinitLock) return
|
|
317
|
+
_reinitLock = true
|
|
320
318
|
try {
|
|
321
319
|
const ed = _editor.value as any
|
|
322
320
|
if (ed && typeof ed.remove === 'function') {
|
|
@@ -332,7 +330,7 @@
|
|
|
332
330
|
editorReady.value = true
|
|
333
331
|
await nextTick()
|
|
334
332
|
} finally {
|
|
335
|
-
_reinitLock
|
|
333
|
+
_reinitLock = false
|
|
336
334
|
}
|
|
337
335
|
}
|
|
338
336
|
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
v-bind="$attrs"
|
|
75
75
|
:headers="colonnesAffichees"
|
|
76
76
|
:item-key="itemKey"
|
|
77
|
-
:items="
|
|
77
|
+
:items="listeEffective"
|
|
78
78
|
:search="recherche"
|
|
79
79
|
:loading="chargementListe"
|
|
80
80
|
:hover="hover"
|
|
@@ -98,6 +98,14 @@
|
|
|
98
98
|
/>
|
|
99
99
|
</template>
|
|
100
100
|
|
|
101
|
+
<!-- Icône ancre (drag & drop) -->
|
|
102
|
+
<template
|
|
103
|
+
v-if="estEnModeModificateurOrdre"
|
|
104
|
+
#item.ancre
|
|
105
|
+
>
|
|
106
|
+
<v-icon class="ancre-drag">mdi-drag-vertical</v-icon>
|
|
107
|
+
</template>
|
|
108
|
+
|
|
101
109
|
<!-- boutons supprimer et modifier -->
|
|
102
110
|
<!-- eslint-disable-next-line -->
|
|
103
111
|
<template v-slot:item.action="{ item }">
|
|
@@ -148,7 +156,7 @@
|
|
|
148
156
|
</template>
|
|
149
157
|
<script setup lang="ts">
|
|
150
158
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
151
|
-
import { computed, onMounted, ref, type PropType, type Slots } from 'vue'
|
|
159
|
+
import { computed, onBeforeUnmount, nextTick, onMounted, ref, watch, type PropType, type Slots } from 'vue'
|
|
152
160
|
import { useI18n } from 'vue-i18n'
|
|
153
161
|
import type { SortItem } from 'vuetify/lib/components/VDataTable/composables/sort.mjs'
|
|
154
162
|
|
|
@@ -158,6 +166,7 @@
|
|
|
158
166
|
import ModaleChoix from './csqcTableModaleChoixColonnes.vue'
|
|
159
167
|
import axios from '../../outils/appAxios'
|
|
160
168
|
import Colonne from '../../modeles/composants/datatableColonne'
|
|
169
|
+
import Sortable from 'sortablejs'
|
|
161
170
|
|
|
162
171
|
type VueColonnes = {
|
|
163
172
|
nomVue: string
|
|
@@ -166,7 +175,15 @@
|
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
const slots = defineSlots<Slots>()
|
|
169
|
-
const emit = defineEmits([
|
|
178
|
+
const emit = defineEmits([
|
|
179
|
+
'ajouter',
|
|
180
|
+
'cliqueLigne',
|
|
181
|
+
'supprimer',
|
|
182
|
+
'modifier',
|
|
183
|
+
'donneesExportees',
|
|
184
|
+
'panneau:etat',
|
|
185
|
+
'ordreRangeeModifie',
|
|
186
|
+
])
|
|
170
187
|
|
|
171
188
|
// ============================================================================
|
|
172
189
|
// Props
|
|
@@ -229,6 +246,9 @@
|
|
|
229
246
|
type: [Array, String] as PropType<string | string[] | SortItem[] | undefined>,
|
|
230
247
|
default: undefined,
|
|
231
248
|
},
|
|
249
|
+
|
|
250
|
+
// Ordre manuel (drag & drop)
|
|
251
|
+
modificateurOrdreChamp: { type: String, default: undefined },
|
|
232
252
|
})
|
|
233
253
|
|
|
234
254
|
const { t } = useI18n({ useScope: 'global' })
|
|
@@ -240,6 +260,8 @@
|
|
|
240
260
|
// ============================================================================
|
|
241
261
|
// State - Choix de colonnes (vues sauvegardées) + sélection courante
|
|
242
262
|
// ============================================================================
|
|
263
|
+
const datatable = ref<any>(null)
|
|
264
|
+
const listeInterne = ref<Record<string, any>[]>([])
|
|
243
265
|
const modaleChoix = ref<InstanceType<typeof ModaleChoix> | null>(null)
|
|
244
266
|
const openChoixColonnes = ref(false)
|
|
245
267
|
const choix = ref<VueColonnes[]>([]) // la liste des vues disponibles
|
|
@@ -319,6 +341,29 @@
|
|
|
319
341
|
if (defaut) appliquerVue(defaut)
|
|
320
342
|
}
|
|
321
343
|
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// Computed - Mode ordre manuel
|
|
346
|
+
// ============================================================================
|
|
347
|
+
const estEnModeModificateurOrdre = computed(() => {
|
|
348
|
+
if (!props.modificateurOrdreChamp) return false
|
|
349
|
+
|
|
350
|
+
if (props.liste == null) return false
|
|
351
|
+
|
|
352
|
+
// Vérifie que le champ existe dans les données (pas nécessairement une colonne visible)
|
|
353
|
+
if (!props.liste.length) return false // liste vide, on attend sans avertissement
|
|
354
|
+
|
|
355
|
+
const premierItem = props.liste[0]
|
|
356
|
+
|
|
357
|
+
if (!(props.modificateurOrdreChamp in premierItem)) {
|
|
358
|
+
console.log(
|
|
359
|
+
`[csqcTable] modificateurOrdreChamp="${props.modificateurOrdreChamp}" n'existe pas dans les données. Le mode d'ordonnancement est désactivé.`,
|
|
360
|
+
)
|
|
361
|
+
return false
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return true
|
|
365
|
+
})
|
|
366
|
+
|
|
322
367
|
// ============================================================================
|
|
323
368
|
// Computed - Colonnes à envoyer à la modale (liste ordonnée + libellé)
|
|
324
369
|
// ============================================================================
|
|
@@ -340,24 +385,36 @@
|
|
|
340
385
|
// 1) colonnes "disponibles" (on enlève celles explicitement cachées)
|
|
341
386
|
const colonnesFiltre = props.colonnes.filter(c => (c as any).align !== 'd-none')
|
|
342
387
|
|
|
343
|
-
// 2)
|
|
344
|
-
|
|
345
|
-
if (!choix.value?.length)
|
|
388
|
+
// 2) résoudre la liste selon les vues sauvegardées (choix colonnes)
|
|
389
|
+
let colonnesBase: Colonne[]
|
|
390
|
+
if (!props.permettreChoixColonnes || !choix.value?.length) {
|
|
391
|
+
colonnesBase = colonnesFiltre
|
|
392
|
+
} else {
|
|
393
|
+
// retrouver la vue sélectionnée (sinon la défaut)
|
|
394
|
+
const choixSelection = choix.value.find(
|
|
395
|
+
x => valeurSelectionChoixColonnes.value === x.nomVue || (!valeurSelectionChoixColonnes.value && x.defaut),
|
|
396
|
+
)
|
|
397
|
+
colonnesBase = choixSelection
|
|
398
|
+
? (choixSelection.colonnes.map(k => colonnesFiltre.find(c => String(c.key) === k)).filter(Boolean) as Colonne[])
|
|
399
|
+
: colonnesFiltre
|
|
400
|
+
}
|
|
346
401
|
|
|
347
|
-
// 3)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
402
|
+
// 3) ordre manuel actif : colonne ancre en tête + toutes les colonnes non triables
|
|
403
|
+
if (estEnModeModificateurOrdre.value) {
|
|
404
|
+
const colonneAncre = { key: 'ancre', title: '', sortable: false, width: '40px' } as Colonne
|
|
405
|
+
return [colonneAncre, ...colonnesBase.map(c => ({ ...c, sortable: false }))]
|
|
406
|
+
}
|
|
352
407
|
|
|
353
|
-
|
|
354
|
-
return choixSelection.colonnes.map(k => colonnesFiltre.find(c => String(c.key) === k)).filter(Boolean) as Colonne[]
|
|
408
|
+
return colonnesBase
|
|
355
409
|
})
|
|
356
410
|
|
|
357
411
|
// ============================================================================
|
|
358
412
|
// Computed - Tri initial (Vuetify sort-by)
|
|
359
413
|
// ============================================================================
|
|
360
414
|
const triDepart = computed<SortItem[] | undefined>(() => {
|
|
415
|
+
// ordre manuel : tri forcé sur le champ ordre, croissant
|
|
416
|
+
if (estEnModeModificateurOrdre.value) return [{ key: props.modificateurOrdreChamp!, order: 'asc' }]
|
|
417
|
+
|
|
361
418
|
if (props.triParDepart == null) return undefined
|
|
362
419
|
|
|
363
420
|
const ordre = props.triDescDepart ? 'desc' : 'asc'
|
|
@@ -373,6 +430,7 @@
|
|
|
373
430
|
})
|
|
374
431
|
|
|
375
432
|
const estMultiTriActif = computed(() => {
|
|
433
|
+
if (estEnModeModificateurOrdre.value) return false
|
|
376
434
|
if (props.triParDepart == null) return false
|
|
377
435
|
if (typeof props.triParDepart === 'string') return false
|
|
378
436
|
return true
|
|
@@ -387,6 +445,73 @@
|
|
|
387
445
|
return props.liste.filter(item => Object.values(item).some(val => String(val).toLowerCase().includes(q)))
|
|
388
446
|
})
|
|
389
447
|
|
|
448
|
+
const listeEffective = computed(() => (estEnModeModificateurOrdre.value ? listeInterne.value : props.liste))
|
|
449
|
+
|
|
450
|
+
// ============================================================================
|
|
451
|
+
// Drag & drop - Ordre manuel (Sortable.js)
|
|
452
|
+
// ============================================================================
|
|
453
|
+
let sortableInstance: Sortable | null = null
|
|
454
|
+
|
|
455
|
+
// Synchronise listeInterne quand le parent met à jour props.liste
|
|
456
|
+
watch(
|
|
457
|
+
() => props.liste,
|
|
458
|
+
val => {
|
|
459
|
+
listeInterne.value = [...val]
|
|
460
|
+
},
|
|
461
|
+
{ immediate: true },
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
// Init/destroy Sortable si la prop change en cours de vie du composant
|
|
465
|
+
watch(estEnModeModificateurOrdre, actif => {
|
|
466
|
+
if (actif) nextTick(initSortable)
|
|
467
|
+
else destroySortable()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
onMounted(() => {
|
|
471
|
+
if (estEnModeModificateurOrdre.value) nextTick(initSortable)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
onBeforeUnmount(() => {
|
|
475
|
+
destroySortable()
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
function initSortable() {
|
|
479
|
+
const tableEl = datatable.value?.$el as HTMLElement | undefined
|
|
480
|
+
if (!tableEl) return
|
|
481
|
+
const tbody = tableEl.querySelector('tbody')
|
|
482
|
+
if (!tbody) return
|
|
483
|
+
|
|
484
|
+
destroySortable() // garantit une seule instance active
|
|
485
|
+
|
|
486
|
+
sortableInstance = Sortable.create(tbody, {
|
|
487
|
+
animation: 150,
|
|
488
|
+
handle: '.ancre-drag',
|
|
489
|
+
onEnd(event) {
|
|
490
|
+
const oldIndex = event.oldIndex
|
|
491
|
+
const newIndex = event.newIndex
|
|
492
|
+
if (oldIndex == null || newIndex == null || oldIndex === newIndex) return
|
|
493
|
+
|
|
494
|
+
const items = [...listeInterne.value]
|
|
495
|
+
const moved = items.splice(oldIndex, 1)[0]
|
|
496
|
+
items.splice(newIndex, 0, moved)
|
|
497
|
+
|
|
498
|
+
// Recalcule la valeur du champ ordre pour maintenir la cohérence
|
|
499
|
+
const champ = props.modificateurOrdreChamp!
|
|
500
|
+
items.forEach((item, idx) => {
|
|
501
|
+
item[champ] = idx + 1
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
listeInterne.value = items
|
|
505
|
+
emit('ordreRangeeModifie', [...items])
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function destroySortable() {
|
|
511
|
+
sortableInstance?.destroy()
|
|
512
|
+
sortableInstance = null
|
|
513
|
+
}
|
|
514
|
+
|
|
390
515
|
// ============================================================================
|
|
391
516
|
// UI Actions - Recherche / Table events
|
|
392
517
|
// ============================================================================
|
|
@@ -534,4 +659,13 @@
|
|
|
534
659
|
.text-grisMoyen:hover {
|
|
535
660
|
color: #095797 !important;
|
|
536
661
|
}
|
|
662
|
+
|
|
663
|
+
/* icône d'ancre drag & drop */
|
|
664
|
+
.ancre-drag {
|
|
665
|
+
cursor: grab;
|
|
666
|
+
color: #808a9d;
|
|
667
|
+
}
|
|
668
|
+
.ancre-drag:active {
|
|
669
|
+
cursor: grabbing;
|
|
670
|
+
}
|
|
537
671
|
</style>
|
package/outils/appAxios.ts
CHANGED
|
@@ -31,9 +31,6 @@ export default {
|
|
|
31
31
|
clearCache: false,
|
|
32
32
|
|
|
33
33
|
getAxios(): AxiosInstance {
|
|
34
|
-
// Singleton + clearCache
|
|
35
|
-
if (client && !this.clearCache) return client
|
|
36
|
-
|
|
37
34
|
const appStore = useAppStore()
|
|
38
35
|
|
|
39
36
|
const rawUrl = appStore.modeleCharger
|
|
@@ -49,7 +46,7 @@ export default {
|
|
|
49
46
|
client = axios.create({
|
|
50
47
|
baseURL: `${urlBase}/api`,
|
|
51
48
|
withCredentials: true,
|
|
52
|
-
timeout:
|
|
49
|
+
timeout: 90_000,
|
|
53
50
|
headers: {
|
|
54
51
|
Accept: 'application/json',
|
|
55
52
|
'Content-Type': 'application/json',
|
|
@@ -75,7 +72,8 @@ export default {
|
|
|
75
72
|
(error: AxiosError<any>) => {
|
|
76
73
|
const status = error.response?.status
|
|
77
74
|
const payload = error.response?.data?.resultat ?? { message: error.message }
|
|
78
|
-
|
|
75
|
+
console.log("Status d'erreur : ", status)
|
|
76
|
+
console.log("Contenu d'erreur", payload)
|
|
79
77
|
// 403 / 404
|
|
80
78
|
if (status === 403 || status === 404) {
|
|
81
79
|
try {
|
|
@@ -100,8 +98,7 @@ export default {
|
|
|
100
98
|
|
|
101
99
|
// Remonter l’erreur normalisée dans appstore
|
|
102
100
|
try {
|
|
103
|
-
if (payload
|
|
104
|
-
else if (payload) appStore.lancerErreur(payload)
|
|
101
|
+
if (payload) appStore.lancerErreur(payload)
|
|
105
102
|
} catch {
|
|
106
103
|
// no-op
|
|
107
104
|
}
|
|
@@ -1,187 +1,188 @@
|
|
|
1
|
-
class RafraichisseurToken {
|
|
2
|
-
private intervalleEnSecondes = 15
|
|
3
|
-
private secondesAvantExpirationTokenPourRafraichir = 20
|
|
4
|
-
private skewSeconds = 5 // marge anti-derives d’horloge
|
|
5
|
-
private popupAffiche = false
|
|
6
|
-
private timerId: number | null = null
|
|
7
|
-
|
|
8
|
-
// Lance une seule fois
|
|
9
|
-
public async demarrer(nomTemoin: string | null, urlPortail: string): Promise<void> {
|
|
10
|
-
urlPortail = urlPortail.replace(/\/+$/, '')
|
|
11
|
-
if (nomTemoin == null || nomTemoin === '') nomTemoin = 'csqc_jeton_secure_expiration'
|
|
12
|
-
await this.verifierJeton(nomTemoin, urlPortail)
|
|
13
|
-
if (this.timerId != null) return
|
|
14
|
-
this.timerId = window.setInterval(() => {
|
|
15
|
-
this.verifierJeton(nomTemoin, urlPortail)
|
|
16
|
-
}, this.intervalleEnSecondes * 1000)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Permet d’arrêter le timer (ex: au logout / destroy)
|
|
20
|
-
public arreter(): void {
|
|
21
|
-
if (this.timerId != null) {
|
|
22
|
-
clearInterval(this.timerId)
|
|
23
|
-
this.timerId = null
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
public existeJeton = (nomTemoin: string) => {
|
|
28
|
-
return this.estJetonValide(nomTemoin)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
private async verifierJeton(nomTemoin: string, urlPortail: string): Promise<void> {
|
|
32
|
-
if (this.popupAffiche) return
|
|
33
|
-
|
|
34
|
-
if (!this.estJetonValide(nomTemoin)) {
|
|
35
|
-
this.rafraichir(nomTemoin, urlPortail)
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
private estJetonValide(nomTemoin: string): boolean {
|
|
41
|
-
if (this.popupAffiche || !nomTemoin) return true //On fait semblant que c'est valide pour ne pas provoquer un autre affichage du popup.
|
|
42
|
-
|
|
43
|
-
const tokenEncode = this.lireCookie(nomTemoin)
|
|
44
|
-
if (!tokenEncode) {
|
|
45
|
-
return false
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let token: any
|
|
49
|
-
try {
|
|
50
|
-
token = this.parseJwt(tokenEncode)
|
|
51
|
-
} catch {
|
|
52
|
-
return false
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const exp = Number(token?.exp)
|
|
56
|
-
if (!Number.isFinite(exp)) {
|
|
57
|
-
// exp manquant/invalide → tente refresh
|
|
58
|
-
|
|
59
|
-
return false
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const now = Math.floor(Date.now() / 1000)
|
|
63
|
-
const refreshAt = exp - this.secondesAvantExpirationTokenPourRafraichir - this.skewSeconds
|
|
64
|
-
if (now >= refreshAt) {
|
|
65
|
-
return false
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return true
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private async rafraichir(nomCookie: string, urlPortail: string): Promise<void> {
|
|
72
|
-
if (!nomCookie) return
|
|
73
|
-
const url = this.getRefreshUrl(urlPortail)
|
|
74
|
-
const controller = new AbortController()
|
|
75
|
-
const timeout = setTimeout(() => controller.abort(), 10_000)
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
//Première tentative sans iframe, pour la majorité des cas.
|
|
79
|
-
const resp = await fetch(url, {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
credentials: 'include',
|
|
82
|
-
headers: { Accept: 'application/json' },
|
|
83
|
-
redirect: 'manual',
|
|
84
|
-
signal: controller.signal,
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
// redirection (souvent => login) → traiter comme non auth
|
|
88
|
-
|
|
89
|
-
if (resp.type === 'opaqueredirect' || resp.status === 302) {
|
|
90
|
-
this.rafraichirParIframe(nomCookie, urlPortail)
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// OK ou No Content: le cookie devrait être réécrit
|
|
95
|
-
if (resp.status === 200 || resp.status === 204) {
|
|
96
|
-
const jeton = this.lireCookie(nomCookie)
|
|
97
|
-
if (!jeton) this.rafraichirParIframe(nomCookie, urlPortail)
|
|
98
|
-
return
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// non auth / expiré (401, 419) + IIS timeout (440)
|
|
102
|
-
if (resp.status === 401 || resp.status === 419 || resp.status === 440) {
|
|
103
|
-
this.rafraichirParIframe(nomCookie, urlPortail)
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
console.warn('Rafraichisseur token: statut inattendu', resp.status)
|
|
108
|
-
} catch (err: any) {
|
|
109
|
-
if (err?.name === 'AbortError') console.warn('RafraichisseurToken timeout')
|
|
110
|
-
else console.error('Erreur rafraichisseur de token', err)
|
|
111
|
-
// on réessaiera au prochain tick
|
|
112
|
-
} finally {
|
|
113
|
-
clearTimeout(timeout)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private rafraichirParIframe(nomCookie: string, urlPortail: string): void {
|
|
118
|
-
// Pour éviter les cross référence, on créé un iframe qui appel portail et force la MAJ du jeton ou l'invalidation du jeton, si jamais l'utilisateur n'est plus connecté
|
|
119
|
-
// ajax vers le refresh
|
|
120
|
-
let iframe = document.createElement('iframe')
|
|
121
|
-
const url = this.getRefreshUrl(urlPortail)
|
|
122
|
-
iframe.src = `${url}?urlRetour=${encodeURI(window.
|
|
123
|
-
iframe.id = 'idRafrToken'
|
|
124
|
-
iframe.style.display = 'none'
|
|
125
|
-
document.body.appendChild(iframe)
|
|
126
|
-
iframe.onload = () => {
|
|
127
|
-
const jetonCSQC = this.lireCookie(nomCookie)
|
|
128
|
-
if (jetonCSQC == null || jetonCSQC === '') {
|
|
129
|
-
this.estDeconnecteAzure(urlPortail)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
iframe.remove()
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
private estDeconnecteAzure(urlPortail: string): void {
|
|
137
|
-
//on envoie au portail, pas le choix
|
|
138
|
-
|
|
139
|
-
const retour = encodeURI(window.location.href)
|
|
140
|
-
window.open(`${urlPortail}/home/SeConnecter?urlRetour=${retour}`, '_self')
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
public deconnecterPortail(urlPortail: string): void {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
.
|
|
173
|
-
.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
1
|
+
class RafraichisseurToken {
|
|
2
|
+
private intervalleEnSecondes = 15
|
|
3
|
+
private secondesAvantExpirationTokenPourRafraichir = 20
|
|
4
|
+
private skewSeconds = 5 // marge anti-derives d’horloge
|
|
5
|
+
private popupAffiche = false
|
|
6
|
+
private timerId: number | null = null
|
|
7
|
+
|
|
8
|
+
// Lance une seule fois
|
|
9
|
+
public async demarrer(nomTemoin: string | null, urlPortail: string): Promise<void> {
|
|
10
|
+
urlPortail = urlPortail.replace(/\/+$/, '')
|
|
11
|
+
if (nomTemoin == null || nomTemoin === '') nomTemoin = 'csqc_jeton_secure_expiration'
|
|
12
|
+
await this.verifierJeton(nomTemoin, urlPortail)
|
|
13
|
+
if (this.timerId != null) return
|
|
14
|
+
this.timerId = window.setInterval(() => {
|
|
15
|
+
this.verifierJeton(nomTemoin, urlPortail)
|
|
16
|
+
}, this.intervalleEnSecondes * 1000)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Permet d’arrêter le timer (ex: au logout / destroy)
|
|
20
|
+
public arreter(): void {
|
|
21
|
+
if (this.timerId != null) {
|
|
22
|
+
clearInterval(this.timerId)
|
|
23
|
+
this.timerId = null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public existeJeton = (nomTemoin: string) => {
|
|
28
|
+
return this.estJetonValide(nomTemoin)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async verifierJeton(nomTemoin: string, urlPortail: string): Promise<void> {
|
|
32
|
+
if (this.popupAffiche) return
|
|
33
|
+
|
|
34
|
+
if (!this.estJetonValide(nomTemoin)) {
|
|
35
|
+
this.rafraichir(nomTemoin, urlPortail)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private estJetonValide(nomTemoin: string): boolean {
|
|
41
|
+
if (this.popupAffiche || !nomTemoin) return true //On fait semblant que c'est valide pour ne pas provoquer un autre affichage du popup.
|
|
42
|
+
|
|
43
|
+
const tokenEncode = this.lireCookie(nomTemoin)
|
|
44
|
+
if (!tokenEncode) {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let token: any
|
|
49
|
+
try {
|
|
50
|
+
token = this.parseJwt(tokenEncode)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const exp = Number(token?.exp)
|
|
56
|
+
if (!Number.isFinite(exp)) {
|
|
57
|
+
// exp manquant/invalide → tente refresh
|
|
58
|
+
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const now = Math.floor(Date.now() / 1000)
|
|
63
|
+
const refreshAt = exp - this.secondesAvantExpirationTokenPourRafraichir - this.skewSeconds
|
|
64
|
+
if (now >= refreshAt) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async rafraichir(nomCookie: string, urlPortail: string): Promise<void> {
|
|
72
|
+
if (!nomCookie) return
|
|
73
|
+
const url = this.getRefreshUrl(urlPortail)
|
|
74
|
+
const controller = new AbortController()
|
|
75
|
+
const timeout = setTimeout(() => controller.abort(), 10_000)
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
//Première tentative sans iframe, pour la majorité des cas.
|
|
79
|
+
const resp = await fetch(url, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
credentials: 'include',
|
|
82
|
+
headers: { Accept: 'application/json' },
|
|
83
|
+
redirect: 'manual',
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// redirection (souvent => login) → traiter comme non auth
|
|
88
|
+
|
|
89
|
+
if (resp.type === 'opaqueredirect' || resp.status === 302) {
|
|
90
|
+
this.rafraichirParIframe(nomCookie, urlPortail)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// OK ou No Content: le cookie devrait être réécrit
|
|
95
|
+
if (resp.status === 200 || resp.status === 204) {
|
|
96
|
+
const jeton = this.lireCookie(nomCookie)
|
|
97
|
+
if (!jeton) this.rafraichirParIframe(nomCookie, urlPortail)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// non auth / expiré (401, 419) + IIS timeout (440)
|
|
102
|
+
if (resp.status === 401 || resp.status === 419 || resp.status === 440) {
|
|
103
|
+
this.rafraichirParIframe(nomCookie, urlPortail)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.warn('Rafraichisseur token: statut inattendu', resp.status)
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
if (err?.name === 'AbortError') console.warn('RafraichisseurToken timeout')
|
|
110
|
+
else console.error('Erreur rafraichisseur de token', err)
|
|
111
|
+
// on réessaiera au prochain tick
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timeout)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private rafraichirParIframe(nomCookie: string, urlPortail: string): void {
|
|
118
|
+
// Pour éviter les cross référence, on créé un iframe qui appel portail et force la MAJ du jeton ou l'invalidation du jeton, si jamais l'utilisateur n'est plus connecté
|
|
119
|
+
// ajax vers le refresh
|
|
120
|
+
let iframe = document.createElement('iframe')
|
|
121
|
+
const url = this.getRefreshUrl(urlPortail)
|
|
122
|
+
iframe.src = `${url}?urlRetour=${encodeURI(window.location.href)}`
|
|
123
|
+
iframe.id = 'idRafrToken'
|
|
124
|
+
iframe.style.display = 'none'
|
|
125
|
+
document.body.appendChild(iframe)
|
|
126
|
+
iframe.onload = () => {
|
|
127
|
+
const jetonCSQC = this.lireCookie(nomCookie)
|
|
128
|
+
if (jetonCSQC == null || jetonCSQC === '') {
|
|
129
|
+
this.estDeconnecteAzure(urlPortail)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
iframe.remove()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private estDeconnecteAzure(urlPortail: string): void {
|
|
137
|
+
//on envoie au portail, pas le choix
|
|
138
|
+
urlPortail = urlPortail.replace(/\/+$/, '')
|
|
139
|
+
const retour = encodeURI(window.location.href)
|
|
140
|
+
window.open(`${urlPortail}/home/SeConnecter?urlRetour=${retour}`, '_self')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
public deconnecterPortail(urlPortail: string): void {
|
|
145
|
+
urlPortail = urlPortail.replace(/\/+$/, '')
|
|
146
|
+
window.location.replace(`${urlPortail}/deconnecte?urlRetour=${encodeURIComponent(window.location.href)}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private lireCookie(nom: string): string | null {
|
|
150
|
+
if (!document.cookie) return null
|
|
151
|
+
const cookies = document.cookie.split(';').map(c => c.trim())
|
|
152
|
+
for (const cookie of cookies) {
|
|
153
|
+
if (cookie.startsWith(`${nom}=`)) {
|
|
154
|
+
try {
|
|
155
|
+
return decodeURIComponent(cookie.substring(nom.length + 1))
|
|
156
|
+
} catch {
|
|
157
|
+
return cookie.substring(nom.length + 1)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
165
|
+
private parseJwt(token: string): any {
|
|
166
|
+
const parts = token.split('.')
|
|
167
|
+
const base64Url = parts[1]
|
|
168
|
+
if (!base64Url) throw new Error('Invalid JWT format')
|
|
169
|
+
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
|
170
|
+
const jsonPayload = decodeURIComponent(
|
|
171
|
+
atob(base64)
|
|
172
|
+
.split('')
|
|
173
|
+
.map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
|
|
174
|
+
.join(''),
|
|
175
|
+
)
|
|
176
|
+
return JSON.parse(jsonPayload)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// URL refresh selon env (dev = proxy Vite ; prod = portail)
|
|
180
|
+
private getRefreshUrl(urlPortail: string): string {
|
|
181
|
+
if (import.meta.env.MODE === 'development') return '/portail-refresh'
|
|
182
|
+
return urlPortail + '/home/Refresh'
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Instance
|
|
187
|
+
const rafraichisseurToken = new RafraichisseurToken()
|
|
188
|
+
export default rafraichisseurToken
|