@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,580 @@
1
+ <template>
2
+ <div class="reset-container">
3
+ <div class="reset-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
+ <!-- Expired / already used link (401) -->
12
+ <template v-if="linkExpired">
13
+ <div class="success-content" data-testid="magic-link-expired">
14
+ <div class="error-icon-outer">
15
+ <q-icon
16
+ :name="icons.ionCloseCircleOutline"
17
+ size="40px"
18
+ style="color: #ef476f" />
19
+ </div>
20
+ <div class="success-subtitle" data-testid="message-dialog">
21
+ {{ $t("auth.error.magicLinkExpired") }}
22
+ </div>
23
+ </div>
24
+
25
+ <div class="back-section">
26
+ <q-btn
27
+ flat
28
+ no-caps
29
+ class="back-button"
30
+ :icon="icons.ionArrowBackOutline"
31
+ data-testid="back-to-first-login-link"
32
+ @click="goToFirstLogin">
33
+ <span class="back-text">{{ $t("auth.backToFirstLogin") }}</span>
34
+ </q-btn>
35
+ </div>
36
+ </template>
37
+
38
+ <!-- Password saved -->
39
+ <template v-else-if="passwordUpdated">
40
+ <div class="success-content" data-testid="feedback-dialog">
41
+ <div class="success-icon-outer">
42
+ <div class="success-icon-inner">
43
+ <q-icon :name="icons.ionCheckmark" size="24px" color="white" />
44
+ </div>
45
+ </div>
46
+ <div class="success-title" data-testid="header-dialog">
47
+ {{ $t("auth.passwordSavedTitle") }}
48
+ </div>
49
+ <div class="success-subtitle" data-testid="message-dialog">
50
+ {{ $t("auth.magicLinkPasswordChangedSubtitle") }}
51
+ </div>
52
+ <q-btn
53
+ unelevated
54
+ no-caps
55
+ class="connect-button"
56
+ :label="$t('auth.goToLogin')"
57
+ data-testid="close-btn"
58
+ @click="goToLoginPage" />
59
+ </div>
60
+ </template>
61
+
62
+ <!-- Set password form -->
63
+ <template v-else>
64
+ <q-form
65
+ class="password-reset-form"
66
+ data-testid="set-password-form"
67
+ @submit.prevent="onSubmit"
68
+ @validation-error="handleInvalidSubmit">
69
+ <div class="reset-body">
70
+ <div class="reset-heading">
71
+ <h1 class="reset-title">{{ $t("auth.setPasswordTitle") }}</h1>
72
+ <p class="reset-subtitle">
73
+ {{ $t("auth.setPasswordDescription") }}
74
+ </p>
75
+ </div>
76
+
77
+ <div class="reset-inputs">
78
+ <q-input
79
+ v-model="password"
80
+ :type="showPassword ? 'text' : 'password'"
81
+ :placeholder="$t('auth.webNewPasswordPlaceholder')"
82
+ :rules="validations.passwordRules"
83
+ hide-bottom-space
84
+ lazy-rules
85
+ data-testid="password-input"
86
+ data-cy="new-password"
87
+ outlined
88
+ no-shadow
89
+ class="form-input">
90
+ <template #append>
91
+ <q-icon
92
+ :name="
93
+ showPassword ? icons.ionEyeOffOutline : icons.ionEyeOutline
94
+ "
95
+ class="password-toggle"
96
+ @click="showPassword = !showPassword" />
97
+ </template>
98
+ </q-input>
99
+
100
+ <q-input
101
+ ref="confirmPasswordInput"
102
+ v-model="confirmPassword"
103
+ :type="showConfirmPassword ? 'text' : 'password'"
104
+ :placeholder="$t('auth.confirmPassword')"
105
+ :rules="validations.passwordConfirmationRules"
106
+ lazy-rules
107
+ data-testid="password-confirmation-input"
108
+ data-cy="confirm-password"
109
+ outlined
110
+ no-shadow
111
+ class="form-input">
112
+ <template #append>
113
+ <q-icon
114
+ :name="
115
+ showConfirmPassword
116
+ ? icons.ionEyeOffOutline
117
+ : icons.ionEyeOutline
118
+ "
119
+ class="password-toggle"
120
+ @click="showConfirmPassword = !showConfirmPassword" />
121
+ </template>
122
+ </q-input>
123
+ </div>
124
+
125
+ <div class="requirements-checklist" data-testid="password-requirements">
126
+ <div
127
+ v-for="requirement in passwordRequirements"
128
+ :key="requirement.label"
129
+ class="requirement-item"
130
+ :class="{
131
+ 'requirement-item--valid': requirement.valid,
132
+ 'requirement-item--invalid': requirement.invalid
133
+ }">
134
+ <q-icon
135
+ :name="
136
+ requirement.valid
137
+ ? icons.ionCheckmarkCircle
138
+ : icons.ionEllipseOutline
139
+ "
140
+ size="16px"
141
+ class="requirement-icon" />
142
+ <span class="requirement-label">{{ requirement.label }}</span>
143
+ </div>
144
+ </div>
145
+
146
+ <!-- Deliberately NOT `disable`: the button stays clickable so an
147
+ invalid submit can turn the unmet rules red instead of leaving
148
+ the user with a dead button and no explanation. -->
149
+ <q-btn
150
+ :label="$t('common.saveNewPassword')"
151
+ :icon="icons.ionLockClosedOutline"
152
+ no-caps
153
+ unelevated
154
+ type="submit"
155
+ :loading="loading"
156
+ data-testid="submit-btn"
157
+ data-cy="submit-new-password"
158
+ class="reset-button"
159
+ :class="{ 'reset-button--inactive': !canSubmit }" />
160
+
161
+ <p v-if="serverError" class="server-error" data-testid="server-error">
162
+ {{ serverError }}
163
+ </p>
164
+ </div>
165
+
166
+ <div class="back-section">
167
+ <q-btn
168
+ flat
169
+ no-caps
170
+ class="back-button"
171
+ :icon="icons.ionArrowBackOutline"
172
+ data-testid="back-to-login-link"
173
+ @click="goToLoginPage">
174
+ <span class="back-text">{{ $t("auth.backToLoginFull") }}</span>
175
+ </q-btn>
176
+ </div>
177
+ </q-form>
178
+ </template>
179
+ </div>
180
+ </div>
181
+ </template>
182
+
183
+ <script lang="ts">
184
+ import { defineComponent, computed, ref, onMounted } from "vue"
185
+ import {
186
+ ionArrowBackOutline,
187
+ ionCheckmark,
188
+ ionCheckmarkCircle,
189
+ ionCloseCircleOutline,
190
+ ionEllipseOutline
191
+ } from "@quasar/extras/ionicons-v5"
192
+
193
+ import { useValidateMagicLink } from "composables/useValidateMagicLink"
194
+ import { $prompts, $router } from "../../injects"
195
+
196
+ export default defineComponent({
197
+ name: "WebSetPassword",
198
+ setup() {
199
+ const router = $router()
200
+ const { LOCAL_LOGIN_ROUTE } = $prompts()
201
+ const $route = router.currentRoute.value
202
+
203
+ const magicToken = computed(() =>
204
+ (($route.query.magic as string) || "").trim()
205
+ )
206
+
207
+ onMounted(() => {
208
+ if (!magicToken.value) {
209
+ router.replace(LOCAL_LOGIN_ROUTE)
210
+ }
211
+ })
212
+
213
+ const {
214
+ password,
215
+ confirmPassword,
216
+ loading,
217
+ passwordUpdated,
218
+ linkExpired,
219
+ serverError,
220
+ showPassword,
221
+ confirmPasswordInput,
222
+ validations,
223
+ passwordRequirements,
224
+ canSubmit,
225
+ onSubmit,
226
+ handleInvalidSubmit,
227
+ goToLoginPage,
228
+ goToFirstLogin,
229
+ icons: composableIcons
230
+ } = useValidateMagicLink(magicToken)
231
+
232
+ // The composable exposes a single visibility flag (mobile UI shows one field
233
+ // at a time); the web form shows both fields, so the confirmation gets its own.
234
+ const showConfirmPassword = ref(false)
235
+
236
+ const icons = {
237
+ ...composableIcons,
238
+ ionArrowBackOutline,
239
+ ionCheckmark,
240
+ ionCheckmarkCircle,
241
+ ionCloseCircleOutline,
242
+ ionEllipseOutline
243
+ }
244
+
245
+ return {
246
+ password,
247
+ confirmPassword,
248
+ loading,
249
+ passwordUpdated,
250
+ linkExpired,
251
+ serverError,
252
+ showPassword,
253
+ showConfirmPassword,
254
+ confirmPasswordInput,
255
+ validations,
256
+ passwordRequirements,
257
+ canSubmit,
258
+ icons,
259
+ onSubmit,
260
+ handleInvalidSubmit,
261
+ goToLoginPage,
262
+ goToFirstLogin
263
+ }
264
+ }
265
+ })
266
+ </script>
267
+
268
+ <style scoped>
269
+ .reset-container {
270
+ min-height: 100vh;
271
+ display: flex;
272
+ align-items: center;
273
+ justify-content: center;
274
+ padding: 20px;
275
+ }
276
+
277
+ .reset-card {
278
+ background: #ffffff;
279
+ border-radius: 20px;
280
+ padding: 52px;
281
+ width: 100%;
282
+ max-width: 634px;
283
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
284
+ display: flex;
285
+ flex-direction: column;
286
+ gap: 40px;
287
+ }
288
+
289
+ .logo-container {
290
+ display: flex;
291
+ justify-content: center;
292
+ position: relative;
293
+ }
294
+
295
+ .logo {
296
+ height: 53px;
297
+ width: auto;
298
+ }
299
+
300
+ .password-reset-form {
301
+ display: flex;
302
+ flex-direction: column;
303
+ gap: 40px;
304
+ }
305
+
306
+ .reset-body {
307
+ display: flex;
308
+ flex-direction: column;
309
+ gap: 29px;
310
+ }
311
+
312
+ .reset-heading {
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 14px;
316
+ }
317
+
318
+ .reset-title {
319
+ font-size: 27px;
320
+ font-weight: 600;
321
+ line-height: 40px;
322
+ letter-spacing: 0.1px;
323
+ color: #152f3b;
324
+ margin: 0;
325
+ }
326
+
327
+ .reset-subtitle {
328
+ font-size: 16px;
329
+ font-weight: 400;
330
+ line-height: normal;
331
+ color: #152f3b;
332
+ margin: 0;
333
+ }
334
+
335
+ .reset-inputs {
336
+ display: flex;
337
+ flex-direction: column;
338
+ gap: 18px;
339
+ }
340
+
341
+ .form-input {
342
+ width: 100%;
343
+ }
344
+
345
+ .form-input :deep(.q-field__control) {
346
+ height: 44px;
347
+ border-radius: 10px !important;
348
+ }
349
+
350
+ .form-input :deep(.q-field__native),
351
+ .form-input :deep(.q-field__input) {
352
+ font-size: 16px;
353
+ font-weight: 500;
354
+ line-height: 1.5em;
355
+ letter-spacing: -0.5px;
356
+ color: #103140;
357
+ padding: 0 16px;
358
+ }
359
+
360
+ .form-input :deep(.q-field__marginal) {
361
+ height: 44px;
362
+ }
363
+
364
+ .form-input :deep(input::placeholder) {
365
+ color: #8f9da3;
366
+ opacity: 1;
367
+ }
368
+
369
+ .form-input :deep(.q-field__bottom) {
370
+ font-size: 14px;
371
+ font-weight: 400;
372
+ line-height: 1.5em;
373
+ letter-spacing: -0.5px;
374
+ color: #ef476f;
375
+ padding-top: 6px;
376
+ padding-left: 0;
377
+ min-height: 20px;
378
+ }
379
+
380
+ .password-toggle {
381
+ cursor: pointer;
382
+ color: #103140;
383
+ font-size: 24px;
384
+ padding-right: 6px;
385
+ }
386
+
387
+ .requirements-checklist {
388
+ background: #f2f4f6;
389
+ border-radius: 8px;
390
+ padding: 17px 16px 16px;
391
+ display: grid;
392
+ grid-template-columns: repeat(2, minmax(0, 1fr));
393
+ gap: 8px 16px;
394
+ }
395
+
396
+ .requirement-item {
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 8px;
400
+ color: #3e484f;
401
+ }
402
+
403
+ .requirement-icon {
404
+ color: #bbcbd2;
405
+ flex-shrink: 0;
406
+ }
407
+
408
+ .requirement-item--valid {
409
+ color: #06d6a0;
410
+ }
411
+
412
+ .requirement-item--valid .requirement-icon {
413
+ color: #06d6a0;
414
+ }
415
+
416
+ .requirement-item--invalid {
417
+ color: #ef476f;
418
+ }
419
+
420
+ .requirement-item--invalid .requirement-icon {
421
+ color: #ef476f;
422
+ }
423
+
424
+ .requirement-label {
425
+ font-size: 12px;
426
+ font-weight: 500;
427
+ line-height: 14.4px;
428
+ }
429
+
430
+ .reset-button {
431
+ width: 100%;
432
+ height: 54px;
433
+ background: #40c4ff;
434
+ color: #ffffff;
435
+ font-size: 16px;
436
+ font-weight: 600;
437
+ line-height: 24px;
438
+ border-radius: 15px;
439
+ text-transform: none;
440
+ }
441
+
442
+ .reset-button:hover {
443
+ background: #35b0e6;
444
+ }
445
+
446
+ .reset-button--inactive {
447
+ opacity: 0.5;
448
+ }
449
+
450
+ .reset-button :deep(.q-icon) {
451
+ font-size: 16px;
452
+ }
453
+
454
+ .server-error {
455
+ font-size: 14px;
456
+ font-weight: 400;
457
+ color: #ef476f;
458
+ margin: 12px 0 0 0;
459
+ text-align: center;
460
+ }
461
+
462
+ .back-section {
463
+ display: flex;
464
+ justify-content: center;
465
+ align-items: center;
466
+ }
467
+
468
+ .back-button {
469
+ color: #103140;
470
+ font-size: 16px;
471
+ font-weight: 600;
472
+ line-height: 1.5em;
473
+ padding: 10px;
474
+ }
475
+
476
+ .back-button :deep(.q-btn__content) {
477
+ display: flex;
478
+ align-items: center;
479
+ justify-content: center;
480
+ gap: 10px;
481
+ }
482
+
483
+ .back-button :deep(.q-icon) {
484
+ margin-right: 0;
485
+ }
486
+
487
+ .success-content {
488
+ display: flex;
489
+ flex-direction: column;
490
+ align-items: center;
491
+ gap: 24px;
492
+ text-align: center;
493
+ }
494
+
495
+ .success-icon-outer {
496
+ width: 96px;
497
+ height: 96px;
498
+ border-radius: 9999px;
499
+ background: rgba(6, 214, 160, 0.2);
500
+ display: flex;
501
+ align-items: center;
502
+ justify-content: center;
503
+ }
504
+
505
+ .success-icon-inner {
506
+ width: 40px;
507
+ height: 40px;
508
+ border-radius: 9999px;
509
+ background: #06d6a0;
510
+ display: flex;
511
+ align-items: center;
512
+ justify-content: center;
513
+ }
514
+
515
+ .error-icon-outer {
516
+ width: 96px;
517
+ height: 96px;
518
+ border-radius: 9999px;
519
+ background: rgba(239, 71, 111, 0.15);
520
+ display: flex;
521
+ align-items: center;
522
+ justify-content: center;
523
+ }
524
+
525
+ .success-title {
526
+ font-size: 27px;
527
+ font-weight: 600;
528
+ line-height: 40px;
529
+ letter-spacing: 0.1px;
530
+ color: #152f3b;
531
+ margin: 0;
532
+ }
533
+
534
+ .success-subtitle {
535
+ font-size: 16px;
536
+ font-weight: 400;
537
+ line-height: normal;
538
+ letter-spacing: 0.1px;
539
+ color: #152f3b;
540
+ margin: 0;
541
+ }
542
+
543
+ .connect-button {
544
+ width: 100%;
545
+ height: 56px;
546
+ background: #40c4ff;
547
+ color: #ffffff;
548
+ font-size: 16px;
549
+ font-weight: 600;
550
+ line-height: 24px;
551
+ border-radius: 15px;
552
+ text-transform: none;
553
+ }
554
+
555
+ .connect-button:hover {
556
+ background: #35b0e6;
557
+ }
558
+
559
+ @media screen and (max-width: 768px) {
560
+ .reset-card {
561
+ padding: 32px 24px;
562
+ max-width: 100%;
563
+ }
564
+
565
+ .logo {
566
+ width: 160px;
567
+ height: auto;
568
+ }
569
+
570
+ .reset-title,
571
+ .success-title {
572
+ font-size: 22px;
573
+ line-height: 1.3em;
574
+ }
575
+
576
+ .requirements-checklist {
577
+ grid-template-columns: 1fr;
578
+ }
579
+ }
580
+ </style>
@@ -52,9 +52,19 @@ export function useMagicLink() {
52
52
  AUTH_SERVER_REQUEST_MAGICLINK_ROUTE,
53
53
  LOCAL_SET_PASSWORD_ROUTE,
54
54
  LOCAL_LOGIN_ROUTE,
55
- APP_SERVER_BASE_URL
55
+ APP_SERVER_BASE_URL,
56
+ MAGICLINK_CHANNELS
56
57
  } = $prompts()
