@sowell/quasar-app-extension-auth 1.6.1 → 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 +117 -14
- package/src/components/pages/SetPassword.vue +35 -0
- package/src/composables/auth.ts +16 -1
- package/src/composables/resetPassword.ts +24 -6
- 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"
|
|
@@ -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,8 @@
|
|
|
117
151
|
:loading="loading"
|
|
118
152
|
type="submit"
|
|
119
153
|
class="full-width q-mt-lg submit-button"
|
|
120
|
-
:
|
|
154
|
+
:class="{ 'submit-button--soft': mode !== 'otp' && !canSubmit }"
|
|
155
|
+
:disable="mode === 'otp' ? !canSubmit : false">
|
|
121
156
|
<q-icon name="lock_open" size="16px" class="q-mr-sm" />
|
|
122
157
|
{{ $t("common.saveNewPassword") }}
|
|
123
158
|
</q-btn>
|
|
@@ -130,7 +165,11 @@
|
|
|
130
165
|
role="status"
|
|
131
166
|
aria-live="polite">
|
|
132
167
|
<div class="success-badge">
|
|
133
|
-
<
|
|
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>
|
|
134
173
|
</div>
|
|
135
174
|
<div
|
|
136
175
|
ref="successTitleRef"
|
|
@@ -140,7 +179,7 @@
|
|
|
140
179
|
{{ $t("auth.passwordSavedTitle") }}
|
|
141
180
|
</div>
|
|
142
181
|
<div class="subtitle text-center q-mt-sm" data-testid="password-updated-message">
|
|
143
|
-
{{ $t("auth.passwordSavedMessage") }}
|
|
182
|
+
{{ successSubtitle || $t("auth.passwordSavedMessage") }}
|
|
144
183
|
</div>
|
|
145
184
|
</div>
|
|
146
185
|
<q-btn
|
|
@@ -160,27 +199,60 @@
|
|
|
160
199
|
</template>
|
|
161
200
|
|
|
162
201
|
<script lang="ts">
|
|
163
|
-
import { defineComponent, nextTick, ref, watch } from "vue"
|
|
202
|
+
import { defineComponent, nextTick, ref, watch, computed, PropType } from "vue"
|
|
164
203
|
import { useResetPassword } from "composables/resetPassword"
|
|
204
|
+
import { useValidateMagicLink } from "composables/useValidateMagicLink"
|
|
165
205
|
|
|
166
206
|
export default defineComponent({
|
|
167
207
|
name: "ResetPassword",
|
|
168
|
-
|
|
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
|
+
|
|
169
233
|
const {
|
|
170
234
|
password,
|
|
171
235
|
confirmPassword,
|
|
172
236
|
loading,
|
|
173
237
|
passwordUpdated,
|
|
238
|
+
linkExpired,
|
|
239
|
+
serverError,
|
|
174
240
|
showPassword,
|
|
241
|
+
highlightInvalidRequirements,
|
|
175
242
|
validations,
|
|
176
243
|
passwordRequirements,
|
|
177
244
|
LOCAL_LOGIN_ROUTE,
|
|
178
245
|
canSubmit,
|
|
179
246
|
confirmPasswordInput,
|
|
180
247
|
onSubmit,
|
|
248
|
+
handleInvalidSubmit,
|
|
181
249
|
goToLoginPage,
|
|
250
|
+
goToFirstLogin,
|
|
182
251
|
icons
|
|
183
|
-
} =
|
|
252
|
+
} =
|
|
253
|
+
props.mode === "magiclink"
|
|
254
|
+
? useValidateMagicLink(magicToken)
|
|
255
|
+
: useResetPassword()
|
|
184
256
|
|
|
185
257
|
const showConfirmPassword = ref(false)
|
|
186
258
|
const successTitleRef = ref<HTMLElement | null>(null)
|
|
@@ -197,6 +269,9 @@ export default defineComponent({
|
|
|
197
269
|
loading,
|
|
198
270
|
canSubmit,
|
|
199
271
|
passwordUpdated,
|
|
272
|
+
linkExpired,
|
|
273
|
+
serverError,
|
|
274
|
+
highlightInvalidRequirements,
|
|
200
275
|
showPassword,
|
|
201
276
|
showConfirmPassword,
|
|
202
277
|
successTitleRef,
|
|
@@ -206,7 +281,9 @@ export default defineComponent({
|
|
|
206
281
|
confirmPasswordInput,
|
|
207
282
|
icons,
|
|
208
283
|
onSubmit,
|
|
209
|
-
|
|
284
|
+
handleInvalidSubmit,
|
|
285
|
+
goToLoginPage,
|
|
286
|
+
goToFirstLogin
|
|
210
287
|
}
|
|
211
288
|
}
|
|
212
289
|
})
|
|
@@ -278,10 +355,18 @@ export default defineComponent({
|
|
|
278
355
|
}
|
|
279
356
|
|
|
280
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 {
|
|
281
366
|
width: 84px;
|
|
282
367
|
height: 84px;
|
|
283
368
|
border-radius: 9999px;
|
|
284
|
-
background: rgba(
|
|
369
|
+
background: rgba(239, 71, 111, 0.1);
|
|
285
370
|
display: flex;
|
|
286
371
|
align-items: center;
|
|
287
372
|
justify-content: center;
|
|
@@ -359,6 +444,20 @@ export default defineComponent({
|
|
|
359
444
|
color: #06d6a0;
|
|
360
445
|
}
|
|
361
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
|
+
|
|
362
461
|
.submit-button {
|
|
363
462
|
border-radius: 15px;
|
|
364
463
|
height: 50px;
|
|
@@ -366,6 +465,10 @@ export default defineComponent({
|
|
|
366
465
|
font-weight: 500;
|
|
367
466
|
}
|
|
368
467
|
|
|
468
|
+
.submit-button--soft {
|
|
469
|
+
opacity: 0.5;
|
|
470
|
+
}
|
|
471
|
+
|
|
369
472
|
@media (max-width: 389px) {
|
|
370
473
|
.reset-password-card {
|
|
371
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>
|
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,
|
|
@@ -71,15 +71,19 @@ export function useResetPassword() {
|
|
|
71
71
|
hasSpecialChar.value
|
|
72
72
|
)
|
|
73
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.
|
|
74
77
|
const passwordRequirements = computed(() => [
|
|
75
|
-
{ label: t("auth.passwordRequirement.minLength"), valid: hasMinLength.value },
|
|
76
|
-
{ label: t("auth.passwordRequirement.uppercase"), valid: hasUppercase.value },
|
|
77
|
-
{ label: t("auth.passwordRequirement.lowercase"), valid: hasLowercase.value },
|
|
78
|
-
{ label: t("auth.passwordRequirement.number"), valid: hasNumber.value },
|
|
79
|
-
{ 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 },
|
|
80
83
|
{
|
|
81
84
|
label: t("auth.passwordRequirement.specialChar"),
|
|
82
|
-
valid: hasSpecialChar.value
|
|
85
|
+
valid: hasSpecialChar.value,
|
|
86
|
+
invalid: false
|
|
83
87
|
}
|
|
84
88
|
])
|
|
85
89
|
|
|
@@ -229,6 +233,15 @@ export function useResetPassword() {
|
|
|
229
233
|
router.push(LOCAL_LOGIN_ROUTE)
|
|
230
234
|
}
|
|
231
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
|
+
|
|
232
245
|
onMounted(() => {
|
|
233
246
|
if (!isOtp.value) {
|
|
234
247
|
const key = $route.query.key as string
|
|
@@ -306,8 +319,13 @@ export function useResetPassword() {
|
|
|
306
319
|
canSubmit,
|
|
307
320
|
LOCAL_LOGIN_ROUTE,
|
|
308
321
|
confirmPasswordInput,
|
|
322
|
+
linkExpired,
|
|
323
|
+
serverError,
|
|
324
|
+
highlightInvalidRequirements,
|
|
309
325
|
onSubmit,
|
|
326
|
+
handleInvalidSubmit,
|
|
310
327
|
goToLoginPage,
|
|
328
|
+
goToFirstLogin,
|
|
311
329
|
icons: {
|
|
312
330
|
ionEyeOutline,
|
|
313
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
|
+
}
|