@sowell/quasar-app-extension-auth 1.8.2 → 1.8.4

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.
@@ -0,0 +1,930 @@
1
+ <template>
2
+ <div class="login-container">
3
+ <div class="login-card">
4
+ <div class="logo-container">
5
+ <img
6
+ src="../../assets/Sowell-plus-logo.svg"
7
+ alt="SoWell+"
8
+ class="logo" />
9
+ </div>
10
+
11
+ <div class="login-content">
12
+ <!-- Step: forbidden (403) - takes precedence over `step`, the backend can
13
+ reject on either magiclink call (with or without channel). -->
14
+ <template v-if="restricted">
15
+ <div class="forbidden-content" data-testid="magic-link-forbidden">
16
+ <div class="warning-icon-outer">
17
+ <svg
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ width="82"
20
+ height="82"
21
+ viewBox="0 0 82 82"
22
+ fill="none">
23
+ <path
24
+ 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"
25
+ fill="#FFEFCF" />
26
+ <path
27
+ d="M63.1242 56.9728L44.1383 21.3586C42.7926 18.8336 39.2078 18.8336 37.861 21.3586L18.8762 56.9728C18.584 57.5209 18.4375 58.1363 18.4511 58.7587C18.4646 59.3811 18.6376 59.9894 18.9533 60.5241C19.269 61.0588 19.7165 61.5017 20.2522 61.8095C20.788 62.1174 21.3935 62.2797 22.0099 62.2806H59.9849C60.6017 62.2807 61.208 62.1191 61.7445 61.8116C62.281 61.5042 62.7293 61.0614 63.0457 60.5265C63.362 59.9916 63.5355 59.383 63.5493 58.7601C63.5631 58.1372 63.4166 57.5214 63.1242 56.9728V56.9728ZM41.0002 56.7669C40.5595 56.7669 40.1288 56.6349 39.7624 56.3876C39.396 56.1403 39.1104 55.7888 38.9418 55.3776C38.7732 54.9664 38.729 54.5139 38.815 54.0773C38.901 53.6408 39.1132 53.2397 39.4248 52.925C39.7364 52.6103 40.1333 52.3959 40.5655 52.3091C40.9977 52.2223 41.4457 52.2668 41.8528 52.4372C42.2599 52.6075 42.6079 52.896 42.8527 53.266C43.0975 53.6361 43.2282 54.0712 43.2282 54.5164C43.2282 54.8119 43.1706 55.1045 43.0586 55.3776C42.9466 55.6506 42.7825 55.8987 42.5756 56.1077C42.3687 56.3167 42.1231 56.4824 41.8528 56.5955C41.5825 56.7086 41.2928 56.7669 41.0002 56.7669ZM43.4198 34.1325L42.7804 47.8605C42.7804 48.338 42.5926 48.7959 42.2583 49.1336C41.924 49.4712 41.4707 49.6609 40.998 49.6609C40.5252 49.6609 40.0719 49.4712 39.7376 49.1336C39.4034 48.7959 39.2156 48.338 39.2156 47.8605L38.5761 34.1381C38.5618 33.8102 38.6129 33.4827 38.7265 33.1752C38.8401 32.8677 39.0138 32.5864 39.2373 32.3481C39.4608 32.1099 39.7295 31.9195 40.0274 31.7884C40.3253 31.6573 40.6463 31.5881 40.9712 31.5849H40.9946C41.3218 31.5847 41.6456 31.6515 41.9465 31.7812C42.2474 31.9109 42.5191 32.1009 42.7453 32.3396C42.9716 32.5783 43.1476 32.8608 43.2627 33.1701C43.3779 33.4794 43.4298 33.8091 43.4153 34.1392L43.4198 34.1325Z"
28
+ fill="#FFBD3D" />
29
+ </svg>
30
+ </div>
31
+ <h1 class="forbidden-title" data-testid="forbidden-title">
32
+ {{ $t("auth.restrictedTitle") }}
33
+ </h1>
34
+ <p class="forbidden-message" data-testid="forbidden-message">
35
+ {{ forbiddenMessageParts.before
36
+ }}<strong>{{ forbiddenMessageParts.bold }}</strong
37
+ >{{ forbiddenMessageParts.after }}
38
+ </p>
39
+
40
+ <div class="forbidden-sso-section">
41
+ <p class="forbidden-sso-label">{{ $t("auth.connectWith") }}</p>
42
+ <q-btn
43
+ data-testid="forbidden-sso-btn"
44
+ data-cy="submit-sso-login"
45
+ unelevated
46
+ no-caps
47
+ dense
48
+ :loading="msalLoading"
49
+ :disable="msalLoading"
50
+ class="forbidden-sso-button"
51
+ @click="msalAuth">
52
+ <img
53
+ src="../../assets/logo-microsoft.svg"
54
+ class="forbidden-sso-icon" />
55
+ {{ $t("auth.microsoft") }}
56
+ </q-btn>
57
+ </div>
58
+ </div>
59
+
60
+ <a
61
+ href="#"
62
+ class="back-link back-link--center back-link--lg"
63
+ data-testid="back-to-login-link"
64
+ @click.prevent="goBackToLogin">
65
+ <q-icon :name="icons.ionArrowBackOutline" size="24px" />
66
+ {{ $t("auth.backToLoginFull") }}
67
+ </a>
68
+ </template>
69
+
70
+ <!-- Step: email -->
71
+ <template v-else-if="step === 'email'">
72
+ <div class="step-header" data-testid="first-login-email-step">
73
+ <h1 class="step-title" data-testid="first-login-title">
74
+ {{ stepTitle }}
75
+ </h1>
76
+ <p class="step-subtitle" data-testid="first-login-description">
77
+ {{ stepDescription }}
78
+ </p>
79
+ </div>
80
+
81
+ <q-form
82
+ class="login-form"
83
+ data-testid="first-login-email-form"
84
+ @submit.prevent="requestChannels">
85
+ <div class="form-group">
86
+ <q-input
87
+ v-model.trim="email"
88
+ type="email"
89
+ :placeholder="$t('auth.emailPlaceholder')"
90
+ :error="!!emailError"
91
+ :error-message="emailError"
92
+ data-testid="first-login-email-input"
93
+ data-cy="first-login-email"
94
+ outlined
95
+ no-shadow
96
+ class="form-input" />
97
+ </div>
98
+
99
+ <q-btn
100
+ :label="$t('auth.continueButton')"
101
+ no-caps
102
+ unelevated
103
+ type="submit"
104
+ :loading="isLoading"
105
+ :disable="!isEmailValid || isLoading"
106
+ data-testid="first-login-continue-btn"
107
+ data-cy="first-login-continue"
108
+ class="primary-button" />
109
+ </q-form>
110
+
111
+ <div
112
+ v-if="apiErrorMessage"
113
+ class="api-error"
114
+ data-testid="first-login-api-error">
115
+ {{ apiErrorMessage }}
116
+ </div>
117
+
118
+ <div class="or-divider">
119
+ <div class="or-divider-line"></div>
120
+ <span class="or-divider-text">{{ $t("common.or") }}</span>
121
+ <div class="or-divider-line"></div>
122
+ </div>
123
+
124
+ <a
125
+ href="#"
126
+ class="back-link back-link--center back-link--lg"
127
+ data-testid="back-to-login-link"
128
+ @click.prevent="goBackToLogin">
129
+ <q-icon :name="icons.ionArrowBackOutline" size="24px" />
130
+ {{ $t("auth.backToLoginFull") }}
131
+ </a>
132
+ </template>
133
+
134
+ <!-- Step: channel -->
135
+ <template v-else-if="step === 'channel'">
136
+ <div class="step-header" data-testid="first-login-channel-step">
137
+ <h1 class="step-title" data-testid="channel-title">
138
+ {{ $t("auth.channelTitle") }}
139
+ </h1>
140
+ <p class="step-subtitle" data-testid="channel-subtitle">
141
+ {{ $t("auth.channelSubtitle") }}
142
+ </p>
143
+ </div>
144
+
145
+ <div class="channel-list" data-testid="channel-list">
146
+ <div
147
+ v-for="option in channelOptions"
148
+ :key="option.value"
149
+ class="channel-option"
150
+ :class="{
151
+ 'channel-option--selected': selectedChannel === option.value
152
+ }"
153
+ :data-testid="`channel-option-${option.value}`"
154
+ @click="selectChannel(option.value)">
155
+ <div class="channel-icon-box">
156
+ <svg
157
+ v-if="option.value === 'email'"
158
+ width="20"
159
+ height="16"
160
+ viewBox="0 0 20 16"
161
+ fill="none">
162
+ <path
163
+ d="M2 16C1.45 16 0.979167 15.8042 0.5875 15.4125C0.195833 15.0208 0 14.55 0 14V2C0 1.45 0.195833 0.979167 0.5875 0.5875C0.979167 0.195833 1.45 0 2 0H18C18.55 0 19.0208 0.195833 19.4125 0.5875C19.8042 0.979167 20 1.45 20 2V14C20 14.55 19.8042 15.0208 19.4125 15.4125C19.0208 15.8042 18.55 16 18 16H2V16M10 9L2 4V14V14V14H18V14V14V4L10 9V9M10 7L18 2H2L10 7V7M2 4V2V2V4V14V14V14V14V14V14V4V4"
164
+ fill="#40C4FF" />
165
+ </svg>
166
+ <svg
167
+ v-else
168
+ width="20"
169
+ height="20"
170
+ viewBox="0 0 20 20"
171
+ fill="none">
172
+ <path
173
+ d="M6 9C6.28333 9 6.52083 8.90417 6.7125 8.7125C6.90417 8.52083 7 8.28333 7 8C7 7.71667 6.90417 7.47917 6.7125 7.2875C6.52083 7.09583 6.28333 7 6 7C5.71667 7 5.47917 7.09583 5.2875 7.2875C5.09583 7.47917 5 7.71667 5 8C5 8.28333 5.09583 8.52083 5.2875 8.7125C5.47917 8.90417 5.71667 9 6 9V9M10 9C10.2833 9 10.5208 8.90417 10.7125 8.7125C10.9042 8.52083 11 8.28333 11 8C11 7.71667 10.9042 7.47917 10.7125 7.2875C10.5208 7.09583 10.2833 7 10 7C9.71667 7 9.47917 7.09583 9.2875 7.2875C9.09583 7.47917 9 7.71667 9 8C9 8.28333 9.09583 8.52083 9.2875 8.7125C9.47917 8.90417 9.71667 9 10 9V9M14 9C14.2833 9 14.5208 8.90417 14.7125 8.7125C14.9042 8.52083 15 8.28333 15 8C15 7.71667 14.9042 7.47917 14.7125 7.2875C14.5208 7.09583 14.2833 7 14 7C13.7167 7 13.4792 7.09583 13.2875 7.2875C13.0958 7.47917 13 7.71667 13 8C13 8.28333 13.0958 8.52083 13.2875 8.7125C13.4792 8.90417 13.7167 9 14 9V9M0 20V2C0 1.45 0.195833 0.979167 0.5875 0.5875C0.979167 0.195833 1.45 0 2 0H18C18.55 0 19.0208 0.195833 19.4125 0.5875C19.8042 0.979167 20 1.45 20 2V14C20 14.55 19.8042 15.0208 19.4125 15.4125C19.0208 15.8042 18.55 16 18 16H4L0 20V20M3.15 14H18V14V14V2V2V2H2V2V2V15.125L3.15 14V14M2 14V14V2V2V2V2V2V2V14V14V14V14V14"
174
+ fill="#00668A" />
175
+ </svg>
176
+ </div>
177
+ <div class="channel-texts">
178
+ <div class="channel-label" data-testid="channel-option-label">
179
+ {{ option.label }}
180
+ </div>
181
+ <div
182
+ class="channel-description"
183
+ data-testid="channel-option-description">
184
+ {{ option.description }}
185
+ </div>
186
+ </div>
187
+ <div
188
+ class="channel-radio"
189
+ :class="{
190
+ 'channel-radio--selected': selectedChannel === option.value
191
+ }">
192
+ <div
193
+ v-if="selectedChannel === option.value"
194
+ class="channel-radio-dot" />
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ <q-btn
200
+ :label="$t('auth.sendMagicLink')"
201
+ no-caps
202
+ unelevated
203
+ :loading="isLoading"
204
+ :disable="isLoading"
205
+ data-testid="send-magic-link-btn"
206
+ data-cy="send-magic-link"
207
+ class="primary-button"
208
+ @click="sendMagicLink(selectedChannel)" />
209
+
210
+ <div
211
+ v-if="apiErrorMessage"
212
+ class="api-error"
213
+ data-testid="first-login-api-error">
214
+ {{ apiErrorMessage }}
215
+ </div>
216
+
217
+ <a
218
+ href="#"
219
+ class="back-link back-link--center back-link--lg"
220
+ data-testid="back-to-login-link"
221
+ @click.prevent="goBackToLogin">
222
+ <q-icon :name="icons.ionArrowBackOutline" size="24px" />
223
+ {{ $t("auth.backToLoginFull") }}
224
+ </a>
225
+ </template>
226
+
227
+ <!-- Step: sent -->
228
+ <template v-else>
229
+ <div class="sent-content" data-testid="magic-link-sent">
230
+ <div class="success-icon-outer">
231
+ <q-icon
232
+ :name="icons.ionCheckmarkCircle"
233
+ size="40px"
234
+ style="color: #06d6a0" />
235
+ </div>
236
+ <h1 class="sent-title" data-testid="sent-title">
237
+ {{ $t("auth.magicLinkSentTitle") }}
238
+ </h1>
239
+ <p class="sent-description" data-testid="sent-description">
240
+ {{ $t("auth.magicLinkSentDescription") }}
241
+ </p>
242
+
243
+ <div
244
+ class="info-banner"
245
+ :data-testid="
246
+ isSmsChannel ? 'check-sms-banner' : 'check-inbox-banner'
247
+ ">
248
+ <q-icon
249
+ v-if="isSmsChannel"
250
+ :name="icons.ionChatbubbleEllipsesOutline"
251
+ size="13px"
252
+ color="primary" />
253
+ <svg v-else width="13" height="11" viewBox="0 0 13 11" fill="none">
254
+ <path
255
+ d="M8.1375 10.5L5.65833 8.02083L6.475 7.20417L8.1375 8.86667L11.4333 5.57083L12.25 6.3875L8.1375 10.5ZM5.83333 4.08333L10.5 1.16667H1.16667L5.83333 4.08333ZM5.83333 5.25L1.16667 2.33333V8.16667H4.17083L5.3375 9.33333H1.16667C0.845833 9.33333 0.571181 9.2191 0.342708 8.99063C0.114236 8.76215 0 8.4875 0 8.16667V1.16667C0 0.845833 0.114236 0.571181 0.342708 0.342708C0.571181 0.114236 0.845833 0 1.16667 0H10.5C10.8208 0 11.0955 0.114236 11.324 0.342708C11.5524 0.571181 11.6667 0.845833 11.6667 1.16667V3.70417L10.5 4.87083V2.33333L5.83333 5.25Z"
256
+ fill="#00668A" />
257
+ </svg>
258
+ <span data-testid="magic-link-check-message">{{
259
+ checkChannelMessage
260
+ }}</span>
261
+ </div>
262
+
263
+ <q-btn
264
+ no-caps
265
+ unelevated
266
+ class="resend-button"
267
+ :disable="resendCountdown > 0 || isLoading"
268
+ data-testid="resend-magic-link-btn"
269
+ data-cy="resend-magic-link"
270
+ @click="resend">
271
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
272
+ <path
273
+ d="M6.75 13.5C5.025 13.5 3.52187 12.9281 2.24062 11.7844C0.959375 10.6406 0.225 9.2125 0.0375 7.5H1.575C1.75 8.8 2.32812 9.875 3.30938 10.725C4.29063 11.575 5.4375 12 6.75 12C8.2125 12 9.45312 11.4906 10.4719 10.4719C11.4906 9.45312 12 8.2125 12 6.75C12 5.2875 11.4906 4.04688 10.4719 3.02813C9.45312 2.00938 8.2125 1.5 6.75 1.5C5.8875 1.5 5.08125 1.7 4.33125 2.1C3.58125 2.5 2.95 3.05 2.4375 3.75H4.5V5.25H0V0.75H1.5V2.5125C2.1375 1.7125 2.91562 1.09375 3.83437 0.65625C4.75312 0.21875 5.725 0 6.75 0C7.6875 0 8.56562 0.178125 9.38437 0.534375C10.2031 0.890625 10.9156 1.37188 11.5219 1.97812C12.1281 2.58437 12.6094 3.29688 12.9656 4.11562C13.3219 4.93437 13.5 5.8125 13.5 6.75C13.5 7.6875 13.3219 8.56562 12.9656 9.38437C12.6094 10.2031 12.1281 10.9156 11.5219 11.5219C10.9156 12.1281 10.2031 12.6094 9.38437 12.9656C8.56562 13.3219 7.6875 13.5 6.75 13.5ZM8.85 9.9L6 7.05V3H7.5V6.45L9.9 8.85L8.85 9.9Z"
274
+ fill="white" />
275
+ </svg>
276
+ <span>{{ resendLabel }}</span>
277
+ </q-btn>
278
+
279
+ <div class="warning-banner" data-testid="magic-link-warning">
280
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
281
+ <path
282
+ d="M9 15H11V9H9V15V15M10 7C10.2833 7 10.5208 6.90417 10.7125 6.7125C10.9042 6.52083 11 6.28333 11 6C11 5.71667 10.9042 5.47917 10.7125 5.2875C10.5208 5.09583 10.2833 5 10 5C9.71667 5 9.47917 5.09583 9.2875 5.2875C9.09583 5.47917 9 5.71667 9 6C9 6.28333 9.09583 6.52083 9.2875 6.7125C9.47917 6.90417 9.71667 7 10 7V7M10 20C8.61667 20 7.31667 19.7375 6.1 19.2125C4.88333 18.6875 3.825 17.975 2.925 17.075C2.025 16.175 1.3125 15.1167 0.7875 13.9C0.2625 12.6833 0 11.3833 0 10C0 8.61667 0.2625 7.31667 0.7875 6.1C1.3125 4.88333 2.025 3.825 2.925 2.925C3.825 2.025 4.88333 1.3125 6.1 0.7875C7.31667 0.2625 8.61667 0 10 0C11.3833 0 12.6833 0.2625 13.9 0.7875C15.1167 1.3125 16.175 2.025 17.075 2.925C17.975 3.825 18.6875 4.88333 19.2125 6.1C19.7375 7.31667 20 8.61667 20 10C20 11.3833 19.7375 12.6833 19.2125 13.9C18.6875 15.1167 17.975 16.175 17.075 17.075C16.175 17.975 15.1167 18.6875 13.9 19.2125C12.6833 19.7375 11.3833 20 10 20V20M10 18C12.2333 18 14.125 17.225 15.675 15.675C17.225 14.125 18 12.2333 18 10C18 7.76667 17.225 5.875 15.675 4.325C14.125 2.775 12.2333 2 10 2C7.76667 2 5.875 2.775 4.325 4.325C2.775 5.875 2 7.76667 2 10C2 12.2333 2.775 14.125 4.325 15.675C5.875 17.225 7.76667 18 10 18V18M10 10V10V10V10V10V10V10V10V10V10"
283
+ fill="#FFCE6E" />
284
+ </svg>
285
+ <span data-testid="magic-link-warning-label">{{
286
+ magicLinkWarningMessage
287
+ }}</span>
288
+ </div>
289
+
290
+ <div
291
+ v-if="apiErrorMessage"
292
+ class="api-error"
293
+ data-testid="first-login-api-error">
294
+ {{ apiErrorMessage }}
295
+ </div>
296
+ </div>
297
+
298
+ <a
299
+ href="#"
300
+ class="back-link back-link--center"
301
+ data-testid="back-to-login-link"
302
+ @click.prevent="goBackToLogin">
303
+ <q-icon :name="icons.ionArrowBackOutline" size="20px" />
304
+ {{ $t("auth.backToLoginFull") }}
305
+ </a>
306
+ </template>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ </template>
311
+
312
+ <script lang="ts">
313
+ import { defineComponent, computed } from "vue"
314
+ import { useI18n } from "vue-i18n"
315
+ import isEmail from "validator/es/lib/isEmail"
316
+ import {
317
+ ionArrowBackOutline,
318
+ ionCheckmarkCircle,
319
+ ionChatbubbleEllipsesOutline
320
+ } from "@quasar/extras/ionicons-v5"
321
+
322
+ import { useMagicLink } from "composables/useMagicLink"
323
+ import { useAuth } from "composables/auth"
324
+
325
+ type Channel = "email" | "sms"
326
+
327
+ interface ChannelOption {
328
+ value: Channel
329
+ label: string
330
+ description: string
331
+ }
332
+
333
+ interface ForbiddenMessageParts {
334
+ before: string
335
+ bold: string
336
+ after: string
337
+ }
338
+
339
+ // The 403 copy names Microsoft explicitly; the word is bolded wherever it lands
340
+ // in the sentence, so the message stays a single i18n string (or the API detail).
341
+ const FORBIDDEN_MESSAGE_BOLD_KEYWORD = "Microsoft"
342
+
343
+ export default defineComponent({
344
+ name: "WebFirstLogin",
345
+ setup() {
346
+ const { t, te } = useI18n()
347
+
348
+ const {
349
+ email,
350
+ emailError,
351
+ apiErrorMessage,
352
+ channels,
353
+ channelTargets,
354
+ selectedChannel,
355
+ step,
356
+ isLoading,
357
+ resendCountdown,
358
+ restricted,
359
+ isActivateContext,
360
+ maskedEmail,
361
+ requestChannels,
362
+ sendMagicLink,
363
+ resend,
364
+ selectChannel,
365
+ goBackToLogin
366
+ } = useMagicLink()
367
+
368
+ const { msalLoading, msalAuth } = useAuth()
369
+
370
+ const icons = {
371
+ ionArrowBackOutline,
372
+ ionCheckmarkCircle,
373
+ ionChatbubbleEllipsesOutline
374
+ }
375
+
376
+ const translateOrFallback = (key: string, fallbackKey: string): string =>
377
+ te(key) ? t(key) : t(fallbackKey)
378
+
379
+ const stepTitle = computed(() =>
380
+ isActivateContext
381
+ ? t("auth.firstConnectionTitle")
382
+ : t("auth.forgotPasswordTitle")
383
+ )
384
+
385
+ const stepDescription = computed(() =>
386
+ isActivateContext
387
+ ? t("auth.firstConnectionDescription")
388
+ : t("auth.forgotPasswordDescription")
389
+ )
390
+
391
+ // The composable only fills emailError on submit; the Continue button needs
392
+ // to know about validity while typing, hence this local check.
393
+ const isEmailValid = computed(() => isEmail(email.value.trim()))
394
+
395
+ const channelDescription = (channel: Channel): string => {
396
+ const target = channelTargets.value[channel]
397
+ if (target) return target
398
+
399
+ return channel === "email"
400
+ ? maskedEmail.value
401
+ : t("auth.channelSmsDescription")
402
+ }
403
+
404
+ const channelOptions = computed<ChannelOption[]>(() =>
405
+ channels.value.map((channel) => ({
406
+ value: channel,
407
+ label:
408
+ channel === "email"
409
+ ? t("auth.channelEmailLabel")
410
+ : t("auth.channelSmsLabel"),
411
+ description: channelDescription(channel)
412
+ }))
413
+ )
414
+
415
+ const isSmsChannel = computed(() => selectedChannel.value === "sms")
416
+
417
+ const checkChannelMessage = computed(() =>
418
+ isSmsChannel.value
419
+ ? translateOrFallback(
420
+ "auth.magicLinkCheckSmsMessage",
421
+ "auth.magicLinkCheckInboxMessage"
422
+ )
423
+ : t("auth.magicLinkCheckInboxMessage")
424
+ )
425
+
426
+ const magicLinkWarningMessage = computed(() =>
427
+ isSmsChannel.value
428
+ ? translateOrFallback(
429
+ "auth.magicLinkWarningSms",
430
+ "auth.magicLinkWarning"
431
+ )
432
+ : t("auth.magicLinkWarning")
433
+ )
434
+
435
+ const resendLabel = computed(() =>
436
+ resendCountdown.value > 0
437
+ ? t("auth.resendMagicLinkCountdown", { seconds: resendCountdown.value })
438
+ : t("auth.resendMagicLink")
439
+ )
440
+
441
+ const forbiddenMessageParts = computed<ForbiddenMessageParts>(() => {
442
+ const message = t("auth.restrictedDescription")
443
+ const keywordIndex = message.indexOf(FORBIDDEN_MESSAGE_BOLD_KEYWORD)
444
+ if (keywordIndex === -1) {
445
+ return { before: message, bold: "", after: "" }
446
+ }
447
+ return {
448
+ before: message.slice(0, keywordIndex),
449
+ bold: FORBIDDEN_MESSAGE_BOLD_KEYWORD,
450
+ after: message.slice(
451
+ keywordIndex + FORBIDDEN_MESSAGE_BOLD_KEYWORD.length
452
+ )
453
+ }
454
+ })
455
+
456
+ return {
457
+ email,
458
+ emailError,
459
+ apiErrorMessage,
460
+ selectedChannel,
461
+ step,
462
+ isLoading,
463
+ resendCountdown,
464
+ restricted,
465
+ msalLoading,
466
+ msalAuth,
467
+ icons,
468
+ stepTitle,
469
+ stepDescription,
470
+ isEmailValid,
471
+ channelOptions,
472
+ isSmsChannel,
473
+ checkChannelMessage,
474
+ magicLinkWarningMessage,
475
+ resendLabel,
476
+ forbiddenMessageParts,
477
+ requestChannels,
478
+ sendMagicLink,
479
+ resend,
480
+ selectChannel,
481
+ goBackToLogin
482
+ }
483
+ }
484
+ })
485
+ </script>
486
+
487
+ <style scoped>
488
+ .login-container {
489
+ min-height: 100vh;
490
+ display: flex;
491
+ align-items: center;
492
+ justify-content: center;
493
+ padding: 20px;
494
+ }
495
+
496
+ .login-card {
497
+ background: #ffffff;
498
+ border-radius: 20px;
499
+ padding: 49px 108px;
500
+ width: 100%;
501
+ max-width: 668px;
502
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
503
+ display: flex;
504
+ flex-direction: column;
505
+ }
506
+
507
+ .login-content {
508
+ flex: 1;
509
+ display: flex;
510
+ flex-direction: column;
511
+ }
512
+
513
+ .logo-container {
514
+ display: flex;
515
+ justify-content: center;
516
+ margin-bottom: 49px;
517
+ }
518
+
519
+ .logo {
520
+ height: 53px;
521
+ width: auto;
522
+ }
523
+
524
+ .step-header {
525
+ margin-bottom: 29px;
526
+ text-align: left;
527
+ }
528
+
529
+ .step-title {
530
+ font-size: 24px;
531
+ font-weight: 600;
532
+ line-height: 1.18em;
533
+ letter-spacing: -0.5px;
534
+ color: #152f3b;
535
+ margin: 0 0 18px 0;
536
+ }
537
+
538
+ .step-subtitle {
539
+ font-size: 16px;
540
+ font-weight: 400;
541
+ line-height: 1.5em;
542
+ color: #152f3b;
543
+ margin: 0;
544
+ }
545
+
546
+ .login-form {
547
+ display: flex;
548
+ flex-direction: column;
549
+ gap: 16px;
550
+ }
551
+
552
+ .form-group {
553
+ display: flex;
554
+ flex-direction: column;
555
+ gap: 9px;
556
+ }
557
+
558
+ .form-input {
559
+ width: 100%;
560
+ }
561
+
562
+ .form-input :deep(.q-field__control) {
563
+ height: 65px;
564
+ border-radius: 15px !important;
565
+ }
566
+
567
+ .form-input :deep(.q-field__native) {
568
+ font-size: 16px;
569
+ font-weight: 500;
570
+ line-height: 1.5em;
571
+ letter-spacing: -0.5px;
572
+ color: #103140;
573
+ padding: 0 16px;
574
+ }
575
+
576
+ .form-input :deep(.q-field__bottom) {
577
+ font-size: 14px;
578
+ font-weight: 400;
579
+ line-height: 1.5em;
580
+ color: #ef476f;
581
+ padding-top: 9px;
582
+ padding-left: 0;
583
+ min-height: 24px;
584
+ }
585
+
586
+ .primary-button {
587
+ width: 100%;
588
+ height: 65px;
589
+ background: #40c4ff;
590
+ color: #ffffff;
591
+ font-size: 18px;
592
+ font-weight: 600;
593
+ line-height: 1.33em;
594
+ letter-spacing: -0.5px;
595
+ border-radius: 15px;
596
+ text-transform: none;
597
+ margin-top: 8px;
598
+ }
599
+
600
+ .primary-button:hover {
601
+ background: #35b0e6;
602
+ }
603
+
604
+ .api-error {
605
+ margin-top: 16px;
606
+ font-size: 14px;
607
+ line-height: 1.5em;
608
+ color: #ef476f;
609
+ text-align: center;
610
+ }
611
+
612
+ .channel-list {
613
+ display: flex;
614
+ flex-direction: column;
615
+ gap: 14px;
616
+ margin-bottom: 22px;
617
+ }
618
+
619
+ .channel-option {
620
+ display: flex;
621
+ align-items: center;
622
+ gap: 14px;
623
+ border: 1px solid #bdc8d1;
624
+ border-radius: 15px;
625
+ padding: 16px;
626
+ cursor: pointer;
627
+ transition: all 0.15s ease;
628
+ }
629
+
630
+ .channel-option--selected {
631
+ border-color: #40c4ff;
632
+ background: #f3fcff;
633
+ }
634
+
635
+ .channel-icon-box {
636
+ flex-shrink: 0;
637
+ width: 48px;
638
+ height: 48px;
639
+ border-radius: 8px;
640
+ background: #cff0ff;
641
+ display: flex;
642
+ align-items: center;
643
+ justify-content: center;
644
+ }
645
+
646
+ .channel-texts {
647
+ flex: 1;
648
+ min-width: 0;
649
+ }
650
+
651
+ .channel-label {
652
+ font-size: 16px;
653
+ font-weight: 600;
654
+ color: #103140;
655
+ }
656
+
657
+ .channel-description {
658
+ font-size: 14px;
659
+ font-weight: 400;
660
+ color: #607680;
661
+ margin-top: 2px;
662
+ }
663
+
664
+ .channel-radio {
665
+ flex-shrink: 0;
666
+ width: 20px;
667
+ height: 20px;
668
+ border-radius: 9999px;
669
+ border: 1.5px solid #bdc8d1;
670
+ display: flex;
671
+ align-items: center;
672
+ justify-content: center;
673
+ }
674
+
675
+ .channel-radio--selected {
676
+ border-color: #40c4ff;
677
+ background: #40c4ff;
678
+ }
679
+
680
+ .channel-radio-dot {
681
+ width: 10px;
682
+ height: 10px;
683
+ border-radius: 9999px;
684
+ background: #ffffff;
685
+ }
686
+
687
+ .back-link {
688
+ color: #001d3d;
689
+ text-decoration: none;
690
+ font-size: 15px;
691
+ line-height: 1.5em;
692
+ cursor: pointer;
693
+ transition: color 0.2s;
694
+ display: flex;
695
+ align-items: center;
696
+ gap: 8px;
697
+ margin-top: 15px;
698
+ }
699
+
700
+ .back-link:hover {
701
+ color: #35b0e6;
702
+ }
703
+
704
+ .back-link--center {
705
+ justify-content: center;
706
+ }
707
+
708
+ .back-link--lg {
709
+ font-size: 16px;
710
+ font-weight: 600;
711
+ margin-top: 40px;
712
+ }
713
+
714
+ .or-divider {
715
+ display: flex;
716
+ align-items: center;
717
+ width: 100%;
718
+ margin-top: 29px;
719
+ }
720
+
721
+ .or-divider-line {
722
+ flex: 1;
723
+ height: 1px;
724
+ background: #bdc8d1;
725
+ }
726
+
727
+ .or-divider-text {
728
+ padding: 0 16px;
729
+ font-size: 12px;
730
+ font-weight: 500;
731
+ color: #6e7980;
732
+ }
733
+
734
+ .sent-content {
735
+ display: flex;
736
+ flex-direction: column;
737
+ align-items: center;
738
+ gap: 16px;
739
+ text-align: center;
740
+ flex: 1;
741
+ }
742
+
743
+ .success-icon-outer {
744
+ width: 96px;
745
+ height: 96px;
746
+ border-radius: 9999px;
747
+ background: rgba(6, 214, 160, 0.2);
748
+ display: flex;
749
+ align-items: center;
750
+ justify-content: center;
751
+ margin-bottom: 8px;
752
+ }
753
+
754
+ .sent-title {
755
+ font-size: 24px;
756
+ font-weight: 600;
757
+ line-height: 1.18em;
758
+ letter-spacing: -0.5px;
759
+ color: #152f3b;
760
+ margin: 0;
761
+ }
762
+
763
+ .sent-description {
764
+ font-size: 16px;
765
+ font-weight: 400;
766
+ line-height: 1.5em;
767
+ color: #152f3b;
768
+ margin: 0;
769
+ }
770
+
771
+ .info-banner {
772
+ width: 100%;
773
+ height: 56px;
774
+ display: flex;
775
+ align-items: center;
776
+ justify-content: center;
777
+ gap: 3px;
778
+ background: #f2f4f6;
779
+ border: 1px solid rgba(189, 200, 209, 0.3);
780
+ border-radius: 15px;
781
+ padding: 0 41px;
782
+ font-size: 14px;
783
+ font-weight: 600;
784
+ color: #3e484f;
785
+ }
786
+
787
+ .resend-button {
788
+ width: 100%;
789
+ height: 56px;
790
+ background: #40c4ff;
791
+ color: #ffffff;
792
+ font-size: 16px;
793
+ font-weight: 600;
794
+ border-radius: 15px;
795
+ text-transform: none;
796
+ }
797
+
798
+ /* q-btn wraps the default slot in .q-btn__content: a gap set on the button
799
+ itself would never reach the icon/label pair inside it. */
800
+ .resend-button :deep(.q-btn__content) {
801
+ display: flex;
802
+ align-items: center;
803
+ justify-content: center;
804
+ gap: 10px;
805
+ }
806
+
807
+ .resend-button:hover {
808
+ background: #35b0e6;
809
+ }
810
+
811
+ .warning-banner {
812
+ width: 100%;
813
+ display: flex;
814
+ align-items: flex-start;
815
+ gap: 10px;
816
+ background: rgba(255, 189, 61, 0.15);
817
+ border-left: 4px solid #ffce6e;
818
+ border-top-right-radius: 8px;
819
+ border-bottom-right-radius: 8px;
820
+ padding: 14px 16px;
821
+ font-size: 13px;
822
+ font-weight: 400;
823
+ line-height: 1.4em;
824
+ color: #607680;
825
+ text-align: left;
826
+ }
827
+
828
+ .warning-banner svg {
829
+ flex-shrink: 0;
830
+ margin-top: 1px;
831
+ }
832
+
833
+ .forbidden-content {
834
+ display: flex;
835
+ flex-direction: column;
836
+ align-items: center;
837
+ gap: 16px;
838
+ text-align: center;
839
+ flex: 1;
840
+ }
841
+
842
+ .warning-icon-outer {
843
+ width: 82px;
844
+ height: 82px;
845
+ display: flex;
846
+ align-items: center;
847
+ justify-content: center;
848
+ margin-bottom: 8px;
849
+ }
850
+
851
+ .forbidden-title {
852
+ font-size: 27px;
853
+ font-weight: 600;
854
+ line-height: 1.18em;
855
+ letter-spacing: -0.5px;
856
+ color: #152f3b;
857
+ margin: 0;
858
+ }
859
+
860
+ .forbidden-message {
861
+ font-size: 16px;
862
+ font-weight: 400;
863
+ line-height: 1.5em;
864
+ color: #152f3b;
865
+ margin: 0;
866
+ text-align: center;
867
+ }
868
+
869
+ .forbidden-sso-section {
870
+ width: 100%;
871
+ margin-top: 8px;
872
+ text-align: left;
873
+ }
874
+
875
+ .forbidden-sso-label {
876
+ font-size: 15px;
877
+ font-weight: 600;
878
+ line-height: 1.5em;
879
+ color: #001d3d;
880
+ margin: 0 0 12px 0;
881
+ }
882
+
883
+ .forbidden-sso-button {
884
+ display: flex;
885
+ width: 260px;
886
+ max-width: 100%;
887
+ height: 36px;
888
+ margin: 0 auto;
889
+ border-radius: 8.85px;
890
+ background: #ebeeef;
891
+ font-size: 15px;
892
+ font-weight: 600;
893
+ color: #112240;
894
+ }
895
+
896
+ .forbidden-sso-button:hover {
897
+ background: #e0e5e7;
898
+ }
899
+
900
+ .forbidden-sso-icon {
901
+ width: 18px;
902
+ height: 18px;
903
+ margin-right: 8px;
904
+ }
905
+
906
+ @media screen and (max-width: 768px) {
907
+ .login-card {
908
+ padding: 32px 24px;
909
+ max-width: 100%;
910
+ min-height: auto;
911
+ }
912
+
913
+ .logo {
914
+ width: 200px;
915
+ height: auto;
916
+ }
917
+
918
+ .step-title {
919
+ font-size: 20px;
920
+ }
921
+
922
+ .form-input :deep(.q-field__control) {
923
+ height: 56px;
924
+ }
925
+
926
+ .primary-button {
927
+ height: 56px;
928
+ }
929
+ }
930
+ </style>