@sowell/quasar-app-extension-auth 1.5.1 → 1.6.1

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.1",
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,109 +1,209 @@
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
+ ref="confirmPasswordInput"
74
+ v-model="confirmPassword"
75
+ data-testid="confirm-password-input"
76
+ data-cy="reset-confirm-password"
77
+ :type="showConfirmPassword ? 'text' : 'password'"
78
+ :rules="validations['passwordConfirmationRules']"
79
+ lazy-rules
80
+ borderless
81
+ :placeholder="$t('auth.confirmPasswordPlaceholder')"
82
+ class="q-mt-sm"
83
+ :input-style="{ fontSize: '13px', fontWeight: '500' }">
84
+ <template #append>
85
+ <q-icon
86
+ :name="
87
+ showConfirmPassword
88
+ ? icons.ionEyeOffOutline
89
+ : icons.ionEyeOutline
90
+ "
91
+ class="cursor-pointer"
92
+ @click="showConfirmPassword = !showConfirmPassword" />
93
+ </template>
94
+ </q-input>
65
95
  </div>
96
+
97
+ <div class="password-requirements q-mt-md" data-testid="password-requirements">
98
+ <div
99
+ v-for="requirement in passwordRequirements"
100
+ :key="requirement.label"
101
+ class="requirement-item"
102
+ :class="{ 'requirement-valid': requirement.valid }">
103
+ <q-icon
104
+ :name="requirement.valid ? 'check_circle' : 'radio_button_unchecked'"
105
+ size="16px" />
106
+ <span class="q-ml-xs">{{ requirement.label }}</span>
107
+ </div>
108
+ </div>
109
+
110
+ <q-btn
111
+ data-testid="submit-btn"
112
+ data-cy="send-new-password"
113
+ color="primary"
114
+ unelevated
115
+ no-caps
116
+ text-color="white"
117
+ :loading="loading"
118
+ type="submit"
119
+ class="full-width q-mt-lg submit-button"
120
+ :disable="!canSubmit">
121
+ <q-icon name="lock_open" size="16px" class="q-mr-sm" />
122
+ {{ $t("common.saveNewPassword") }}
123
+ </q-btn>
66
124
  </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>
125
+ </template>
126
+ <template v-else>
127
+ <div
128
+ class="column items-center header-section"
129
+ data-testid="password-updated-confirmation"
130
+ role="status"
131
+ aria-live="polite">
132
+ <div class="success-badge">
133
+ <q-icon name="check_circle" size="48px" color="primary" />
134
+ </div>
135
+ <div
136
+ ref="successTitleRef"
137
+ class="title text-center q-mt-md"
138
+ data-testid="password-updated-title"
139
+ tabindex="-1">
140
+ {{ $t("auth.passwordSavedTitle") }}
141
+ </div>
142
+ <div class="subtitle text-center q-mt-sm" data-testid="password-updated-message">
143
+ {{ $t("auth.passwordSavedMessage") }}
144
+ </div>
145
+ </div>
146
+ <q-btn
147
+ data-testid="go-to-login-btn"
148
+ data-cy="go-to-login"
149
+ color="primary"
150
+ unelevated
151
+ no-caps
152
+ text-color="white"
153
+ class="full-width q-mt-lg submit-button"
154
+ @click="goToLoginPage">
155
+ {{ $t("auth.goToLogin") }}
156
+ </q-btn>
157
+ </template>
75
158
  </div>
76
159
  </q-layout>
77
160
  </template>
78
161
 
79
162
  <script lang="ts">
80
- import { defineComponent } from "vue"
81
- import SowellMessage from "../dialogs/Message.vue"
163
+ import { defineComponent, nextTick, ref, watch } from "vue"
82
164
  import { useResetPassword } from "composables/resetPassword"
83
165
 
