From 544ee2269f726c6e684d195831cb495e7ddd39f0 Mon Sep 17 00:00:00 2001 From: Aditya Jindal Date: Sat, 11 Jan 2025 08:53:24 +0530 Subject: [PATCH] Fix: Switch to shadcn form in userResetPassword (#9708) --- public/locale/en.json | 38 +- src/Utils/request/api.tsx | 2 +- src/Utils/request/errorHandler.ts | 32 +- src/Utils/request/types.ts | 4 +- src/components/Auth/Login.tsx | 4 +- src/components/Auth/ResetPassword.tsx | 182 +++++---- src/components/Form/Form.tsx | 169 --------- src/components/Form/FormContext.ts | 19 - src/components/Users/CreateUserForm.tsx | 134 ++++--- src/components/Users/UserFormValidations.tsx | 35 +- src/components/Users/UserResetPassword.tsx | 377 ++++++++++--------- src/components/ui/input-password.tsx | 35 ++ src/components/ui/input.tsx | 1 + src/pluginTypes.ts | 17 - 14 files changed, 479 insertions(+), 570 deletions(-) delete mode 100644 src/components/Form/Form.tsx delete mode 100644 src/components/Form/FormContext.ts create mode 100644 src/components/ui/input-password.tsx diff --git a/public/locale/en.json b/public/locale/en.json index 3666e114a8c..3b789e63ef3 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -510,7 +510,6 @@ "change_avatar": "Change Avatar", "change_avatar_note": "JPG, GIF or PNG. 1MB max.", "change_file": "Change File", - "change_password": "Change Password", "change_phone_number": "Change Phone Number", "change_status": "Change Status", "chat_on_whatsapp": "Chat on Whatsapp", @@ -586,7 +585,6 @@ "confirm_delete": "Confirm Delete", "confirm_discontinue": "Confirm Discontinue", "confirm_password": "Confirm Password", - "confirm_password_required": "Confirm password is required", "confirm_transfer_complete": "Confirm Transfer Complete!", "confirm_unavailability": "Confirm Unavailability", "confirmed": "Confirmed", @@ -1012,7 +1010,6 @@ "filter_by_category": "Filter by category", "filters": "Filters", "first_name": "First Name", - "first_name_required": "First Name is required", "footer_body": "Open Healthcare Network is an open-source public utility designed by a multi-disciplinary team of innovators and volunteers. Open Healthcare Network CARE is a Digital Public Good recognised by the United Nations.", "forget_password": "Forgot password?", "forget_password_instruction": "Enter your username, and if it exists, we will send you a link to reset your password.", @@ -1102,6 +1099,7 @@ "invalid_asset_id_msg": "Oops! The asset ID you entered does not appear to be valid.", "invalid_date_format": "Invalid date format, expected {{format}}", "invalid_email": "Please enter a valid email address", + "invalid_email_address": "Invalid email address", "invalid_ip_address": "Invalid IP Address", "invalid_link_msg": "It appears that the password reset link you have used is either invalid or expired. Please request a new password reset link.", "invalid_otp": "Invalid OTP, Please check the OPT and try Again", @@ -1117,7 +1115,6 @@ "invalid_url_http_https": "URL should start with http:// or https://", "invalid_url_javascript": "URL should not include javascript, please enter a valid URL.", "invalid_username": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - "invalid_username_format": "Please enter a 4-16 characters long username with lowercase letters, digits and . _ - only and it should not start or end with . _ -", "inventory_management": "Inventory Management", "investigation_report": "Investigation Report", "investigation_report_for_{{name}}": "Investigation Report for {{name}}", @@ -1164,7 +1161,6 @@ "last_modified": "Last Modified", "last_modified_by": "Last Modified By", "last_name": "Last Name", - "last_name_required": "Last Name is required", "last_online": "Last Online", "last_serviced_on": "Last Serviced On", "last_updated_by": "Last updated by", @@ -1284,7 +1280,8 @@ "never_logged_in": "Never Logged In", "new_password": "New Password", "new_password_confirmation": "Confirm New Password", - "new_password_same_as_old": "New password is same as old password, please enter a different new password.", + "new_password_different_from_old": "Your new password is different from the old password.", + "new_password_same_as_old": "Your new password must not match the old password.", "new_password_validation": "New password is not valid.", "new_session": "New Session", "next_fortnight_short": "Next 2wk", @@ -1382,6 +1379,7 @@ "occupancy": "Occupancy", "occupation": "Occupation", "occupied": "Occupied", + "old_password": "Current Password", "on": "on", "on_emergency_basis": " on emergency basis", "ongoing_medications": "Ongoing Medications", @@ -1412,17 +1410,22 @@ "pain_chart_description": "Mark region and intensity of pain", "passport_number": "Passport Number", "password": "Password", - "password_length_validation": "Password must be at least 8 characters long", - "password_lowercase_validation": "Password must contain at least one lowercase letter (a-z)", - "password_mismatch": "New password and confirm password must be the same.", - "password_number_validation": "Password must contain at least one number (0-9)", + "password_length_met": "It’s at least 8 characters long", + "password_length_validation": "Use at least 8 characters", + "password_lowercase_met": "It includes at least one lowercase letter", + "password_lowercase_validation": "Include at least one lowercase letter", + "password_mismatch": "Passwords do not match", + "password_number_met": "It includes at least one number.", + "password_number_validation": "Include at least one number.", "password_required": "Password is required", "password_reset_failure": "Password Reset Failed", "password_reset_success": "Password Reset successfully", "password_sent": "Password Reset Email Sent", "password_update_error": "Error while updating password. Try again later.", - "password_uppercase_validation": "Password must contain at least one uppercase letter (A-Z)", - "password_validation": "Password must contain at least: 8 characters, 1 uppercase letter (A-Z), 1 lowercase letter (a-z), and 1 number (0-9)", + "password_updated": "Password updated successfully", + "password_uppercase_met": "It includes at least one uppercase letter.", + "password_uppercase_validation": "Include at least one uppercase letter.", + "passwords_match": "Passwords match.", "patient": "Patient", "patient-notes": "Notes", "patient__general-info": "General Info", @@ -1492,6 +1495,7 @@ "phone_number_at_current_facility": "Phone Number of Contact person at current Facility", "phone_number_min_error": "Phone number must be at least 10 characters long", "phone_number_not_found": "Phone number not found", + "phone_number_validation": "Phone number must start with +91 followed by 10 digits", "phone_number_verified": "Phone Number Verified", "pincode": "Pincode", "pincode_autofill": "State and District auto-filled from Pincode", @@ -1501,6 +1505,7 @@ "please_check_your_messages": "Please check your messages", "please_confirm_password": "Please confirm your new password.", "please_enter_a_reason_for_the_shift": "Please enter a reason for the shift.", + "please_enter_confirm_password": "Please confirm your new password", "please_enter_current_password": "Please enter your current password.", "please_enter_new_password": "Please enter your new password.", "please_enter_username": "Please enter the username", @@ -1948,6 +1953,7 @@ "update_facility": "Update Facility", "update_facility_middleware_success": "Facility middleware updated successfully", "update_log": "Update Log", + "update_password": "Update Password", "update_patient_details": "Update Patient Details", "update_preset": "Update Preset", "update_preset_position_to_current": "Update preset's position to camera's current position", @@ -1962,7 +1968,7 @@ "updated": "Updated", "updated_on": "Updated On", "updates": "Updates", - "updating": "Updating", + "updating": "Updating...", "upload": "Upload", "upload_an_image": "Upload an image", "upload_file": "Upload File", @@ -1993,8 +1999,14 @@ "username": "Username", "username_already_exists": "This username already exists", "username_available": "Username is available", + "username_characters_validation": "Only lowercase letters, numbers, and . _ - are allowed", + "username_consecutive_validation": "Cannot contain consecutive special characters", + "username_max_length_validation": "Use at most 16 characters", + "username_min_length_validation": "Use at least 4 characters", "username_not_available": "Username is not available", + "username_start_end_validation": "Must start and end with a letter or number", "username_userdetails_not_found": "Unable to fetch details as username or user details not found", + "username_valid": "Username is valid", "users": "Users", "vacant": "Vacant", "vaccinated": "Vaccinated", diff --git a/src/Utils/request/api.tsx b/src/Utils/request/api.tsx index 773aba670a1..940e5c383dc 100644 --- a/src/Utils/request/api.tsx +++ b/src/Utils/request/api.tsx @@ -173,7 +173,7 @@ const routes = { updatePassword: { path: "/api/v1/password_change/", method: "PUT", - TRes: Type>(), + TRes: Type<{ message: string }>(), TBody: Type(), }, // User Endpoints diff --git a/src/Utils/request/errorHandler.ts b/src/Utils/request/errorHandler.ts index 73363218800..e83973cfeeb 100644 --- a/src/Utils/request/errorHandler.ts +++ b/src/Utils/request/errorHandler.ts @@ -3,7 +3,7 @@ import { navigate } from "raviger"; import { toast } from "sonner"; import * as Notifications from "@/Utils/Notifications"; -import { HTTPError } from "@/Utils/request/types"; +import { HTTPError, StructuredError } from "@/Utils/request/types"; export function handleHttpError(error: Error) { if (error.name === "AbortError") { @@ -37,6 +37,12 @@ export function handleHttpError(error: Error) { handlePydanticErrors(errs); return; } + + if (isStructuredError(cause)) { + handleStructuredErrors(cause); + return; + } + Notifications.BadRequest({ errs }); return; } @@ -70,11 +76,28 @@ function isNotFound(error: HTTPError) { type PydanticError = { type: string; loc?: string[]; - msg: string; + msg: string | Record; input?: unknown; url?: string; }; +function isStructuredError(err: HTTPError["cause"]): err is StructuredError { + return typeof err === "object" && !Array.isArray(err); +} + +function handleStructuredErrors(cause: StructuredError) { + for (const value of Object.values(cause)) { + if (Array.isArray(value)) { + value.forEach((err) => toast.error(err)); + return; + } + if (typeof value === "string") { + toast.error(value); + return; + } + } +} + function isPydanticError(errors: unknown): errors is PydanticError[] { return ( Array.isArray(errors) && @@ -86,14 +109,15 @@ function isPydanticError(errors: unknown): errors is PydanticError[] { function handlePydanticErrors(errors: PydanticError[]) { errors.map(({ type, loc, msg }) => { + const message = typeof msg === "string" ? msg : Object.values(msg)[0]; if (!loc) { - toast.error(msg); + toast.error(message); return; } type = type .replace("_", " ") .replace(/\b\w/g, (char) => char.toUpperCase()); - toast.error(msg, { + toast.error(message, { description: `${type}: '${loc.join(".")}'`, duration: 8000, }); diff --git a/src/Utils/request/types.ts b/src/Utils/request/types.ts index bdb1038ed38..e63b5430c60 100644 --- a/src/Utils/request/types.ts +++ b/src/Utils/request/types.ts @@ -51,7 +51,9 @@ export interface APICallOptions { headers?: HeadersInit; } -type HTTPErrorCause = Record | undefined; +export type StructuredError = Record; + +type HTTPErrorCause = StructuredError | Record | undefined; export class HTTPError extends Error { status: number; diff --git a/src/components/Auth/Login.tsx b/src/components/Auth/Login.tsx index 1bd8747e66d..9fcd37a55ac 100644 --- a/src/components/Auth/Login.tsx +++ b/src/components/Auth/Login.tsx @@ -19,6 +19,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/input-password"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -515,10 +516,9 @@ const Login = (props: LoginProps) => {
- { const { t } = useTranslation(); const handleChange = (e: any) => { - const { value, name } = e; + const { value, name } = e.target; const fieldValue = Object.assign({}, form); const errorField = Object.assign({}, errors); if (errorField[name]) { @@ -107,89 +107,105 @@ const ResetPassword = (props: ResetPasswordProps) => { }, []); return ( -
-
-
-
{ - handleSubmit(e); - }} - > -
- {t("reset_password")} -
-
- setPasswordInputInFocus(true)} - onBlur={() => setPasswordInputInFocus(false)} - /> - {passwordInputInFocus && ( -
- {validateRule( - form.password?.length >= 8, - t("password_length_validation"), - !form.password, - )} - {validateRule( - form.password !== form.password.toUpperCase(), - t("password_lowercase_validation"), - !form.password, - )} - {validateRule( - form.password !== form.password.toLowerCase(), - t("password_uppercase_validation"), - !form.password, - )} - {validateRule( - /\d/.test(form.password), - t("password_number_validation"), - !form.password, - )} -
- )} - setConfirmPasswordInputInFocus(true)} - onBlur={() => setConfirmPasswordInputInFocus(false)} - /> - {confirmPasswordInputInFocus && - form.confirm.length > 0 && - form.password.length > 0 && - validateRule( - form.confirm === form.password, - t("password_mismatch"), - !form.password && form.password.length > 0, +
+ { + handleSubmit(e); + }} + > +
+ {t("reset_password")} +
+ +
+
+ setPasswordInputInFocus(true)} + onBlur={() => setPasswordInputInFocus(false)} + /> + {errors.password && ( +
+ {errors.password} +
+ )} + {passwordInputInFocus && ( +
+ {validateRule( + form.password?.length >= 8, + t("password_length_validation"), + !form.password, + t("password_length_met"), + )} + {validateRule( + form.password !== form.password.toUpperCase(), + t("password_lowercase_validation"), + !form.password, + t("password_lowercase_met"), + )} + {validateRule( + form.password !== form.password.toLowerCase(), + t("password_uppercase_validation"), + !form.password, + t("password_uppercase_met"), + )} + {validateRule( + /\d/.test(form.password), + t("password_number_validation"), + !form.password, + t("password_number_met"), )} -
-
- - -
- +
+ )} +
+ +
+ setConfirmPasswordInputInFocus(true)} + onBlur={() => setConfirmPasswordInputInFocus(false)} + /> + {errors.confirm && ( +
+ {errors.confirm} +
+ )} + {confirmPasswordInputInFocus && + form.confirm.length > 0 && + form.password.length > 0 && + validateRule( + form.confirm === form.password, + t("password_mismatch"), + !form.password && form.password.length > 0, + t("password_match"), + )} +
+
+ +
+ +
-
+
); }; diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx deleted file mode 100644 index 374bec7cf3a..00000000000 --- a/src/components/Form/Form.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; - -import { FieldValidator } from "@/components/Form/FieldValidators"; -import { - FormContextValue, - createFormContext, -} from "@/components/Form/FormContext"; -import { FieldChangeEvent } from "@/components/Form/FormFields/Utils"; -import { - FormDetails, - FormErrors, - FormState, - formReducer, -} from "@/components/Form/Utils"; - -import { DraftSection, useAutoSaveReducer } from "@/Utils/AutoSave"; -import { classNames, isEmpty, omitBy } from "@/Utils/utils"; - -type Props = { - className?: string; - defaults: T; - asyncGetDefaults?: (() => Promise) | false; - validate?: (form: T) => FormErrors; - onSubmit: (form: T) => Promise | void>; - onCancel?: () => void; - noPadding?: true; - disabled?: boolean; - submitLabel?: string; - cancelLabel?: string; - onDraftRestore?: (newState: FormState) => void; - children: (props: FormContextValue) => React.ReactNode; - hideRestoreDraft?: boolean; - resetFormValsOnCancel?: boolean; - resetFormValsOnSubmit?: boolean; - hideCancelButton?: boolean; - submitButtonClassName?: string; - hideSubmitButton?: boolean; - disableMarginOnChildren?: boolean; -}; - -const Form = ({ - asyncGetDefaults, - validate, - hideCancelButton = false, - hideSubmitButton = false, - disableMarginOnChildren = false, - ...props -}: Props) => { - const initial = { form: props.defaults, errors: {} }; - const [isLoading, setIsLoading] = useState(!!asyncGetDefaults); - const [state, dispatch] = useAutoSaveReducer(formReducer, initial); - const formVals = useRef(props.defaults); - - useEffect(() => { - if (!asyncGetDefaults) return; - - asyncGetDefaults().then((form) => { - dispatch({ type: "set_form", form }); - setIsLoading(false); - }); - }, [asyncGetDefaults]); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - event.stopPropagation(); - - if (validate) { - const errors = omitBy(validate(state.form), isEmpty) as FormErrors; - - if (Object.keys(errors).length) { - dispatch({ type: "set_errors", errors }); - - if (errors.$all) { - toast.error(errors.$all); - } - return; - } - } - - const errors = await props.onSubmit(state.form); - if (errors) { - dispatch({ - type: "set_errors", - errors: { ...state.errors, ...errors }, - }); - } else if (props.resetFormValsOnSubmit) { - dispatch({ type: "set_form", form: formVals.current }); - } - }; - - const handleCancel = () => { - if (props.resetFormValsOnCancel) { - dispatch({ type: "set_form", form: formVals.current }); - } - props.onCancel?.(); - }; - - const { Provider, Consumer } = useMemo(() => createFormContext(), []); - const disabled = isLoading || props.disabled; - - return ( -
- ) => { - dispatch({ type: "set_state", state: newState }); - props.onDraftRestore?.(newState); - }} - formData={state.form} - hidden={props.hideRestoreDraft} - > - ) => { - return { - name, - id: name, - onChange: ({ name, value }: FieldChangeEvent) => - dispatch({ - type: "set_field", - name, - value, - error: validate?.(value), - }), - value: state.form[name], - error: state.errors[name], - disabled, - }; - }} - > -
- {props.children} -
- {(!hideCancelButton || !hideSubmitButton) && ( -
- {!hideCancelButton && ( - - )} - {!hideSubmitButton && ( - - )} -
- )} -
-
-
- ); -}; - -export default Form; diff --git a/src/components/Form/FormContext.ts b/src/components/Form/FormContext.ts deleted file mode 100644 index 2be5c12234c..00000000000 --- a/src/components/Form/FormContext.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext } from "react"; - -import { FieldError, FieldValidator } from "@/components/Form/FieldValidators"; -import { FormDetails } from "@/components/Form/Utils"; - -export type FormContextValue = ( - name: keyof T, - validate?: FieldValidator, - excludeFromDraft?: boolean, -) => { - id: keyof T; - name: keyof T; - onChange: any; - value: any; - error: FieldError | undefined; -}; - -export const createFormContext = () => - createContext>(undefined as any); diff --git a/src/components/Users/CreateUserForm.tsx b/src/components/Users/CreateUserForm.tsx index 93d3eeb8d51..da7273b0fe1 100644 --- a/src/components/Users/CreateUserForm.tsx +++ b/src/components/Users/CreateUserForm.tsx @@ -19,6 +19,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/input-password"; import { Select, SelectContent, @@ -38,63 +39,6 @@ import { UserBase } from "@/types/user/user"; import UserApi from "@/types/user/userApi"; import userApi from "@/types/user/userApi"; -const userFormSchema = z - .object({ - user_type: z.enum(["doctor", "nurse", "staff", "volunteer"]), - username: z - .string() - .min(4, "Username must be at least 4 characters") - .max(16, "Username must be less than 16 characters") - .regex( - /^[a-z0-9._-]*$/, - "Username can only contain lowercase letters, numbers, and . _ -", - ) - .regex( - /^[a-z0-9].*[a-z0-9]$/, - "Username must start and end with a letter or number", - ) - .refine( - (val) => !val.match(/(?:[._-]{2,})/), - "Username can't contain consecutive special characters", - ), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[a-z]/, "Password must contain at least one lowercase letter") - .regex(/[A-Z]/, "Password must contain at least one uppercase letter") - .regex(/[0-9]/, "Password must contain at least one number"), - c_password: z.string(), - first_name: z.string().min(1, "First name is required"), - last_name: z.string().min(1, "Last name is required"), - email: z.string().email("Invalid email address"), - phone_number: z - .string() - .regex( - /^\+91[0-9]{10}$/, - "Phone number must start with +91 followed by 10 digits", - ), - alt_phone_number: z - .string() - .regex( - /^\+91[0-9]{10}$/, - "Phone number must start with +91 followed by 10 digits", - ) - .optional(), - phone_number_is_whatsapp: z.boolean().default(true), - date_of_birth: z.string().min(1, "Date of birth is required"), - gender: z.enum(["male", "female", "other"]), - qualification: z.string().optional(), - doctor_experience_commenced_on: z.string().optional(), - doctor_medical_council_registration: z.string().optional(), - geo_organization: z.string().min(1, "Organization is required"), - }) - .refine((data) => data.password === data.c_password, { - message: "Passwords don't match", - path: ["c_password"], - }); - -type UserFormValues = z.infer; - interface Props { onSubmitSuccess?: (user: UserBase) => void; } @@ -102,6 +46,51 @@ interface Props { export default function CreateUserForm({ onSubmitSuccess }: Props) { const { t } = useTranslation(); + const userFormSchema = z + .object({ + user_type: z.enum(["doctor", "nurse", "staff", "volunteer"]), + username: z + .string() + .min(4, t("username_min_length_validation")) + .max(16, t("username_max_length_validation")) + .regex(/^[a-z0-9._-]*$/, t("username_characters_validation")) + .regex(/^[a-z0-9].*[a-z0-9]$/, t("username_start_end_validation")) + .refine( + (val) => !val.match(/(?:[._-]{2,})/), + t("username_consecutive_validation"), + ), + password: z + .string() + .min(8, t("password_length_validation")) + .regex(/[a-z]/, t("password_lowercase_validation")) + .regex(/[A-Z]/, t("password_uppercase_validation")) + .regex(/[0-9]/, t("password_number_validation")), + c_password: z.string(), + first_name: z.string().min(1, t("field_required")), + last_name: z.string().min(1, t("field_required")), + email: z.string().email(t("invalid_email_address")), + phone_number: z + .string() + .regex(/^\+91[0-9]{10}$/, t("phone_number_validation")), + alt_phone_number: z + .string() + .regex(/^\+91[0-9]{10}$/, t("phone_number_validation")) + .optional(), + phone_number_is_whatsapp: z.boolean().default(true), + date_of_birth: z.string().min(1, t("field_required")), + gender: z.enum(["male", "female", "other"]), + qualification: z.string().optional(), + doctor_experience_commenced_on: z.string().optional(), + doctor_medical_council_registration: z.string().optional(), + geo_organization: z.string().min(1, t("field_required")), + }) + .refine((data) => data.password === data.c_password, { + message: t("password_mismatch"), + path: ["c_password"], + }); + + type UserFormValues = z.infer; + const form = useForm({ resolver: zodResolver(userFormSchema), defaultValues: { @@ -127,7 +116,7 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { } }, [phoneNumber, isWhatsApp, form, usernameInput]); - const { error, isLoading } = useQuery({ + const { isLoading: isUsernameChecking, isError: isUsernameTaken } = useQuery({ queryKey: ["checkUsername", usernameInput], queryFn: query(userApi.checkUsername, { pathParams: { username: usernameInput }, @@ -140,9 +129,16 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { const { errors: { username }, } = form.formState; + const isInitialRender = usernameInput === ""; + if (username?.message) { - return validateRule(false, username.message); - } else if (isLoading) { + return validateRule( + false, + username.message, + isInitialRender, + t("username_valid"), + ); + } else if (isUsernameChecking) { return (
); - } else if (error) { - return validateRule(false, t("username_not_available")); } else if (usernameInput) { - return validateRule(true, t("username_available")); + return validateRule( + !isUsernameTaken, + t("username_not_available"), + isInitialRender, + t("username_available"), + ); } }; @@ -266,11 +265,7 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { {t("password")} - + @@ -284,8 +279,7 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { {t("confirm_password")} - diff --git a/src/components/Users/UserFormValidations.tsx b/src/components/Users/UserFormValidations.tsx index 9b6e90b92a4..d0a6c06402b 100644 --- a/src/components/Users/UserFormValidations.tsx +++ b/src/components/Users/UserFormValidations.tsx @@ -52,29 +52,34 @@ export const editContactInfoFields: Array = [ ]; export const validateRule = ( - condition: boolean, - content: JSX.Element | string, - isInitialState: boolean = false, + isConditionMet: boolean, + initialMessage: JSX.Element | string, + isInitialRender: boolean = false, + successMessage: JSX.Element | string, ) => { return ( -
- {isInitialState ? ( - - ) : condition ? ( - +
+ {isInitialRender ? ( + + ) : isConditionMet ? ( + ) : ( - + )}{" "} - {content} + {isInitialRender + ? initialMessage + : isConditionMet + ? successMessage + : initialMessage}
); diff --git a/src/components/Users/UserResetPassword.tsx b/src/components/Users/UserResetPassword.tsx index e30155f5eba..5a706832e25 100644 --- a/src/components/Users/UserResetPassword.tsx +++ b/src/components/Users/UserResetPassword.tsx @@ -1,214 +1,239 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; +import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { z } from "zod"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { PasswordInput } from "@/components/ui/input-password"; -import Form from "@/components/Form/Form"; -import TextFormField from "@/components/Form/FormFields/TextFormField"; import { validateRule } from "@/components/Users/UserFormValidations"; import { UpdatePasswordForm } from "@/components/Users/models"; import routes from "@/Utils/request/api"; -import request from "@/Utils/request/request"; +import mutate from "@/Utils/request/mutate"; import { UserBase } from "@/types/user/user"; -interface PasswordForm { - username: string; - old_password: string; - new_password_1: string; - new_password_2: string; -} - export default function UserResetPassword({ userData, }: { userData: UserBase; }) { const { t } = useTranslation(); - const [isSubmitting, setisSubmitting] = useState(false); const [isEditing, setIsEditing] = useState(false); + const [isPasswordFieldFocused, setIsPasswordFieldFocused] = useState(false); + + const PasswordSchema = z + .object({ + old_password: z + .string() + .min(1, { message: t("please_enter_current_password") }), + new_password_1: z + .string() + .min(8, { message: t("invalid_password") }) + .regex(/\d/, { message: t("invalid_password") }) + .regex(/[a-z]/, { + message: t("invalid_password"), + }) + .regex(/[A-Z]/, { + message: t("invalid_password"), + }), + new_password_2: z + .string() + .min(1, { message: t("please_enter_confirm_password") }), + }) + .refine((values) => values.new_password_1 === values.new_password_2, { + message: t("password_mismatch"), + path: ["new_password_2"], + }) + .refine((values) => values.new_password_1 !== values.old_password, { + message: t("new_password_same_as_old"), + path: ["new_password_1"], + }); - const initForm: PasswordForm = { - username: userData.username, - old_password: "", - new_password_1: "", - new_password_2: "", - }; - - const validateNewPassword = (password: string) => { - if ( - password.length < 8 || - !/\d/.test(password) || - password === password.toUpperCase() || - password === password.toLowerCase() - ) { - return false; - } - return true; - }; - - const validateForm = (formData: PasswordForm) => { - const errors: Partial> = {}; - - if (!formData.old_password) { - errors.old_password = t("please_enter_current_password"); - } - - if (!formData.new_password_1) { - errors.new_password_1 = t("please_enter_new_password"); - } else if (!validateNewPassword(formData.new_password_1)) { - errors.new_password_1 = t("new_password_validation"); - } - - if (!formData.new_password_2) { - errors.new_password_2 = t("please_confirm_password"); - } else if (formData.new_password_1 !== formData.new_password_2) { - errors.new_password_2 = t("password_mismatch"); - } - - if (formData.new_password_1 === formData.old_password) { - errors.new_password_1 = t("new_password_same_as_old"); - } - - return errors; - }; - - const handleSubmit = async (formData: PasswordForm) => { - setisSubmitting(true); + const form = useForm({ + resolver: zodResolver(PasswordSchema), + defaultValues: { + old_password: "", + new_password_1: "", + new_password_2: "", + }, + }); + const { mutate: resetPassword, isPending } = useMutation({ + mutationFn: mutate(routes.updatePassword), + onSuccess: () => { + toast.success(t("password_updated")); + form.reset(); + }, + }); + + const handleSubmitPassword = async ( + formData: z.infer, + ) => { const form: UpdatePasswordForm = { old_password: formData.old_password, username: userData.username, new_password: formData.new_password_1, }; - - const { res, data, error } = await request(routes.updatePassword, { - body: form, - }); - - if (res?.ok) { - toast.success(data?.message as string); - } else { - toast.error((error?.message as string) ?? t("password_update_error")); - } - setisSubmitting(false); + resetPassword(form); }; - const renderPasswordForm = () => { - return ( - - defaults={initForm} - validate={validateForm} - onSubmit={handleSubmit} - resetFormValsOnCancel - resetFormValsOnSubmit - hideRestoreDraft - noPadding - disabled={isSubmitting} - hideCancelButton - > - {(field) => ( -
- + {!isEditing && ( +
+ +
+ )} + {isEditing && ( +
+ +
+ ( + + {t("old_password")} + + { + field.onChange(e.target.value); + }} + /> + + + )} -
-
-
- - {field("new_password_2").value?.length > 0 && ( -
- {validateRule( - field("new_password_1").value === - field("new_password_2").value, - t("password_mismatch"), - !field("new_password_2").value, +
+ ( + + {t("new_password")} + + { + field.onChange(e.target.value); + }} + onFocus={() => setIsPasswordFieldFocused(true)} + onBlur={() => setIsPasswordFieldFocused(false)} + /> + + {isPasswordFieldFocused ? ( +
+ {validateRule( + field.value.length >= 8, + t("password_length_validation"), + !field.value, + t("password_length_met"), + )} + {validateRule( + /[a-z]/.test(field.value), + t("password_lowercase_validation"), + !field.value, + t("password_lowercase_met"), + )} + {validateRule( + /[A-Z]/.test(field.value), + t("password_uppercase_validation"), + !field.value, + t("password_uppercase_met"), + )} + {validateRule( + /\d/.test(field.value), + t("password_number_validation"), + !field.value, + t("password_number_met"), + )} + {validateRule( + field.value !== form.watch("old_password"), + t("new_password_same_as_old"), + !field.value, + t("new_password_different_from_old"), + )} +
+ ) : ( + + )} +
)} -
- )} + /> + + ( + + {t("new_password_confirmation")} + + { + field.onChange(e.target.value); + }} + /> + + + + )} + /> +
-
- )} - - ); - }; - const editButton = () => ( -
- -
- ); - - return ( -
- {editButton()} - {isEditing && renderPasswordForm()} +
+ + +
+ + + )}
); } diff --git a/src/components/ui/input-password.tsx b/src/components/ui/input-password.tsx new file mode 100644 index 00000000000..18d37c561f7 --- /dev/null +++ b/src/components/ui/input-password.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Input } from "@/components/ui/input"; + +const PasswordInput = React.forwardRef< + HTMLInputElement, + React.ComponentProps<"input"> +>(({ className, ...props }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + return ( +
+ + +
+ ); +}); + +PasswordInput.displayName = "PasswordInput"; + +export { PasswordInput }; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index a8f245154c7..c23ce217662 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -17,6 +17,7 @@ const Input = React.forwardRef>( if (type === "date" || type === "time") { e.target.showPicker(); } + props.onFocus?.(e); }} /> ); diff --git a/src/pluginTypes.ts b/src/pluginTypes.ts index 464c7aebf0a..30d0dcf0779 100644 --- a/src/pluginTypes.ts +++ b/src/pluginTypes.ts @@ -6,7 +6,6 @@ import { UserAssignedModel } from "@/components/Users/models"; import { EncounterTabProps } from "@/pages/Encounters/EncounterShow"; import { AppRoutes } from "./Routers/AppRouter"; -import { FormContextValue } from "./components/Form/FormContext"; import { PatientMeta } from "./components/Patient/models"; import { QuestionnaireFormState } from "./components/Questionnaire/QuestionnaireForm"; import { pluginMap } from "./pluginMap"; @@ -31,21 +30,6 @@ export type ExtendFacilityConfigureComponentType = React.FC<{ facilityId: string; }>; -export type ExtendPatientRegisterFormComponentType = React.FC<{ - facilityId: string; - patientId?: string; - state: { - form: { - [key: string]: any; - }; - errors: { - [key: string]: string; - }; - }; - dispatch: React.Dispatch; - field: FormContextValue; -}>; - // Define supported plugin components export type SupportedPluginComponents = { DoctorConnectButtons: DoctorConnectButtonComponentType; @@ -53,7 +37,6 @@ export type SupportedPluginComponents = { ManageFacilityOptions: ManageFacilityOptionsComponentType; EncounterContextEnabler: React.FC; ExtendFacilityConfigure: ExtendFacilityConfigureComponentType; - ExtendPatientRegisterForm: ExtendPatientRegisterFormComponentType; }; // Create a type for lazy-loaded components