@sowell/quasar-app-extension-auth 1.7.1 → 1.8.1
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/package.json
CHANGED
|
@@ -128,7 +128,9 @@
|
|
|
128
128
|
</div>
|
|
129
129
|
<div class="channel-text">
|
|
130
130
|
<div class="channel-label">{{ $t("auth.channelEmailLabel") }}</div>
|
|
131
|
-
<div class="channel-description"
|
|
131
|
+
<div class="channel-description" data-testid="channel-email-hint">
|
|
132
|
+
{{ channelHint("email") }}
|
|
133
|
+
</div>
|
|
132
134
|
</div>
|
|
133
135
|
<div
|
|
134
136
|
class="channel-radio"
|
|
@@ -149,7 +151,9 @@
|
|
|
149
151
|
</div>
|
|
150
152
|
<div class="channel-text">
|
|
151
153
|
<div class="channel-label">{{ $t("auth.channelSmsLabel") }}</div>
|
|
152
|
-
<div class="channel-description"
|
|
154
|
+
<div class="channel-description" data-testid="channel-sms-hint">
|
|
155
|
+
{{ channelHint("sms") }}
|
|
156
|
+
</div>
|
|
153
157
|
</div>
|
|
154
158
|
<div
|
|
155
159
|
class="channel-radio"
|
|
@@ -197,12 +201,15 @@
|
|
|
197
201
|
<div class="subtitle text-center q-mt-sm">
|
|
198
202
|
{{ $t("auth.magicLinkSentDescription") }}
|
|
199
203
|
</div>
|
|
200
|
-
<div
|
|
201
|
-
|
|
204
|
+
<div
|
|
205
|
+
class="subtitle-secondary text-center q-mt-md"
|
|
206
|
+
data-testid="magic-link-check-message">
|
|
207
|
+
{{ checkChannelMessage }}
|
|
202
208
|
</div>
|
|
203
209
|
</div>
|
|
204
210
|
|
|
205
211
|
<q-btn
|
|
212
|
+
v-if="!isSmsChannel"
|
|
206
213
|
data-testid="open-mailbox-btn"
|
|
207
214
|
data-cy="open-mailbox"
|
|
208
215
|
color="primary"
|
|
@@ -236,7 +243,9 @@
|
|
|
236
243
|
<div class="banner-icon-badge">
|
|
237
244
|
<q-icon :name="icons.ionInformationCircleOutline" size="12px" color="white" />
|
|
238
245
|
</div>
|
|
239
|
-
<span class="q-ml-sm"
|
|
246
|
+
<span class="q-ml-sm" data-testid="magic-link-warning">{{
|
|
247
|
+
magicLinkWarningMessage
|
|
248
|
+
}}</span>
|
|
240
249
|
</div>
|
|
241
250
|
</template>
|
|
242
251
|
</div>
|
|
@@ -244,7 +253,8 @@
|
|
|
244
253
|
</template>
|
|
245
254
|
|
|
246
255
|
<script lang="ts">
|
|
247
|
-
import { defineComponent } from "vue"
|
|
256
|
+
import { computed, defineComponent } from "vue"
|
|
257
|
+
import { useI18n } from "vue-i18n"
|
|
248
258
|
import {
|
|
249
259
|
ionWarningOutline,
|
|
250
260
|
ionInformationCircleOutline
|
|
@@ -254,11 +264,14 @@ import { useMagicLink } from "composables/useMagicLink"
|
|
|
254
264
|
export default defineComponent({
|
|
255
265
|
name: "FirstLogin",
|
|
256
266
|
setup() {
|
|
267
|
+
const { t, te } = useI18n()
|
|
268
|
+
|
|
257
269
|
const {
|
|
258
270
|
email,
|
|
259
271
|
emailError,
|
|
260
272
|
apiErrorMessage,
|
|
261
273
|
channels,
|
|
274
|
+
channelTargets,
|
|
262
275
|
selectedChannel,
|
|
263
276
|
step,
|
|
264
277
|
isLoading,
|
|
@@ -280,11 +293,41 @@ export default defineComponent({
|
|
|
280
293
|
ionInformationCircleOutline
|
|
281
294
|
}
|
|
282
295
|
|
|
296
|
+
const translateOrFallback = (key: string, fallbackKey: string): string =>
|
|
297
|
+
te(key) ? t(key) : t(fallbackKey)
|
|
298
|
+
|
|
299
|
+
const channelHint = (channel: "email" | "sms"): string => {
|
|
300
|
+
const target = channelTargets.value[channel]
|
|
301
|
+
if (target) return target
|
|
302
|
+
|
|
303
|
+
return channel === "email"
|
|
304
|
+
? maskedEmail.value
|
|
305
|
+
: t("auth.channelSmsDescription")
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const isSmsChannel = computed(() => selectedChannel.value === "sms")
|
|
309
|
+
|
|
310
|
+
const checkChannelMessage = computed(() =>
|
|
311
|
+
isSmsChannel.value
|
|
312
|
+
? translateOrFallback(
|
|
313
|
+
"auth.magicLinkCheckSmsMessage",
|
|
314
|
+
"auth.magicLinkCheckInboxMessage"
|
|
315
|
+
)
|
|
316
|
+
: t("auth.magicLinkCheckInboxMessage")
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const magicLinkWarningMessage = computed(() =>
|
|
320
|
+
isSmsChannel.value
|
|
321
|
+
? translateOrFallback("auth.magicLinkWarningSms", "auth.magicLinkWarning")
|
|
322
|
+
: t("auth.magicLinkWarning")
|
|
323
|
+
)
|
|
324
|
+
|
|
283
325
|
return {
|
|
284
326
|
email,
|
|
285
327
|
emailError,
|
|
286
328
|
apiErrorMessage,
|
|
287
329
|
channels,
|
|
330
|
+
channelTargets,
|
|
288
331
|
selectedChannel,
|
|
289
332
|
step,
|
|
290
333
|
isLoading,
|
|
@@ -294,6 +337,10 @@ export default defineComponent({
|
|
|
294
337
|
isActivateContext,
|
|
295
338
|
maskedEmail,
|
|
296
339
|
icons,
|
|
340
|
+
channelHint,
|
|
341
|
+
isSmsChannel,
|
|
342
|
+
checkChannelMessage,
|
|
343
|
+
magicLinkWarningMessage,
|
|
297
344
|
requestChannels,
|
|
298
345
|
sendMagicLink,
|
|
299
346
|
resend,
|
package/src/composables/auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ref, onMounted, inject } from "vue"
|
|
1
|
+
import { ref, computed, onMounted, onUnmounted, inject } from "vue"
|
|
2
2
|
import isEmail from "validator/es/lib/isEmail"
|
|
3
3
|
import { useI18n } from "vue-i18n"
|
|
4
4
|
import {
|
|
@@ -27,6 +27,11 @@ interface HeadersInterface {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Duree de verrouillage si l'API ne renvoie pas d'en-tete Retry-After exploitable
|
|
31
|
+
const LOCKOUT_FALLBACK_SECONDS = 60
|
|
32
|
+
// Persiste l'echeance : un rechargement de page ne doit pas degriser le bouton
|
|
33
|
+
const LOCKOUT_STORAGE_KEY = "loginLockoutUntil"
|
|
34
|
+
|
|
30
35
|
export function useAuth() {
|
|
31
36
|
const authState = useAuthState()
|
|
32
37
|
const msalState = useMsalState()
|
|
@@ -74,10 +79,45 @@ export function useAuth() {
|
|
|
74
79
|
const errorEmailMessage = ref("")
|
|
75
80
|
const errorPasswordMessage = ref("")
|
|
76
81
|
const apiErrorMessage = ref("")
|
|
82
|
+
|
|
83
|
+
// Verrouillage du bouton de connexion apres trop de tentatives (HTTP 429).
|
|
84
|
+
// L'echeance est persistee : ni le changement d'email, ni un rechargement de
|
|
85
|
+
// page ne degrisent le bouton, seule l'expiration du delai le fait.
|
|
86
|
+
const lockedUntil = ref(0)
|
|
87
|
+
const isLockedOut = computed(() => lockedUntil.value > 0)
|
|
88
|
+
let unlockTimer: ReturnType<typeof setTimeout> | undefined
|
|
89
|
+
|
|
90
|
+
const releaseLock = () => {
|
|
91
|
+
lockedUntil.value = 0
|
|
92
|
+
localStorage.remove(LOCKOUT_STORAGE_KEY)
|
|
93
|
+
if (apiErrorMessage.value === t("auth.tooManyAttempts")) {
|
|
94
|
+
apiErrorMessage.value = ""
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lockUntil = (until: number) => {
|
|
99
|
+
clearTimeout(unlockTimer)
|
|
100
|
+
lockedUntil.value = until
|
|
101
|
+
localStorage.set(LOCKOUT_STORAGE_KEY, until)
|
|
102
|
+
unlockTimer = setTimeout(releaseLock, Math.max(until - Date.now(), 0))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// L'API indique le delai d'attente en secondes via Retry-After
|
|
106
|
+
const retryAfterSeconds = (err: { response?: { headers?: Record<string, string> } }) => {
|
|
107
|
+
const parsed = Number(err.response?.headers?.["retry-after"])
|
|
108
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : LOCKOUT_FALLBACK_SECONDS
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Pendant le verrouillage le message doit rester affiche : sinon la saisie
|
|
112
|
+
// d'un autre identifiant laisse un bouton grise sans aucune explication
|
|
113
|
+
const clearApiError = () => {
|
|
114
|
+
if (!isLockedOut.value) apiErrorMessage.value = ""
|
|
115
|
+
}
|
|
116
|
+
|
|
77
117
|
const validations = ref({
|
|
78
118
|
email: [
|
|
79
119
|
(val: string) => {
|
|
80
|
-
|
|
120
|
+
clearApiError()
|
|
81
121
|
errorEmailMessage.value = !!val ? "" : t("auth.emailPresenceError")
|
|
82
122
|
return !!val || t("auth.emailPresenceError")
|
|
83
123
|
},
|
|
@@ -90,7 +130,7 @@ export function useAuth() {
|
|
|
90
130
|
],
|
|
91
131
|
password: [
|
|
92
132
|
(val: string) => {
|
|
93
|
-
|
|
133
|
+
clearApiError()
|
|
94
134
|
errorPasswordMessage.value = !!val
|
|
95
135
|
? ""
|
|
96
136
|
: t("auth.passwordPresenceError")
|
|
@@ -122,8 +162,13 @@ export function useAuth() {
|
|
|
122
162
|
}
|
|
123
163
|
|
|
124
164
|
const onSubmit = async (provider = "SoWell") => {
|
|
165
|
+
// Le bouton est grise, mais la touche Entree et l'auto-login le contournent
|
|
166
|
+
if (isLockedOut.value) {
|
|
167
|
+
apiErrorMessage.value = t("auth.tooManyAttempts")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
125
170
|
apiErrorMessage.value = ""
|
|
126
|
-
|
|
171
|
+
|
|
127
172
|
if (provider === "SoWell") {
|
|
128
173
|
loading.value = true
|
|
129
174
|
}
|
|
@@ -181,7 +226,10 @@ export function useAuth() {
|
|
|
181
226
|
|
|
182
227
|
if (err && typeof err === "object" && "response" in err) {
|
|
183
228
|
const axiosError = err as any
|
|
184
|
-
if (axiosError.response?.status ===
|
|
229
|
+
if (axiosError.response?.status === 429) {
|
|
230
|
+
lockUntil(Date.now() + retryAfterSeconds(axiosError) * 1000)
|
|
231
|
+
apiErrorMessage.value = t("auth.tooManyAttempts")
|
|
232
|
+
} else if (axiosError.response?.status === 401) {
|
|
185
233
|
apiErrorMessage.value = t("auth.loginFailed")
|
|
186
234
|
} else if (axiosError.response?.status === 403) {
|
|
187
235
|
apiErrorMessage.value = t("auth.403")
|
|
@@ -197,11 +245,14 @@ export function useAuth() {
|
|
|
197
245
|
}
|
|
198
246
|
|
|
199
247
|
const isSubmitBtnDisabled = () => {
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
248
|
+
return (
|
|
249
|
+
isLockedOut.value ||
|
|
250
|
+
!(
|
|
251
|
+
email.value &&
|
|
252
|
+
isEmail(email.value) &&
|
|
253
|
+
password.value &&
|
|
254
|
+
password.value.length > 5
|
|
255
|
+
)
|
|
205
256
|
)
|
|
206
257
|
}
|
|
207
258
|
|
|
@@ -244,6 +295,15 @@ export function useAuth() {
|
|
|
244
295
|
}
|
|
245
296
|
|
|
246
297
|
onMounted(async () => {
|
|
298
|
+
// Reprise d'un verrou en cours apres rechargement
|
|
299
|
+
const storedUntil = Number(localStorage.getItem(LOCKOUT_STORAGE_KEY))
|
|
300
|
+
if (Number.isFinite(storedUntil) && storedUntil > Date.now()) {
|
|
301
|
+
lockUntil(storedUntil)
|
|
302
|
+
apiErrorMessage.value = t("auth.tooManyAttempts")
|
|
303
|
+
} else {
|
|
304
|
+
localStorage.remove(LOCKOUT_STORAGE_KEY)
|
|
305
|
+
}
|
|
306
|
+
|
|
247
307
|
await deviceInfo()
|
|
248
308
|
if (authState.raw) {
|
|
249
309
|
replace(LOCAL_SUCCESS_REDIRECTION_ROUTE)
|
|
@@ -254,11 +314,14 @@ export function useAuth() {
|
|
|
254
314
|
tryAutoLogin()
|
|
255
315
|
})
|
|
256
316
|
|
|
317
|
+
onUnmounted(() => clearTimeout(unlockTimer))
|
|
318
|
+
|
|
257
319
|
return {
|
|
258
320
|
email,
|
|
259
321
|
password,
|
|
260
322
|
loading,
|
|
261
323
|
msalLoading,
|
|
324
|
+
isLockedOut,
|
|
262
325
|
showPassword,
|
|
263
326
|
validations,
|
|
264
327
|
errorEmailMessage,
|
|
@@ -8,6 +8,36 @@ import { $api, $prompts, $router } from "../injects"
|
|
|
8
8
|
type Channel = "email" | "sms"
|
|
9
9
|
type Step = "email" | "channel" | "sent"
|
|
10
10
|
|
|
11
|
+
interface ChannelEntry {
|
|
12
|
+
channel: string
|
|
13
|
+
target?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ChannelTargets = Partial<Record<Channel, string>>
|
|
17
|
+
|
|
18
|
+
const AVAILABLE_CHANNELS: Channel[] = ["email", "sms"]
|
|
19
|
+
|
|
20
|
+
const isChannel = (value: string): value is Channel =>
|
|
21
|
+
(AVAILABLE_CHANNELS as string[]).includes(value)
|
|
22
|
+
|
|
23
|
+
const parseChannels = (
|
|
24
|
+
entries: Array<string | ChannelEntry> | undefined
|
|
25
|
+
): { channels: Channel[]; targets: ChannelTargets } => {
|
|
26
|
+
const channels: Channel[] = []
|
|
27
|
+
const targets: ChannelTargets = {}
|
|
28
|
+
|
|
29
|
+
for (const entry of entries || []) {
|
|
30
|
+
const name = typeof entry === "string" ? entry : entry?.channel
|
|
31
|
+
if (!name || !isChannel(name)) continue
|
|
32
|
+
|
|
33
|
+
channels.push(name)
|
|
34
|
+
const target = typeof entry === "string" ? undefined : entry?.target
|
|
35
|
+
if (target) targets[name] = target
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { channels, targets }
|
|
39
|
+
}
|
|
40
|
+
|
|
11
41
|
interface ApiErrorPayload {
|
|
12
42
|
errors?: Array<{ detail?: string; title?: string }>
|
|
13
43
|
}
|
|
@@ -29,6 +59,7 @@ export function useMagicLink() {
|
|
|
29
59
|
const emailError = ref("")
|
|
30
60
|
const apiErrorMessage = ref("")
|
|
31
61
|
const channels = ref<Channel[]>([])
|
|
62
|
+
const channelTargets = ref<ChannelTargets>({})
|
|
32
63
|
const selectedChannel = ref<Channel>("email")
|
|
33
64
|
const step = ref<Step>("email")
|
|
34
65
|
const isLoading = ref(false)
|
|
@@ -90,7 +121,9 @@ export function useMagicLink() {
|
|
|
90
121
|
const { data } = await api.post(AUTH_SERVER_REQUEST_MAGICLINK_ROUTE, {
|
|
91
122
|
auth: { email: email.value.trim() }
|
|
92
123
|
})
|
|
93
|
-
channels
|
|
124
|
+
const { channels: available, targets } = parseChannels(data?.channels)
|
|
125
|
+
channels.value = available.length ? available : ["email"]
|
|
126
|
+
channelTargets.value = targets
|
|
94
127
|
selectedChannel.value = "email"
|
|
95
128
|
step.value = "channel"
|
|
96
129
|
} catch (error) {
|
|
@@ -189,6 +222,7 @@ export function useMagicLink() {
|
|
|
189
222
|
emailError,
|
|
190
223
|
apiErrorMessage,
|
|
191
224
|
channels,
|
|
225
|
+
channelTargets,
|
|
192
226
|
selectedChannel,
|
|
193
227
|
step,
|
|
194
228
|
isLoading,
|
package/src/i18n/en-US/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export default {
|
|
|
12
12
|
passwordPresenceError: "The password must be provided",
|
|
13
13
|
passwordLengthError: "The password length be at least 6",
|
|
14
14
|
loginFailed: "Incorrect credentials",
|
|
15
|
+
tooManyAttempts:
|
|
16
|
+
"Too many incorrect login attempts. Please wait 1 minute before trying again.",
|
|
15
17
|
emailNotRecognize: "Login failed, please check your credentials",
|
|
16
18
|
emailSent: "Email sent, check your inbox",
|
|
17
19
|
tapEmail: "Please enter your email",
|
|
@@ -47,7 +49,10 @@ export default {
|
|
|
47
49
|
back: "Back",
|
|
48
50
|
backToLoginFull: "Back to login",
|
|
49
51
|
continueWith: "Or continue with",
|
|
50
|
-
emailOrPasswordIncorrect: "Email or password incorrect"
|
|
52
|
+
emailOrPasswordIncorrect: "Email or password incorrect",
|
|
53
|
+
magicLinkCheckSmsMessage: "Check your text messages",
|
|
54
|
+
magicLinkWarningSms:
|
|
55
|
+
"If you do not receive anything within 2 minutes, check your mobile network or contact support."
|
|
51
56
|
},
|
|
52
57
|
common: {
|
|
53
58
|
register: "Save",
|
package/src/i18n/fr-FR/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export default {
|
|
|
12
12
|
passwordPresenceError: "Le mot de passe doit être fourni",
|
|
13
13
|
passwordLengthError: "La longueur du mot de passe doit être au minimum 6",
|
|
14
14
|
loginFailed: "Identifiants incorrectes",
|
|
15
|
+
tooManyAttempts:
|
|
16
|
+
"Plusieurs tentatives de connexion incorrectes. Veuillez attendre 1 minute avant de réessayer.",
|
|
15
17
|
emailNotRecognize: "L'adresse email fournie n'est pas reconnue",
|
|
16
18
|
emailSent: "Email envoyé, vérifier votre boite email",
|
|
17
19
|
tapEmail: "Entrer votre email",
|
|
@@ -50,7 +52,10 @@ export default {
|
|
|
50
52
|
back: "Retour",
|
|
51
53
|
backToLoginFull: "Retour à la connexion",
|
|
52
54
|
continueWith: "Ou continuer avec",
|
|
53
|
-
emailOrPasswordIncorrect: "Email ou mot de passe incorrect"
|
|
55
|
+
emailOrPasswordIncorrect: "Email ou mot de passe incorrect",
|
|
56
|
+
magicLinkCheckSmsMessage: "Vérifiez vos messages",
|
|
57
|
+
magicLinkWarningSms:
|
|
58
|
+
"Si vous ne recevez rien d'ici 2 minutes, vérifiez votre réseau mobile ou contactez le support."
|
|
54
59
|
},
|
|
55
60
|
common: {
|
|
56
61
|
register: "Enregistrer",
|