codevdesign 1.0.23 → 1.0.25

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.
@@ -7,7 +7,10 @@
7
7
  >
8
8
  <v-combobox
9
9
  v-model="codeBudgetaire"
10
- :items="codeBudgetairesProp"
10
+ :items="itemsCombobox"
11
+ item-value="code"
12
+ item-title="code"
13
+ :return-object="false"
11
14
  v-bind="$attrs"
12
15
  persistent-hint
13
16
  variant="outlined"
@@ -20,7 +23,15 @@
20
23
  @keydown="caractereAutorises"
21
24
  @update:modelValue="gererChangement"
22
25
  @paste="gererPaste"
23
- />
26
+ >
27
+ <template #item="{ props, item }">
28
+ <v-list-item
29
+ v-bind="props"
30
+ :title="item.raw.nom || item.raw.code"
31
+ :subtitle="item.raw.nom ? item.raw.code : undefined"
32
+ />
33
+ </template>
34
+ </v-combobox>
24
35
  </v-form>
25
36
  </div>
26
37
  </template>
@@ -31,13 +42,14 @@
31
42
 
32
43
  const emit = defineEmits<{
33
44
  'update:modelValue': [string | null]
34
- 'update:codeBudgetairesProp': [string[]]
35
- 'update:valide': [boolean] // 👈 nouveau
45
+ 'update:codeBudgetairesProp': [CodeBudgetaireItem[]]
46
+ 'update:valide': [boolean]
36
47
  }>()
48
+ type CodeBudgetaireItem = string | [string, string]
37
49
 
38
50
  const props = withDefaults(
39
51
  defineProps<{
40
- codeBudgetairesProp: string[]
52
+ codeBudgetairesProp: CodeBudgetaireItem[]
41
53
  modelValue: string | null
42
54
  afficherHint?: boolean
43
55
  regleMessageErreur: string
@@ -64,6 +76,22 @@
64
76
  derniereValeurSauvegardee.value = codeBudgetaire.value
65
77
  })
66
78
 