84
166
  export default defineComponent({
85
167
  name: "ResetPassword",
86
- components: { SowellMessage },
87
168
  setup() {
88
169
  const {
89
170
  password,
171
+ confirmPassword,
90
172
  loading,
91
173
  passwordUpdated,
174
+ showPassword,
92
175
  validations,
176
+ passwordRequirements,
93
177
  LOCAL_LOGIN_ROUTE,
94
178
  canSubmit,
179
+ confirmPasswordInput,
95
180
  onSubmit,
96
181
  goToLoginPage,
97
182
  icons
98
183
  } = useResetPassword()
99
184
 
185
+ const showConfirmPassword = ref(false)
186
+ const successTitleRef = ref<HTMLElement | null>(null)
187
+
188
+ watch(passwordUpdated, (updated) => {
189
+ if (updated) {
190
+ nextTick(() => successTitleRef.value?.focus())
191
+ }
192
+ })
193
+
100
194
  return {
101
195
  password,
196
+ confirmPassword,
102
197
  loading,
103
198
  canSubmit,
104
199
  passwordUpdated,
200
+ showPassword,
201
+ showConfirmPassword,
202
+ successTitleRef,
105
203
  LOCAL_LOGIN_ROUTE,
106
204
  validations,
205
+ passwordRequirements,
206
+ confirmPasswordInput,
107
207
  icons,
108
208
  onSubmit,
109
209
  goToLoginPage
@@ -113,12 +213,162 @@ export default defineComponent({
113
213
  </script>
114
214
 
115
215
  <style scoped>
116
- .form {
117
- height: calc(100% - 225px);
216
+ .reset-password-page {
217
+ min-height: 100vh;
118
218
  }
119
- @media screen and (min-width: 450px) {
120
- .mobile {
121
- display: none;
219
+
220
+ .background-logo-container {
221
+ position: fixed;
222
+ top: -53px;
223
+ left: 0;
224
+ width: 100%;
225
+ height: 100vh;
226
+ display: flex;
227
+ justify-content: center;
228
+ align-items: center;
229
+ opacity: 0.25;
230
+ overflow: hidden;
231
+ }
232
+
233
+ .background-logo {
234
+ width: 100%;
235
+ height: 100%;
236
+ object-fit: cover;
237
+ max-width: 100%;
238
+ max-height: 100vh;
239
+ }
240
+
241
+ .top-bar {
242
+ z-index: 1;
243
+ max-width: 430px;
244
+ }
245
+
246
+ .back-link {
247
+ cursor: pointer;
248
+ color: #00668a;
249
+ font-size: 14px;
250
+ font-weight: 600;
251
+ }
252
+
253
+ .reset-password-card {
254
+ z-index: 1;
255
+ max-width: 398px;
256
+ padding: 32px 24px;
257
+ margin-top: 24px;
258
+ }
259
+
260
+ .reset-password-card--centered {
261
+ flex: 1;
262
+ display: flex;
263
+ flex-direction: column;
264
+ justify-content: center;
265
+ align-items: center;
266
+ margin-top: 0 !important;
267
+ }
268
+
269
+ .icon-badge {
270
+ width: 64px;
271
+ height: 64px;
272
+ border-radius: 9999px;
273
+ background: rgba(56, 189, 248, 0.1);
274
+ border: 1px solid rgba(56, 189, 248, 0.3);
275
+ display: flex;
276
+ align-items: center;
277
+ justify-content: center;
278
+ }
279
+
280
+ .success-badge {
281
+ width: 84px;
282
+ height: 84px;
283
+ border-radius: 9999px;
284
+ background: rgba(56, 189, 248, 0.1);
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: center;
288
+ }
289
+
290
+ .title {
291
+ font-size: 21px;
292
+ font-weight: 600;
293
+ color: #20627f;
294
+ line-height: 1.6;
295
+ }
296
+
297
+ .subtitle {
298
+ font-size: 14px;
299
+ font-weight: 400;
300
+ color: #3e484f;
301
+ }
302
+
303
+ .input-label {
304
+ font-size: 14px;
305
+ font-weight: 700;
306
+ color: #112240;
307
+ }
308
+
309
+ .reset-password-page :deep(.q-field__control-container) {
310
+ padding: 4px;
311
+ }
312
+
313
+ .reset-password-page :deep(div.q-field__control) {
314
+ border: 1px solid #bdc8d1;
315
+ border-radius: 8px;
316
+ height: 55px;
317
+ padding: 0 12px;
318
+ }
319
+
320
+ .reset-password-page :deep(.q-field--error .q-field__control) {
321
+ border-color: #ff8b87;
322
+ }
323
+
324
+ .reset-password-page :deep(.q-field--error .q-field__bottom) {
325
+ color: #ef476f;
326
+ }
327
+
328
+ .reset-password-page :deep(.q-field__bottom) {
329
+ padding: 4px 0px;
330
+ }
331
+
332
+ .password-requirements {
333
+ width: 100%;
334
+ background: #f2f4f6;
335
+ border-radius: 8px;
336
+ padding: 16px;
337
+ display: grid;
338
+ grid-template-columns: repeat(2, 1fr);
339
+ gap: 10px 16px;
340
+ }
341
+
342
+ .requirement-item {
343
+ display: flex;
344
+ align-items: center;
345
+ font-size: 12px;
346
+ font-weight: 500;
347
+ color: #3e484f;
348
+ }
349
+
350
+ .requirement-item .q-icon {
351
+ color: #bdc8d1;
352
+ }
353
+
354
+ .requirement-item.requirement-valid {
355
+ color: #06d6a0;
356
+ }
357
+
358
+ .requirement-item.requirement-valid .q-icon {
359
+ color: #06d6a0;
360
+ }
361
+
362
+ .submit-button {
363
+ border-radius: 15px;
364
+ height: 50px;
365
+ font-size: 14px;
366
+ font-weight: 500;
367
+ }
368
+
369
+ @media (max-width: 389px) {
370
+ .reset-password-card {
371
+ padding: 24px 16px;
122
372
  }
123
373
  }
124
374
  </style>
@@ -1,111 +1,198 @@
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
+ ref="confirmPasswordInput"
48
+ v-model="confirmPassword"
49
+ data-testid="confirm-password-input"
50
+ data-cy="reset-confirm-password"
51
+ :type="showConfirmPassword ? 'text' : 'password'"
52
+ :rules="validations['passwordConfirmationRules']"
53
+ lazy-rules
54
+ :placeholder="$t('auth.confirmPassword')"
55
+ outlined
56
+ no-shadow
57
+ class="form-input">
58
+ <template #append>
59
+ <q-icon
60
+ :name="
61
+ showConfirmPassword
62
+ ? icons.ionEyeOffOutline
63
+ : icons.ionEyeOutline
64
+ "
65
+ class="password-toggle"
66
+ @click="showConfirmPassword = !showConfirmPassword" />
67
+ </template>
68
+ </q-input>
29
69
  </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>
70
+
71
+ <div
72
+ class="requirements-checklist"
73
+ data-testid="password-requirements">
74
+ <div
75
+ v-for="requirement in passwordRequirements"
76
+ :key="requirement.label"
77
+ class="requirement-item"
78
+ :class="{ 'requirement-item--valid': requirement.valid }">
42
79
  <q-icon
43
80
  :name="
44
- showPassword
45
- ? icons.ionEyeOffOutline
46
- : icons.ionEyeOutline
81
+ requirement.valid ? 'check_circle' : 'radio_button_unchecked'
47
82
  "
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>
83
+ size="16px"
84
+ class="requirement-icon" />
85
+ <span class="requirement-label">{{ requirement.label }}</span>
86
+ </div>
87
+ </div>
88
+
55
89
  <q-btn
90
+ :label="$t('common.saveNewPassword')"
91
+ icon="lock"
92
+ no-caps
93
+ unelevated
56
94
  data-testid="submit-btn"
57
- :label="$t('common.register')"
58
- color="primary"
95
+ data-cy="send-new-password"
59
96
  :loading="loading"
60
97
  type="submit"
61
- no-caps
62
- class="submitBtn"
63
- data-cy="send-new-password"
64
- :disable="!canSubmit" />
98
+ :disable="!canSubmit"
99
+ class="reset-button" />
100
+ </q-form>
101
+ </div>
102
+
103
+ <div class="back-section">
104
+ <q-btn
105
+ flat
106
+ no-caps
107
+ class="back-button"
108
+ icon="arrow_back"
109
+ data-testid="back-btn"
110
+ @click="$router.push(LOCAL_LOGIN_ROUTE)">
111
+ <span class="back-text">{{ $t("auth.backToLoginFull") }}</span>
112
+ </q-btn>
113
+ </div>
114
+ </template>
115
+
116
+ <template v-else>
117
+ <div
118
+ class="success-content"
119
+ data-testid="password-updated-confirmation"
120
+ role="status"
121
+ aria-live="polite">
122
+ <div class="success-icon-outer">
123
+ <div class="success-icon-inner">
124
+ <q-icon name="check" size="24px" color="white" />
125
+ </div>
65
126
  </div>
66
- </q-form>
67
- </div>
127
+ <div
128
+ ref="successTitleRef"
129
+ class="success-title"
130
+ data-testid="password-updated-title"
131
+ tabindex="-1">
132
+ {{ $t("auth.passwordSavedTitle") }}
133
+ </div>
134
+ <div class="success-subtitle" data-testid="password-updated-message">
135
+ {{ $t("auth.passwordSavedMessage") }}
136
+ </div>
137
+ <q-btn
138
+ unelevated
139
+ no-caps
140
+ class="connect-button"
141
+ :label="$t('auth.goToLogin')"
142
+ data-testid="go-to-login-btn"
143
+ @click="goToLoginPage" />
144
+ </div>
145
+ </template>
68
146
  </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
147
  </div>
78
148
  </q-layout>
79
149
  </template>
80
150
 
81
151
  <script lang="ts">
82
- import { defineComponent } from "vue"
83
- import SwInfoDialog from "../dialogs/sw-info-dialog.vue"
152
+ import { defineComponent, nextTick, ref, watch } from "vue"
84
153
  import { useResetPassword } from "composables/resetPassword"
85
154
 
86
155
  export default defineComponent({
87
- name: "ResetPassword",
88
- components: { SwInfoDialog },
156
+ name: "WebResetPassword",
89
157
  setup() {
90
158
  const {
91
159
  password,
160
+ confirmPassword,
92
161
  loading,
93
162
  passwordUpdated,
94
163
  showPassword,
95
164
  validations,
165
+ passwordRequirements,
166
+ LOCAL_LOGIN_ROUTE,
96
167
  canSubmit,
97
- icons,
168
+ confirmPasswordInput,
98
169
  onSubmit,
99
- goToLoginPage
170
+ goToLoginPage,
171
+ icons
100
172
  } = useResetPassword()
101
173
 
174
+ const showConfirmPassword = ref(false)
175
+ const successTitleRef = ref<HTMLElement | null>(null)
176
+
177
+ watch(passwordUpdated, (updated) => {
178
+ if (updated) {
179
+ nextTick(() => successTitleRef.value?.focus())
180
+ }
181
+ })
182
+
102
183
  return {
103
184
  password,
185
+ confirmPassword,
104
186
  validations,
187
+ passwordRequirements,
105
188
  loading,
106
189
  canSubmit,
107
190
  passwordUpdated,
108
191
  showPassword,
192
+ showConfirmPassword,
193
+ successTitleRef,
194
+ LOCAL_LOGIN_ROUTE,
195
+ confirmPasswordInput,
109
196
  icons,
110
197
  goToLoginPage,
111
198
  onSubmit
@@ -115,7 +202,295 @@ export default defineComponent({
115
202
  </script>
116
203
 
117
204
  <style scoped>
118
- .submitBtn {
119
- width: 254px;
205
+ .web-reset-password-page {
206
+ background: linear-gradient(
207
+ 224deg,
208
+ rgba(233, 236, 237, 1) 0%,
209
+ rgba(64, 196, 255, 1) 59%,
210
+ rgba(255, 255, 255, 1) 100%
211
+ );
212
+ min-height: 100vh;
213
+ }
214
+
215
+ .reset-container {
216
+ min-height: 100vh;
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ padding: 20px;
221
+ }
222
+
223
+ .reset-card {
224
+ background: #ffffff;
225
+ border-radius: 20px;
226
+ padding: 52px;
227
+ width: 100%;
228
+ max-width: 634px;
229
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
230
+ display: flex;
231
+ flex-direction: column;
232
+ gap: 40px;
233
+ }
234
+
235
+ .logo-container {
236
+ display: flex;
237
+ justify-content: center;
238
+ }
239
+
240
+ .logo {
241
+ height: 53px;
242
+ width: auto;
243
+ }
244
+
245
+ .content-section {
246
+ display: flex;
247
+ flex-direction: column;
248
+ gap:29px
249
+ }
250
+
251
+ .reset-form {
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 29px;
255
+ }
256
+
257
+ .reset-heading {
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 14px;
261
+ }
262
+
263
+ .reset-title {
264
+ font-family: "Poppins", sans-serif;
265
+ font-size: 27px;
266
+ font-weight: 600;
267
+ line-height: 40px;
268
+ letter-spacing: 0.1px;
269
+ color: #152f3b;
270
+ margin: 0;
271
+ }
272
+
273
+ .reset-subtitle {
274
+ font-size: 16px;
275
+ font-weight: 400;
276
+ line-height: normal;
277
+ color: #152f3b;
278
+ margin: 0;
279
+ }
280
+
281
+ .reset-inputs {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: 18px;
285
+ }
286
+
287
+ .form-input {
288
+ width: 100%;
289
+ }
290
+
291
+ .form-input :deep(.q-field__control) {
292
+ height: 44px;
293
+ border-radius: 10px !important;
294
+ }
295
+
296
+ .form-input :deep(.q-field__native),
297
+ .form-input :deep(.q-field__input) {
298
+ font-size: 16px;
299
+ font-weight: 500;
300
+ line-height: 1.5em;
301
+ letter-spacing: -0.5px;
302
+ color: #103140;
303
+ padding: 0 16px;
304
+ }
305
+
306
+ .form-input :deep(.q-field__marginal) {
307
+ height: 44px;
308
+ }
309
+
310
+ .form-input :deep(input::placeholder) {
311
+ color: #8f9da3;
312
+ opacity: 1;
313
+ }
314
+
315
+ .form-input :deep(.q-field__bottom) {
316
+ font-size: 12px;
317
+ font-weight: 400;
318
+ line-height: 1.5em;
319
+ letter-spacing: -0.5px;
320
+ color: #ef476f;
321
+ padding-top: 4px;
322
+ padding-left: 0;
323
+ min-height: 20px;
324
+ }
325
+ :deep(.q-field--with-bottom) {
326
+ padding-bottom: 8px;
327
+ }
328
+
329
+ .password-toggle {
330
+ cursor: pointer;
331
+ color: #103140;
332
+ font-size: 24px;
333
+ padding-right: 6px;
334
+ }
335
+
336
+ .requirements-checklist {
337
+ background: #f2f4f6;
338
+ border-radius: 8px;
339
+ padding: 17px 16px 16px;
340
+ display: grid;
341
+ grid-template-columns: repeat(2, minmax(0, 1fr));
342
+ gap: 8px 16px;
343
+ }
344
+
345
+ .requirement-item {
346
+ display: flex;
347
+ align-items: center;
348
+ gap: 8px;
349
+ color: #3e484f;
350
+ }
351
+
352
+ .requirement-icon {
353
+ color: #bbcbd2;
354
+ flex-shrink: 0;
355
+ }
356
+
357
+ .requirement-item--valid {
358
+ color: #06d6a0;
359
+ }
360
+
361
+ .requirement-item--valid .requirement-icon {
362
+ color: #06d6a0;
363
+ }
364
+
365
+ .requirement-label {
366
+ font-size: 12px;
367
+ font-weight: 500;
368
+ line-height: 14.4px;
369
+ }
370
+
371
+ .reset-button {
372
+ width: 100%;
373
+ height: 54px;
374
+ background: #40c4ff;
375
+ color: #ffffff;
376
+ font-size: 16px;
377
+ font-weight: 600;
378
+ line-height: 24px;
379
+ border-radius: 15px;
380
+ text-transform: none;
381
+ }
382
+
383
+ .reset-button:hover {
384
+ background: #35b0e6;
385
+ }
386
+
387
+ .reset-button :deep(.q-icon) {
388
+ font-size: 16px;
389
+ }
390
+
391
+ .back-section {
392
+ display: flex;
393
+ justify-content: center;
394
+ align-items: center;
395
+ }
396
+
397
+ .back-button {
398
+ display: flex;
399
+ align-items: center;
400
+ gap: 10px;
401
+ color: #103140;
402
+ font-size: 16px;
403
+ font-weight: 600;
404
+ line-height: 1.5em;
405
+ padding: 10px;
406
+ }
407
+
408
+ .back-button :deep(.q-icon) {
409
+ margin-right: 0;
410
+ }
411
+
412
+ .success-content {
413
+ display: flex;
414
+ flex-direction: column;
415
+ align-items: center;
416
+ gap: 24px;
417
+ text-align: center;
418
+ }
419
+
420
+ .success-icon-outer {
421
+ width: 96px;
422
+ height: 96px;
423
+ border-radius: 9999px;
424
+ background: rgba(6, 214, 160, 0.2);
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: center;
428
+ }
429
+
430
+ .success-icon-inner {
431
+ width: 40px;
432
+ height: 40px;
433
+ border-radius: 9999px;
434
+ background: #06d6a0;
435
+ display: flex;
436
+ align-items: center;
437
+ justify-content: center;
438
+ }
439
+
440
+ .success-title {
441
+ font-family: "Poppins", sans-serif;
442
+ font-size: 27px;
443
+ font-weight: 600;
444
+ line-height: 40px;
445
+ letter-spacing: 0.1px;
446
+ color: #152f3b;
447
+ margin: 0;
448
+ }
449
+
450
+ .success-subtitle {
451
+ font-size: 16px;
452
+ font-weight: 400;
453
+ line-height: normal;
454
+ letter-spacing: 0.1px;
455
+ color: #152f3b;
456
+ margin: 0;
457
+ }
458
+
459
+ .connect-button {
460
+ width: 100%;
461
+ height: 56px;
462
+ background: #40c4ff;
463
+ color: #ffffff;
464
+ font-size: 16px;
465
+ font-weight: 600;
466
+ line-height: 24px;
467
+ border-radius: 15px;
468
+ text-transform: none;
469
+ }
470
+
471
+ .connect-button:hover {
472
+ background: #35b0e6;
473
+ }
474
+
475
+ @media screen and (max-width: 768px) {
476
+ .reset-card {
477
+ padding: 32px 24px;
478
+ max-width: 100%;
479
+ }
480
+
481
+ .logo {
482
+ width: 160px;
483
+ height: auto;
484
+ }
485
+
486
+ .reset-title,
487
+ .success-title {
488
+ font-size: 22px;
489
+ line-height: 1.3em;
490
+ }
491
+
492
+ .requirements-checklist {
493
+ grid-template-columns: 1fr;
494
+ }
120
495
  }
121
496
  </style>
@@ -1,7 +1,7 @@
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
- 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,
@@ -19,18 +19,21 @@ 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
  }
35
+ type ValidatableInput = { resetValidation: () => void }
36
+ const confirmPasswordInput = ref<ValidatableInput | null>(null)
34
37
 
35
38
  const { patch, post } = $api()
36
39
  const router = $router()
@@ -49,26 +52,86 @@ export function useResetPassword() {
49
52
 
50
53
  const isOtp = computed(() => IS_OTP.toLowerCase() === "y")
51
54
 
55
+ const hasMinLength = computed(() => password.value.length >= 8)
56
+ const hasUppercase = computed(() => /[A-Z]/.test(password.value))
57
+ const hasLowercase = computed(() => /[a-z]/.test(password.value))
58
+ const hasNumber = computed(() => /[0-9]/.test(password.value))
59
+ const hasLetter = computed(() => /[a-zA-Z]/.test(password.value))
60
+ const hasSpecialChar = computed(() =>
61
+ /[^a-zA-Z0-9\s]/.test(password.value)
62
+ )
63
+
64
+ const isPasswordValid = computed(
65
+ () =>
66
+ hasMinLength.value &&
67
+ hasUppercase.value &&
68
+ hasLowercase.value &&
69
+ hasNumber.value &&
70
+ hasLetter.value &&
71
+ hasSpecialChar.value
72
+ )
73
+
74
+ const passwordRequirements = computed(() => [
75
+ { label: t("auth.passwordRequirement.minLength"), valid: hasMinLength.value },
76
+ { label: t("auth.passwordRequirement.uppercase"), valid: hasUppercase.value },
77
+ { label: t("auth.passwordRequirement.lowercase"), valid: hasLowercase.value },
78
+ { label: t("auth.passwordRequirement.number"), valid: hasNumber.value },
79
+ { label: t("auth.passwordRequirement.letter"), valid: hasLetter.value },
80
+ {
81
+ label: t("auth.passwordRequirement.specialChar"),
82
+ valid: hasSpecialChar.value
83
+ }
84
+ ])
85
+
52
86
  const canSubmit = computed(() => {
53
87
  return (
54
88
  (decodedToken.value !== null || userInfos?.uid) &&
55
- password.value.length >= 6
89
+ isPasswordValid.value &&
90
+ confirmPassword.value === password.value
56
91
  )
57
92
  })
58
93
 
59
94
  const validations = {
60
95
  passwordRules: [
61
96
  (val: string) => !!val || t("auth.passwordPresenceError"),
62
- (val: string) => val.length >= 6 || t("auth.passwordLengthError")
97
+ () => isPasswordValid.value || t("auth.passwordRequirementsError")
63
98
  ],
64
99
  passwordConfirmationRules: [
65
- (val: string) =>
66
- val === password.value || t("auth.newPasswordConfirmationFailed"),
67
100
  (val: string) => !!val || t("auth.passwordPresenceError"),
68
- (val: string) => val.length >= 6 || t("auth.passwordLengthError")
101
+ (val: string) =>
102
+ val === password.value || t("auth.newPasswordConfirmationFailed")
69
103
  ]
70
104
  }
71
105
 
106
+ // Quasar's lazy-rules only re-validates the confirmation field on blur/submit,
107
+ // never while typing. Without this, correcting the field after an error was
108
+ // shown (e.g. clearing it, then retyping) leaves the previous, now-stale
109
+ // error message on screen until the field is blurred again.
110
+ watch([password, confirmPassword], () => {
111
+ confirmPasswordInput.value?.resetValidation()
112
+ })
113
+
114
+ const KNOWN_API_PASSWORD_ERRORS: Array<{ pattern: RegExp; key: string }> = [
115
+ {
116
+ pattern: /simple sequence of \d\+ characters/i,
117
+ key: "auth.passwordSequenceError"
118
+ },
119
+ {
120
+ pattern: /\d+ or more sequential characters/i,
121
+ key: "auth.passwordSequenceError"
122
+ }
123
+ ]
124
+
125
+ const translateApiErrorDetail = (detail?: string) => {
126
+ if (!detail) {
127
+ return undefined
128
+ }
129
+ const knownError = KNOWN_API_PASSWORD_ERRORS.find((entry) =>
130
+ entry.pattern.test(detail)
131
+ )
132
+ return knownError ? t(knownError.key) : undefined
133
+ }
134
+
72
135
  const decodeKey = (key: string) => {
73
136
  try {
74
137
  // remove backslash to avoid decoding error
@@ -129,24 +192,37 @@ export function useResetPassword() {
129
192
  .then(() => {
130
193
  loading.value = false
131
194
  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
- })
195
+ LocalStorage.remove("emailResetPassword")
196
+ LocalStorage.remove("userInfos")
149
197
  })
198
+ .catch(
199
+ (error: {
200
+ response?: {
201
+ status?: number
202
+ data?: { errors?: Array<{ detail?: string; title?: string }> }
203
+ }
204
+ }) => {
205
+ loading.value = false
206
+
207
+ const apiErrorDetail =
208
+ error.response?.data?.errors?.[0]?.detail ||
209
+ error.response?.data?.errors?.[0]?.title
210
+ const apiErrorMessage = translateApiErrorDetail(apiErrorDetail)
211
+
212
+ notify({
213
+ message: apiErrorMessage || t("auth.passwordRecoveryFailed"),
214
+ color: "negative",
215
+ icon: ionAlertCircleOutline,
216
+ position: "top",
217
+ actions: [
218
+ {
219
+ color: "white",
220
+ icon: ionCloseOutline
221
+ }
222
+ ]
223
+ })
224
+ }
225
+ )
150
226
  }
151
227
 
152
228
  const goToLoginPage = () => {
@@ -221,12 +297,15 @@ export function useResetPassword() {
221
297
 
222
298
  return {
223
299
  password,
300
+ confirmPassword,
224
301
  loading,
225
302
  passwordUpdated,
226
303
  showPassword,
227
304
  validations,
305
+ passwordRequirements,
228
306
  canSubmit,
229
307
  LOCAL_LOGIN_ROUTE,
308
+ confirmPasswordInput,
230
309
  onSubmit,
231
310
  goToLoginPage,
232
311
  icons: {
@@ -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
  }