| 1 | <template> |
| 2 | <div> |
| 3 | <n-spin :show="loading"> |
| 4 | <n-form ref="formRef" :model :rules> |
| 5 | <n-steps :current="wizardCurrent" vertical> |
| 6 | <n-step title="Account"> |
| 7 | <div v-show="wizardCurrent === 1" class="pt-3"> |
| 8 | <n-form-item path="email" label="Email" first> |
| 9 | <n-input |
| 10 | v-model:value="model.email" |
| 11 | size="large" |
| 12 | placeholder="Email..." |
| 13 | @keydown.enter="signUp" |
| 14 | /> |
| 15 | </n-form-item> |
| 16 | <n-form-item path="password" label="Password" first> |
| 17 | <n-input |
| 18 | v-model:value="model.password" |
| 19 | type="password" |
| 20 | size="large" |
| 21 | show-password-on="click" |
| 22 | placeholder="At least 8 characters" |
| 23 | @keydown.enter="signUp" |
| 24 | /> |
| 25 | </n-form-item> |
| 26 | <n-form-item path="confirmPassword" label="Confirm Password" first> |
| 27 | <n-input |
| 28 | v-model:value="model.confirmPassword" |
| 29 | type="password" |
| 30 | :disabled="!model.password" |
| 31 | size="large" |
| 32 | show-password-on="click" |
| 33 | placeholder="At least 8 characters" |
| 34 | @keydown.enter="signUp" |
| 35 | /> |
| 36 | </n-form-item> |
| 37 | <div class="mt-3 flex items-center justify-between gap-3"> |
| 38 | <n-button secondary size="large" @click="reset(1)">Reset</n-button> |
| 39 | |
| 40 | <n-button |
| 41 | type="primary" |
| 42 | size="large" |
| 43 | icon-placement="right" |
| 44 | :disabled="!accountStepValid" |
| 45 | @click="wizardCurrent = 2" |
| 46 | > |
| 47 | <template #icon> |
| 48 | <Icon :name="ArrowRightIcon" /> |
| 49 | </template> |
| 50 | Next |
| 51 | </n-button> |
| 52 | </div> |
| 53 | </div> |
| 54 | </n-step> |
| 55 | <n-step title="Details"> |
| 56 | <div v-show="wizardCurrent === 2" class="pt-3"> |
| 57 | <n-form-item path="username" label="Username" first> |
| 58 | <n-input |
| 59 | v-model:value="model.username" |
| 60 | size="large" |
| 61 | placeholder="Username..." |
| 62 | @keydown.enter="signUp" |
| 63 | /> |
| 64 | </n-form-item> |
| 65 | <n-form-item path="role" label="Role"> |
| 66 | <n-select |
| 67 | v-model:value="model.role" |
| 68 | :options="roleOptions" |
| 69 | placeholder="Choose a role" |
| 70 | size="large" |
| 71 | to="body" |
| 72 | @keydown.enter="signUp" |
| 73 | /> |
| 74 | </n-form-item> |
| 75 | |
| 76 | <!-- |
| 77 | <div class="propic flex gap-5 mb-5"> |
| 78 | <div class="avatar"> |
| 79 | <n-avatar :size="80" :src="model.propic" round /> |
| 80 | </div> |
| 81 | <div class="buttons flex flex-col gap-2 justify-center"> |
| 82 | <ImageCropper |
| 83 | v-slot="{ openCropper }" |
| 84 | @crop="setCroppedImage" |
| 85 | shape="circle" |
| 86 | :placeholder="'Select your profile picture'" |
| 87 | > |
| 88 | <n-button type="primary" @click="openCropper()" size="small"> |
| 89 | <template #icon> |
| 90 | <Icon :name="ImageIcon"/> |
| 91 | </template> |
| 92 | {{ model.propic ? "Edit" : "Add" }} Photo |
| 93 | </n-button> |
| 94 | </ImageCropper> |
| 95 | <n-button @click="model.propic = ''" v-if="model.propic" size="small"> |
| 96 | <template #icon> |
| 97 | <Icon :name="RemoveImageIcon"/> |
| 98 | </template> |
| 99 | Remove Photo |
| 100 | </n-button> |
| 101 | </div> |
| 102 | </div> |
| 103 | <n-form-item path="firstName" label="First Name"> |
| 104 | <n-input |
| 105 | v-model:value="model.firstName" |
| 106 | @keydown.enter="signUp" |
| 107 | size="large" |
| 108 | placeholder="First Name..." |
| 109 | /> |
| 110 | </n-form-item> |
| 111 | <n-form-item path="lastName" label="Last Name"> |
| 112 | <n-input |
| 113 | v-model:value="model.lastName" |
| 114 | @keydown.enter="signUp" |
| 115 | size="large" |
| 116 | placeholder="Last Name..." |
| 117 | /> |
| 118 | </n-form-item> |
| 119 | <n-form-item path="customerCode" label="Customer Code"> |
| 120 | <n-input |
| 121 | v-model:value="model.customerCode" |
| 122 | @keydown.enter="signUp" |
| 123 | size="large" |
| 124 | placeholder="Customer Code..." |
| 125 | /> |
| 126 | </n-form-item> |
| 127 | --> |
| 128 | |
| 129 | <div class="mt-3 flex items-center justify-between gap-3"> |
| 130 | <div class="flex items-center justify-between gap-3"> |
| 131 | <n-button size="large" @click="wizardCurrent = 1"> |
| 132 | <template #icon> |
| 133 | <Icon :name="ArrowLeftIcon" /> |
| 134 | </template> |
| 135 | Back |
| 136 | </n-button> |
| 137 | <n-button secondary size="large" @click="reset(2)">Reset</n-button> |
| 138 | </div> |
| 139 | <n-button |
| 140 | type="success" |
| 141 | size="large" |
| 142 | :loading |
| 143 | :disabled="!accountStepValid || !detailsStepValid" |
| 144 | @click="signUp" |
| 145 | > |
| 146 | <template #icon> |
| 147 | <Icon :name="UserAddIcon" /> |
| 148 | </template> |
| 149 | Create account |
| 150 | </n-button> |
| 151 | </div> |
| 152 | </div> |
| 153 | </n-step> |
| 154 | </n-steps> |
| 155 | </n-form> |
| 156 | </n-spin> |
| 157 | </div> |
| 158 | </template> |
| 159 | |
| 160 | <script lang="ts" setup> |
| 161 | // TODO-FE: refactor |
| 162 | import type { FormInst, FormItemRule, FormRules, FormValidationError } from "naive-ui" |
| 163 | import type { SelectBaseOption } from "naive-ui/es/select/src/interface" |
| 164 | import type { RegisterPayload } from "@/types/auth.d" |
| 165 | import _trim from "lodash/trim" |
| 166 | import { NButton, NForm, NFormItem, NInput, NSelect, NSpin, NStep, NSteps, useMessage } from "naive-ui" |
| 167 | import PasswordValidator from "password-validator" |
| 168 | import isEmail from "validator/es/lib/isEmail" |
| 169 | import { computed, ref } from "vue" |
| 170 | import Api from "@/api" |
| 171 | import Icon from "@/components/common/Icon.vue" |
| 172 | import { AuthUserRole } from "@/types/auth.d" |
| 173 | // import ImageCropper, { type ImageCropperResult } from "@/components/common/ImageCropper.vue" |
| 174 | |
| 175 | interface ModelType { |
| 176 | email: string | null |
| 177 | password: string | null |
| 178 | username: string | null |
| 179 | confirmPassword: string | null |
| 180 | role: number | null |
| 181 | /* |
| 182 | customerCode: string | null |
| 183 | firstName: string | null |
| 184 | lastName: string | null |
| 185 | propic: string | null |
| 186 | */ |
| 187 | } |
| 188 | |
| 189 | const { unavailableUsernameList, unavailableEmailList } = defineProps<{ |
| 190 | unavailableUsernameList?: string[] |
| 191 | unavailableEmailList?: string[] |
| 192 | }>() |
| 193 | |
| 194 | const emit = defineEmits<{ |
| 195 | (e: "success"): void |
| 196 | }>() |
| 197 | |
| 198 | // const ImageIcon = "carbon:image" |
| 199 | // const RemoveImageIcon = "carbon:no-image" |
| 200 | const ArrowRightIcon = "carbon:arrow-right" |
| 201 | const ArrowLeftIcon = "carbon:arrow-left" |
| 202 | const UserAddIcon = "carbon:user-admin" |
| 203 | |
| 204 | const wizardCurrent = ref(1) |
| 205 | const loading = ref(false) |
| 206 | const formRef = ref<FormInst | null>(null) |
| 207 | const message = useMessage() |
| 208 | const model = ref<ModelType>(getModel()) |
| 209 | |
| 210 | const roleOptions: SelectBaseOption[] = Object.values(AuthUserRole) |
| 211 | .filter(o => typeof o === "number" && o !== 0) |
| 212 | .map(o => ({ |
| 213 | label: `${Object.entries(AuthUserRole).find(e => e[1] === o)?.[0]}`, |
| 214 | value: o |
| 215 | })) |
| 216 | |
| 217 | const accountStepValid = computed( |
| 218 | () => |
| 219 | !!_trim(model.value.email || "") && |
| 220 | !!model.value.password && |
| 221 | !!model.value.confirmPassword && |
| 222 | model.value.password === model.value.confirmPassword |
| 223 | ) |
| 224 | const detailsStepValid = computed(() => !!_trim(model.value.username || "") && !!model.value.role) |
| 225 | // const detailsStepValid = computed(() => !!model.value.customerCode && !!model.value.firstName && !!model.value.lastName) |
| 226 | |
| 227 | const passwordSchema = new PasswordValidator() |
| 228 | passwordSchema |
| 229 | .is() |
| 230 | .min(8) // Minimum length 8 |
| 231 | .is() |
| 232 | .max(100) // Maximum length 100 |
| 233 | .has() |
| 234 | .uppercase() // Must have uppercase letters |
| 235 | .has() |
| 236 | .lowercase() // Must have lowercase letters |
| 237 | .has() |
| 238 | .digits(1) // Must have at least 1 digit |
| 239 | .has() |
| 240 | .symbols(1) // Must have at least 1 symbol |
| 241 | |
| 242 | const rules: FormRules = { |
| 243 | email: [ |
| 244 | { |
| 245 | required: true, |
| 246 | trigger: ["blur", "input"], |
| 247 | message: "Email is required" |
| 248 | }, |
| 249 | { |
| 250 | validator: (_rule: FormItemRule, value: string): boolean => { |
| 251 | return isEmail(value) |
| 252 | }, |
| 253 | message: "The email is not formatted correctly", |
| 254 | trigger: ["blur", "input"] |
| 255 | }, |
| 256 | { |
| 257 | validator: (_rule: FormItemRule, value: string): boolean => { |
| 258 | return !unavailableEmailList?.length || !unavailableEmailList.includes(value) |
| 259 | }, |
| 260 | message: "The Email is already used", |
| 261 | trigger: ["blur", "input"] |
| 262 | } |
| 263 | ], |
| 264 | password: [ |
| 265 | { |
| 266 | required: true, |
| 267 | trigger: ["blur"], |
| 268 | message: "Password is required" |
| 269 | }, |
| 270 | { |
| 271 | validator: (_rule: FormItemRule, value: string): boolean => { |
| 272 | return !!passwordSchema.validate(value, { details: false }) |
| 273 | }, |
| 274 | message: |
| 275 | "The string should have a minimum length of 8 characters, minimum of 1 uppercase and lowercase letter, minimum of 1 digit and 1 symbol", |
| 276 | trigger: ["blur"] |
| 277 | } |
| 278 | ], |
| 279 | confirmPassword: [ |
| 280 | { |
| 281 | required: true, |
| 282 | trigger: ["blur", "input"], |
| 283 | message: "Confirm Password is required" |
| 284 | }, |
| 285 | { |
| 286 | validator: (_rule: FormItemRule, value: string): boolean => { |
| 287 | return value === model.value.password |
| 288 | }, |
| 289 | message: "Password is not same as re-entered password", |
| 290 | trigger: ["blur"] |
| 291 | } |
| 292 | ], |
| 293 | username: [ |
| 294 | { |
| 295 | required: true, |
| 296 | trigger: ["blur", "input"], |
| 297 | message: "Username is required" |
| 298 | }, |
| 299 | { |
| 300 | validator: (_rule: FormItemRule, value: string): boolean => { |
| 301 | return !unavailableUsernameList?.length || !unavailableUsernameList.includes(value) |
| 302 | }, |
| 303 | message: "The Username is already used", |
| 304 | trigger: ["blur", "input"] |
| 305 | } |
| 306 | ], |
| 307 | role: [ |
| 308 | { |
| 309 | required: true, |
| 310 | trigger: ["blur"], |
| 311 | type: "number", |
| 312 | message: "Role is required" |
| 313 | } |
| 314 | ] |
| 315 | /* |
| 316 | customerCode: [ |
| 317 | { |
| 318 | required: true, |
| 319 | trigger: ["blur"], |
| 320 | message: "Customer Code is required" |
| 321 | } |
| 322 | ], |
| 323 | firstName: [ |
| 324 | { |
| 325 | required: true, |
| 326 | trigger: ["blur"], |
| 327 | message: "First Name is required" |
| 328 | } |
| 329 | ], |
| 330 | lastName: [ |
| 331 | { |
| 332 | required: true, |
| 333 | trigger: ["blur"], |
| 334 | message: "Last Name is required" |
| 335 | } |
| 336 | ] |
| 337 | */ |
| 338 | } |
| 339 | |
| 340 | function getModel(): ModelType { |
| 341 | return { |
| 342 | email: null, |
| 343 | password: null, |
| 344 | confirmPassword: null, |
| 345 | username: null, |
| 346 | role: null |
| 347 | /* |
| 348 | customerCode: null, |
| 349 | firstName: null, |
| 350 | lastName: null, |
| 351 | propic: null |
| 352 | */ |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | function reset(step?: number) { |
| 357 | switch (step) { |
| 358 | case 1: |
| 359 | model.value.email = null |
| 360 | model.value.password = null |
| 361 | model.value.confirmPassword = null |
| 362 | break |
| 363 | case 2: |
| 364 | model.value.username = null |
| 365 | model.value.role = null |
| 366 | break |
| 367 | default: |
| 368 | model.value = getModel() |
| 369 | wizardCurrent.value = 1 |
| 370 | break |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | function signUp(e: Event) { |
| 375 | e.preventDefault() |
| 376 | formRef.value?.validate((errors: Array<FormValidationError> | undefined) => { |
| 377 | if (!errors) { |
| 378 | loading.value = true |
| 379 | |
| 380 | const payload: RegisterPayload = { |
| 381 | password: model.value.password || "", |
| 382 | email: _trim(model.value.email || ""), |
| 383 | username: _trim(model.value.username || ""), |
| 384 | role_id: model.value.role || 1 |
| 385 | /* |
| 386 | customerCode: model.value.customerCode, |
| 387 | usersFirstName: model.value.firstName, |
| 388 | usersLastName: model.value.lastName, |
| 389 | usersEmail: model.value.email, |
| 390 | usersRole: "admin", |
| 391 | imageFile: model.value.propic, |
| 392 | notifications: 0, |
| 393 | */ |
| 394 | } |
| 395 | |
| 396 | Api.auth |
| 397 | .register(payload) |
| 398 | .then(res => { |
| 399 | if (res.data.success) { |
| 400 | message.success("User registered successfully.") |
| 401 | reset() |
| 402 | emit("success") |
| 403 | } else { |
| 404 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 405 | } |
| 406 | }) |
| 407 | .catch(err => { |
| 408 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 409 | }) |
| 410 | .finally(() => { |
| 411 | loading.value = false |
| 412 | }) |
| 413 | } else { |
| 414 | for (const err of errors) { |
| 415 | message.error(err[0]?.message ?? "Invalid fields") |
| 416 | } |
| 417 | } |
| 418 | }) |
| 419 | } |
| 420 | /* |
| 421 | function setCroppedImage(result: ImageCropperResult) { |
| 422 | const canvas = result.canvas as HTMLCanvasElement |
| 423 | // model.value.propic = canvas.toDataURL() |
| 424 | } |
| 425 | */ |
| 426 | </script> |