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