@sowell/quasar-app-extension-auth 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"
@@ -70,12 +96,14 @@
70
96
  </div>
71
97
  <q-input
72
98
  id="confirmPassword"
99
+ ref="confirmPasswordInput"
73
100
  v-model="confirmPassword"
74
101
  data-testid="confirm-password-input"
75
102
  data-cy="reset-confirm-password"
76
103
  :type="showConfirmPassword ? 'text' : 'password'"
77
104
  :rules="validations['passwordConfirmationRules']"
78
105
  lazy-rules
106
+ :hide-bottom-space="mode === 'magiclink'"
79
107
  borderless
80
108
  :placeholder="$t('auth.confirmPasswordPlaceholder')"
81
109
  class="q-mt-sm"
@@ -98,7 +126,10 @@
98
126
  v-for="requirement in passwordRequirements"
99
127
  :key="requirement.label"
100
128
  class="requirement-item"
101
- :class="{ 'requirement-valid': requirement.valid }">
129
+ :class="{
130
+ 'requirement-valid': requirement.valid,
131
+ 'requirement-invalid': requirement.invalid
132
+ }">
102
133
  <q-icon
103
134
  :name="requirement.valid ? 'check_circle' : 'radio_button_unchecked'"
104
135
  size="16px" />
@@ -106,6 +137,10 @@
106
137
  </div>
107
138
  </div>
108
139
 
140
+ <div v-if="serverError" class="server-error q-mt-md" data-testid="server-error">
141
+ {{ serverError }}
142
+ </div>
143
+
109
144
  <q-btn
110
145
  data-testid="submit-btn"
111
146
  data-cy="send-new-password"
@@ -116,7 +151,8 @@
116
151
  :loading="loading"
117
152
  type="submit"
118
153
  class="full-width q-mt-lg submit-button"
119
- :disable="!canSubmit">
154
+ :class="{ 'submit-button--soft': mode !== 'otp' && !canSubmit }"
155
+ :disable="mode === 'otp' ? !canSubmit : false">
120
156
  <q-icon name="lock_open" size="16px" class="q-mr-sm" />
121
157
  {{ $t("common.saveNewPassword") }}
122
158
  </q-btn>
@@ -129,7 +165,11 @@
129
165
  role="status"
130
166
  aria-live="polite">
131
167
  <div class="success-badge">
132
- <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>
133
173
  </div>
134
174
  <div
135
175
  ref="successTitleRef"
@@ -139,7 +179,7 @@
139
179
  {{ $t("auth.passwordSavedTitle") }}
140
180
  </div>
141
181
  <div class="subtitle text-center q-mt-sm" data-testid="password-updated-message">
142
- {{ $t("auth.passwordSavedMessage") }}
182
+ {{ successSubtitle || $t("auth.passwordSavedMessage") }}
143
183
  </div>
144
184
  </div>
145
185
  <q-btn
@@ -159,26 +199,60 @@
159
199
  </template>
160
200
 
161
201
  <script lang="ts">
162
- import { defineComponent, nextTick, ref, watch } from "vue"
202
+ import { defineComponent, nextTick, ref, watch, computed, PropType } from "vue"
163
203
  import { useResetPassword } from "composables/resetPassword"
204
+ import { useValidateMagicLink } from "composables/useValidateMagicLink"
164
205
 
