@sowell/quasar-app-extension-auth 1.5.1 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sowell/quasar-app-extension-auth",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "A Quasar app extension to manage Authentication",
5
5
  "main": "src/index.js",
6
6
  "repository": "https://github.com/sowell-ai/quasar.extension.auth",
@@ -359,14 +359,14 @@ export default defineComponent({
359
359
  z-index: 1;
360
360
  }
361
361
 
362
- .login-page .q-field__control-container {
362
+ .login-page :deep(.q-field__control-container) {
363
363
  padding: 4px;
364
364
  }
365
365
 
366
- .login-page div.q-field__control {
367
- padding: 0;
366
+ .login-page :deep(div.q-field__control) {
368
367
  border: 1px solid #d5d7da;
369
368
  height: 55px;
369
+ padding: 0;
370
370
  }
371
371
 
372
372
  .q-field--with-bottom {
@@ -420,19 +420,19 @@ export default defineComponent({
420
420
  gap: 6px;
421
421
  }
422
422
 
423
- .login-page .q-field--error .q-field__bottom {
423
+ .login-page :deep(.q-field--error .q-field__bottom) {
424
424
  color: #ef476f;
425
425
  }
426
426
 
427
- .login-page .q-field__bottom {
427
+ .login-page :deep(.q-field__bottom) {
428
428
  padding: 4px 0px;
429
429
  }
430
430
 
431
- .login-page .q-field--error .q-field__append .q-icon:not(.cursor-pointer) {
431
+ .login-page :deep(.q-field--error .q-field__append .q-icon:not(.cursor-pointer)) {
432
432
  display: none;
433
433
  }
434
434
 
435
- .input-error .q-field__control {
435
+ .input-error :deep(.q-field__control) {
436
436
  border: 1px solid #ff8b87 !important;
437
437
  }
438
438
 
@@ -1,95 +1,178 @@
1
1
  <template>
2
- <q-layout class="q-pa-md row justify-center full-height">
3
- <div>
4
- <div style="max-width: 342px">
5
- <div>
6
- <q-btn
7
- class="q-pt-md"
8
- flat
9
- dense
10
- size="12px"
11
- icon="arrow_back_ios_new"
12
- @click="$router.push(LOCAL_LOGIN_ROUTE)"
13
- />
14
- </div>
15
- <div
16
- class="text-subtitle1 q-mt-lg"
17
- style="font-size: 34px; font-weight: 600; line-height: 34px"
18
- >
19
- {{ $t("auth.passwordRecovery") }}
20
- </div>
21
- <div
22
- class="text-subtitle1 q-mt-lg q-mb-lg"
23
- style="font-size: 16px; font-weight: 400; line-height: 24px"
24
- >
25
- {{ $t("auth.recommendPassword") }}
2
+ <q-layout
3
+ class="column justify-start items-center q-pb-lg bg-white reset-password-page"
4
+ style="max-width: 100vw; overflow-x: hidden">
5
+ <div class="background-logo-container">
6
+ <img
7
+ src="../../assets/Background-logo.svg"
8
+ class="background-logo"
9
+ alt="Background logo" />
10
+ </div>
11
+ <div class="row justify-between items-center full-width q-px-lg q-pt-lg top-bar">
12
+ <div
13
+ class="row items-center back-link"
14
+ data-testid="back-btn"
15
+ @click="$router.push(LOCAL_LOGIN_ROUTE)">
16
+ <q-icon name="arrow_back" size="16px" />
17
+ <span class="q-ml-xs">{{ $t("auth.back") }}</span>
18
+ </div>
19
+ <img src="../../assets/sowell-min.svg" style="width: auto; height: 33px" />
20
+ </div>
21
+ <div
22
+ class="full-width reset-password-card q-mt-lg"
23
+ :class="{ 'reset-password-card--centered': passwordUpdated }">
24
+ <template v-if="!passwordUpdated">
25
+ <div class="column items-center header-section">
26
+ <div class="icon-badge">
27
+ <q-icon name="verified_user" size="28px" color="primary" />
28
+ </div>
29
+ <div class="title text-center q-mt-md">
30
+ {{ $t("auth.passwordRecovery") }}
31
+ </div>
32
+ <div class="subtitle text-center q-mt-sm">
33
+ {{ $t("auth.recommendPassword") }}
34
+ </div>
26
35
  </div>
27
36
  <q-form
28
- class="column form"
29
- @submit="onSubmit"
37
+ class="column q-mt-lg"
30
38
  data-testid="reset-password-form"
31
- >
32
- <div>
33
- <div class="q-mb-sm" style="font-size: 14px; font-weight: 600">
39
+ @submit="onSubmit">
40
+ <div class="input-container">
41
+ <div class="input-label">
34
42
  {{ $t("auth.newPassword") }}
35
43
  </div>
36
44
  <q-input
37
- data-testid="password-input"
38
45
  id="password"
39
46
  v-model="password"
47
+ data-testid="password-input"
40
48
  data-cy="reset-password"
49
+ :type="showPassword ? 'text' : 'password'"
41
50
  :rules="validations['passwordRules']"
51
+ lazy-rules
42
52
  borderless
43
- bg-color="white"
44
- dense
45
- class="full-width q-mb-md"
46
- >
47
- <template #prepend>
48
- <q-icon :name="icons.ionLockClosedOutline" />
53
+ :placeholder="$t('auth.newPasswordPlaceholder')"
54
+ class="q-mt-sm"
55
+ :input-style="{ fontSize: '13px', fontWeight: '500' }">
56
+ <template #append>
57
+ <q-icon
58
+ :name="
59
+ showPassword ? icons.ionEyeOffOutline : icons.ionEyeOutline
60
+ "
61
+ class="cursor-pointer"
62
+ @click="showPassword = !showPassword" />
49
63
  </template>
50
64
  </q-input>
51
65
  </div>
52
- <q-space class="mobile" />
53
- <div>
54
- <q-btn
55
- data-testid="submit-btn"
56
- :label="$t('common.register')"
57
- color="primary"
58
- :loading="loading"
59
- type="submit"
60
- no-caps
61
- data-cy="send-new-password"
62
- class="full-width"
63
- :disable="!canSubmit"
64
- />
66
+
67
+ <div class="input-container q-mt-sm">
68
+ <div class="input-label">
69
+ {{ $t("auth.confirmPassword") }}
70
+ </div>
71
+ <q-input
72
+ id="confirmPassword"
73
+ v-model="confirmPassword"
74
+ data-testid="confirm-password-input"
75
+ data-cy="reset-confirm-password"
76
+ :type="showConfirmPassword ? 'text' : 'password'"
77
+ :rules="validations['passwordConfirmationRules']"
78
+ lazy-rules
79
+ borderless
80
+ :placeholder="$t('auth.confirmPasswordPlaceholder')"
81
+ class="q-mt-sm"
82
+ :input-style="{ fontSize: '13px', fontWeight: '500' }">
83
+ <template #append>
84
+ <q-icon
85
+ :name="
86
+ showConfirmPassword
87
+ ? icons.ionEyeOffOutline
88
+ : icons.ionEyeOutline
89
+ "
90
+ class="cursor-pointer"
91
+ @click="showConfirmPassword = !showConfirmPassword" />
92
+ </template>
93
+ </q-input>
65
94
  </div>
95
+
96
+ <div class="password-requirements q-mt-md" data-testid="password-requirements">
97
+ <div
98
+ v-for="requirement in passwordRequirements"
99
+ :key="requirement.label"
100
+ class="requirement-item"
101
+ :class="{ 'requirement-valid': requirement.valid }">
102
+ <q-icon
103
+ :name="requirement.valid ? 'check_circle' : 'radio_button_unchecked'"
104
+ size="16px" />
105
+ <span class="q-ml-xs">{{ requirement.label }}</span>
106
+ </div>
107
+ </div>
108
+
109
+ <q-btn
110
+ data-testid="submit-btn"
111
+ data-cy="send-new-password"
112
+ color="primary"
113
+ unelevated
114
+ no-caps
115
+ text-color="white"
116
+ :loading="loading"
117
+ type="submit"
118
+ class="full-width q-mt-lg submit-button"
119
+ :disable="!canSubmit">
120
+ <q-icon name="lock_open" size="16px" class="q-mr-sm" />
121
+ {{ $t("common.saveNewPassword") }}
122
+ </q-btn>
66
123
  </q-form>
67
- </div>
68
- <q-dialog v-model="passwordUpdated" persistent>
69
- <sowell-message
70
- :passwordUpdated="passwordUpdated"
71
- @goToLoginPage="goToLoginPage"
72
- @close="() => passwordUpdated = false"
73
- />
74
- </q-dialog>
124
+ </template>
125
+ <template v-else>
126
+ <div
127
+ class="column items-center header-section"
128
+ data-testid="password-updated-confirmation"
129
+ role="status"
130
+ aria-live="polite">
131
+ <div class="success-badge">
132
+ <q-icon name="check_circle" size="48px" color="primary" />
133
+ </div>
134
+ <div
135
+ ref="successTitleRef"
136
+ class="title text-center q-mt-md"
137
+ data-testid="password-updated-title"
138
+ tabindex="-1">
139
+ {{ $t("auth.passwordSavedTitle") }}
140
+ </div>
141
+ <div class="subtitle text-center q-mt-sm" data-testid="password-updated-message">
142
+ {{ $t("auth.passwordSavedMessage") }}
143
+ </div>
144
+ </div>
145
+ <q-btn
146
+ data-testid="go-to-login-btn"
147
+ data-cy="go-to-login"
148
+ color="primary"
149
+ unelevated
150
+ no-caps
151
+ text-color="white"
152
+ class="full-width q-mt-lg submit-button"
153
+ @click="goToLoginPage">
154
+ {{ $t("auth.goToLogin") }}
155
+ </q-btn>
156
+ </template>
75
157
  </div>
76
158
  </q-layout>
77
159
  </template>
78
160
 
79
161
  <script lang="ts">
80
- import { defineComponent } from "vue"
81
- import SowellMessage from "../dialogs/Message.vue"
162
+ import { defineComponent, nextTick, ref, watch } from "vue"
82
163
  import { useResetPassword } from "composables/resetPassword"
83
164
 
84
165
  export default defineComponent({
85
166
  name: "ResetPassword",
86
- components: { SowellMessage },
87
167
  setup() {
88
168
  const {
89
169
  password,
170
+ confirmPassword,
90
171
  loading,
91
172
  passwordUpdated,
173
+ showPassword,
92
174
  validations,
175
+ passwordRequirements,
93
176
  LOCAL_LOGIN_ROUTE,
94
177
  canSubmit,
95
178
  onSubmit,
@@ -97,13 +180,27 @@ export default defineComponent({
97
180
  icons
98
181
  } = useResetPassword()
99
182
 
183
+ const showConfirmPassword = ref(false)
184
+ const successTitleRef = ref<HTMLElement | null>(null)
185
+
186
+ watch(passwordUpdated, (updated) => {
187
+ if (updated) {
188
+ nextTick(() => successTitleRef.value?.focus())
189
+ }
190
+ })
191
+
100
192
  return {
101
193
  password,
194
+ confirmPassword,
102
195
  loading,
103
196
  canSubmit,
104
197
  passwordUpdated,
198
+ showPassword,
199
+ showConfirmPassword,
200
+ successTitleRef,
105
201
  LOCAL_LOGIN_ROUTE,
106
202
  validations,
203
+ passwordRequirements,
107
204
  icons,
108
205
  onSubmit,
109
206
  goToLoginPage
@@ -113,12 +210,162 @@ export default defineComponent({
113
210
  </script>
114
211
 
115
212
  <style scoped>
116
- .form {
117
- height: calc(100% - 225px);
213
+ .reset-password-page {
214
+ min-height: 100vh;
118
215
  }
119
- @media screen and (min-width: 450px) {
120
- .mobile {
121
- display: none;
216
+
217
+ .background-logo-container {
218
+ position: fixed;
219
+ top: -53px;
220
+ left: 0;
221
+ width: 100%;
222
+ height: 100vh;
223
+ display: flex;
224
+ justify-content: center;
225
+ align-items: center;
226
+ opacity: 0.25;
227
+ overflow: hidden;
228
+ }
229
+
230
+ .background-logo {
231
+ width: 100%;
232
+ height: 100%;
233
+ object-fit: cover;
234
+ max-width: 100%;
235
+ max-height: 100vh;
236
+ }
237
+
238
+ .top-bar {
239
+ z-index: 1;
240
+ max-width: 430px;
241
+ }
242
+
243
+ .back-link {
244
+ cursor: pointer;
245
+ color: #00668a;
246
+ font-size: 14px;
247
+ font-weight: 600;
248
+ }
249
+
250
+ .reset-password-card {
251
+ z-index: 1;
252
+ max-width: 398px;
253
+ padding: 32px 24px;
254
+ margin-top: 24px;
255
+ }
256
+
257
+ .reset-password-card--centered {
258
+ flex: 1;
259
+ display: flex;
260
+ flex-direction: column;
261
+ justify-content: center;
262
+ align-items: center;
263
+ margin-top: 0 !important;
264
+ }
265
+
266
+ .icon-badge {
267
+ width: 64px;
268
+ height: 64px;
269
+ border-radius: 9999px;
270
+ background: rgba(56, 189, 248, 0.1);
271
+ border: 1px solid rgba(56, 189, 248, 0.3);
272
+ display: flex;
273
+ align-items: center;
274
+ justify-content: center;
275
+ }
276
+
277
+ .success-badge {
278
+ width: 84px;
279
+ height: 84px;
280
+ border-radius: 9999px;
281
+ background: rgba(56, 189, 248, 0.1);
282
+ display: flex;
283
+ align-items: center;
284
+ justify-content: center;
285
+ }
286
+
287
+ .title {
288
+ font-size: 21px;
289
+ font-weight: 600;
290
+ color: #20627f;
291
+ line-height: 1.6;
292
+ }
293
+
294
+ .subtitle {
295
+ font-size: 14px;
296
+ font-weight: 400;
297
+ color: #3e484f;
298
+ }
299
+
300
+ .input-label {
301
+ font-size: 14px;
302
+ font-weight: 700;
303
+ color: #112240;
304
+ }
305
+
306
+ .reset-password-page :deep(.q-field__control-container) {
307
+ padding: 4px;
308
+ }
309
+
310
+ .reset-password-page :deep(div.q-field__control) {
311
+ border: 1px solid #bdc8d1;
312
+ border-radius: 8px;
313
+ height: 55px;
314
+ padding: 0 12px;
315
+ }
316
+
317
+ .reset-password-page :deep(.q-field--error .q-field__control) {
318
+ border-color: #ff8b87;
319
+ }
320
+
321
+ .reset-password-page :deep(.q-field--error .q-field__bottom) {
322
+ color: #ef476f;
323
+ }
324
+
325
+ .reset-password-page :deep(.q-field__bottom) {
326
+ padding: 4px 0px;
327
+ }
328
+
329
+ .password-requirements {
330
+ width: 100%;
331
+ background: #f2f4f6;
332
+ border-radius: 8px;
333
+ padding: 16px;
334
+ display: grid;
335
+ grid-template-columns: repeat(2, 1fr);
336
+ gap: 10px 16px;
337
+ }
338
+
339
+ .requirement-item {
340
+ display: flex;
341
+ align-items: center;
342
+ font-size: 12px;
343
+ font-weight: 500;
344
+ color: #3e484f;
345
+ }
346
+
347
+ .requirement-item .q-icon {
348
+ color: #bdc8d1;
349
+ }
350
+
351
+ .requirement-item.requirement-valid {
352
+ color: #06d6a0;
353
+ }
354
+
355
+ .requirement-item.requirement-valid .q-icon {
356
+ color: #06d6a0;
357
+ }
358
+
359
+ .submit-button {
360
+ border-radius: 15px;
361
+ height: 50px;
362
+ font-size: 14px;
363
+ font-weight: 500;
364
+ }
365
+
366
+ @media (max-width: 389px) {
367
+ .reset-password-card {
368
+ padding: 24px 16px;
122
369
  }
123
370
  }
124
371
  </style>
@@ -1,111 +1,195 @@
1
1
  <template>
2
- <q-layout class="q-pa-md row justify-center">
3
- <div>
4
- <div>
5
- <div class="q-mt-xl">
6
- <img
7
- src="../../assets/Sowell-logo.svg"
8
- class="q-mb-sm q-mr-md"
9
- style="width: 190px" />
2
+ <q-layout class="web-reset-password-page">
3
+ <div class="reset-container">
4
+ <div class="reset-card">
5
+ <div class="logo-container">
6
+ <img src="../../assets/Sowell-logo.svg" alt="SoWell" class="logo" />
10
7
  </div>
11
- <div class="column justify-center full-height" style="max-width: 550px">
12
- <div
13
- class="text-subtitle1 q-mt-lg"
14
- style="font-size: 34px; font-weight: 600; line-height: 34px">
15
- {{ $t("auth.passwordRecovery") }}
16
- </div>
17
- <div
18
- class="text-subtitle1 q-mt-lg q-mb-lg"
19
- style="font-size: 16px; font-weight: 400; line-height: 24px">
20
- {{ $t("auth.recommendPassword") }}
21
- </div>
22
- <q-form
23
- class="column form"
24
- @submit="onSubmit"
25
- data-testid="reset-password-form">
26
- <div>
27
- <div class="q-mb-sm" style="font-size: 14px; font-weight: 600">
28
- {{ $t("auth.newPassword") }}
8
+
9
+ <template v-if="!passwordUpdated">
10
+ <div class="content-section">
11
+ <div class="reset-heading">
12
+ <h1 class="reset-title">{{ $t("auth.passwordRecovery") }}</h1>
13
+ <p class="reset-subtitle">{{ $t("auth.recommendPassword") }}</p>
14
+ </div>
15
+
16
+ <q-form
17
+ class="reset-form"
18
+ data-testid="reset-password-form"
19
+ @submit="onSubmit">
20
+ <div class="reset-inputs">
21
+ <q-input
22
+ id="password"
23
+ v-model="password"
24
+ data-testid="password-input"
25
+ data-cy="reset-password"
26
+ :type="showPassword ? 'text' : 'password'"
27
+ :rules="validations['passwordRules']"
28
+ lazy-rules
29
+ :placeholder="$t('auth.webNewPasswordPlaceholder')"
30
+ outlined
31
+ no-shadow
32
+ class="form-input">
33
+ <template #append>
34
+ <q-icon
35
+ :name="
36
+ showPassword
37
+ ? icons.ionEyeOffOutline
38
+ : icons.ionEyeOutline
39
+ "
40
+ class="password-toggle"
41
+ @click="showPassword = !showPassword" />
42
+ </template>
43
+ </q-input>
44
+
45
+ <q-input
46
+ id="confirmPassword"
47
+ v-model="confirmPassword"
48
+ data-testid="confirm-password-input"
49
+ data-cy="reset-confirm-password"
50
+ :type="showConfirmPassword ? 'text' : 'password'"
51
+ :rules="validations['passwordConfirmationRules']"
52
+ lazy-rules
53
+ :placeholder="$t('auth.confirmPassword')"
54
+ outlined
55
+ no-shadow
56
+ class="form-input">
57
+ <template #append>
58
+ <q-icon
59
+ :name="
60
+ showConfirmPassword
61
+ ? icons.ionEyeOffOutline
62
+ : icons.ionEyeOutline
63
+ "
64
+ class="password-toggle"
65
+ @click="showConfirmPassword = !showConfirmPassword" />
66
+ </template>
67
+ </q-input>
29
68
  </div>
30
- <q-input
31
- data-testid="password-input"
32
- id="password"
33
- v-model="password"
34
- data-cy="reset-password"
35
- :rules="validations['passwordRules']"
36
- outlined
37
- :type="showPassword ? 'text' : 'password'"
38
- bg-color="white"
39
- dense
40
- class="full-width q-mb-md">
41
- <template #append>
69
+
70
+ <div
71
+ class="requirements-checklist"
72
+ data-testid="password-requirements">
73
+ <div
74
+ v-for="requirement in passwordRequirements"
75
+ :key="requirement.label"
76
+ class="requirement-item"
77
+ :class="{ 'requirement-item--valid': requirement.valid }">
42
78
  <q-icon
43
79
  :name="
44
- showPassword
45
- ? icons.ionEyeOffOutline
46
- : icons.ionEyeOutline
80
+ requirement.valid ? 'check_circle' : 'radio_button_unchecked'
47
81
  "
48
- class="cursor-pointer q-mr-sm"
49
- @click="showPassword = !showPassword" />
50
- </template>
51
- </q-input>
52
- </div>
53
- <q-space class="mobile" />
54
- <div>
82
+ size="16px"
83
+ class="requirement-icon" />
84
+ <span class="requirement-label">{{ requirement.label }}</span>
85
+ </div>
86
+ </div>
87
+
55
88
  <q-btn
89
+ :label="$t('common.saveNewPassword')"
90
+ icon="lock"
91
+ no-caps
92
+ unelevated
56
93
  data-testid="submit-btn"
57
- :label="$t('common.register')"
58
- color="primary"
94
+ data-cy="send-new-password"
59
95
  :loading="loading"
60
96
  type="submit"
61
- no-caps
62
- class="submitBtn"
63
- data-cy="send-new-password"
64
- :disable="!canSubmit" />
97
+ :disable="!canSubmit"
98
+ class="reset-button" />
99
+ </q-form>
100
+ </div>
101
+
102
+ <div class="back-section">
103
+ <q-btn
104
+ flat
105
+ no-caps
106
+ class="back-button"
107
+ icon="arrow_back"
108
+ data-testid="back-btn"
109
+ @click="$router.push(LOCAL_LOGIN_ROUTE)">
110
+ <span class="back-text">{{ $t("auth.backToLoginFull") }}</span>
111
+ </q-btn>
112
+ </div>
113
+ </template>
114
+
115
+ <template v-else>
116
+ <div
117
+ class="success-content"
118
+ data-testid="password-updated-confirmation"
119
+ role="status"
120
+ aria-live="polite">
121
+ <div class="success-icon-outer">
122
+ <div class="success-icon-inner">
123
+ <q-icon name="check" size="24px" color="white" />
124
+ </div>
65
125
  </div>
66
- </q-form>
67
- </div>
126
+ <div
127
+ ref="successTitleRef"
128
+ class="success-title"
129
+ data-testid="password-updated-title"
130
+ tabindex="-1">
131
+ {{ $t("auth.passwordSavedTitle") }}
132
+ </div>
133
+ <div class="success-subtitle" data-testid="password-updated-message">
134
+ {{ $t("auth.passwordSavedMessage") }}
135
+ </div>
136
+ <q-btn
137
+ unelevated
138
+ no-caps
139
+ class="connect-button"
140
+ :label="$t('auth.goToLogin')"
141
+ data-testid="go-to-login-btn"
142
+ @click="goToLoginPage" />
143
+ </div>
144
+ </template>
68
145
  </div>
69
- <q-dialog v-model="passwordUpdated" persistent>
70
- <sw-info-dialog
71
- :title="$t('auth.passwordChanged')"
72
- :content="$t('auth.passwordUpdated')"
73
- :image="'passwordUpdated'"
74
- :passwordUpdated="passwordUpdated"
75
- @goToLoginPage="goToLoginPage" />
76
- </q-dialog>
77
146
  </div>
78
147
  </q-layout>
79
148
  </template>
80
149
 
81
150
  <script lang="ts">
82
- import { defineComponent } from "vue"
83
- import SwInfoDialog from "../dialogs/sw-info-dialog.vue"
151
+ import { defineComponent, nextTick, ref, watch } from "vue"
84
152
  import { useResetPassword } from "composables/resetPassword"
85
153
 
86
154
  export default defineComponent({
87
- name: "ResetPassword",
88
- components: { SwInfoDialog },
155
+ name: "WebResetPassword",
89
156
  setup() {
90
157
  const {
91
158
  password,
159
+ confirmPassword,
92
160
  loading,
93
161
  passwordUpdated,
94
162
  showPassword,
95
163
  validations,
164
+ passwordRequirements,
165
+ LOCAL_LOGIN_ROUTE,
96
166
  canSubmit,
97
- icons,
98
167
  onSubmit,
99
- goToLoginPage
168
+ goToLoginPage,
169
+ icons
100
170
  } = useResetPassword()
101
171
 
172
+ const showConfirmPassword = ref(false)
173
+ const successTitleRef = ref<HTMLElement | null>(null)
174
+
175
+ watch(passwordUpdated, (updated) => {
176
+ if (updated) {
177
+ nextTick(() => successTitleRef.value?.focus())
178
+ }
179
+ })
180
+
102
181
  return {
103
182
  password,
183
+ confirmPassword,
104
184
  validations,
185
+ passwordRequirements,
105
186
  loading,
106
187
  canSubmit,
107
188
  passwordUpdated,
108
189
  showPassword,
190
+ showConfirmPassword,
191
+ successTitleRef,
192
+ LOCAL_LOGIN_ROUTE,
109
193
  icons,
110
194
  goToLoginPage,
111
195
  onSubmit
@@ -115,7 +199,295 @@ export default defineComponent({
115
199
  </script>
116
200
 
117
201
  <style scoped>
118
- .submitBtn {
119
- width: 254px;
202
+ .web-reset-password-page {
203
+ background: linear-gradient(
204
+ 224deg,
205
+ rgba(233, 236, 237, 1) 0%,
206
+ rgba(64, 196, 255, 1) 59%,
207
+ rgba(255, 255, 255, 1) 100%
208
+ );
209
+ min-height: 100vh;
210
+ }
211
+
212
+ .reset-container {
213
+ min-height: 100vh;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ padding: 20px;
218
+ }
219
+
220
+ .reset-card {
221
+ background: #ffffff;
222
+ border-radius: 20px;
223
+ padding: 52px;
224
+ width: 100%;
225
+ max-width: 634px;
226
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 40px;
230
+ }
231
+
232
+ .logo-container {
233
+ display: flex;
234
+ justify-content: center;
235
+ }
236
+
237
+ .logo {
238
+ height: 53px;
239
+ width: auto;
240
+ }
241
+
242
+ .content-section {
243
+ display: flex;
244
+ flex-direction: column;
245
+ gap:29px
246
+ }
247
+
248
+ .reset-form {
249
+ display: flex;
250
+ flex-direction: column;
251
+ gap: 29px;
252
+ }
253
+
254
+ .reset-heading {
255
+ display: flex;
256
+ flex-direction: column;
257
+ gap: 14px;
258
+ }
259
+
260
+ .reset-title {
261
+ font-family: "Poppins", sans-serif;
262
+ font-size: 27px;
263
+ font-weight: 600;
264
+ line-height: 40px;
265
+ letter-spacing: 0.1px;
266
+ color: #152f3b;
267
+ margin: 0;
268
+ }
269
+
270
+ .reset-subtitle {
271
+ font-size: 16px;
272
+ font-weight: 400;
273
+ line-height: normal;
274
+ color: #152f3b;
275
+ margin: 0;
276
+ }
277
+
278
+ .reset-inputs {
279
+ display: flex;
280
+ flex-direction: column;
281
+ gap: 18px;
282
+ }
283
+
284
+ .form-input {
285
+ width: 100%;
286
+ }
287
+
288
+ .form-input :deep(.q-field__control) {
289
+ height: 44px;
290
+ border-radius: 10px !important;
291
+ }
292
+
293
+ .form-input :deep(.q-field__native),
294
+ .form-input :deep(.q-field__input) {
295
+ font-size: 16px;
296
+ font-weight: 500;
297
+ line-height: 1.5em;
298
+ letter-spacing: -0.5px;
299
+ color: #103140;
300
+ padding: 0 16px;
301
+ }
302
+
303
+ .form-input :deep(.q-field__marginal) {
304
+ height: 44px;
305
+ }
306
+
307
+ .form-input :deep(input::placeholder) {
308
+ color: #8f9da3;
309
+ opacity: 1;
310
+ }
311
+
312
+ .form-input :deep(.q-field__bottom) {
313
+ font-size: 12px;
314
+ font-weight: 400;
315
+ line-height: 1.5em;
316
+ letter-spacing: -0.5px;
317
+ color: #ef476f;
318
+ padding-top: 4px;
319
+ padding-left: 0;
320
+ min-height: 20px;
321
+ }
322
+ :deep(.q-field--with-bottom) {
323
+ padding-bottom: 8px;
324
+ }
325
+
326
+ .password-toggle {
327
+ cursor: pointer;
328
+ color: #103140;
329
+ font-size: 24px;
330
+ padding-right: 6px;
331
+ }
332
+
333
+ .requirements-checklist {
334
+ background: #f2f4f6;
335
+ border-radius: 8px;
336
+ padding: 17px 16px 16px;
337
+ display: grid;
338
+ grid-template-columns: repeat(2, minmax(0, 1fr));
339
+ gap: 8px 16px;
340
+ }
341
+
342
+ .requirement-item {
343
+ display: flex;
344
+ align-items: center;
345
+ gap: 8px;
346
+ color: #3e484f;
347
+ }
348
+
349
+ .requirement-icon {
350
+ color: #bbcbd2;
351
+ flex-shrink: 0;
352
+ }
353
+
354
+ .requirement-item--valid {
355
+ color: #06d6a0;
356
+ }
357
+
358
+ .requirement-item--valid .requirement-icon {
359
+ color: #06d6a0;
360
+ }
361
+
362
+ .requirement-label {
363
+ font-size: 12px;
364
+ font-weight: 500;
365
+ line-height: 14.4px;
366
+ }
367
+
368
+ .reset-button {
369
+ width: 100%;
370
+ height: 54px;
371
+ background: #40c4ff;
372
+ color: #ffffff;
373
+ font-size: 16px;
374
+ font-weight: 600;
375
+ line-height: 24px;
376
+ border-radius: 15px;
377
+ text-transform: none;
378
+ }
379
+
380
+ .reset-button:hover {
381
+ background: #35b0e6;
382
+ }
383
+
384
+ .reset-button :deep(.q-icon) {
385
+ font-size: 16px;
386
+ }
387
+
388
+ .back-section {
389
+ display: flex;
390
+ justify-content: center;
391
+ align-items: center;
392
+ }
393
+
394
+ .back-button {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 10px;
398
+ color: #103140;
399
+ font-size: 16px;
400
+ font-weight: 600;
401
+ line-height: 1.5em;
402
+ padding: 10px;
403
+ }
404
+
405
+ .back-button :deep(.q-icon) {
406
+ margin-right: 0;
407
+ }
408
+
409
+ .success-content {
410
+ display: flex;
411
+ flex-direction: column;
412
+ align-items: center;
413
+ gap: 24px;
414
+ text-align: center;
415
+ }
416
+
417
+ .success-icon-outer {
418
+ width: 96px;
419
+ height: 96px;
420
+ border-radius: 9999px;
421
+ background: rgba(6, 214, 160, 0.2);
422
+ display: flex;
423
+ align-items: center;
424
+ justify-content: center;
425
+ }
426
+
427
+ .success-icon-inner {
428
+ width: 40px;
429
+ height: 40px;
430
+ border-radius: 9999px;
431
+ background: #06d6a0;
432
+ display: flex;
433
+ align-items: center;
434
+ justify-content: center;
435
+ }
436
+
437
+ .success-title {
438
+ font-family: "Poppins", sans-serif;
439
+ font-size: 27px;
440
+ font-weight: 600;
441
+ line-height: 40px;
442
+ letter-spacing: 0.1px;
443
+ color: #152f3b;
444
+ margin: 0;
445
+ }
446
+
447
+ .success-subtitle {
448
+ font-size: 16px;
449
+ font-weight: 400;
450
+ line-height: normal;
451
+ letter-spacing: 0.1px;
452
+ color: #152f3b;
453
+ margin: 0;
454
+ }
455
+
456
+ .connect-button {
457
+ width: 100%;
458
+ height: 56px;
459
+ background: #40c4ff;
460
+ color: #ffffff;
461
+ font-size: 16px;
462
+ font-weight: 600;
463
+ line-height: 24px;
464
+ border-radius: 15px;
465
+ text-transform: none;
466
+ }
467
+
468
+ .connect-button:hover {
469
+ background: #35b0e6;
470
+ }
471
+
472
+ @media screen and (max-width: 768px) {
473
+ .reset-card {
474
+ padding: 32px 24px;
475
+ max-width: 100%;
476
+ }
477
+
478
+ .logo {
479
+ width: 160px;
480
+ height: auto;
481
+ }
482
+
483
+ .reset-title,
484
+ .success-title {
485
+ font-size: 22px;
486
+ line-height: 1.3em;
487
+ }
488
+
489
+ .requirements-checklist {
490
+ grid-template-columns: 1fr;
491
+ }
120
492
  }
121
493
  </style>
@@ -1,4 +1,4 @@
1
- import { useQuasar } from "quasar"
1
+ import { useQuasar, LocalStorage } from "quasar"
2
2
  import { useI18n } from "vue-i18n"
3
3
  import jwt from "jwt-decode"
4
4
  import { computed, ref, Ref, onMounted } from "vue"
@@ -19,15 +19,16 @@ interface DecodedToken {
19
19
  token: string
20
20
  }
21
21
  export function useResetPassword() {
22
- const { localStorage, notify } = useQuasar()
22
+ const { notify } = useQuasar()
23
23
  const { t } = useI18n()
24
24
 
25
25
  const password = ref("")
26
+ const confirmPassword = ref("")
26
27
  const loading = ref(false)
27
28
  const passwordUpdated = ref(false)
28
29
  const showPassword = ref(false)
29
30
  const tempToken = ref("")
30
- const userInfos = JSON.parse(localStorage.getItem("userInfos") || "{}") as {
31
+ const userInfos = JSON.parse(LocalStorage.getItem("userInfos") || "{}") as {
31
32
  uid: string
32
33
  token: string
33
34
  }
@@ -49,26 +50,78 @@ export function useResetPassword() {
49
50
 
50
51
  const isOtp = computed(() => IS_OTP.toLowerCase() === "y")
51
52
 
53
+ const hasMinLength = computed(() => password.value.length >= 8)
54
+ const hasUppercase = computed(() => /[A-Z]/.test(password.value))
55
+ const hasLowercase = computed(() => /[a-z]/.test(password.value))
56
+ const hasNumber = computed(() => /[0-9]/.test(password.value))
57
+ const hasLetter = computed(() => /[a-zA-Z]/.test(password.value))
58
+ const hasSpecialChar = computed(() =>
59
+ /[^a-zA-Z0-9\s]/.test(password.value)
60
+ )
61
+
62
+ const isPasswordValid = computed(
63
+ () =>
64
+ hasMinLength.value &&
65
+ hasUppercase.value &&
66
+ hasLowercase.value &&
67
+ hasNumber.value &&
68
+ hasLetter.value &&
69
+ hasSpecialChar.value
70
+ )
71
+
72
+ 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
+ {
79
+ label: t("auth.passwordRequirement.specialChar"),
80
+ valid: hasSpecialChar.value
81
+ }
82
+ ])
83
+
52
84
  const canSubmit = computed(() => {
53
85
  return (
54
86
  (decodedToken.value !== null || userInfos?.uid) &&
55
- password.value.length >= 6
87
+ isPasswordValid.value &&
88
+ confirmPassword.value === password.value
56
89
  )
57
90
  })
58
91
 
59
92
  const validations = {
60
93
  passwordRules: [
61
94
  (val: string) => !!val || t("auth.passwordPresenceError"),
62
- (val: string) => val.length >= 6 || t("auth.passwordLengthError")
95
+ () => isPasswordValid.value || t("auth.passwordRequirementsError")
63
96
  ],
64
97
  passwordConfirmationRules: [
65
- (val: string) =>
66
- val === password.value || t("auth.newPasswordConfirmationFailed"),
67
98
  (val: string) => !!val || t("auth.passwordPresenceError"),
68
- (val: string) => val.length >= 6 || t("auth.passwordLengthError")
99
+ (val: string) =>
100
+ val === password.value || t("auth.newPasswordConfirmationFailed")
69
101
  ]
70
102
  }
71
103
 
104
+ const KNOWN_API_PASSWORD_ERRORS: Array<{ pattern: RegExp; key: string }> = [
105
+ {
106
+ pattern: /simple sequence of \d+ characters/i,
107
+ key: "auth.passwordSequenceError"
108
+ },
109
+ {
110
+ pattern: /\d+ or more sequential characters/i,
111
+ key: "auth.passwordSequenceError"
112
+ }
113
+ ]
114
+
115
+ const translateApiErrorDetail = (detail?: string) => {
116
+ if (!detail) {
117
+ return undefined
118
+ }
119
+ const knownError = KNOWN_API_PASSWORD_ERRORS.find((entry) =>
120
+ entry.pattern.test(detail)
121
+ )
122
+ return knownError ? t(knownError.key) : undefined
123
+ }
124
+
72
125
  const decodeKey = (key: string) => {
73
126
  try {
74
127
  // remove backslash to avoid decoding error
@@ -129,24 +182,37 @@ export function useResetPassword() {
129
182
  .then(() => {
130
183
  loading.value = false
131
184
  passwordUpdated.value = true
132
- localStorage.remove("emailResetPassword")
133
- localStorage.remove("userInfos")
134
- })
135
- .catch(() => {
136
- loading.value = false
137
- notify({
138
- message: t("auth.passwordRecoveryFailed"),
139
- color: "negative",
140
- icon: ionAlertCircleOutline,
141
- position: "top",
142
- actions: [
143
- {
144
- color: "white",
145
- icon: ionCloseOutline
146
- }
147
- ]
148
- })
185
+ LocalStorage.remove("emailResetPassword")
186
+ LocalStorage.remove("userInfos")
149
187
  })
188
+ .catch(
189
+ (error: {
190
+ response?: {
191
+ status?: number
192
+ data?: { errors?: Array<{ detail?: string; title?: string }> }
193
+ }
194
+ }) => {
195
+ loading.value = false
196
+
197
+ const apiErrorDetail =
198
+ error.response?.data?.errors?.[0]?.detail ||
199
+ error.response?.data?.errors?.[0]?.title
200
+ const apiErrorMessage = translateApiErrorDetail(apiErrorDetail)
201
+
202
+ notify({
203
+ message: apiErrorMessage || t("auth.passwordRecoveryFailed"),
204
+ color: "negative",
205
+ icon: ionAlertCircleOutline,
206
+ position: "top",
207
+ actions: [
208
+ {
209
+ color: "white",
210
+ icon: ionCloseOutline
211
+ }
212
+ ]
213
+ })
214
+ }
215
+ )
150
216
  }
151
217
 
152
218
  const goToLoginPage = () => {
@@ -221,10 +287,12 @@ export function useResetPassword() {
221
287
 
222
288
  return {
223
289
  password,
290
+ confirmPassword,
224
291
  loading,
225
292
  passwordUpdated,
226
293
  showPassword,
227
294
  validations,
295
+ passwordRequirements,
228
296
  canSubmit,
229
297
  LOCAL_LOGIN_ROUTE,
230
298
  onSubmit,
@@ -17,13 +17,35 @@ export default {
17
17
  tapEmail: "Please enter your email",
18
18
  404: "No user found with the provided credentials",
19
19
  403: "Insufficient rights, your account does not allow access to this interface",
20
- passwordRecovery: "Password recovery",
21
- newPassword: "New password",
20
+ passwordRecovery: "Password reset",
21
+ recommendPassword: "Please set a new password",
22
+ newPassword: "Password",
23
+ newPasswordPlaceholder: "Minimum 8 characters",
24
+ webNewPasswordPlaceholder: "Password (Minimum 8 characters)",
25
+ confirmPassword: "Confirm password",
26
+ confirmPasswordPlaceholder: "Repeat your password",
22
27
  newPasswordConfirmationFailed: "The passwords you typed don't match",
28
+ passwordRequirementsError: "The password does not meet the requirements",
29
+ passwordSequenceError:
30
+ "Password must not contain a simple sequence of 4+ characters (e.g. 1234, abcd, azerty, qwerty).",
31
+ passwordRequirement: {
32
+ minLength: "8 characters minimum",
33
+ uppercase: "1 uppercase letter",
34
+ lowercase: "1 lowercase letter",
35
+ number: "1 number",
36
+ letter: "1 letter",
37
+ specialChar: "1 special character"
38
+ },
23
39
  passwordRecoverySuccess: "Your password has been updated",
24
40
  passwordRecoveryFailed: "An error occured while updating your password",
25
41
  passwordRecoveryExpiredLink: "The link expired",
26
42
  passwordRecoveryInvalidLink: "the link is invalid",
43
+ passwordSavedTitle: "Password saved!",
44
+ passwordSavedMessage:
45
+ "You can now access all of your SoWell+ services.",
46
+ goToLogin: "Log in",
47
+ back: "Back",
48
+ backToLoginFull: "Back to login",
27
49
  continueWith: "Or continue with",
28
50
  emailOrPasswordIncorrect: "Email or password incorrect"
29
51
  },
@@ -32,6 +54,7 @@ export default {
32
54
  send: "Send",
33
55
  cancel: "Cancel",
34
56
  or: "or",
57
+ saveNewPassword: "Save my new password",
35
58
  privacyPolicy: "Privacy policy"
36
59
  }
37
60
  }
@@ -17,15 +17,38 @@ export default {
17
17
  tapEmail: "Entrer votre email",
18
18
  404: "Aucun utilisateur trouvé avec les identifiants donnés",
19
19
  403: "Droits insuffisants, votre compte ne permet pas d'acceder à cette interface",
20
- passwordRecovery: "Récupération de mot de passe",
21
- newPassword: "Nouveau mot de passe",
20
+ passwordRecovery: "Réinitialisation de votre mot de passe",
21
+ recommendPassword: "Veuillez configurer un nouveau mot de passe",
22
+ newPassword: "Mot de passe",
23
+ newPasswordPlaceholder: "Minimum 8 caractères",
24
+ webNewPasswordPlaceholder: "Mot de passe (Minimum 8 caractères)",
25
+ confirmPassword: "Confirmer le mot de passe",
26
+ confirmPasswordPlaceholder: "Répéter le mot de passe",
22
27
  newPasswordConfirmationFailed:
23
28
  "Les mot de passes que vous avez saisi ne correspondent pas",
29
+ passwordRequirementsError:
30
+ "Le mot de passe ne respecte pas les critères requis",
31
+ passwordSequenceError:
32
+ "Le mot de passe ne doit pas contenir de séquence simple de 4 caractères ou plus (ex. 1234, abcd, azerty, qwerty).",
33
+ passwordRequirement: {
34
+ minLength: "8 caractères minimum",
35
+ uppercase: "1 majuscule",
36
+ lowercase: "1 minuscule",
37
+ number: "1 chiffre",
38
+ letter: "1 lettre",
39
+ specialChar: "1 Caractère spécial"
40
+ },
24
41
  passwordRecoverySuccess: "Votre mot de passe a été mis à jour",
25
42
  passwordRecoveryFailed:
26
43
  "Une erreur s'est produite lors de la mise à jour de votre mot de passe",
27
44
  passwordRecoveryExpiredLink: "Lien de récupération expiré",
28
45
  passwordRecoveryInvalidLink: "Lien de récupération invalide",
46
+ passwordSavedTitle: "Mot de passe enregistré !",
47
+ passwordSavedMessage:
48
+ "Vous pouvez maintenant accéder à l'intégralité de vos services SoWell+.",
49
+ goToLogin: "Me connecter",
50
+ back: "Retour",
51
+ backToLoginFull: "Retour à la connexion",
29
52
  continueWith: "Ou continuer avec",
30
53
  emailOrPasswordIncorrect: "Email ou mot de passe incorrect"
31
54
  },
@@ -34,6 +57,7 @@ export default {
34
57
  send: "Envoyer",
35
58
  cancel: "Annuler",
36
59
  or: "ou",
60
+ saveNewPassword: "Enregistrer mon nouveau mot de passe",
37
61
  privacyPolicy: "Politique de confidentialité"
38
62
  }
39
63
  }