@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.
@@ -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="!passwordUpdated">
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="{ 'requirement-valid': requirement.valid }">
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
- :disable="!canSubmit">
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
- <q-icon name="check_circle" size="48px" color="primary" />
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
- setup() {
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
- } = useResetPassword()
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
- goToLoginPage
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(56, 189, 248, 0.1);
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>
@@ -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
- { 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 },
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
+ }