@sowell/quasar-app-extension-auth 1.7.0 → 1.8.0
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 +1 -1
- package/src/components/pages/AuthReporter.vue +3 -5
- package/src/components/pages/ResetPassword.vue +7 -3
- package/src/composables/auth.ts +73 -15
- package/src/composables/resetPassword.ts +39 -14
- package/src/composables/useMagicLink.ts +8 -10
- package/src/i18n/en-US/index.ts +2 -0
- package/src/i18n/fr-FR/index.ts +2 -0
package/package.json
CHANGED
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
margin: 12px 0;
|
|
94
94
|
text-decoration: underline;
|
|
95
95
|
"
|
|
96
|
-
@click="
|
|
96
|
+
@click="forgottenPassword = true"
|
|
97
97
|
data-testid="ask-password-change-btn"
|
|
98
98
|
data-cy="forgot-password">
|
|
99
99
|
{{ $t("auth.forgottenPassword") }}
|
|
@@ -247,8 +247,7 @@ export default defineComponent({
|
|
|
247
247
|
retypeEmail,
|
|
248
248
|
isSubmitBtnDisabled,
|
|
249
249
|
goToOtpValidation,
|
|
250
|
-
goToFirstLogin
|
|
251
|
-
goToForgotPassword
|
|
250
|
+
goToFirstLogin
|
|
252
251
|
} = useAuth()
|
|
253
252
|
|
|
254
253
|
const { localStorage } = useQuasar()
|
|
@@ -336,8 +335,7 @@ export default defineComponent({
|
|
|
336
335
|
handleSubmit,
|
|
337
336
|
resetPassword,
|
|
338
337
|
goToPrivacyPolicy,
|
|
339
|
-
goToFirstLogin
|
|
340
|
-
goToForgotPassword
|
|
338
|
+
goToFirstLogin
|
|
341
339
|
}
|
|
342
340
|
}
|
|
343
341
|
})
|
|
@@ -151,8 +151,7 @@
|
|
|
151
151
|
:loading="loading"
|
|
152
152
|
type="submit"
|
|
153
153
|
class="full-width q-mt-lg submit-button"
|
|
154
|
-
:class="{ 'submit-button--soft':
|
|
155
|
-
:disable="mode === 'otp' ? !canSubmit : false">
|
|
154
|
+
:class="{ 'submit-button--soft': !canSubmit }">
|
|
156
155
|
<q-icon name="lock_open" size="16px" class="q-mr-sm" />
|
|
157
156
|
{{ $t("common.saveNewPassword") }}
|
|
158
157
|
</q-btn>
|
|
@@ -420,18 +419,23 @@ export default defineComponent({
|
|
|
420
419
|
border-radius: 8px;
|
|
421
420
|
padding: 16px;
|
|
422
421
|
display: grid;
|
|
423
|
-
grid-template-columns: repeat(2, 1fr);
|
|
422
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
424
423
|
gap: 10px 16px;
|
|
425
424
|
}
|
|
426
425
|
|
|
427
426
|
.requirement-item {
|
|
428
427
|
display: flex;
|
|
429
428
|
align-items: center;
|
|
429
|
+
min-width: 0;
|
|
430
430
|
font-size: 12px;
|
|
431
431
|
font-weight: 500;
|
|
432
432
|
color: #3e484f;
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
+
.requirement-item span {
|
|
436
|
+
overflow-wrap: anywhere;
|
|
437
|
+
}
|
|
438
|
+
|
|
435
439
|
.requirement-item .q-icon {
|
|
436
440
|
color: #bdc8d1;
|
|
437
441
|
}
|
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()
|
|
@@ -61,10 +66,6 @@ export function useAuth() {
|
|
|
61
66
|
push(`${LOCAL_FIRST_LOGIN_ROUTE || "/first-login"}?context=activate`)
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
const goToForgotPassword = () => {
|
|
65
|
-
push(LOCAL_FIRST_LOGIN_ROUTE || "/first-login")
|
|
66
|
-
}
|
|
67
|
-
|
|
68
69
|
const email = ref("")
|
|
69
70
|
const password = ref("")
|
|
70
71
|
const msalToken = ref("")
|
|
@@ -78,10 +79,45 @@ export function useAuth() {
|
|
|
78
79
|
const errorEmailMessage = ref("")
|
|
79
80
|
const errorPasswordMessage = ref("")
|
|
80
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
|
+
|
|
81
117
|
const validations = ref({
|
|
82
118
|
email: [
|
|
83
119
|
(val: string) => {
|
|
84
|
-
|
|
120
|
+
clearApiError()
|
|
85
121
|
errorEmailMessage.value = !!val ? "" : t("auth.emailPresenceError")
|
|
86
122
|
return !!val || t("auth.emailPresenceError")
|
|
87
123
|
},
|
|
@@ -94,7 +130,7 @@ export function useAuth() {
|
|
|
94
130
|
],
|
|
95
131
|
password: [
|
|
96
132
|
(val: string) => {
|
|
97
|
-
|
|
133
|
+
clearApiError()
|
|
98
134
|
errorPasswordMessage.value = !!val
|
|
99
135
|
? ""
|
|
100
136
|
: t("auth.passwordPresenceError")
|
|
@@ -126,8 +162,13 @@ export function useAuth() {
|
|
|
126
162
|
}
|
|
127
163
|
|
|
128
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
|
+
}
|
|
129
170
|
apiErrorMessage.value = ""
|
|
130
|
-
|
|
171
|
+
|
|
131
172
|
if (provider === "SoWell") {
|
|
132
173
|
loading.value = true
|
|
133
174
|
}
|
|
@@ -185,7 +226,10 @@ export function useAuth() {
|
|
|
185
226
|
|
|
186
227
|
if (err && typeof err === "object" && "response" in err) {
|
|
187
228
|
const axiosError = err as any
|
|
188
|
-
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) {
|
|
189
233
|
apiErrorMessage.value = t("auth.loginFailed")
|
|
190
234
|
} else if (axiosError.response?.status === 403) {
|
|
191
235
|
apiErrorMessage.value = t("auth.403")
|
|
@@ -201,11 +245,14 @@ export function useAuth() {
|
|
|
201
245
|
}
|
|
202
246
|
|
|
203
247
|
const isSubmitBtnDisabled = () => {
|
|
204
|
-
return
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
248
|
+
return (
|
|
249
|
+
isLockedOut.value ||
|
|
250
|
+
!(
|
|
251
|
+
email.value &&
|
|
252
|
+
isEmail(email.value) &&
|
|
253
|
+
password.value &&
|
|
254
|
+
password.value.length > 5
|
|
255
|
+
)
|
|
209
256
|
)
|
|
210
257
|
}
|
|
211
258
|
|
|
@@ -248,6 +295,15 @@ export function useAuth() {
|
|
|
248
295
|
}
|
|
249
296
|
|
|
250
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
|
+
|
|
251
307
|
await deviceInfo()
|
|
252
308
|
if (authState.raw) {
|
|
253
309
|
replace(LOCAL_SUCCESS_REDIRECTION_ROUTE)
|
|
@@ -258,11 +314,14 @@ export function useAuth() {
|
|
|
258
314
|
tryAutoLogin()
|
|
259
315
|
})
|
|
260
316
|
|
|
317
|
+
onUnmounted(() => clearTimeout(unlockTimer))
|
|
318
|
+
|
|
261
319
|
return {
|
|
262
320
|
email,
|
|
263
321
|
password,
|
|
264
322
|
loading,
|
|
265
323
|
msalLoading,
|
|
324
|
+
isLockedOut,
|
|
266
325
|
showPassword,
|
|
267
326
|
validations,
|
|
268
327
|
errorEmailMessage,
|
|
@@ -285,7 +344,6 @@ export function useAuth() {
|
|
|
285
344
|
msalAuth,
|
|
286
345
|
goToOtpValidation,
|
|
287
346
|
goToFirstLogin,
|
|
288
|
-
goToForgotPassword,
|
|
289
347
|
onSubmit,
|
|
290
348
|
retypeEmail,
|
|
291
349
|
isSubmitBtnDisabled,
|
|
@@ -34,6 +34,7 @@ export function useResetPassword() {
|
|
|
34
34
|
}
|
|
35
35
|
type ValidatableInput = { resetValidation: () => void }
|
|
36
36
|
const confirmPasswordInput = ref<ValidatableInput | null>(null)
|
|
37
|
+
const highlightInvalidRequirements = ref(false)
|
|
37
38
|
|
|
38
39
|
const { patch, post } = $api()
|
|
39
40
|
const router = $router()
|
|
@@ -71,19 +72,36 @@ export function useResetPassword() {
|
|
|
71
72
|
hasSpecialChar.value
|
|
72
73
|
)
|
|
73
74
|
|
|
74
|
-
// NOTE: `invalid` always false here (highlight-on-invalid-submit is a magiclink-only
|
|
75
|
-
// affordance, see useValidateMagicLink) - kept on every item so ResetPassword.vue can
|
|
76
|
-
// bind the same class regardless of which composable (OTP vs magiclink) it's using.
|
|
77
75
|
const passwordRequirements = computed(() => [
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
{
|
|
77
|
+
label: t("auth.passwordRequirement.minLength"),
|
|
78
|
+
valid: hasMinLength.value,
|
|
79
|
+
invalid: highlightInvalidRequirements.value && !hasMinLength.value
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
label: t("auth.passwordRequirement.uppercase"),
|
|
83
|
+
valid: hasUppercase.value,
|
|
84
|
+
invalid: highlightInvalidRequirements.value && !hasUppercase.value
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
label: t("auth.passwordRequirement.lowercase"),
|
|
88
|
+
valid: hasLowercase.value,
|
|
89
|
+
invalid: highlightInvalidRequirements.value && !hasLowercase.value
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
label: t("auth.passwordRequirement.number"),
|
|
93
|
+
valid: hasNumber.value,
|
|
94
|
+
invalid: highlightInvalidRequirements.value && !hasNumber.value
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
label: t("auth.passwordRequirement.letter"),
|
|
98
|
+
valid: hasLetter.value,
|
|
99
|
+
invalid: highlightInvalidRequirements.value && !hasLetter.value
|
|
100
|
+
},
|
|
83
101
|
{
|
|
84
102
|
label: t("auth.passwordRequirement.specialChar"),
|
|
85
103
|
valid: hasSpecialChar.value,
|
|
86
|
-
invalid:
|
|
104
|
+
invalid: highlightInvalidRequirements.value && !hasSpecialChar.value
|
|
87
105
|
}
|
|
88
106
|
])
|
|
89
107
|
|
|
@@ -162,6 +180,11 @@ export function useResetPassword() {
|
|
|
162
180
|
}
|
|
163
181
|
|
|
164
182
|
const onSubmit = () => {
|
|
183
|
+
if (!canSubmit.value) {
|
|
184
|
+
highlightInvalidRequirements.value = true
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
165
188
|
let idUser = decodedToken?.value?.sub
|
|
166
189
|
if (isOtp.value && userInfos?.uid) {
|
|
167
190
|
idUser = userInfos?.uid
|
|
@@ -233,14 +256,16 @@ export function useResetPassword() {
|
|
|
233
256
|
router.push(LOCAL_LOGIN_ROUTE)
|
|
234
257
|
}
|
|
235
258
|
|
|
236
|
-
// NOTE: OTP flow never expires this way /
|
|
237
|
-
//
|
|
238
|
-
//
|
|
259
|
+
// NOTE: OTP flow never expires this way / has nowhere to send the user back to
|
|
260
|
+
// "first login" - these are inert magiclink-only affordances, kept here only so
|
|
261
|
+
// ResetPassword.vue has a uniform shape regardless of composable used.
|
|
239
262
|
const linkExpired = ref(false)
|
|
240
263
|
const serverError = ref("")
|
|
241
|
-
const highlightInvalidRequirements = ref(false)
|
|
242
264
|
const goToFirstLogin = goToLoginPage
|
|
243
|
-
|
|
265
|
+
|
|
266
|
+
const handleInvalidSubmit = () => {
|
|
267
|
+
highlightInvalidRequirements.value = true
|
|
268
|
+
}
|
|
244
269
|
|
|
245
270
|
onMounted(() => {
|
|
246
271
|
if (!isOtp.value) {
|
|
@@ -21,7 +21,8 @@ export function useMagicLink() {
|
|
|
21
21
|
const {
|
|
22
22
|
AUTH_SERVER_REQUEST_MAGICLINK_ROUTE,
|
|
23
23
|
LOCAL_SET_PASSWORD_ROUTE,
|
|
24
|
-
LOCAL_LOGIN_ROUTE
|
|
24
|
+
LOCAL_LOGIN_ROUTE,
|
|
25
|
+
APP_SERVER_BASE_URL
|
|
25
26
|
} = $prompts()
|
|
26
27
|
|
|
27
28
|
const email = ref("")
|
|
@@ -61,16 +62,13 @@ export function useMagicLink() {
|
|
|
61
62
|
return true
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// Built from the configured APP_SERVER_BASE_URL (fixed per app in
|
|
66
|
+
// quasar.extensions.json), never from window.location: in a Capacitor build
|
|
67
|
+
// (APK/IPA) the WebView origin is something like capacitor://localhost, not the
|
|
68
|
+
// app's real public domain, so the magic link would otherwise be unusable once
|
|
69
|
+
// installed as a native app.
|
|
64
70
|
const buildBaseUrl = (): string => {
|
|
65
|
-
|
|
66
|
-
// DEV-ONLY BYPASS: MAGICLINK_ALLOWED_HOSTS rejects a bare "localhost" base_url
|
|
67
|
-
// (422). Substitute localhost.sowell.app (port kept) so the link is accepted -
|
|
68
|
-
// requires the local DNS to resolve localhost.sowell.app -> 127.0.0.1.
|
|
69
|
-
// Never let this reach production (see playbook piège #2).
|
|
70
|
-
const { protocol, hostname, port } = window.location
|
|
71
|
-
const effectiveHostname = hostname === "localhost" ? "localhost.sowell.app" : hostname
|
|
72
|
-
const origin = `${protocol}//${effectiveHostname}${port ? `:${port}` : ""}`
|
|
73
|
-
return `${origin}${LOCAL_SET_PASSWORD_ROUTE || "/set-password"}`
|
|
71
|
+
return `${APP_SERVER_BASE_URL}${LOCAL_SET_PASSWORD_ROUTE || "/set-password"}`
|
|
74
72
|
}
|
|
75
73
|
|
|
76
74
|
const readApiError = (error: unknown): string | undefined => {
|
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",
|
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",
|