79
+ const itemsCombobox = computed(() => {
80
+ return props.codeBudgetairesProp.map(item => {
81
+ if (typeof item === 'string') {
82
+ return {
83
+ code: item,
84
+ nom: '', // pas de nom
85
+ }
86
+ }
87
+
88
+ const [code, nomBrut] = item
89
+ const nom = (nomBrut ?? '').toString().trim()
90
+
91
+ return { code, nom }
92
+ })
93
+ })
94
+
67
95
  const placeholder = computed(() => {
68
96
  const base = format.replace(/9/g, '0')
69
97
  const extension = activerExtension ? '-XXX/XXX' : ''
@@ -246,13 +274,15 @@
246
274
  const sauvegarder = () => {
247
275
  codeBudgetaire.value = formaterCodeBudgetaire(codeBudgetaire.value)
248
276
 
249
- // on utilise la validité globale
250
277
  if (!estValideComplet.value) return
251
278
  if (codeBudgetaire.value === derniereValeurSauvegardee.value) return
252
279
 
253
- const existe = props.codeBudgetairesProp.some(
254
- item => item.trim().toUpperCase() === codeBudgetaire.value.trim().toUpperCase(),
255
- )
280
+ const codeNormalise = codeBudgetaire.value.trim().toUpperCase()
281
+
282
+ const existe = props.codeBudgetairesProp.some(item => {
283
+ const code = typeof item === 'string' ? item : item[0]
284
+ return code.trim().toUpperCase() === codeNormalise
285
+ })
256
286
 
257
287
  if (!existe) {
258
288
  const nouvelleListe = [...props.codeBudgetairesProp, codeBudgetaire.value]
@@ -263,13 +293,35 @@
263
293
  emit('update:modelValue', codeBudgetaire.value)
264
294
  }
265
295
 
266
- const gererChangement = (val: string) => {
267
- codeBudgetaire.value = formaterCodeBudgetaire(val)
296
+ const extraireCode = (val: unknown): string => {
297
+ if (val == null) return ''
298
+ if (typeof val === 'string') return val
268
299
 
269
- const valeurFormatee = codeBudgetaire.value
270
- const estDansListe = props.codeBudgetairesProp.includes(valeurFormatee)
300
+ // Cas Vuetify enverrait { code, label }
301
+ if (typeof val === 'object' && 'code' in (val as Record<string, unknown>)) {
302
+ const obj = val as { code?: unknown }
303
+ return typeof obj.code === 'string' ? obj.code : String(obj.code ?? '')
304
+ }
305
+
306
+ return String(val)
307
+ }
308
+
309
+ const gererChangement = (val: unknown) => {
310
+ const code = extraireCode(val)
311
+ codeBudgetaire.value = formaterCodeBudgetaire(code)
312
+
313
+ const valeurFormatee = codeBudgetaire.value.trim().toUpperCase()
314
+
315
+ const estDansListe = props.codeBudgetairesProp.some(item => {
316
+ const codeItem = typeof item === 'string' ? item : item[0]
317
+ return codeItem.trim().toUpperCase() === valeurFormatee
318
+ })
271
319
 
272
- if (estDansListe && valeurFormatee !== derniereValeurSauvegardee.value && estValideComplet.value) {
320
+ if (
321
+ estDansListe &&
322
+ valeurFormatee !== (derniereValeurSauvegardee.value ?? '').toUpperCase() &&
323
+ estValideComplet.value
324
+ ) {
273
325
  sauvegarder()
274
326
  }
275
327
  }
@@ -1,203 +1,205 @@
1
- <template>
2
- <v-toolbar
3
- color="primary"
4
- height="72px"
5
- elevation="1"
6
- >
7
- <v-row
8
- class="pl-6 pr-6"
9
- align="center"
10
- no-gutters
11
- >
12
- <!-- Première colonne : Logo -->
13
- <v-col cols="auto">
14
- <a
15
- :href="href"
16
- :target="cssUrlValide ? '_blank' : undefined"
17
- :rel="cssUrlValide ? 'noopener noreferrer' : undefined"
18
- >
19
- <!-- Placeholder (même taille) pendant la décision -->
20
- <div
21
- v-if="!ready"
22
- class="logo-placeholder"
23
- ></div>
24
- <!-- On ne rend l'image qu'une fois la source choisie -->
25
- <img
26
- v-else
27
- class="logo-img"
28
- id="pivImage"
29
- :src="currentSrc!"
30
- :alt="$t('csqc.pivFooter.logoAlt')"
31
- decoding="async"
32
- loading="eager"
33
- @error="ErreurLogo"
34
- />
35
- </a>
36
- </v-col>
37
-
38
- <!-- Colonne pour le nom de l'application (Pour le mode desktop) -->
39
- <v-col
40
- v-if="!estMobile"
41
- class="d-flex justify-center"
42
- >
43
- <v-app-bar-title
44
- class="pl-12 ml-12"
45
- style="font-size: 16px !important"
46
- >
47
- {{ formulaireNom }}
48
- </v-app-bar-title>
49
- </v-col>
50
-
51
- <!-- Colonne pour le bouton de langue et icône d'aide -->
52
- <v-col class="d-none d-flex justify-end">
53
- <!-- langue -->
54
- <v-btn
55
- variant="text"
56
- @click="enregistrerLangue()"
57
- >{{ $t('csqc.pivEntete.langue') }}
58
- </v-btn>
59
-
60
- <!-- icône d'aide si dispo -->
61
- <v-btn
62
- v-if="aideUrl"
63
- icon="mdi-help-circle-outline"
64
- :href="aideUrl"
65
- target="_blank"
66
- rel="noopener noreferrer"
67
- style="margin-top: -6px"
68
- >
69
- </v-btn>
70
- </v-col>
71
-
72
- <!-- Colonne pour le nom de l'application (Pour le mode mobile) -->
73
- <v-col
74
- v-if="props.estMobile"
75
- cols="12"
76
- >
77
- <v-app-bar-title style="font-size: 16px !important">
78
- {{ formulaireNom }}
79
- </v-app-bar-title>
80
- </v-col>
81
- </v-row>
82
- </v-toolbar>
83
- </template>
84
-
85
- <script setup lang="ts">
86
- import { ref, watch, computed } from 'vue'
87
- import { useLocale } from 'vuetify'
88
- import { useI18n } from 'vue-i18n'
89
-
90
- const { current } = useLocale()
91
- const props = defineProps({
92
- estMobile: { type: Boolean, default: false },
93
- urlBase: { type: String, required: true },
94
- aideUrl: { type: String, default: '' },
95
- cssUrl: { type: String, default: '' },
96
- logoUrl: { type: String, default: '' },
97
- lienLogo: { type: String, default: '' },
98
- formulaireId: { type: Number, default: 0 },
99
- formulaireNom: { type: String, default: '' },
100
- })
101
- const emit = defineEmits(['changementLangue'])
102
- const { t } = useI18n()
103
-
104
- const FALLBACK = '/images/QUEBEC_blanc.svg'
105
- const currentSrc = ref<string | null>(null) // pas d'image tant que null
106
- const ready = ref(false)
107
-
108
- const formulaireNom = computed(() => {
109
- return props.formulaireNom || t('nom_application')
110
- })
111
-
112
- const href = computed(() => (props.cssUrl?.trim() ? props.cssUrl.trim() : '/'))
113
- const cssUrlValide = computed(() => /^https?:\/\//i.test(href.value))
114
-
115
- let loadToken = 0 // ← identifiant de “requête” pour annuler logiquement
116
-
117
- // Sur changement de l’URL du logo, on tente de le charger, sinon fallback apres 3.5 secs
118
- watch(
119
- () => props.logoUrl,
120
- async (nouvelle, ancienne) => {
121
- const url = (nouvelle ?? '').trim()
122
-
123
- // même URL ne rien faire
124
- if (url === (ancienne ?? '').trim() && currentSrc.value !== null) return
125
-
126
- // pas d’URL fallback immédiat
127
- if (!url) {
128
- currentSrc.value = FALLBACK
129
- ready.value = true
130
- return
131
- }
132
-
133
- // nouvelle tentative (avec “annulation logique”)
134
- const token = ++loadToken
135
- ready.value = false
136
- const ok = await loadWithTimeout(url, 3500)
137
- if (token !== loadToken) return // une nouvelle tentative a démarré entre-temps
138
-
139
- currentSrc.value = ok ? url : FALLBACK
140
- ready.value = true
141
- },
142
- { immediate: true },
143
- )
144
-
145
- // Charge une image avec un timeout
146
- function loadWithTimeout(url: string, timeoutMs: number): Promise<boolean> {
147
- return new Promise<boolean>(resolve => {
148
- const img = new Image()
149
- const timer = setTimeout(() => {
150
- // trop long on abandonne
151
- img.src = '' // stoppe le chargement
152
- resolve(false)
153
- }, timeoutMs)
154
-
155
- img.onload = () => {
156
- clearTimeout(timer)
157
- resolve(true)
158
- }
159
- img.onerror = () => {
160
- clearTimeout(timer)
161
- resolve(false)
162
- }
163
- img.src = url
164
- })
165
- }
166
-
167
- // Si l'image choisie a un problème, switch sur le fallback
168
- function ErreurLogo() {
169
- if (currentSrc.value !== FALLBACK) currentSrc.value = FALLBACK
170
- }
171
-
172
- const enregistrerLangue = (): void => {
173
- const langueDispo: string = current.value === 'fr' ? 'en' : 'fr'
174
- let returnUrl = window.location.pathname + window.location.search
175
- if (import.meta.env.MODE === 'development') {
176
- returnUrl = '/'
177
- }
178
- window.location.href =
179
- props.urlBase + `/Traducteur/SetLanguage?culture=${langueDispo}&returnUrl=${encodeURIComponent(returnUrl)}`
180
- emit('changementLangue')
181
- }
182
- </script>
183
-
184
- <style scoped>
185
- .container {
186
- max-width: none !important;
187
- }
188
- .theme--light.v-app-bar.v-toolbar.v-sheet {
189
- background: #095797;
190
-
191
- color: #fff;
192
- }
193
- .logo-placeholder {
194
- height: 72px;
195
- width: 180px;
196
- }
197
- .logo-img {
198
- height: 72px;
199
- transition: opacity 0.15s;
200
- opacity: 1;
201
- display: block;
202
- }
203
- </style>
1
+ <template>
2
+ <v-toolbar
3
+ color="primary"
4
+ height="72px"
5
+ elevation="1"
6
+ >
7
+ <v-row
8
+ class="pl-6 pr-6"
9
+ align="center"
10
+ no-gutters
11
+ >
12
+ <!-- Première colonne : Logo -->
13
+ <v-col cols="auto">
14
+ <a
15
+ :href="href"
16
+ :target="cssUrlValide ? '_blank' : undefined"
17
+ :rel="cssUrlValide ? 'noopener noreferrer' : undefined"
18
+ >
19
+ <!-- Placeholder (même taille) pendant la décision -->
20
+ <div
21
+ v-if="!ready"
22
+ class="logo-placeholder"
23
+ ></div>
24
+ <!-- On ne rend l'image qu'une fois la source choisie -->
25
+ <img
26
+ v-else
27
+ class="logo-img"
28
+ id="pivImage"
29
+ :src="currentSrc!"
30
+ :alt="$t('csqc.pivFooter.logoAlt')"
31
+ decoding="async"
32
+ loading="eager"
33
+ @error="ErreurLogo"
34
+ />
35
+ </a>
36
+ </v-col>
37
+
38
+ <!-- Colonne pour le nom de l'application (Pour le mode desktop) -->
39
+ <v-col
40
+ v-if="!estMobile"
41
+ class="d-flex justify-center"
42
+ >
43
+ <v-app-bar-title
44
+ class="pl-12 ml-12"
45
+ style="font-size: 16px !important"
46
+ >
47
+ {{ formulaireNom }}
48
+ </v-app-bar-title>
49
+ </v-col>
50
+
51
+ <!-- Colonne pour le bouton de langue et icône d'aide -->
52
+ <v-col class="d-none d-flex justify-end">
53
+ <!-- langue -->
54
+ <v-btn
55
+ variant="text"
56
+ @click="enregistrerLangue()"
57
+ >{{ $t('csqc.pivEntete.langue') }}
58
+ </v-btn>
59
+
60
+ <!-- icône d'aide si dispo -->
61
+ <v-btn
62
+ v-if="aideUrl"
63
+ icon="mdi-help-circle-outline"
64
+ :href="aideUrl"
65
+ target="_blank"
66
+ rel="noopener noreferrer"
67
+ style="margin-top: -6px"
68
+ >
69
+ </v-btn>
70
+
71
+ <slot name="droite"></slot>
72
+ </v-col>
73
+
74
+ <!-- Colonne pour le nom de l'application (Pour le mode mobile) -->
75
+ <v-col
76
+ v-if="props.estMobile"
77
+ cols="12"
78
+ >
79
+ <v-app-bar-title style="font-size: 16px !important">
80
+ {{ formulaireNom }}
81
+ </v-app-bar-title>
82
+ </v-col>
83
+ </v-row>
84
+ </v-toolbar>
85
+ </template>
86
+
87
+ <script setup lang="ts">
88
+ import { ref, watch, computed } from 'vue'
89
+ import { useLocale } from 'vuetify'
90
+ import { useI18n } from 'vue-i18n'
91
+
92
+ const { current } = useLocale()
93
+ const props = defineProps({
94
+ estMobile: { type: Boolean, default: false },
95
+ urlBase: { type: String, required: true },
96
+ aideUrl: { type: String, default: '' },
97
+ cssUrl: { type: String, default: '' },
98
+ logoUrl: { type: String, default: '' },
99
+ lienLogo: { type: String, default: '' },
100
+ formulaireId: { type: Number, default: 0 },
101
+ formulaireNom: { type: String, default: '' },
102
+ })
103
+ const emit = defineEmits(['changementLangue'])
104
+ const { t } = useI18n()
105
+
106
+ const FALLBACK = '/images/QUEBEC_blanc.svg'
107
+ const currentSrc = ref<string | null>(null) // pas d'image tant que null
108
+ const ready = ref(false)
109
+
110
+ const formulaireNom = computed(() => {
111
+ return props.formulaireNom || t('nom_application')
112
+ })
113
+
114
+ const href = computed(() => (props.cssUrl?.trim() ? props.cssUrl.trim() : '/'))
115
+ const cssUrlValide = computed(() => /^https?:\/\//i.test(href.value))
116
+
117
+ let loadToken = 0 // identifiant de “requête” pour annuler logiquement
118
+
119
+ // Sur changement de l’URL du logo, on tente de le charger, sinon fallback apres 3.5 secs
120
+ watch(
121
+ () => props.logoUrl,
122
+ async (nouvelle, ancienne) => {
123
+ const url = (nouvelle ?? '').trim()
124
+
125
+ // même URL → ne rien faire
126
+ if (url === (ancienne ?? '').trim() && currentSrc.value !== null) return
127
+
128
+ // pas d’URL → fallback immédiat
129
+ if (!url) {
130
+ currentSrc.value = FALLBACK
131
+ ready.value = true
132
+ return
133
+ }
134
+
135
+ // nouvelle tentative (avec “annulation logique”)
136
+ const token = ++loadToken
137
+ ready.value = false
138
+ const ok = await loadWithTimeout(url, 3500)
139
+ if (token !== loadToken) return // une nouvelle tentative a démarré entre-temps
140
+
141
+ currentSrc.value = ok ? url : FALLBACK
142
+ ready.value = true
143
+ },
144
+ { immediate: true },
145
+ )
146
+
147
+ // Charge une image avec un timeout
148
+ function loadWithTimeout(url: string, timeoutMs: number): Promise<boolean> {
149
+ return new Promise<boolean>(resolve => {
150
+ const img = new Image()
151
+ const timer = setTimeout(() => {
152
+ // trop long → on abandonne
153
+ img.src = '' // stoppe le chargement
154
+ resolve(false)
155
+ }, timeoutMs)
156
+
157
+ img.onload = () => {
158
+ clearTimeout(timer)
159
+ resolve(true)
160
+ }
161
+ img.onerror = () => {
162
+ clearTimeout(timer)
163
+ resolve(false)
164
+ }
165
+ img.src = url
166
+ })
167
+ }
168
+
169
+ // Si l'image choisie a un problème, switch sur le fallback
170
+ function ErreurLogo() {
171
+ if (currentSrc.value !== FALLBACK) currentSrc.value = FALLBACK
172
+ }
173
+
174
+ const enregistrerLangue = (): void => {
175
+ const langueDispo: string = current.value === 'fr' ? 'en' : 'fr'
176
+ let returnUrl = window.location.pathname + window.location.search
177
+ if (import.meta.env.MODE === 'development') {
178
+ returnUrl = '/'
179
+ }
180
+ window.location.href =
181
+ props.urlBase + `/Traducteur/SetLanguage?culture=${langueDispo}&returnUrl=${encodeURIComponent(returnUrl)}`
182
+ emit('changementLangue')
183
+ }
184
+ </script>
185
+
186
+ <style scoped>
187
+ .container {
188
+ max-width: none !important;
189
+ }
190
+ .theme--light.v-app-bar.v-toolbar.v-sheet {
191
+ background: #095797;
192
+
193
+ color: #fff;
194
+ }
195
+ .logo-placeholder {
196
+ height: 72px;
197
+ width: 180px;
198
+ }
199
+ .logo-img {
200
+ height: 72px;
201
+ transition: opacity 0.15s;
202
+ opacity: 1;
203
+ display: block;
204
+ }
205
+ </style>
@@ -1,186 +1,186 @@
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
- if (nomTemoin == null || nomTemoin === '') nomTemoin = 'csqc_jeton_secure_expiration'
11
- await this.verifierJeton(nomTemoin, urlPortail)
12
- if (this.timerId != null) return
13
- this.timerId = window.setInterval(() => {
14
- this.verifierJeton(nomTemoin, urlPortail)
15
- }, this.intervalleEnSecondes * 1000)
16
- }
17
-
18
- // Permet d’arrêter le timer (ex: au logout / destroy)
19
- public arreter(): void {
20
- if (this.timerId != null) {
21
- clearInterval(this.timerId)
22
- this.timerId = null
23
- }
24
- }
25
-
26
- public existeJeton = (nomTemoin: string) => {
27
- return this.estJetonValide(nomTemoin)
28
- }
29
-
30
- private async verifierJeton(nomTemoin: string, urlPortail: string): Promise<void> {
31
- if (this.popupAffiche) return
32
-
33
- if (!this.estJetonValide(nomTemoin)) {
34
- this.rafraichir(nomTemoin, urlPortail)
35
- return
36
- }
37
- }
38
-
39
- private estJetonValide(nomTemoin: string): boolean {
40
- if (this.popupAffiche || !nomTemoin) return true //On fait semblant que c'est valide pour ne pas provoquer un autre affichage du popup.
41
-
42
- const tokenEncode = this.lireCookie(nomTemoin)
43
- if (!tokenEncode) {
44
- return false
45
- }
46
-
47
- let token: any
48
- try {
49
- token = this.parseJwt(tokenEncode)
50
- } catch {
51
- return false
52
- }
53
-
54
- const exp = Number(token?.exp)
55
- if (!Number.isFinite(exp)) {
56
- // exp manquant/invalide → tente refresh
57
-
58
- return false
59
- }
60
-
61
- const now = Math.floor(Date.now() / 1000)
62
- const refreshAt = exp - this.secondesAvantExpirationTokenPourRafraichir - this.skewSeconds
63
- if (now >= refreshAt) {
64
- return false
65
- }
66
-
67
- return true
68
- }
69
-
70
- private async rafraichir(nomCookie: string, urlPortail: string): Promise<void> {
71
- if (!nomCookie) return
72
- const url = this.getRefreshUrl(urlPortail)
73
- const controller = new AbortController()
74
- const timeout = setTimeout(() => controller.abort(), 10_000)
75
-
76
- try {
77
- //Première tentative sans iframe, pour la majorité des cas.
78
- const resp = await fetch(url, {
79
- method: 'POST',
80
- credentials: 'include',
81
- headers: { Accept: 'application/json' },
82
- redirect: 'manual',
83
- signal: controller.signal,
84
- })
85
-
86
- // redirection (souvent => login) → traiter comme non auth
87
-
88
- if (resp.type === 'opaqueredirect' || resp.status === 302) {
89
- this.rafraichirParIframe(nomCookie, urlPortail)
90
- return
91
- }
92
-
93
- // OK ou No Content: le cookie devrait être réécrit
94
- if (resp.status === 200 || resp.status === 204) {
95
- const jeton = this.lireCookie(nomCookie)
96
- if (!jeton) this.rafraichirParIframe(nomCookie, urlPortail)
97
- return
98
- }
99
-
100
- // non auth / expiré (401, 419) + IIS timeout (440)
101
- if (resp.status === 401 || resp.status === 419 || resp.status === 440) {
102
- this.rafraichirParIframe(nomCookie, urlPortail)
103
- return
104
- }
105
-
106
- console.warn('Rafraichisseur token: statut inattendu', resp.status)
107
- } catch (err: any) {
108
- if (err?.name === 'AbortError') console.warn('RafraichisseurToken timeout')
109
- else console.error('Erreur rafraichisseur de token', err)
110
- // on réessaiera au prochain tick
111
- } finally {
112
- clearTimeout(timeout)
113
- }
114
- }
115
-
116
- private rafraichirParIframe(nomCookie: string, urlPortail: string): void {
117
- // 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é
118
- // ajax vers le refresh
119
- let iframe = document.createElement('iframe')
120
- const url = this.getRefreshUrl(urlPortail)
121
- iframe.src = `${url}?urlRetour=${encodeURI(window.localStorage.href)}`
122
- iframe.id = 'idRafrToken'
123
- iframe.style.display = 'none'
124
- document.body.appendChild(iframe)
125
- iframe.onload = () => {
126
- const jetonCSQC = this.lireCookie(nomCookie)
127
- if (jetonCSQC == null || jetonCSQC === '') {
128
- this.estDeconnecteAzure(urlPortail)
129
- }
130
-
131
- iframe.remove()
132
- }
133
- }
134
-
135
- private estDeconnecteAzure(urlPortail: string): void {
136
- //on envoie au portail, pas le choix
137
-
138
- const retour = encodeURI(window.location.href)
139
- window.open(`${urlPortail}/home/SeConnecter?urlRetour=${retour}`, '_self')
140
- return
141
- }
142
-
143
- public deconnecterPortail(urlPortail: string): void {
144
- window.location.replace(`${urlPortail}deconnecte?urlRetour=${encodeURIComponent(window.location.href)}`)
145
- }
146
-
147
- private lireCookie(nom: string): string | null {
148
- if (!document.cookie) return null
149
- const cookies = document.cookie.split(';').map(c => c.trim())
150
- for (const cookie of cookies) {
151
- if (cookie.startsWith(`${nom}=`)) {
152
- try {
153
- return decodeURIComponent(cookie.substring(nom.length + 1))
154
- } catch {
155
- return cookie.substring(nom.length + 1)
156
- }
157
- }
158
- }
159
- return null
160
- }
161
-
162
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
- private parseJwt(token: string): any {
164
- const parts = token.split('.')
165
- const base64Url = parts[1]
166
- if (!base64Url) throw new Error('Invalid JWT format')
167
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
168
- const jsonPayload = decodeURIComponent(
169
- atob(base64)
170
- .split('')
171
- .map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
172
- .join(''),
173
- )
174
- return JSON.parse(jsonPayload)
175
- }
176
-
177
- // URL refresh selon env (dev = proxy Vite ; prod = portail)
178
- private getRefreshUrl(urlPortail: string): string {
179
- if (import.meta.env.MODE === 'development') return '/portail-refresh'
180
- return urlPortail + '/Home/Refresh'
181
- }
182
- }
183
-
184
- // Instance
185
- const rafraichisseurToken = new RafraichisseurToken()
186
- export default rafraichisseurToken
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
+ if (nomTemoin == null || nomTemoin === '') nomTemoin = 'csqc_jeton_secure_expiration'
11
+ await this.verifierJeton(nomTemoin, urlPortail)
12
+ if (this.timerId != null) return
13
+ this.timerId = window.setInterval(() => {
14
+ this.verifierJeton(nomTemoin, urlPortail)
15
+ }, this.intervalleEnSecondes * 1000)
16
+ }
17
+
18
+ // Permet d’arrêter le timer (ex: au logout / destroy)
19
+ public arreter(): void {
20
+ if (this.timerId != null) {
21
+ clearInterval(this.timerId)
22
+ this.timerId = null
23
+ }
24
+ }
25
+
26
+ public existeJeton = (nomTemoin: string) => {
27
+ return this.estJetonValide(nomTemoin)
28
+ }
29
+
30
+ private async verifierJeton(nomTemoin: string, urlPortail: string): Promise<void> {
31
+ if (this.popupAffiche) return
32
+
33
+ if (!this.estJetonValide(nomTemoin)) {
34
+ this.rafraichir(nomTemoin, urlPortail)
35
+ return
36
+ }
37
+ }
38
+
39
+ private estJetonValide(nomTemoin: string): boolean {
40
+ if (this.popupAffiche || !nomTemoin) return true //On fait semblant que c'est valide pour ne pas provoquer un autre affichage du popup.
41
+
42
+ const tokenEncode = this.lireCookie(nomTemoin)
43
+ if (!tokenEncode) {
44
+ return false
45
+ }
46
+
47
+ let token: any
48
+ try {
49
+ token = this.parseJwt(tokenEncode)
50
+ } catch {
51
+ return false
52
+ }
53
+
54
+ const exp = Number(token?.exp)
55
+ if (!Number.isFinite(exp)) {
56
+ // exp manquant/invalide → tente refresh
57
+
58
+ return false
59
+ }
60
+
61
+ const now = Math.floor(Date.now() / 1000)
62
+ const refreshAt = exp - this.secondesAvantExpirationTokenPourRafraichir - this.skewSeconds
63
+ if (now >= refreshAt) {
64
+ return false
65
+ }
66
+
67
+ return true
68
+ }
69
+
70
+ private async rafraichir(nomCookie: string, urlPortail: string): Promise<void> {
71
+ if (!nomCookie) return
72
+ const url = this.getRefreshUrl(urlPortail)
73
+ const controller = new AbortController()
74
+ const timeout = setTimeout(() => controller.abort(), 10_000)
75
+
76
+ try {
77
+ //Première tentative sans iframe, pour la majorité des cas.
78
+ const resp = await fetch(url, {
79
+ method: 'POST',
80
+ credentials: 'include',
81
+ headers: { Accept: 'application/json' },
82
+ redirect: 'manual',
83
+ signal: controller.signal,
84
+ })
85
+
86
+ // redirection (souvent => login) → traiter comme non auth
87
+
88
+ if (resp.type === 'opaqueredirect' || resp.status === 302) {
89
+ this.rafraichirParIframe(nomCookie, urlPortail)
90
+ return
91
+ }
92
+
93
+ // OK ou No Content: le cookie devrait être réécrit
94
+ if (resp.status === 200 || resp.status === 204) {
95
+ const jeton = this.lireCookie(nomCookie)
96
+ if (!jeton) this.rafraichirParIframe(nomCookie, urlPortail)
97
+ return
98
+ }
99
+
100
+ // non auth / expiré (401, 419) + IIS timeout (440)
101
+ if (resp.status === 401 || resp.status === 419 || resp.status === 440) {
102
+ this.rafraichirParIframe(nomCookie, urlPortail)
103
+ return
104
+ }
105
+
106
+ console.warn('Rafraichisseur token: statut inattendu', resp.status)
107
+ } catch (err: any) {
108
+ if (err?.name === 'AbortError') console.warn('RafraichisseurToken timeout')
109
+ else console.error('Erreur rafraichisseur de token', err)
110
+ // on réessaiera au prochain tick
111
+ } finally {
112
+ clearTimeout(timeout)
113
+ }
114
+ }
115
+
116
+ private rafraichirParIframe(nomCookie: string, urlPortail: string): void {
117
+ // 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é
118
+ // ajax vers le refresh
119
+ let iframe = document.createElement('iframe')
120
+ const url = this.getRefreshUrl(urlPortail)
121
+ iframe.src = `${url}?urlRetour=${encodeURI(window.localStorage.href)}`
122
+ iframe.id = 'idRafrToken'
123
+ iframe.style.display = 'none'
124
+ document.body.appendChild(iframe)
125
+ iframe.onload = () => {
126
+ const jetonCSQC = this.lireCookie(nomCookie)
127
+ if (jetonCSQC == null || jetonCSQC === '') {
128
+ this.estDeconnecteAzure(urlPortail)
129
+ }
130
+
131
+ iframe.remove()
132
+ }
133
+ }
134
+
135
+ private estDeconnecteAzure(urlPortail: string): void {
136
+ //on envoie au portail, pas le choix
137
+
138
+ const retour = encodeURI(window.location.href)
139
+ window.open(`${urlPortail}/home/SeConnecter?urlRetour=${retour}`, '_self')
140
+ return
141
+ }
142
+
143
+ public deconnecterPortail(urlPortail: string): void {
144
+ window.location.replace(`${urlPortail}deconnecte?urlRetour=${encodeURIComponent(window.location.href)}`)
145
+ }
146
+
147
+ private lireCookie(nom: string): string | null {
148
+ if (!document.cookie) return null
149
+ const cookies = document.cookie.split(';').map(c => c.trim())
150
+ for (const cookie of cookies) {
151
+ if (cookie.startsWith(`${nom}=`)) {
152
+ try {
153
+ return decodeURIComponent(cookie.substring(nom.length + 1))
154
+ } catch {
155
+ return cookie.substring(nom.length + 1)
156
+ }
157
+ }
158
+ }
159
+ return null
160
+ }
161
+
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ private parseJwt(token: string): any {
164
+ const parts = token.split('.')
165
+ const base64Url = parts[1]
166
+ if (!base64Url) throw new Error('Invalid JWT format')
167
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
168
+ const jsonPayload = decodeURIComponent(
169
+ atob(base64)
170
+ .split('')
171
+ .map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
172
+ .join(''),
173
+ )
174
+ return JSON.parse(jsonPayload)
175
+ }
176
+
177
+ // URL refresh selon env (dev = proxy Vite ; prod = portail)
178
+ private getRefreshUrl(urlPortail: string): string {
179
+ if (import.meta.env.MODE === 'development') return '/portail-refresh'
180
+ return urlPortail + '/Home/Refresh'
181
+ }
182
+ }
183
+
184
+ // Instance
185
+ const rafraichisseurToken = new RafraichisseurToken()
186
+ export default rafraichisseurToken
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codevdesign",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "Composants Vuetify 3 pour les projets Codev",
5
5
  "files": [
6
6
  "./**/*.vue",