57
58
 
59
+ // Some backends advertise SMS for users who happen to have a phone number on
60
+ // file even when the product doesn't send SMS at all (the V1 API is in that
61
+ // case). MAGICLINK_CHANNELS lets an app restrict what it offers; left empty,
62
+ // whatever the API returns is shown.
63
+ const allowedChannels = (MAGICLINK_CHANNELS || "")
64
+ .split(",")
65
+ .map((channel: string) => channel.trim().toLowerCase())
66
+ .filter((channel: string): channel is Channel => isChannel(channel))
67
+
58
68
  const email = ref("")
59
69
  const emailError = ref("")
60
70
  const apiErrorMessage = ref("")
@@ -122,7 +132,10 @@ export function useMagicLink() {
122
132
  auth: { email: email.value.trim() }
123
133
  })
124
134
  const { channels: available, targets } = parseChannels(data?.channels)
125
- channels.value = available.length ? available : ["email"]
135
+ const offered = allowedChannels.length
136
+ ? available.filter((channel) => allowedChannels.includes(channel))
137
+ : available
138
+ channels.value = offered.length ? offered : ["email"]
126
139
  channelTargets.value = targets
127
140
  selectedChannel.value = "email"
128
141
  step.value = "channel"
@@ -24,6 +24,7 @@ export interface PromptsInterface {
24
24
  LOCAL_SET_PASSWORD_ROUTE: string
25
25
  AUTH_SERVER_REQUEST_MAGICLINK_ROUTE: string
26
26
  AUTH_SERVER_VALIDATE_MAGICLINK_ROUTE: string
27
+ MAGICLINK_CHANNELS?: string
27
28
  }