165
206
  export default defineComponent({
166
207
  name: "ResetPassword",
167
- 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
+
168
233
  const {
169
234
  password,
170
235
  confirmPassword,
171
236
  loading,
172
237
  passwordUpdated,
238
+ linkExpired,
239
+ serverError,
173
240
  showPassword,
241
+ highlightInvalidRequirements,
174
242
  validations,
175
243
  passwordRequirements,
176
244
  LOCAL_LOGIN_ROUTE,
177
245
  canSubmit,
246
+ confirmPasswordInput,
178
247
  onSubmit,
248
+ handleInvalidSubmit,
179
249
  goToLoginPage,
250
+ goToFirstLogin,
180
251
  icons
181
- } = useResetPassword()
252
+ } =
253
+ props.mode === "magiclink"
254
+ ? useValidateMagicLink(magicToken)
255
+ : useResetPassword()
182
256
 
183
257
  const showConfirmPassword = ref(false)
184
258
  const successTitleRef = ref<HTMLElement | null>(null)
@@ -195,15 +269,21 @@ export default defineComponent({
195
269
  loading,
196
270
  canSubmit,
197
271
  passwordUpdated,
272
+ linkExpired,
273
+ serverError,
274
+ highlightInvalidRequirements,
198
275
  showPassword,
199
276
  showConfirmPassword,
200
277
  successTitleRef,
201
278
  LOCAL_LOGIN_ROUTE,
202
279
  validations,
203
280
  passwordRequirements,
281
+ confirmPasswordInput,
204
282
  icons,
205
283
  onSubmit,
206
- goToLoginPage
284
+ handleInvalidSubmit,
285
+ goToLoginPage,
286
+ goToFirstLogin
207
287
  }
208
288
  }
209
289
  })
