@sowell/quasar-app-extension-auth 1.6.0 → 1.7.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/boot/router-guards.ts +2 -0
- package/src/components/pages/AuthReporter.vue +50 -17
- package/src/components/pages/FirstLogin.vue +604 -0
- package/src/components/pages/ResetPassword.vue +120 -14
- package/src/components/pages/SetPassword.vue +35 -0
- package/src/components/pages/WebResetPassword.vue +3 -0
- package/src/composables/auth.ts +16 -1
- package/src/composables/resetPassword.ts +37 -8
- package/src/composables/useMagicLink.ts +210 -0
- package/src/composables/useValidateMagicLink.ts +183 -0
- package/src/injects/prompts.ts +5 -0
- package/src/prompts.js +35 -0
- package/src/routes/index.ts +27 -1
|
@@ -20,23 +20,48 @@
|
|
|
20
20
|
</div>
|
|
21
21
|
<div
|
|
22
22
|
class="full-width reset-password-card q-mt-lg"
|
|
23
|
-
:class="{ 'reset-password-card--centered': passwordUpdated }">
|
|
24
|
-
<template v-if="
|
|
23
|
+
:class="{ 'reset-password-card--centered': passwordUpdated || linkExpired }">
|
|
24
|
+
<template v-if="linkExpired">
|
|
25
|
+
<div
|
|
26
|
+
class="column items-center header-section"
|
|
27
|
+
data-testid="magic-link-expired"
|
|
28
|
+
role="status"
|
|
29
|
+
aria-live="polite">
|
|
30
|
+
<div class="error-badge">
|
|
31
|
+
<q-icon name="error_outline" size="48px" color="negative" />
|
|
32
|
+
</div>
|
|
33
|
+
<div class="title text-center q-mt-md">
|
|
34
|
+
{{ $t("auth.error.magicLinkExpired") }}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<q-btn
|
|
38
|
+
data-testid="back-to-first-login-btn"
|
|
39
|
+
color="primary"
|
|
40
|
+
unelevated
|
|
41
|
+
no-caps
|
|
42
|
+
text-color="white"
|
|
43
|
+
class="full-width q-mt-lg submit-button"
|
|
44
|
+
@click="goToFirstLogin">
|
|
45
|
+
{{ $t("auth.backToFirstLogin") }}
|
|
46
|
+
</q-btn>
|
|
47
|
+
</template>
|
|
48
|
+
<template v-else-if="!passwordUpdated">
|
|
25
49
|
<div class="column items-center header-section">
|
|
26
50
|
<div class="icon-badge">
|
|
27
51
|
<q-icon name="verified_user" size="28px" color="primary" />
|
|
28
52
|
</div>
|
|
29
53
|
<div class="title text-center q-mt-md">
|
|
30
|
-
{{ $t("auth.passwordRecovery") }}
|
|
54
|
+
{{ title || $t("auth.passwordRecovery") }}
|
|
31
55
|
</div>
|
|
32
56
|
<div class="subtitle text-center q-mt-sm">
|
|
33
|
-
{{ $t("auth.recommendPassword") }}
|
|
57
|
+
{{ description || $t("auth.recommendPassword") }}
|
|
34
58
|
</div>
|
|
35
59
|
</div>
|
|
36
60
|
<q-form
|
|
37
61
|
class="column q-mt-lg"
|
|
38
62
|
data-testid="reset-password-form"
|
|
39
|
-
@submit="onSubmit"
|
|
63
|
+
@submit="onSubmit"
|
|
64
|
+
@validation-error="handleInvalidSubmit">
|
|
40
65
|
<div class="input-container">
|
|
41
66
|
<div class="input-label">
|
|
42
67
|
{{ $t("auth.newPassword") }}
|
|
@@ -49,6 +74,7 @@
|
|
|
49
74
|
:type="showPassword ? 'text' : 'password'"
|
|
50
75
|
:rules="validations['passwordRules']"
|
|
51
76
|
lazy-rules
|
|
77
|
+
:hide-bottom-space="mode === 'magiclink'"
|
|
52
78
|
borderless
|
|
53
79
|
:placeholder="$t('auth.newPasswordPlaceholder')"
|
|
54
80
|
class="q-mt-sm"
|
|
@@ -70,12 +96,14 @@
|
|
|
70
96
|
</div>
|
|
71
97
|
<q-input
|
|
72
98
|
id="confirmPassword"
|
|
99
|
+
ref="confirmPasswordInput"
|
|
73
100
|
v-model="confirmPassword"
|
|
74
101
|
data-testid="confirm-password-input"
|
|
75
102
|
data-cy="reset-confirm-password"
|
|
76
103
|
:type="showConfirmPassword ? 'text' : 'password'"
|
|
77
104
|
:rules="validations['passwordConfirmationRules']"
|
|
78
105
|
lazy-rules
|
|
106
|
+
:hide-bottom-space="mode === 'magiclink'"
|
|
79
107
|
borderless
|
|
80
108
|
:placeholder="$t('auth.confirmPasswordPlaceholder')"
|
|
81
109
|
class="q-mt-sm"
|
|
@@ -98,7 +126,10 @@
|
|
|
98
126
|
v-for="requirement in passwordRequirements"
|
|
99
127
|
:key="requirement.label"
|
|
100
128
|
class="requirement-item"
|
|
101
|
-
:class="{
|
|
129
|
+
:class="{
|
|
130
|
+
'requirement-valid': requirement.valid,
|
|
131
|
+
'requirement-invalid': requirement.invalid
|
|
132
|
+
}">
|
|
102
133
|
<q-icon
|
|
103
134
|
:name="requirement.valid ? 'check_circle' : 'radio_button_unchecked'"
|
|
104
135
|
size="16px" />
|
|
@@ -106,6 +137,10 @@
|
|
|
106
137
|
</div>
|
|
107
138
|
</div>
|
|
108
139
|
|
|
140
|
+
<div v-if="serverError" class="server-error q-mt-md" data-testid="server-error">
|
|
141
|
+
{{ serverError }}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
109
144
|
<q-btn
|
|
110
145
|
data-testid="submit-btn"
|
|
111
146
|
data-cy="send-new-password"
|
|
@@ -116,7 +151,8 @@
|
|
|
116
151
|
:loading="loading"
|
|
117
152
|
type="submit"
|
|
118
153
|
class="full-width q-mt-lg submit-button"
|
|
119
|
-
:
|
|
154
|
+
:class="{ 'submit-button--soft': mode !== 'otp' && !canSubmit }"
|
|
155
|
+
:disable="mode === 'otp' ? !canSubmit : false">
|
|
120
156
|
<q-icon name="lock_open" size="16px" class="q-mr-sm" />
|
|
121
157
|
{{ $t("common.saveNewPassword") }}
|
|
122
158
|
</q-btn>
|
|
@@ -129,7 +165,11 @@
|
|
|
129
165
|
role="status"
|
|
130
166
|
aria-live="polite">
|
|
131
167
|
<div class="success-badge">
|
|
132
|
-
<
|
|
168
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="82" height="82" viewBox="0 0 82 82" fill="none">
|
|
169
|
+
<path d="M20.467 13.2638C20.467 13.2638 32.783 22.7041 51.5063 5.05236C68.1346 -10.6241 81.7117 14.1097 81.8067 25.9544C81.9298 41.298 65.1784 53.5748 73.3078 63.6526C81.4372 73.7304 57.1861 90.3722 44.116 75.9698C27.8572 58.0538 23.4525 72.6106 14.185 72.6106C7.53374 72.6106 -6.12216 55.9168 3.09953 43.4971C10.8594 33.0461 6.6273 29.5776 4.57785 25.9544C1.62159 20.7288 8.64242 6.54534 20.467 13.2638Z" fill="#E7F8FF"/>
|
|
170
|
+
<path d="M41.0002 63.6615C53.4542 63.6615 63.5502 53.4896 63.5502 40.9419C63.5502 28.3942 53.4542 18.2223 41.0002 18.2223C28.5462 18.2223 18.4502 28.3942 18.4502 40.9419C18.4502 53.4896 28.5462 63.6615 41.0002 63.6615Z" fill="#40C4FF"/>
|
|
171
|
+
<path d="M37.9271 52.7776C37.5379 52.7781 37.1541 52.6868 36.8062 52.511C36.4584 52.3353 36.1562 52.08 35.9238 51.7655L29.7663 43.4957C29.5555 43.2317 29.3996 42.9277 29.3076 42.6018C29.2157 42.276 29.1896 41.9348 29.231 41.5986C29.2724 41.2624 29.3803 40.938 29.5485 40.6446C29.7166 40.3512 29.9415 40.0948 30.2099 39.8906C30.4782 39.6865 30.7845 39.5387 31.1106 39.456C31.4367 39.3734 31.776 39.3575 32.1082 39.4095C32.4405 39.4614 32.7591 39.58 33.045 39.7583C33.3309 39.9366 33.5784 40.1709 33.7728 40.4473L37.8266 45.8712L48.162 30.2426C48.5412 29.7088 49.1114 29.3443 49.7523 29.2261C50.3932 29.108 51.0545 29.2454 51.5968 29.6093C52.139 29.9733 52.5196 30.5353 52.6583 31.1767C52.7969 31.818 52.6828 32.4886 52.3399 33.0468L40.0131 51.6464C39.7907 51.9823 39.4917 52.2598 39.1411 52.4556C38.7906 52.6513 38.3986 52.7597 37.998 52.7717L37.9271 52.7776Z" fill="white"/>
|
|
172
|
+
</svg>
|
|
133
173
|
</div>
|
|
134
174
|
<div
|
|
135
175
|
ref="successTitleRef"
|
|
@@ -139,7 +179,7 @@
|
|
|
139
179
|
{{ $t("auth.passwordSavedTitle") }}
|
|
140
180
|
</div>
|
|
141
181
|
<div class="subtitle text-center q-mt-sm" data-testid="password-updated-message">
|
|
142
|
-
{{ $t("auth.passwordSavedMessage") }}
|
|
182
|
+
{{ successSubtitle || $t("auth.passwordSavedMessage") }}
|
|
143
183
|
</div>
|
|
144
184
|
</div>
|
|
145
185
|
<q-btn
|
|
@@ -159,26 +199,60 @@
|
|
|
159
199
|
</template>
|
|
160
200
|
|
|
161
201
|
<script lang="ts">
|
|
162
|
-
import { defineComponent, nextTick, ref, watch } from "vue"
|
|
202
|
+
import { defineComponent, nextTick, ref, watch, computed, PropType } from "vue"
|
|
163
203
|
import { useResetPassword } from "composables/resetPassword"
|
|
204
|
+
import { useValidateMagicLink } from "composables/useValidateMagicLink"
|
|
164
205
|
|
|
165
206
|
export default defineComponent({
|
|
166
207
|
name: "ResetPassword",
|
|
167
|
-
|
|
208
|
+
props: {
|
|
209
|
+
mode: {
|
|
210
|
+
type: String as PropType<"otp" | "magiclink">,
|
|
211
|
+
default: "otp"
|
|
212
|
+
},
|
|
213
|
+
magicToken: {
|
|
214
|
+
type: String,
|
|
215
|
+
default: ""
|
|
216
|
+
},
|
|
217
|
+
title: {
|
|
218
|
+
type: String,
|
|
219
|
+
default: ""
|
|
220
|
+
},
|
|
221
|
+
description: {
|
|
222
|
+
type: String,
|
|
223
|
+
default: ""
|
|
224
|
+
},
|
|
225
|
+
successSubtitle: {
|
|
226
|
+
type: String,
|
|
227
|
+
default: ""
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
setup(props) {
|
|
231
|
+
const magicToken = computed(() => props.magicToken)
|
|
232
|
+
|
|
168
233
|
const {
|
|
169
234
|
password,
|
|
170
235
|
confirmPassword,
|
|
171
236
|
loading,
|
|
172
237
|
passwordUpdated,
|
|
238
|
+
linkExpired,
|
|
239
|
+
serverError,
|
|
173
240
|
showPassword,
|
|
241
|
+
highlightInvalidRequirements,
|
|
174
242
|
validations,
|
|
175
243
|
passwordRequirements,
|
|
176
244
|
LOCAL_LOGIN_ROUTE,
|
|
177
245
|
canSubmit,
|
|
246
|
+
confirmPasswordInput,
|
|
178
247
|
onSubmit,
|
|
248
|
+
handleInvalidSubmit,
|
|
179
249
|
goToLoginPage,
|
|
250
|
+
goToFirstLogin,
|
|
180
251
|
icons
|
|
181
|
-
} =
|
|
252
|
+
} =
|
|
253
|
+
props.mode === "magiclink"
|
|
254
|
+
? useValidateMagicLink(magicToken)
|
|
255
|
+
: useResetPassword()
|
|
182
256
|
|
|
183
257
|
const showConfirmPassword = ref(false)
|
|
184
258
|
const successTitleRef = ref<HTMLElement | null>(null)
|
|
@@ -195,15 +269,21 @@ export default defineComponent({
|
|
|
195
269
|
loading,
|
|
196
270
|
canSubmit,
|
|
197
271
|
passwordUpdated,
|
|
272
|
+
linkExpired,
|
|
273
|
+
serverError,
|
|
274
|
+
highlightInvalidRequirements,
|
|
198
275
|
showPassword,
|
|
199
276
|
showConfirmPassword,
|
|
200
277
|
successTitleRef,
|
|
201
278
|
LOCAL_LOGIN_ROUTE,
|
|
202
279
|
validations,
|
|
203
280
|
passwordRequirements,
|
|
281
|
+
confirmPasswordInput,
|
|
204
282
|
icons,
|
|
205
283
|
onSubmit,
|
|
206
|
-
|
|
284
|
+
handleInvalidSubmit,
|
|
285
|
+
goToLoginPage,
|
|
286
|
+
goToFirstLogin
|
|
207
287
|
}
|
|
208
288
|
}
|
|
209
289
|
})
|
|
@@ -275,10 +355,18 @@ export default defineComponent({
|
|
|
275
355
|
}
|
|
276
356
|
|
|
277
357
|
.success-badge {
|
|
358
|
+
width: 82px;
|
|
359
|
+
height: 82px;
|
|
360
|
+
display: flex;
|
|
361
|
+
align-items: center;
|
|
362
|
+
justify-content: center;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.error-badge {
|
|
278
366
|
width: 84px;
|
|
279
367
|
height: 84px;
|
|
280
368
|
border-radius: 9999px;
|
|
281
|
-
background: rgba(
|
|
369
|
+
background: rgba(239, 71, 111, 0.1);
|
|
282
370
|
display: flex;
|
|
283
371
|
align-items: center;
|
|
284
372
|
justify-content: center;
|
|
@@ -356,6 +444,20 @@ export default defineComponent({
|
|
|
356
444
|
color: #06d6a0;
|
|
357
445
|
}
|
|
358
446
|
|
|
447
|
+
.requirement-item.requirement-invalid {
|
|
448
|
+
color: #ef476f;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.requirement-item.requirement-invalid .q-icon {
|
|
452
|
+
color: #ef476f;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.server-error {
|
|
456
|
+
font-size: 13px;
|
|
457
|
+
font-weight: 500;
|
|
458
|
+
color: #ef476f;
|
|
459
|
+
}
|
|
460
|
+
|
|
359
461
|
.submit-button {
|
|
360
462
|
border-radius: 15px;
|
|
361
463
|
height: 50px;
|
|
@@ -363,6 +465,10 @@ export default defineComponent({
|
|
|
363
465
|
font-weight: 500;
|
|
364
466
|
}
|
|
365
467
|
|
|
468
|
+
.submit-button--soft {
|
|
469
|
+
opacity: 0.5;
|
|
470
|
+
}
|
|
471
|
+
|
|
366
472
|
@media (max-width: 389px) {
|
|
367
473
|
.reset-password-card {
|
|
368
474
|
padding: 24px 16px;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<ResetPassword
|
|
3
|
+
v-if="magicToken"
|
|
4
|
+
mode="magiclink"
|
|
5
|
+
:magic-token="magicToken"
|
|
6
|
+
:title="$t('auth.setPasswordTitle')"
|
|
7
|
+
:description="$t('auth.setPasswordDescription')"
|
|
8
|
+
:success-subtitle="$t('auth.magicLinkPasswordChangedSubtitle')" />
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { defineComponent, computed, onMounted } from "vue"
|
|
13
|
+
import ResetPassword from "./ResetPassword.vue"
|
|
14
|
+
import { $prompts, $router } from "../../injects"
|
|
15
|
+
|
|
16
|
+
export default defineComponent({
|
|
17
|
+
name: "SetPassword",
|
|
18
|
+
components: { ResetPassword },
|
|
19
|
+
setup() {
|
|
20
|
+
const router = $router()
|
|
21
|
+
const { LOCAL_LOGIN_ROUTE } = $prompts()
|
|
22
|
+
const $route = router.currentRoute.value
|
|
23
|
+
|
|
24
|
+
const magicToken = computed(() => (($route.query.magic as string) || "").trim())
|
|
25
|
+
|
|
26
|
+
onMounted(() => {
|
|
27
|
+
if (!magicToken.value) {
|
|
28
|
+
router.replace(LOCAL_LOGIN_ROUTE)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
return { magicToken }
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
</script>
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
<q-input
|
|
46
46
|
id="confirmPassword"
|
|
47
|
+
ref="confirmPasswordInput"
|
|
47
48
|
v-model="confirmPassword"
|
|
48
49
|
data-testid="confirm-password-input"
|
|
49
50
|
data-cy="reset-confirm-password"
|
|
@@ -164,6 +165,7 @@ export default defineComponent({
|
|
|
164
165
|
passwordRequirements,
|
|
165
166
|
LOCAL_LOGIN_ROUTE,
|
|
166
167
|
canSubmit,
|
|
168
|
+
confirmPasswordInput,
|
|
167
169
|
onSubmit,
|
|
168
170
|
goToLoginPage,
|
|
169
171
|
icons
|
|
@@ -190,6 +192,7 @@ export default defineComponent({
|
|
|
190
192
|
showConfirmPassword,
|
|
191
193
|
successTitleRef,
|
|
192
194
|
LOCAL_LOGIN_ROUTE,
|
|
195
|
+
confirmPasswordInput,
|
|
193
196
|
icons,
|
|
194
197
|
goToLoginPage,
|
|
195
198
|
onSubmit
|
package/src/composables/auth.ts
CHANGED
|
@@ -49,10 +49,22 @@ export function useAuth() {
|
|
|
49
49
|
IS_OTP,
|
|
50
50
|
LOCAL_OTP_ROUTE,
|
|
51
51
|
APP_VERSION,
|
|
52
|
-
USE_SSO
|
|
52
|
+
USE_SSO,
|
|
53
|
+
ENABLE_MAGIC_LINK,
|
|
54
|
+
LOCAL_FIRST_LOGIN_ROUTE
|
|
53
55
|
} = $prompts()
|
|
54
56
|
const { push, replace } = $router()
|
|
55
57
|
|
|
58
|
+
const isMagicLinkEnabled = (ENABLE_MAGIC_LINK || "").toLowerCase() === "y"
|
|
59
|
+
|
|
60
|
+
const goToFirstLogin = () => {
|
|
61
|
+
push(`${LOCAL_FIRST_LOGIN_ROUTE || "/first-login"}?context=activate`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const goToForgotPassword = () => {
|
|
65
|
+
push(LOCAL_FIRST_LOGIN_ROUTE || "/first-login")
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
const email = ref("")
|
|
57
69
|
const password = ref("")
|
|
58
70
|
const msalToken = ref("")
|
|
@@ -268,9 +280,12 @@ export function useAuth() {
|
|
|
268
280
|
IS_OTP,
|
|
269
281
|
LOCAL_OTP_ROUTE,
|
|
270
282
|
USE_SSO,
|
|
283
|
+
isMagicLinkEnabled,
|
|
271
284
|
msalState,
|
|
272
285
|
msalAuth,
|
|
273
286
|
goToOtpValidation,
|
|
287
|
+
goToFirstLogin,
|
|
288
|
+
goToForgotPassword,
|
|
274
289
|
onSubmit,
|
|
275
290
|
retypeEmail,
|
|
276
291
|
isSubmitBtnDisabled,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQuasar, LocalStorage } from "quasar"
|
|
2
2
|
import { useI18n } from "vue-i18n"
|
|
3
3
|
import jwt from "jwt-decode"
|
|
4
|
-
import { computed, ref, Ref, onMounted } from "vue"
|
|
4
|
+
import { computed, ref, Ref, onMounted, watch } from "vue"
|
|
5
5
|
import {
|
|
6
6
|
ionCloseOutline,
|
|
7
7
|
ionEyeOutline,
|
|
@@ -32,6 +32,8 @@ export function useResetPassword() {
|
|
|
32
32
|
uid: string
|
|
33
33
|
token: string
|
|
34
34
|
}
|
|
35
|
+
type ValidatableInput = { resetValidation: () => void }
|
|
36
|
+
const confirmPasswordInput = ref<ValidatableInput | null>(null)
|
|
35
37
|
|
|
36
38
|
const { patch, post } = $api()
|
|
37
39
|
const router = $router()
|
|
@@ -69,15 +71,19 @@ export function useResetPassword() {
|
|
|
69
71
|
hasSpecialChar.value
|
|
70
72
|
)
|
|
71
73
|
|
|
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.
|
|
72
77
|
const passwordRequirements = computed(() => [
|
|
73
|
-
{ label: t("auth.passwordRequirement.minLength"), valid: hasMinLength.value },
|
|
74
|
-
{ label: t("auth.passwordRequirement.uppercase"), valid: hasUppercase.value },
|
|
75
|
-
{ label: t("auth.passwordRequirement.lowercase"), valid: hasLowercase.value },
|
|
76
|
-
{ label: t("auth.passwordRequirement.number"), valid: hasNumber.value },
|
|
77
|
-
{ label: t("auth.passwordRequirement.letter"), valid: hasLetter.value },
|
|
78
|
+
{ label: t("auth.passwordRequirement.minLength"), valid: hasMinLength.value, invalid: false },
|
|
79
|
+
{ label: t("auth.passwordRequirement.uppercase"), valid: hasUppercase.value, invalid: false },
|
|
80
|
+
{ label: t("auth.passwordRequirement.lowercase"), valid: hasLowercase.value, invalid: false },
|
|
81
|
+
{ label: t("auth.passwordRequirement.number"), valid: hasNumber.value, invalid: false },
|
|
82
|
+
{ label: t("auth.passwordRequirement.letter"), valid: hasLetter.value, invalid: false },
|
|
78
83
|
{
|
|
79
84
|
label: t("auth.passwordRequirement.specialChar"),
|
|
80
|
-
valid: hasSpecialChar.value
|
|
85
|
+
valid: hasSpecialChar.value,
|
|
86
|
+
invalid: false
|
|
81
87
|
}
|
|
82
88
|
])
|
|
83
89
|
|
|
@@ -101,9 +107,17 @@ export function useResetPassword() {
|
|
|
101
107
|
]
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
// Quasar's lazy-rules only re-validates the confirmation field on blur/submit,
|
|
111
|
+
// never while typing. Without this, correcting the field after an error was
|
|
112
|
+
// shown (e.g. clearing it, then retyping) leaves the previous, now-stale
|
|
113
|
+
// error message on screen until the field is blurred again.
|
|
114
|
+
watch([password, confirmPassword], () => {
|
|
115
|
+
confirmPasswordInput.value?.resetValidation()
|
|
116
|
+
})
|
|
117
|
+
|
|
104
118
|
const KNOWN_API_PASSWORD_ERRORS: Array<{ pattern: RegExp; key: string }> = [
|
|
105
119
|
{
|
|
106
|
-
pattern: /simple sequence of \d
|
|
120
|
+
pattern: /simple sequence of \d\+ characters/i,
|
|
107
121
|
key: "auth.passwordSequenceError"
|
|
108
122
|
},
|
|
109
123
|
{
|
|
@@ -219,6 +233,15 @@ export function useResetPassword() {
|
|
|
219
233
|
router.push(LOCAL_LOGIN_ROUTE)
|
|
220
234
|
}
|
|
221
235
|
|
|
236
|
+
// NOTE: OTP flow never expires this way / never blocks submit / has nowhere to send
|
|
237
|
+
// the user back to "first login" - these are inert magiclink-only affordances, kept
|
|
238
|
+
// here only so ResetPassword.vue has a uniform shape regardless of composable used.
|
|
239
|
+
const linkExpired = ref(false)
|
|
240
|
+
const serverError = ref("")
|
|
241
|
+
const highlightInvalidRequirements = ref(false)
|
|
242
|
+
const goToFirstLogin = goToLoginPage
|
|
243
|
+
const handleInvalidSubmit = () => {}
|
|
244
|
+
|
|
222
245
|
onMounted(() => {
|
|
223
246
|
if (!isOtp.value) {
|
|
224
247
|
const key = $route.query.key as string
|
|
@@ -295,8 +318,14 @@ export function useResetPassword() {
|
|
|
295
318
|
passwordRequirements,
|
|
296
319
|
canSubmit,
|
|
297
320
|
LOCAL_LOGIN_ROUTE,
|
|
321
|
+
confirmPasswordInput,
|
|
322
|
+
linkExpired,
|
|
323
|
+
serverError,
|
|
324
|
+
highlightInvalidRequirements,
|
|
298
325
|
onSubmit,
|
|
326
|
+
handleInvalidSubmit,
|
|
299
327
|
goToLoginPage,
|
|
328
|
+
goToFirstLogin,
|
|
300
329
|
icons: {
|
|
301
330
|
ionEyeOutline,
|
|
302
331
|
ionEyeOffOutline,
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { ref, computed } from "vue"
|
|
2
|
+
import isEmail from "validator/es/lib/isEmail"
|
|
3
|
+
import { useI18n } from "vue-i18n"
|
|
4
|
+
import { AxiosError } from "axios"
|
|
5
|
+
|
|
6
|
+
import { $api, $prompts, $router } from "../injects"
|
|
7
|
+
|
|
8
|
+
type Channel = "email" | "sms"
|
|
9
|
+
type Step = "email" | "channel" | "sent"
|
|
10
|
+
|
|
11
|
+
interface ApiErrorPayload {
|
|
12
|
+
errors?: Array<{ detail?: string; title?: string }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RESEND_COUNTDOWN_SECONDS = 58
|
|
16
|
+
|
|
17
|
+
export function useMagicLink() {
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
const api = $api()
|
|
20
|
+
const router = $router()
|
|
21
|
+
const {
|
|
22
|
+
AUTH_SERVER_REQUEST_MAGICLINK_ROUTE,
|
|
23
|
+
LOCAL_SET_PASSWORD_ROUTE,
|
|
24
|
+
LOCAL_LOGIN_ROUTE
|
|
25
|
+
} = $prompts()
|
|
26
|
+
|
|
27
|
+
const email = ref("")
|
|
28
|
+
const emailError = ref("")
|
|
29
|
+
const apiErrorMessage = ref("")
|
|
30
|
+
const channels = ref<Channel[]>([])
|
|
31
|
+
const selectedChannel = ref<Channel>("email")
|
|
32
|
+
const step = ref<Step>("email")
|
|
33
|
+
const isLoading = ref(false)
|
|
34
|
+
const resendCountdown = ref(0)
|
|
35
|
+
const restricted = ref(false)
|
|
36
|
+
const restrictedMessage = ref("")
|
|
37
|
+
let countdownTimer: ReturnType<typeof setInterval> | undefined
|
|
38
|
+
|
|
39
|
+
// "activate" (from the login page's "Activer mon compte" button) vs. bare (from
|
|
40
|
+
// "Mot de passe oublié ?") - same page/composable, only the email step copy differs.
|
|
41
|
+
const isActivateContext = router.currentRoute.value.query.context === "activate"
|
|
42
|
+
|
|
43
|
+
const maskEmail = (value: string): string => {
|
|
44
|
+
const [local, domain] = value.split("@")
|
|
45
|
+
if (!local || !domain) return value
|
|
46
|
+
return `${local.charAt(0)}***@${domain}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const maskedEmail = computed(() => maskEmail(email.value))
|
|
50
|
+
|
|
51
|
+
const validateEmail = (): boolean => {
|
|
52
|
+
if (!email.value) {
|
|
53
|
+
emailError.value = t("auth.emailPresenceError")
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
if (!isEmail(email.value.trim())) {
|
|
57
|
+
emailError.value = t("auth.emailValidationError")
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
emailError.value = ""
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const buildBaseUrl = (): string => {
|
|
65
|
+
if (typeof window === "undefined") return ""
|
|
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"}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const readApiError = (error: unknown): string | undefined => {
|
|
77
|
+
const axiosError = error as AxiosError<ApiErrorPayload>
|
|
78
|
+
const apiError = axiosError.response?.data?.errors?.[0]
|
|
79
|
+
if (!apiError) return undefined
|
|
80
|
+
if (apiError.detail) return apiError.detail
|
|
81
|
+
if (apiError.title && apiError.title.toLowerCase() !== "unprocessable content") {
|
|
82
|
+
return apiError.title
|
|
83
|
+
}
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const requestChannels = async () => {
|
|
88
|
+
apiErrorMessage.value = ""
|
|
89
|
+
if (!validateEmail()) return
|
|
90
|
+
isLoading.value = true
|
|
91
|
+
try {
|
|
92
|
+
const { data } = await api.post(AUTH_SERVER_REQUEST_MAGICLINK_ROUTE, {
|
|
93
|
+
auth: { email: email.value.trim() }
|
|
94
|
+
})
|
|
95
|
+
channels.value = data?.channels?.length ? data.channels : ["email"]
|
|
96
|
+
selectedChannel.value = "email"
|
|
97
|
+
step.value = "channel"
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const axiosError = error as AxiosError
|
|
100
|
+
if (axiosError.response?.status === 403) {
|
|
101
|
+
// Backend check: ENV["COMPANY_RESET_PASSWORD_FORBIDDEN"] vs user.company_id,
|
|
102
|
+
// rescued as Rescueable::Forbidden with i18n key errors.magiclink.forbidden.
|
|
103
|
+
// No pre-flight signal exists (no Company.config field, nothing on /me) - the
|
|
104
|
+
// front can only ever find out by attempting the call, hence no button hiding.
|
|
105
|
+
restricted.value = true
|
|
106
|
+
restrictedMessage.value = readApiError(error) || ""
|
|
107
|
+
} else if (axiosError.response?.status === 404) {
|
|
108
|
+
apiErrorMessage.value = t("auth.error.magicLinkAccountNotFound")
|
|
109
|
+
} else {
|
|
110
|
+
apiErrorMessage.value = readApiError(error) || t("auth.error.unprocessableRequest")
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
isLoading.value = false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const startCountdown = () => {
|
|
118
|
+
resendCountdown.value = RESEND_COUNTDOWN_SECONDS
|
|
119
|
+
if (countdownTimer) clearInterval(countdownTimer)
|
|
120
|
+
countdownTimer = setInterval(() => {
|
|
121
|
+
if (resendCountdown.value > 0) {
|
|
122
|
+
resendCountdown.value -= 1
|
|
123
|
+
} else if (countdownTimer) {
|
|
124
|
+
clearInterval(countdownTimer)
|
|
125
|
+
}
|
|
126
|
+
}, 1000)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sendMagicLink = async (channel?: Channel) => {
|
|
130
|
+
if (channel) selectedChannel.value = channel
|
|
131
|
+
apiErrorMessage.value = ""
|
|
132
|
+
isLoading.value = true
|
|
133
|
+
try {
|
|
134
|
+
await api.post(AUTH_SERVER_REQUEST_MAGICLINK_ROUTE, {
|
|
135
|
+
auth: {
|
|
136
|
+
email: email.value.trim(),
|
|
137
|
+
channel: selectedChannel.value,
|
|
138
|
+
base_url: buildBaseUrl()
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
step.value = "sent"
|
|
142
|
+
startCountdown()
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const axiosError = error as AxiosError
|
|
145
|
+
if (axiosError.response?.status === 403) {
|
|
146
|
+
restricted.value = true
|
|
147
|
+
restrictedMessage.value = readApiError(error) || ""
|
|
148
|
+
} else {
|
|
149
|
+
apiErrorMessage.value = readApiError(error) || t("auth.error.unprocessableRequest")
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
isLoading.value = false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const resend = () => {
|
|
157
|
+
if (resendCountdown.value > 0 || isLoading.value) return
|
|
158
|
+
sendMagicLink()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const selectChannel = (channel: Channel) => {
|
|
162
|
+
selectedChannel.value = channel
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const goBackToLogin = () => {
|
|
166
|
+
router.push(LOCAL_LOGIN_ROUTE)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const WEBMAIL_INBOX_BY_DOMAIN: Record<string, string> = {
|
|
170
|
+
"gmail.com": "https://mail.google.com/mail/u/0/#inbox",
|
|
171
|
+
"outlook.com": "https://outlook.live.com/mail/0/inbox",
|
|
172
|
+
"hotmail.com": "https://outlook.live.com/mail/0/inbox",
|
|
173
|
+
"live.com": "https://outlook.live.com/mail/0/inbox",
|
|
174
|
+
"yahoo.com": "https://mail.yahoo.com",
|
|
175
|
+
"icloud.com": "https://www.icloud.com/mail"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const openMailbox = () => {
|
|
179
|
+
if (typeof window === "undefined") return
|
|
180
|
+
const domain = email.value.split("@")[1]?.toLowerCase() || ""
|
|
181
|
+
const inboxUrl = WEBMAIL_INBOX_BY_DOMAIN[domain]
|
|
182
|
+
if (inboxUrl) {
|
|
183
|
+
window.open(inboxUrl, "_blank")
|
|
184
|
+
} else {
|
|
185
|
+
window.location.href = `mailto:${email.value}`
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
email,
|
|
191
|
+
emailError,
|
|
192
|
+
apiErrorMessage,
|
|
193
|
+
channels,
|
|
194
|
+
selectedChannel,
|
|
195
|
+
step,
|
|
196
|
+
isLoading,
|
|
197
|
+
resendCountdown,
|
|
198
|
+
restricted,
|
|
199
|
+
restrictedMessage,
|
|
200
|
+
isActivateContext,
|
|
201
|
+
maskedEmail,
|
|
202
|
+
maskEmail,
|
|
203
|
+
requestChannels,
|
|
204
|
+
sendMagicLink,
|
|
205
|
+
resend,
|
|
206
|
+
selectChannel,
|
|
207
|
+
goBackToLogin,
|
|
208
|
+
openMailbox
|
|
209
|
+
}
|
|
210
|
+
}
|