28
29
  export const $prompts = (): PromptsInterface => {
29
30
  const $prompts = inject<Record<"@sowell/auth", PromptsInterface>>("$prompts")
package/src/prompts.js CHANGED
@@ -168,5 +168,13 @@ module.exports = function () {
168
168
  message: "API endpoint to validate a magic link and set the password (without host)",
169
169
  default: "/validate-magiclink",
170
170
  },
171
+ {
172
+ name: "MAGICLINK_CHANNELS",
173
+ type: "input",
174
+ required: false,
175
+ message:
176
+ "Magic-link channels offered to the user, comma separated (empty = whatever the API returns)",
177
+ default: "",
178
+ },
171
179
  ];
172
180
  };
@@ -52,6 +52,19 @@ const isInRoles = (X_REQUESTED_ROLES: string): string => {
52
52
  : "ResetPassword.vue"
53
53
  }
54
54
 
55
+ // Magiclink has two distinct UIs: the desktop one (ported from manager.sowell.app)
56
+ // for the web consoles, the mobile one for mobile-clean / mobile-incivility /
57
+ // reporter. Same rule as the login and reset-password pages above.
58
+ const isWebRole = (X_REQUESTED_ROLES: string): boolean =>
59
+ webAuthRoles.includes(X_REQUESTED_ROLES) ||
60
+ AdminAuthRoles.includes(X_REQUESTED_ROLES)
61
+
62
+ const firstLoginPage = (X_REQUESTED_ROLES: string): string =>
63
+ isWebRole(X_REQUESTED_ROLES) ? "WebFirstLogin.vue" : "FirstLogin.vue"
64
+
65
+ const setPasswordPage = (X_REQUESTED_ROLES: string): string =>
66
+ isWebRole(X_REQUESTED_ROLES) ? "WebSetPassword.vue" : "SetPassword.vue"
67
+
55
68
  const routes: RouteRecordRaw[] = [
56
69
  {
57
70
  path: LOCAL_LOGIN_ROUTE,
@@ -86,7 +99,7 @@ if (isMagicLinkEnabled) {
86
99
  component: () =>
87
100
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
88
101
  // @ts-ignore
89
- import("@sowell/auth/pages/FirstLogin.vue"),
102
+ import(`@sowell/auth/pages/${firstLoginPage(X_REQUESTED_ROLES)}`),
90
103
  },
91
104
  {
92
105
  path: LOCAL_SET_PASSWORD_ROUTE || "/set-password",
@@ -94,7 +107,7 @@ if (isMagicLinkEnabled) {
94
107
  component: () =>
95
108
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
96
109
  // @ts-ignore
97
- import("@sowell/auth/pages/SetPassword.vue"),
110
+ import(`@sowell/auth/pages/${setPasswordPage(X_REQUESTED_ROLES)}`),
98
111
  }
99
112
  )
100
113
  }