@@ -275,10 +355,18 @@ export default defineComponent({
275
355
  }
276
356
 
277
357
  .success-badge {
358
+ width: 82px;
359
+ height: 82px;
360
+ display: flex;
361
+ align-items: center;
362
+ justify-content: center;
363
+ }
364
+
365
+ .error-badge {
278
366
  width: 84px;
279
367
  height: 84px;
280
368
  border-radius: 9999px;
281
- background: rgba(56, 189, 248, 0.1);
369
+ background: rgba(239, 71, 111, 0.1);
282
370
  display: flex;
283
371
  align-items: center;
284
372
  justify-content: center;
@@ -356,6 +444,20 @@ export default defineComponent({
356
444
  color: #06d6a0;
357
445
  }
358
446
 
447
+ .requirement-item.requirement-invalid {
448
+ color: #ef476f;
449
+ }
450
+
451
+ .requirement-item.requirement-invalid .q-icon {
452
+ color: #ef476f;
453
+ }
454
+
455
+ .server-error {
456
+ font-size: 13px;
457
+ font-weight: 500;
458
+ color: #ef476f;
459
+ }
460
+
359
461
  .submit-button {
360
462
  border-radius: 15px;
361
463
  height: 50px;
@@ -363,6 +465,10 @@ export default defineComponent({
363
465
  font-weight: 500;
364
466
  }
365
467
 
468
+ .submit-button--soft {
469
+ opacity: 0.5;
470
+ }
471
+
366
472
  @media (max-width: 389px) {
367
473
  .reset-password-card {
368
474
  padding: 24px 16px;
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <ResetPassword
3
+ v-if="magicToken"
4
+ mode="magiclink"
5
+ :magic-token="magicToken"
6
+ :title="$t('auth.setPasswordTitle')"
7
+ :description="$t('auth.setPasswordDescription')"
8
+ :success-subtitle="$t('auth.magicLinkPasswordChangedSubtitle')" />
9
+ </template>
10
+
11
+ <script lang="ts">
12
+ import { defineComponent, computed, onMounted } from "vue"
13
+ import ResetPassword from "./ResetPassword.vue"
14
+ import { $prompts, $router } from "../../injects"
15
+
16
+ export default defineComponent({
17
+ name: "SetPassword",
18
+ components: { ResetPassword },
19
+ setup() {
20
+ const router = $router()
21
+ const { LOCAL_LOGIN_ROUTE } = $prompts()
22
+ const $route = router.currentRoute.value
23
+
24
+ const magicToken = computed(() => (($route.query.magic as string) || "").trim())
25
+
26
+ onMounted(() => {
27
+ if (!magicToken.value) {
28
+ router.replace(LOCAL_LOGIN_ROUTE)
29
+ }
30
+ })
31
+
32
+ return { magicToken }
33
+ }
34
+ })
35
+ </script>
@@ -44,6 +44,7 @@
44
44
 
45
45
  <q-input
46
46
  id="confirmPassword"
47
+ ref="confirmPasswordInput"
47
48
  v-model="confirmPassword"
48
49
  data-testid="confirm-password-input"
49
50
  data-cy="reset-confirm-password"
@@ -164,6 +165,7 @@ export default defineComponent({
164
165
  passwordRequirements,
165
166
  LOCAL_LOGIN_ROUTE,
166
167
  canSubmit,
168
+ confirmPasswordInput,
167
169
  onSubmit,
168
170
  goToLoginPage,
169
171
  icons
@@ -190,6 +192,7 @@ export default defineComponent({
190
192
  showConfirmPassword,
191
193
  successTitleRef,
192
194
  LOCAL_LOGIN_ROUTE,
195
+ confirmPasswordInput,
193
196
  icons,
194
197
  goToLoginPage,
195
198
  onSubmit
@@ -49,10 +49,22 @@ export function useAuth() {
49
49
  IS_OTP,
50
50
  LOCAL_OTP_ROUTE,
51
51
  APP_VERSION,
52
- USE_SSO
52
+ USE_SSO,
53
+ ENABLE_MAGIC_LINK,
54
+ LOCAL_FIRST_LOGIN_ROUTE
53
55
  } = $prompts()
54
56
  const { push, replace } = $router()
55
57
 
58
+ const isMagicLinkEnabled = (ENABLE_MAGIC_LINK || "").toLowerCase() === "y"
59
+
60
+ const goToFirstLogin = () => {
61
+ push(`${LOCAL_FIRST_LOGIN_ROUTE || "/first-login"}?context=activate`)
62
+ }
63
+
64
+ const goToForgotPassword = () => {
65
+ push(LOCAL_FIRST_LOGIN_ROUTE || "/first-login")
66
+ }
67
+
56
68
  const email = ref("")
57
69
  const password = ref("")
58
70
  const msalToken = ref("")
@@ -268,9 +280,12 @@ export function useAuth() {
268
280
  IS_OTP,
269
281
  LOCAL_OTP_ROUTE,
270
282
  USE_SSO,
283
+ isMagicLinkEnabled,
271
284
  msalState,
272
285
  msalAuth,
273
286
  goToOtpValidation,
287
+ goToFirstLogin,
288
+ goToForgotPassword,
274
289
  onSubmit,
275
290
  retypeEmail,
276
291
  isSubmitBtnDisabled,
@@ -1,7 +1,7 @@
1
1
  import { useQuasar, LocalStorage } from "quasar"
2
2
  import { useI18n } from "vue-i18n"
3
3
  import jwt from "jwt-decode"
4
- import { computed, ref, Ref, onMounted } from "vue"
4
+ import { computed, ref, Ref, onMounted, watch } from "vue"
5
5
  import {
6
6
  ionCloseOutline,
7
7
  ionEyeOutline,
@@ -32,6 +32,8 @@ export function useResetPassword() {
32
32
  uid: string
33
33
  token: string
34
34
  }
35
+ type ValidatableInput = { resetValidation: () => void }
36
+ const confirmPasswordInput = ref<ValidatableInput | null>(null)
35
37
 
36
38
  const { patch, post } = $api()
37
39
  const router = $router()
@@ -69,15 +71,19 @@ export function useResetPassword() {
69
71
  hasSpecialChar.value
70
72
  )
71
73
 
74
+ // NOTE: `invalid` always false here (highlight-on-invalid-submit is a magiclink-only
75
+ // affordance, see useValidateMagicLink) - kept on every item so ResetPassword.vue can
76
+ // bind the same class regardless of which composable (OTP vs magiclink) it's using.
72
77
  const passwordRequirements = computed(() => [
73
- { label: t("auth.passwordRequirement.minLength"), valid: hasMinLength.value },
74
- { label: t("auth.passwordRequirement.uppercase"), valid: hasUppercase.value },
75
- { label: t("auth.passwordRequirement.lowercase"), valid: hasLowercase.value },
76
- { label: t("auth.passwordRequirement.number"), valid: hasNumber.value },
77
- { label: t("auth.passwordRequirement.letter"), valid: hasLetter.value },
78
+ { label: t("auth.passwordRequirement.minLength"), valid: hasMinLength.value, invalid: false },
79
+ { label: t("auth.passwordRequirement.uppercase"), valid: hasUppercase.value, invalid: false },
80
+ { label: t("auth.passwordRequirement.lowercase"), valid: hasLowercase.value, invalid: false },
81
+ { label: t("auth.passwordRequirement.number"), valid: hasNumber.value, invalid: false },
82
+ { label: t("auth.passwordRequirement.letter"), valid: hasLetter.value, invalid: false },
78
83
  {
79
84
  label: t("auth.passwordRequirement.specialChar"),
80
- valid: hasSpecialChar.value
85
+ valid: hasSpecialChar.value,
86
+ invalid: false
81
87
  }
82
88
  ])
83
89
 
@@ -101,9 +107,17 @@ export function useResetPassword() {
101
107
  ]
102
108
  }
103
109
 
110
+ // Quasar's lazy-rules only re-validates the confirmation field on blur/submit,
111
+ // never while typing. Without this, correcting the field after an error was
112
+ // shown (e.g. clearing it, then retyping) leaves the previous, now-stale
113
+ // error message on screen until the field is blurred again.
114
+ watch([password, confirmPassword], () => {
115
+ confirmPasswordInput.value?.resetValidation()
116
+ })
117
+
104
118
  const KNOWN_API_PASSWORD_ERRORS: Array<{ pattern: RegExp; key: string }> = [
105
119
  {
106
- pattern: /simple sequence of \d+ characters/i,
120
+ pattern: /simple sequence of \d\+ characters/i,
107
121
  key: "auth.passwordSequenceError"
108
122
  },
109
123
  {
@@ -219,6 +233,15 @@ export function useResetPassword() {
219
233
  router.push(LOCAL_LOGIN_ROUTE)
220
234
  }
221
235
 
236
+ // NOTE: OTP flow never expires this way / never blocks submit / has nowhere to send
237
+ // the user back to "first login" - these are inert magiclink-only affordances, kept
238
+ // here only so ResetPassword.vue has a uniform shape regardless of composable used.
239
+ const linkExpired = ref(false)
240
+ const serverError = ref("")
241
+ const highlightInvalidRequirements = ref(false)
242
+ const goToFirstLogin = goToLoginPage
243
+ const handleInvalidSubmit = () => {}
244
+
222
245
  onMounted(() => {
223
246
  if (!isOtp.value) {
224
247
  const key = $route.query.key as string
@@ -295,8 +318,14 @@ export function useResetPassword() {
295
318
  passwordRequirements,
296
319
  canSubmit,
297
320
  LOCAL_LOGIN_ROUTE,
321
+ confirmPasswordInput,
322
+ linkExpired,
323
+ serverError,
324
+ highlightInvalidRequirements,
298
325
  onSubmit,
326
+ handleInvalidSubmit,
299
327
  goToLoginPage,
328
+ goToFirstLogin,
300
329
  icons: {
301
330
  ionEyeOutline,
302
331
  ionEyeOffOutline,
@@ -0,0 +1,210 @@
1
+ import { ref, computed } from "vue"
2
+ import isEmail from "validator/es/lib/isEmail"
3
+ import { useI18n } from "vue-i18n"
4
+ import { AxiosError } from "axios"
5
+
6
+ import { $api, $prompts, $router } from "../injects"
7
+
8
+ type Channel = "email" | "sms"
9
+ type Step = "email" | "channel" | "sent"
10
+
11
+ interface ApiErrorPayload {
12
+ errors?: Array<{ detail?: string; title?: string }>
13
+ }
14
+
15
+ const RESEND_COUNTDOWN_SECONDS = 58
16
+
17
+ export function useMagicLink() {
18
+ const { t } = useI18n()
19
+ const api = $api()
20
+ const router = $router()
21
+ const {
22
+ AUTH_SERVER_REQUEST_MAGICLINK_ROUTE,
23
+ LOCAL_SET_PASSWORD_ROUTE,
24
+ LOCAL_LOGIN_ROUTE
25
+ } = $prompts()
26
+
27
+ const email = ref("")
28
+ const emailError = ref("")
29
+ const apiErrorMessage = ref("")
30
+ const channels = ref<Channel[]>([])
31
+ const selectedChannel = ref<Channel>("email")
32
+ const step = ref<Step>("email")
33
+ const isLoading = ref(false)
34
+ const resendCountdown = ref(0)
35
+ const restricted = ref(false)
36
+ const restrictedMessage = ref("")
37
+ let countdownTimer: ReturnType<typeof setInterval> | undefined
38
+
39
+ // "activate" (from the login page's "Activer mon compte" button) vs. bare (from
40
+ // "Mot de passe oublié ?") - same page/composable, only the email step copy differs.
41
+ const isActivateContext = router.currentRoute.value.query.context === "activate"
42
+
43
+ const maskEmail = (value: string): string => {
44
+ const [local, domain] = value.split("@")
45
+ if (!local || !domain) return value
46
+ return `${local.charAt(0)}***@${domain}`
47
+ }
48
+
49
+ const maskedEmail = computed(() => maskEmail(email.value))
50
+
51
+ const validateEmail = (): boolean => {
52
+ if (!email.value) {
53
+ emailError.value = t("auth.emailPresenceError")
54
+ return false
55
+ }
56
+ if (!isEmail(email.value.trim())) {
57
+ emailError.value = t("auth.emailValidationError")
58
+ return false
59
+ }
60
+ emailError.value = ""
61
+ return true
62
+ }
63
+
64
+ const buildBaseUrl = (): string => {
65
+ if (typeof window === "undefined") return ""
66
+ // DEV-ONLY BYPASS: MAGICLINK_ALLOWED_HOSTS rejects a bare "localhost" base_url
67
+ // (422). Substitute localhost.sowell.app (port kept) so the link is accepted -
68
+ // requires the local DNS to resolve localhost.sowell.app -> 127.0.0.1.
69
+ // Never let this reach production (see playbook piège #2).
70
+ const { protocol, hostname, port } = window.location
71
+ const effectiveHostname = hostname === "localhost" ? "localhost.sowell.app" : hostname
72
+ const origin = `${protocol}//${effectiveHostname}${port ? `:${port}` : ""}`
73
+ return `${origin}${LOCAL_SET_PASSWORD_ROUTE || "/set-password"}`
74
+ }
75
+
76
+ const readApiError = (error: unknown): string | undefined => {
77
+ const axiosError = error as AxiosError<ApiErrorPayload>
78
+ const apiError = axiosError.response?.data?.errors?.[0]
79
+ if (!apiError) return undefined
80
+ if (apiError.detail) return apiError.detail
81
+ if (apiError.title && apiError.title.toLowerCase() !== "unprocessable content") {
82
+ return apiError.title
83
+ }
84
+ return undefined
85
+ }
86
+
87
+ const requestChannels = async () => {
88
+ apiErrorMessage.value = ""
89
+ if (!validateEmail()) return
90
+ isLoading.value = true
91
+ try {
92
+ const { data } = await api.post(AUTH_SERVER_REQUEST_MAGICLINK_ROUTE, {
93
+ auth: { email: email.value.trim() }
94
+ })
95
+ channels.value = data?.channels?.length ? data.channels : ["email"]
96
+ selectedChannel.value = "email"
97
+ step.value = "channel"
98
+ } catch (error) {
99
+ const axiosError = error as AxiosError
100
+ if (axiosError.response?.status === 403) {
101
+ // Backend check: ENV["COMPANY_RESET_PASSWORD_FORBIDDEN"] vs user.company_id,
102
+ // rescued as Rescueable::Forbidden with i18n key errors.magiclink.forbidden.
103
+ // No pre-flight signal exists (no Company.config field, nothing on /me) - the
104
+ // front can only ever find out by attempting the call, hence no button hiding.
105
+ restricted.value = true
106
+ restrictedMessage.value = readApiError(error) || ""
107
+ } else if (axiosError.response?.status === 404) {
108
+ apiErrorMessage.value = t("auth.error.magicLinkAccountNotFound")
109
+ } else {
110
+ apiErrorMessage.value = readApiError(error) || t("auth.error.unprocessableRequest")
111
+ }
112
+ } finally {
113
+ isLoading.value = false
114
+ }
115
+ }
116
+
117
+ const startCountdown = () => {
118
+ resendCountdown.value = RESEND_COUNTDOWN_SECONDS
119
+ if (countdownTimer) clearInterval(countdownTimer)
120
+ countdownTimer = setInterval(() => {
121
+ if (resendCountdown.value > 0) {
122
+ resendCountdown.value -= 1
123
+ } else if (countdownTimer) {
124
+ clearInterval(countdownTimer)
125
+ }
126
+ }, 1000)
127
+ }
128
+
129
+ const sendMagicLink = async (channel?: Channel) => {
130
+ if (channel) selectedChannel.value = channel
131
+ apiErrorMessage.value = ""
132
+ isLoading.value = true
133
+ try {
134
+ await api.post(AUTH_SERVER_REQUEST_MAGICLINK_ROUTE, {
135
+ auth: {
136
+ email: email.value.trim(),
137
+ channel: selectedChannel.value,
138
+ base_url: buildBaseUrl()
139
+ }
140
+ })
141
+ step.value = "sent"
142
+ startCountdown()
143
+ } catch (error) {
144
+ const axiosError = error as AxiosError
145
+ if (axiosError.response?.status === 403) {
146
+ restricted.value = true
147
+ restrictedMessage.value = readApiError(error) || ""
148
+ } else {
149
+ apiErrorMessage.value = readApiError(error) || t("auth.error.unprocessableRequest")
150
+ }
151
+ } finally {
152
+ isLoading.value = false
153
+ }
154
+ }
155
+
156
+ const resend = () => {
157
+ if (resendCountdown.value > 0 || isLoading.value) return
158
+ sendMagicLink()
159
+ }
160
+
161
+ const selectChannel = (channel: Channel) => {
162
+ selectedChannel.value = channel
163
+ }
164
+
165
+ const goBackToLogin = () => {
166
+ router.push(LOCAL_LOGIN_ROUTE)
167
+ }
168
+
169
+ const WEBMAIL_INBOX_BY_DOMAIN: Record<string, string> = {
170
+ "gmail.com": "https://mail.google.com/mail/u/0/#inbox",
171
+ "outlook.com": "https://outlook.live.com/mail/0/inbox",
172
+ "hotmail.com": "https://outlook.live.com/mail/0/inbox",
173
+ "live.com": "https://outlook.live.com/mail/0/inbox",
174
+ "yahoo.com": "https://mail.yahoo.com",
175
+ "icloud.com": "https://www.icloud.com/mail"
176
+ }
177
+
178
+ const openMailbox = () => {
179
+ if (typeof window === "undefined") return
180
+ const domain = email.value.split("@")[1]?.toLowerCase() || ""
181
+ const inboxUrl = WEBMAIL_INBOX_BY_DOMAIN[domain]
182
+ if (inboxUrl) {
183
+ window.open(inboxUrl, "_blank")
184
+ } else {
185
+ window.location.href = `mailto:${email.value}`
186
+ }
187
+ }
188
+
189
+ return {
190
+ email,
191
+ emailError,
192
+ apiErrorMessage,
193
+ channels,
194
+ selectedChannel,
195
+ step,
196
+ isLoading,
197
+ resendCountdown,
198
+ restricted,
199
+ restrictedMessage,
200
+ isActivateContext,
201
+ maskedEmail,
202
+ maskEmail,
203
+ requestChannels,
204
+ sendMagicLink,
205
+ resend,
206
+ selectChannel,
207
+ goBackToLogin,
208
+ openMailbox
209
+ }
210
+ }