@sowell/quasar-app-extension-auth 1.6.1 → 1.7.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 +1 -1
- package/src/boot/router-guards.ts +2 -0
- package/src/components/pages/AuthReporter.vue +47 -16
- package/src/components/pages/FirstLogin.vue +604 -0
- package/src/components/pages/ResetPassword.vue +122 -15
- package/src/components/pages/SetPassword.vue +35 -0
- package/src/composables/auth.ts +11 -1
- package/src/composables/resetPassword.ts +49 -6
- package/src/composables/useMagicLink.ts +208 -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"
|
|
@@ -77,6 +103,7 @@
|
|
|
77
103
|
:type="showConfirmPassword ? 'text' : 'password'"
|
|
78
104
|
:rules="validations['passwordConfirmationRules']"
|
|
79
105
|
lazy-rules
|
|
106
|
+
:hide-bottom-space="mode === 'magiclink'"
|
|
80
107
|
borderless
|
|
81
108
|
:placeholder="$t('auth.confirmPasswordPlaceholder')"
|
|
82
109
|
class="q-mt-sm"
|
|
@@ -99,7 +126,10 @@
|
|
|
99
126
|
v-for="requirement in passwordRequirements"
|
|
100
127
|
:key="requirement.label"
|
|
101
128
|
class="requirement-item"
|
|
102
|
-
:class="{
|
|
129
|
+
:class="{
|
|
130
|
+
'requirement-valid': requirement.valid,
|
|
131
|
+
'requirement-invalid': requirement.invalid
|
|
132
|
+
}">
|
|
103
133
|
<q-icon
|
|
104
134
|
:name="requirement.valid ? 'check_circle' : 'radio_button_unchecked'"
|
|
105
135
|
size="16px" />
|
|
@@ -107,6 +137,10 @@
|
|
|
107
137
|
</div>
|
|
108
138
|
</div>
|
|
109
139
|
|
|
140
|
+
<div v-if="serverError" class="server-error q-mt-md" data-testid="server-error">
|
|
141
|
+
{{ serverError }}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
110
144
|
<q-btn
|
|
111
145
|
data-testid="submit-btn"
|
|
112
146
|
data-cy="send-new-password"
|
|
@@ -117,7 +151,7 @@
|
|
|
117
151
|
:loading="loading"
|
|
118
152
|
type="submit"
|
|
119
153
|
class="full-width q-mt-lg submit-button"
|
|
120
|
-
:
|
|
154
|
+
:class="{ 'submit-button--soft': !canSubmit }">
|
|
121
155
|
<q-icon name="lock_open" size="16px" class="q-mr-sm" />
|
|
122
156
|
{{ $t("common.saveNewPassword") }}
|
|
123
157
|
</q-btn>
|
|
@@ -130,7 +164,11 @@
|
|
|
130
164
|
role="status"
|
|
131
165
|
aria-live="polite">
|
|
132
166
|
<div class="success-badge">
|
|
133
|
-
<
|
|
167
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="82" height="82" viewBox="0 0 82 82" fill="none">
|
|
168
|
+
<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"/>
|
|
169
|
+
<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"/>
|
|
170
|
+
<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"/>
|
|
171
|
+
</svg>
|
|
134
172
|
</div>
|
|
135
173
|
<div
|
|
136
174
|
ref="successTitleRef"
|
|
@@ -140,7 +178,7 @@
|
|
|
140
178
|
{{ $t("auth.passwordSavedTitle") }}
|
|
141
179
|
</div>
|
|
142
180
|
<div class="subtitle text-center q-mt-sm" data-testid="password-updated-message">
|
|
143
|
-
{{ $t("auth.passwordSavedMessage") }}
|
|
181
|
+
{{ successSubtitle || $t("auth.passwordSavedMessage") }}
|
|
144
182
|
</div>
|
|
145
183
|
</div>
|
|
146
184
|
<q-btn
|
|
@@ -160,27 +198,60 @@
|
|
|
160
198
|
</template>
|
|
161
199
|
|
|
162
200
|
<script lang="ts">
|
|
163
|
-
import { defineComponent, nextTick, ref, watch } from "vue"
|
|
201
|
+
import { defineComponent, nextTick, ref, watch, computed, PropType } from "vue"
|
|
164
202
|
import { useResetPassword } from "composables/resetPassword"
|
|
203
|
+
import { useValidateMagicLink } from "composables/useValidateMagicLink"
|
|
165
204
|
|
|
166
205
|
export default defineComponent({
|
|
167
206
|
name: "ResetPassword",
|
|
168
|
-
|
|
207
|
+
props: {
|
|
208
|
+
mode: {
|
|
209
|
+
type: String as PropType<"otp" | "magiclink">,
|
|
210
|
+
default: "otp"
|
|
211
|
+
},
|
|
212
|
+
magicToken: {
|
|
213
|
+
type: String,
|
|
214
|
+
default: ""
|
|
215
|
+
},
|
|
216
|
+
title: {
|
|
217
|
+
type: String,
|
|
218
|
+
default: ""
|
|
219
|
+
},
|
|
220
|
+
description: {
|
|
221
|
+
type: String,
|
|
222
|
+
default: ""
|
|
223
|
+
},
|
|
224
|
+
successSubtitle: {
|
|
225
|
+
type: String,
|
|
226
|
+
default: ""
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
setup(props) {
|
|
230
|
+
const magicToken = computed(() => props.magicToken)
|
|
231
|
+
|
|
169
232
|
const {
|
|
170
233
|
password,
|
|
171
234
|
confirmPassword,
|
|
172
235
|
loading,
|
|
173
236
|
passwordUpdated,
|
|
237
|
+
linkExpired,
|
|
238
|
+
serverError,
|
|
174
239
|
showPassword,
|
|
240
|
+
highlightInvalidRequirements,
|
|
175
241
|
validations,
|
|
176
242
|
passwordRequirements,
|
|
177
243
|
LOCAL_LOGIN_ROUTE,
|
|
178
244
|
canSubmit,
|
|
179
245
|
confirmPasswordInput,
|
|
180
246
|
onSubmit,
|
|
247
|
+
handleInvalidSubmit,
|
|
181
248
|
goToLoginPage,
|
|
249
|
+
goToFirstLogin,
|
|
182
250
|
icons
|
|
183
|
-
} =
|
|
251
|
+
} =
|
|
252
|
+
props.mode === "magiclink"
|
|
253
|
+
? useValidateMagicLink(magicToken)
|
|
254
|
+
: useResetPassword()
|
|
184
255
|
|
|
185
256
|
const showConfirmPassword = ref(false)
|
|
186
257
|
const successTitleRef = ref<HTMLElement | null>(null)
|
|
@@ -197,6 +268,9 @@ export default defineComponent({
|
|
|
197
268
|
loading,
|
|
198
269
|
canSubmit,
|
|
199
270
|
passwordUpdated,
|
|
271
|
+
linkExpired,
|
|
272
|
+
serverError,
|
|
273
|
+
highlightInvalidRequirements,
|
|
200
274
|
showPassword,
|
|
201
275
|
showConfirmPassword,
|
|
202
276
|
successTitleRef,
|
|
@@ -206,7 +280,9 @@ export default defineComponent({
|
|
|
206
280
|
confirmPasswordInput,
|
|
207
281
|
icons,
|
|
208
282
|
onSubmit,
|
|
209
|
-
|
|
283
|
+
handleInvalidSubmit,
|
|
284
|
+
goToLoginPage,
|
|
285
|
+
goToFirstLogin
|
|
210
286
|
}
|
|
211
287
|
}
|
|
212
288
|
})
|
|
@@ -278,10 +354,18 @@ export default defineComponent({
|
|
|
278
354
|
}
|
|
279
355
|
|
|
280
356
|
.success-badge {
|
|
357
|
+
width: 82px;
|
|
358
|
+
height: 82px;
|
|
359
|
+
display: flex;
|
|
360
|
+
align-items: center;
|
|
361
|
+
justify-content: center;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.error-badge {
|
|
281
365
|
width: 84px;
|
|
282
366
|
height: 84px;
|
|
283
367
|
border-radius: 9999px;
|
|
284
|
-
background: rgba(
|
|
368
|
+
background: rgba(239, 71, 111, 0.1);
|
|
285
369
|
display: flex;
|
|
286
370
|
align-items: center;
|
|
287
371
|
justify-content: center;
|
|
@@ -335,18 +419,23 @@ export default defineComponent({
|
|
|
335
419
|
border-radius: 8px;
|
|
336
420
|
padding: 16px;
|
|
337
421
|
display: grid;
|
|
338
|
-
grid-template-columns: repeat(2, 1fr);
|
|
422
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
339
423
|
gap: 10px 16px;
|
|
340
424
|
}
|
|
341
425
|
|
|
342
426
|
.requirement-item {
|
|
343
427
|
display: flex;
|
|
344
428
|
align-items: center;
|
|
429
|
+
min-width: 0;
|
|
345
430
|
font-size: 12px;
|
|
346
431
|
font-weight: 500;
|
|
347
432
|
color: #3e484f;
|
|
348
433
|
}
|
|
349
434
|
|
|
435
|
+
.requirement-item span {
|
|
436
|
+
overflow-wrap: anywhere;
|
|
437
|
+
}
|
|
438
|
+
|
|
350
439
|
.requirement-item .q-icon {
|
|
351
440
|
color: #bdc8d1;
|
|
352
441
|
}
|
|
@@ -359,6 +448,20 @@ export default defineComponent({
|
|
|
359
448
|
color: #06d6a0;
|
|
360
449
|
}
|
|
361
450
|
|
|
451
|
+
.requirement-item.requirement-invalid {
|
|
452
|
+
color: #ef476f;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.requirement-item.requirement-invalid .q-icon {
|
|
456
|
+
color: #ef476f;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.server-error {
|
|
460
|
+
font-size: 13px;
|
|
461
|
+
font-weight: 500;
|
|
462
|
+
color: #ef476f;
|
|
463
|
+
}
|
|
464
|
+
|
|
362
465
|
.submit-button {
|
|
363
466
|
border-radius: 15px;
|
|
364
467
|
height: 50px;
|
|
@@ -366,6 +469,10 @@ export default defineComponent({
|
|
|
366
469
|
font-weight: 500;
|
|
367
470
|
}
|
|
368
471
|
|
|
472
|
+
.submit-button--soft {
|
|
473
|
+
opacity: 0.5;
|
|
474
|
+
}
|
|
475
|
+
|
|
369
476
|
@media (max-width: 389px) {
|
|
370
477
|
.reset-password-card {
|
|
371
478
|
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>
|
package/src/composables/auth.ts
CHANGED
|
@@ -49,10 +49,18 @@ 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
|
+
|
|
56
64
|
const email = ref("")
|
|
57
65
|
const password = ref("")
|
|
58
66
|
const msalToken = ref("")
|
|
@@ -268,9 +276,11 @@ export function useAuth() {
|
|
|
268
276
|
IS_OTP,
|
|
269
277
|
LOCAL_OTP_ROUTE,
|
|
270
278
|
USE_SSO,
|
|
279
|
+
isMagicLinkEnabled,
|
|
271
280
|
msalState,
|
|
272
281
|
msalAuth,
|
|
273
282
|
goToOtpValidation,
|
|
283
|
+
goToFirstLogin,
|
|
274
284
|
onSubmit,
|
|
275
285
|
retypeEmail,
|
|
276
286
|
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()
|
|
@@ -72,14 +73,35 @@ export function useResetPassword() {
|
|
|
72
73
|
)
|
|
73
74
|
|
|
74
75
|
const passwordRequirements = computed(() => [
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
},
|
|
80
101
|
{
|
|
81
102
|
label: t("auth.passwordRequirement.specialChar"),
|
|
82
|
-
valid: hasSpecialChar.value
|
|
103
|
+
valid: hasSpecialChar.value,
|
|
104
|
+
invalid: highlightInvalidRequirements.value && !hasSpecialChar.value
|
|
83
105
|
}
|
|
84
106
|
])
|
|
85
107
|
|
|
@@ -158,6 +180,11 @@ export function useResetPassword() {
|
|
|
158
180
|
}
|
|
159
181
|
|
|
160
182
|
const onSubmit = () => {
|
|
183
|
+
if (!canSubmit.value) {
|
|
184
|
+
highlightInvalidRequirements.value = true
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
161
188
|
let idUser = decodedToken?.value?.sub
|
|
162
189
|
if (isOtp.value && userInfos?.uid) {
|
|
163
190
|
idUser = userInfos?.uid
|
|
@@ -229,6 +256,17 @@ export function useResetPassword() {
|
|
|
229
256
|
router.push(LOCAL_LOGIN_ROUTE)
|
|
230
257
|
}
|
|
231
258
|
|
|
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.
|
|
262
|
+
const linkExpired = ref(false)
|
|
263
|
+
const serverError = ref("")
|
|
264
|
+
const goToFirstLogin = goToLoginPage
|
|
265
|
+
|
|
266
|
+
const handleInvalidSubmit = () => {
|
|
267
|
+
highlightInvalidRequirements.value = true
|
|
268
|
+
}
|
|
269
|
+
|
|
232
270
|
onMounted(() => {
|
|
233
271
|
if (!isOtp.value) {
|
|
234
272
|
const key = $route.query.key as string
|
|
@@ -306,8 +344,13 @@ export function useResetPassword() {
|
|
|
306
344
|
canSubmit,
|
|
307
345
|
LOCAL_LOGIN_ROUTE,
|
|
308
346
|
confirmPasswordInput,
|
|
347
|
+
linkExpired,
|
|
348
|
+
serverError,
|
|
349
|
+
highlightInvalidRequirements,
|
|
309
350
|
onSubmit,
|
|
351
|
+
handleInvalidSubmit,
|
|
310
352
|
goToLoginPage,
|
|
353
|
+
goToFirstLogin,
|
|
311
354
|
icons: {
|
|
312
355
|
ionEyeOutline,
|
|
313
356
|
ionEyeOffOutline,
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
APP_SERVER_BASE_URL
|
|
26
|
+
} = $prompts()
|
|
27
|
+
|
|
28
|
+
const email = ref("")
|
|
29
|
+
const emailError = ref("")
|
|
30
|
+
const apiErrorMessage = ref("")
|
|
31
|
+
const channels = ref<Channel[]>([])
|
|
32
|
+
const selectedChannel = ref<Channel>("email")
|
|
33
|
+
const step = ref<Step>("email")
|
|
34
|
+
const isLoading = ref(false)
|
|
35
|
+
const resendCountdown = ref(0)
|
|
36
|
+
const restricted = ref(false)
|
|
37
|
+
const restrictedMessage = ref("")
|
|
38
|
+
let countdownTimer: ReturnType<typeof setInterval> | undefined
|
|
39
|
+
|
|
40
|
+
// "activate" (from the login page's "Activer mon compte" button) vs. bare (from
|
|
41
|
+
// "Mot de passe oublié ?") - same page/composable, only the email step copy differs.
|
|
42
|
+
const isActivateContext = router.currentRoute.value.query.context === "activate"
|
|
43
|
+
|
|
44
|
+
const maskEmail = (value: string): string => {
|
|
45
|
+
const [local, domain] = value.split("@")
|
|
46
|
+
if (!local || !domain) return value
|
|
47
|
+
return `${local.charAt(0)}***@${domain}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const maskedEmail = computed(() => maskEmail(email.value))
|
|
51
|
+
|
|
52
|
+
const validateEmail = (): boolean => {
|
|
53
|
+
if (!email.value) {
|
|
54
|
+
emailError.value = t("auth.emailPresenceError")
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
if (!isEmail(email.value.trim())) {
|
|
58
|
+
emailError.value = t("auth.emailValidationError")
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
emailError.value = ""
|
|
62
|
+
return true
|
|
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.
|
|
70
|
+
const buildBaseUrl = (): string => {
|
|
71
|
+
return `${APP_SERVER_BASE_URL}${LOCAL_SET_PASSWORD_ROUTE || "/set-password"}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const readApiError = (error: unknown): string | undefined => {
|
|
75
|
+
const axiosError = error as AxiosError<ApiErrorPayload>
|
|
76
|
+
const apiError = axiosError.response?.data?.errors?.[0]
|
|
77
|
+
if (!apiError) return undefined
|
|
78
|
+
if (apiError.detail) return apiError.detail
|
|
79
|
+
if (apiError.title && apiError.title.toLowerCase() !== "unprocessable content") {
|
|
80
|
+
return apiError.title
|
|
81
|
+
}
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const requestChannels = async () => {
|
|
86
|
+
apiErrorMessage.value = ""
|
|
87
|
+
if (!validateEmail()) return
|
|
88
|
+
isLoading.value = true
|
|
89
|
+
try {
|
|
90
|
+
const { data } = await api.post(AUTH_SERVER_REQUEST_MAGICLINK_ROUTE, {
|
|
91
|
+
auth: { email: email.value.trim() }
|
|
92
|
+
})
|
|
93
|
+
channels.value = data?.channels?.length ? data.channels : ["email"]
|
|
94
|
+
selectedChannel.value = "email"
|
|
95
|
+
step.value = "channel"
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const axiosError = error as AxiosError
|
|
98
|
+
if (axiosError.response?.status === 403) {
|
|
99
|
+
// Backend check: ENV["COMPANY_RESET_PASSWORD_FORBIDDEN"] vs user.company_id,
|
|
100
|
+
// rescued as Rescueable::Forbidden with i18n key errors.magiclink.forbidden.
|
|
101
|
+
// No pre-flight signal exists (no Company.config field, nothing on /me) - the
|
|
102
|
+
// front can only ever find out by attempting the call, hence no button hiding.
|
|
103
|
+
restricted.value = true
|
|
104
|
+
restrictedMessage.value = readApiError(error) || ""
|
|
105
|
+
} else if (axiosError.response?.status === 404) {
|
|
106
|
+
apiErrorMessage.value = t("auth.error.magicLinkAccountNotFound")
|
|
107
|
+
} else {
|
|
108
|
+
apiErrorMessage.value = readApiError(error) || t("auth.error.unprocessableRequest")
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
isLoading.value = false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const startCountdown = () => {
|
|
116
|
+
resendCountdown.value = RESEND_COUNTDOWN_SECONDS
|
|
117
|
+
if (countdownTimer) clearInterval(countdownTimer)
|
|
118
|
+
countdownTimer = setInterval(() => {
|
|
119
|
+
if (resendCountdown.value > 0) {
|
|
120
|
+
resendCountdown.value -= 1
|
|
121
|
+
} else if (countdownTimer) {
|
|
122
|
+
clearInterval(countdownTimer)
|
|
123
|
+
}
|
|
124
|
+
}, 1000)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const sendMagicLink = async (channel?: Channel) => {
|
|
128
|
+
if (channel) selectedChannel.value = channel
|
|
129
|
+
apiErrorMessage.value = ""
|
|
130
|
+
isLoading.value = true
|
|
131
|
+
try {
|
|
132
|
+
await api.post(AUTH_SERVER_REQUEST_MAGICLINK_ROUTE, {
|
|
133
|
+
auth: {
|
|
134
|
+
email: email.value.trim(),
|
|
135
|
+
channel: selectedChannel.value,
|
|
136
|
+
base_url: buildBaseUrl()
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
step.value = "sent"
|
|
140
|
+
startCountdown()
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const axiosError = error as AxiosError
|
|
143
|
+
if (axiosError.response?.status === 403) {
|
|
144
|
+
restricted.value = true
|
|
145
|
+
restrictedMessage.value = readApiError(error) || ""
|
|
146
|
+
} else {
|
|
147
|
+
apiErrorMessage.value = readApiError(error) || t("auth.error.unprocessableRequest")
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
isLoading.value = false
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const resend = () => {
|
|
155
|
+
if (resendCountdown.value > 0 || isLoading.value) return
|
|
156
|
+
sendMagicLink()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const selectChannel = (channel: Channel) => {
|
|
160
|
+
selectedChannel.value = channel
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const goBackToLogin = () => {
|
|
164
|
+
router.push(LOCAL_LOGIN_ROUTE)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const WEBMAIL_INBOX_BY_DOMAIN: Record<string, string> = {
|
|
168
|
+
"gmail.com": "https://mail.google.com/mail/u/0/#inbox",
|
|
169
|
+
"outlook.com": "https://outlook.live.com/mail/0/inbox",
|
|
170
|
+
"hotmail.com": "https://outlook.live.com/mail/0/inbox",
|
|
171
|
+
"live.com": "https://outlook.live.com/mail/0/inbox",
|
|
172
|
+
"yahoo.com": "https://mail.yahoo.com",
|
|
173
|
+
"icloud.com": "https://www.icloud.com/mail"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const openMailbox = () => {
|
|
177
|
+
if (typeof window === "undefined") return
|
|
178
|
+
const domain = email.value.split("@")[1]?.toLowerCase() || ""
|
|
179
|
+
const inboxUrl = WEBMAIL_INBOX_BY_DOMAIN[domain]
|
|
180
|
+
if (inboxUrl) {
|
|
181
|
+
window.open(inboxUrl, "_blank")
|
|
182
|
+
} else {
|
|
183
|
+
window.location.href = `mailto:${email.value}`
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
email,
|
|
189
|
+
emailError,
|
|
190
|
+
apiErrorMessage,
|
|
191
|
+
channels,
|
|
192
|
+
selectedChannel,
|
|
193
|
+
step,
|
|
194
|
+
isLoading,
|
|
195
|
+
resendCountdown,
|
|
196
|
+
restricted,
|
|
197
|
+
restrictedMessage,
|
|
198
|
+
isActivateContext,
|
|
199
|
+
maskedEmail,
|
|
200
|
+
maskEmail,
|
|
201
|
+
requestChannels,
|
|
202
|
+
sendMagicLink,
|
|
203
|
+
resend,
|
|
204
|
+
selectChannel,
|
|
205
|
+
goBackToLogin,
|
|
206
|
+
openMailbox
|
|
207
|
+
}
|
|
208
|
+
}
|