@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sowell/quasar-app-extension-auth",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "A Quasar app extension to manage Authentication",
5
5
  "main": "src/index.js",
6
6
  "repository": "https://github.com/sowell-ai/quasar.extension.auth",
@@ -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">{{ maskedEmail }}</div>
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">{{ $t("auth.channelSmsDescription") }}</div>
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 class="subtitle-secondary text-center q-mt-md">
201
- {{ $t("auth.magicLinkCheckInboxMessage") }}
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">{{ $t("auth.magicLinkWarning") }}</span>
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,
@@ -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
- apiErrorMessage.value = ""
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
- apiErrorMessage.value = ""
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 === 401) {
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
- email.value &&
202
- isEmail(email.value) &&
203
- password.value &&
204
- password.value.length > 5
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.value = data?.channels?.length ? data.channels : ["email"]
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,
@@ -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",
@@ -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",