diff --git a/public/locale/en.json b/public/locale/en.json index cf54a0e11f7..ead37516fa7 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -930,6 +930,7 @@ "back_to_app_store": "Back to App Store", "back_to_consultation": "Go back to Consultation", "back_to_dispense_queue": "Back to Dispense Queue", + "back_to_email_reset": "Back to email reset", "back_to_encounter": "Back to Encounter", "back_to_facilities": "Back to Facilities", "back_to_home": "Back to Home", @@ -1110,6 +1111,7 @@ "cannot_select_month_out_of_range": "Cannot select month out of range", "cannot_select_year_out_of_range": "Cannot select year out of range", "cant_access_code": "Can't access your code?", + "cant_access_email": "Can't access your email?", "cant_scan_copy_key": "Can't scan? Copy setup key to add it to your authenticator app.", "cap": "Cap", "capacity": "Capacity", @@ -2698,6 +2700,8 @@ "footer_image": "Footer Image", "forget_password": "Forgot password?", "forget_password_instruction": "Enter your username, and if it exists, we will send you a link to reset your password.", + "forget_password_phone_instruction": "Enter your registered phone number, and if it exists, we will send you an OTP to reset your password.", + "forget_password_phone_otp_instruction": "Enter the OTP sent to your phone, then create and confirm your new password.", "form_preview": "Form Preview", "form_submission_discard_failed": "Failed to discard form submission", "form_submission_discarded": "Form submission discarded", @@ -3564,6 +3568,8 @@ "moving_camera": "Moving Camera", "mrp": "MRP", "multi_invoice": "Multi Invoice", + "multiple_users_linked_to_phone": "Multiple users linked to this phone number", + "multiple_users_linked_to_phone_hint": "Enter your username so we can reset the correct account.", "must_be_greater_than_value": "Must be greater than {{value}}", "my_dept": "My Dept.", "my_doctors": "My Doctors", @@ -4149,6 +4155,7 @@ "other_encounters": "Other Encounters", "other_medications": "Other Medications", "other_tags": "Other Tags", + "otp_password_reset_sent": "If this phone number is registered, you will receive an OTP.", "otp_verification_error": "Failed to verify OTP. Please try again later.", "otp_verification_success": "OTP has been verified successfully.", "out": "out", @@ -5012,6 +5019,7 @@ "reset_password_note_self": "Enter your current password, then create and confirm your new password", "reset_pinned_links": "Reset Pinned Links", "reset_to_default": "Reset to Default", + "reset_using_phone_number": "Reset using phone number", "resolved": "Resolved", "resource": "Resource", "resource_approving_facility": "Resource approving facility", diff --git a/src/components/Auth/ForgotPasswordPanel.tsx b/src/components/Auth/ForgotPasswordPanel.tsx new file mode 100644 index 00000000000..98f3a08c727 --- /dev/null +++ b/src/components/Auth/ForgotPasswordPanel.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +import { ForgotPasswordPhone } from "@/components/Auth/ForgotPasswordPhone"; +import CircularProgress from "@/components/Common/CircularProgress"; + +type ForgotMethod = "email" | "phone"; + +interface ForgotPasswordPanelProps { + username: string; + usernameError?: string | null; + onUsernameChange: (e: React.ChangeEvent) => void; + onSubmitEmail: (e: React.SubmitEvent) => void; + onBackToLogin: () => void; + isSubmitting: boolean; +} + +export function ForgotPasswordPanel({ + username, + usernameError, + onUsernameChange, + onSubmitEmail, + onBackToLogin, + isSubmitting, +}: ForgotPasswordPanelProps) { + const { t } = useTranslation(); + const [forgotMethod, setForgotMethod] = useState("email"); + + if (forgotMethod === "phone") { + return ( + setForgotMethod("email")} + onSuccess={onBackToLogin} + /> + ); + } + + return ( +
+ + +
+
+

+ {t("forget_password")} +

+

+ {t("forget_password_instruction")} +

+
+ +
+ + + {usernameError && ( +

{t(usernameError)}

+ )} +
+ + + +
+

+ {t("cant_access_email")} +

+ +
+
+
+ ); +} diff --git a/src/components/Auth/ForgotPasswordPhone.tsx b/src/components/Auth/ForgotPasswordPhone.tsx new file mode 100644 index 00000000000..92e147ee7cb --- /dev/null +++ b/src/components/Auth/ForgotPasswordPhone.tsx @@ -0,0 +1,440 @@ +import careConfig from "@careConfig"; +import { useMutation } from "@tanstack/react-query"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; +import { navigate } from "raviger"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { isValidPhoneNumber } from "react-phone-number-input"; +import { toast } from "sonner"; + +import { cn } from "@/lib/utils"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { Label } from "@/components/ui/label"; +import { PhoneInput } from "@/components/ui/phone-input"; + +import { + PasswordErrors, + PasswordFields, + validatePasswordFields, +} from "@/components/Auth/PasswordFields"; +import CircularProgress from "@/components/Common/CircularProgress"; + +import { LocalStorageKeys } from "@/common/constants"; + +import mutate from "@/Utils/request/mutate"; +import { HTTPError } from "@/Utils/request/types"; +import otpApi from "@/types/otp/otpApi"; + +const OTP_LENGTH = 5; + +type PhoneResetStep = "phone" | "confirm"; + +interface ForgotPasswordPhoneProps { + onBackToEmail: () => void; + onSuccess: () => void; +} + +interface ConfirmFieldErrors extends PasswordErrors { + otp?: string | null; + username?: string | null; + error?: string | null; +} + +function extractFieldErrors(cause: unknown): ConfirmFieldErrors { + const result: ConfirmFieldErrors = {}; + if (!cause || typeof cause !== "object") return result; + + const data = cause as Record; + const errors = data.errors; + + if (Array.isArray(errors)) { + for (const entry of errors) { + if (!entry || typeof entry !== "object") continue; + const msg = (entry as { msg?: unknown }).msg; + if (typeof msg === "string") { + result.error = msg; + } else if (msg && typeof msg === "object") { + for (const [key, value] of Object.entries( + msg as Record, + )) { + if (typeof value === "string") { + result[key as keyof ConfirmFieldErrors] = value; + } else if (Array.isArray(value) && typeof value[0] === "string") { + result[key as keyof ConfirmFieldErrors] = value[0]; + } + } + } + } + } + + if (typeof data.error === "string") { + result.error = data.error; + } + if (typeof data.otp === "string") { + result.otp = data.otp; + } + if (typeof data.password === "string") { + result.password = data.password; + } + + return result; +} + +export function ForgotPasswordPhone({ + onBackToEmail, + onSuccess, +}: ForgotPasswordPhoneProps) { + const { t } = useTranslation(); + const { resendOtpTimeout } = careConfig; + + const [step, setStep] = useState("phone"); + const [phone, setPhone] = useState(""); + const [otp, setOtp] = useState(""); + const [username, setUsername] = useState(""); + const [requiresUsername, setRequiresUsername] = useState(false); + const [passwordForm, setPasswordForm] = useState({ + password: "", + confirm: "", + }); + const [errors, setErrors] = useState({}); + const [phoneError, setPhoneError] = useState(""); + const [isPasswordFieldFocused, setIsPasswordFieldFocused] = useState(false); + const [resendOtpCountdown, setResendOtpCountdown] = useState(0); + + useEffect(() => { + if (resendOtpCountdown <= 0) return; + const timer = setInterval(() => { + setResendOtpCountdown((prev) => prev - 1); + }, 1000); + return () => clearInterval(timer); + }, [resendOtpCountdown]); + + const { mutate: sendOtp, isPending: sendOtpPending } = useMutation({ + mutationFn: mutate(otpApi.sendPasswordResetOtp), + onSuccess: () => { + setStep("confirm"); + setPhoneError(""); + setResendOtpCountdown(resendOtpTimeout); + toast.success(t("otp_password_reset_sent")); + }, + onError: (error) => { + const fieldErrors = extractFieldErrors(error.cause); + setPhoneError( + fieldErrors.error || fieldErrors.otp || t("send_otp_error"), + ); + }, + }); + + const { mutate: confirmReset, isPending: confirmPending } = useMutation({ + mutationFn: mutate(otpApi.confirmPasswordResetOtp, { silent: true }), + onSuccess: () => { + localStorage.removeItem(LocalStorageKeys.accessToken); + localStorage.removeItem(LocalStorageKeys.refreshToken); + toast.success(t("password_reset_success")); + onSuccess(); + navigate("/login"); + }, + onError: (error) => { + if (!(error instanceof HTTPError)) { + toast.error(t("something_went_wrong")); + return; + } + + if (error.status === 409) { + setRequiresUsername(true); + const message = + typeof error.cause?.error === "string" + ? error.cause.error + : t("multiple_users_linked_to_phone"); + setErrors({ error: message }); + return; + } + + const fieldErrors = extractFieldErrors(error.cause); + setErrors(fieldErrors); + if (fieldErrors.otp) { + toast.error(fieldErrors.otp); + } else if (fieldErrors.error) { + toast.error(fieldErrors.error); + } else if (fieldErrors.password) { + toast.error( + typeof fieldErrors.password === "string" + ? fieldErrors.password + : t("invalid_password"), + ); + } else { + toast.error(t("something_went_wrong")); + } + }, + }); + + const handleSendOtp = (e: React.SubmitEvent) => { + e.preventDefault(); + if (!isValidPhoneNumber(phone)) { + setPhoneError(t("phone_number_validation_error")); + return; + } + setPhoneError(""); + sendOtp({ phone_number: phone }); + }; + + const handleResendOtp = () => { + if (resendOtpCountdown > 0 || !isValidPhoneNumber(phone)) return; + sendOtp({ phone_number: phone }); + }; + + const handlePasswordChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setPasswordForm((prev) => ({ ...prev, [name]: value })); + if (errors[name as keyof ConfirmFieldErrors]) { + setErrors((prev) => ({ ...prev, [name]: null })); + } + }; + + const handleConfirm = (e: React.SubmitEvent) => { + e.preventDefault(); + + const nextErrors: ConfirmFieldErrors = {}; + if (otp.length !== OTP_LENGTH) { + nextErrors.otp = t("invalid_otp"); + } + + const passwordErrors = validatePasswordFields(passwordForm, t); + if (passwordErrors) { + Object.assign(nextErrors, passwordErrors); + } + + if (requiresUsername && !username.trim()) { + nextErrors.username = t("field_required"); + } + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + return; + } + + setErrors({}); + confirmReset({ + phone_number: phone, + otp, + password: passwordForm.password, + ...(requiresUsername ? { username: username.trim() } : {}), + }); + }; + + const handleChangePhone = () => { + setStep("phone"); + setOtp(""); + setUsername(""); + setRequiresUsername(false); + setPasswordForm({ password: "", confirm: "" }); + setErrors({}); + setIsPasswordFieldFocused(false); + }; + + if (step === "phone") { + return ( +
+ + +
+
+

+ {t("reset_using_phone_number")} +

+

+ {t("forget_password_phone_instruction")} +

+
+ +
+ + { + setPhone(value ?? ""); + setPhoneError(""); + }} + placeholder={t("enter_phone_number")} + /> + {phoneError &&

{phoneError}

} +
+ + +
+
+ ); + } + + return ( +
+ + +
+
+

+ {t("reset_password")} +

+

+ {t("forget_password_phone_otp_instruction")} +

+
+ +
+ +
+ { + setOtp(value); + if (errors.otp) { + setErrors((prev) => ({ ...prev, otp: null })); + } + }} + pattern={REGEXP_ONLY_DIGITS} + > + + {Array.from({ length: OTP_LENGTH }).map((_, index) => ( + + ))} + + +
+ {errors.otp && ( +

{errors.otp}

+ )} +
+ + + + {requiresUsername && ( +
+ + { + setUsername(e.target.value.toLowerCase()); + if (errors.username || errors.error) { + setErrors((prev) => ({ + ...prev, + username: null, + error: null, + })); + } + }} + placeholder={t("enter_your_username")} + className={cn( + (errors.username || errors.error) && + "border-red-500 focus-visible:ring-red-500", + )} + /> + {(errors.username || errors.error) && ( +

+ {errors.username || errors.error} +

+ )} +

+ {t("multiple_users_linked_to_phone_hint")} +

+
+ )} + + {!requiresUsername && errors.error && ( +

{errors.error}

+ )} + + + +

{t("dont_share_code")}

+ +
+ {resendOtpCountdown <= 0 ? ( + + ) : ( +

+ {t("resend_otp_timer", { time: resendOtpCountdown })} +

+ )} +
+
+
+ ); +} diff --git a/src/components/Auth/Login.tsx b/src/components/Auth/Login.tsx index f0c2d1d63c5..ed5046126ef 100644 --- a/src/components/Auth/Login.tsx +++ b/src/components/Auth/Login.tsx @@ -10,8 +10,6 @@ import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import CareIcon from "@/CAREUI/icons/CareIcon"; - import { Button } from "@/components/ui/button"; import { Card, @@ -31,6 +29,7 @@ import { Label } from "@/components/ui/label"; import { PhoneInput } from "@/components/ui/phone-input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ForgotPasswordPanel } from "@/components/Auth/ForgotPasswordPanel"; import CircularProgress from "@/components/Common/CircularProgress"; import LanguageSelectorLogin from "@/components/Common/LanguageSelectorLogin"; @@ -279,7 +278,7 @@ const Login = (props: LoginProps) => { } return form; }; - const handleForgetSubmit = async (e: React.SubmitEvent) => { + const handleForgetSubmit = async (e: React.SubmitEvent) => { e.preventDefault(); const valid = validateForgetData(); if (!valid) return; @@ -447,64 +446,14 @@ const Login = (props: LoginProps) => { ) : ( -
- - -
-
-

- {t("forget_password")} -

-

- {t("forget_password_instruction")} -

-
- -
- - - {errors.username && ( -

- {t(errors.username)} -

- )} -
- - -
-
+ setForgotPassword(false)} + isSubmitting={isLoading || forgotPasswordPending} + /> )} ) : ( @@ -606,67 +555,14 @@ const Login = (props: LoginProps) => { ) : ( -
- - -
-
-

- {t("forget_password")} -

-

- {t("forget_password_instruction")} -

-
- -
- - - {errors.username && ( -

- {t(errors.username)} -

- )} -
- - -
-
+ setForgotPassword(false)} + isSubmitting={isLoading || forgotPasswordPending} + /> )} diff --git a/src/components/Auth/PasswordFields.tsx b/src/components/Auth/PasswordFields.tsx new file mode 100644 index 00000000000..a79737bbd15 --- /dev/null +++ b/src/components/Auth/PasswordFields.tsx @@ -0,0 +1,160 @@ +import { useTranslation } from "react-i18next"; + +import { PasswordInput } from "@/components/ui/input-password"; +import { Label } from "@/components/ui/label"; + +import { ValidationHelper } from "@/components/Users/UserFormValidations"; + +import { validatePassword } from "@/common/validation"; + +export interface PasswordFormValues { + password: string; + confirm: string; +} + +export interface PasswordErrors { + password?: string | null; + confirm?: string | null; +} + +interface PasswordFieldsProps { + password: string; + confirm: string; + errors: PasswordErrors; + onChange: (e: React.ChangeEvent) => void; + isPasswordFieldFocused: boolean; + onPasswordFocusChange: (focused: boolean) => void; +} + +export function validatePasswordFields( + form: PasswordFormValues, + t: (key: string) => string, +): PasswordErrors | null { + const err: PasswordErrors = {}; + let hasError = false; + + if (form.password !== form.confirm) { + hasError = true; + err.confirm = t("password_mismatch"); + } + + if (!validatePassword(form.password)) { + hasError = true; + err.password = t("invalid_password"); + } + + if (!form.password) { + hasError = true; + err.password = t("field_required"); + } + + if (!form.confirm) { + hasError = true; + err.confirm = t("field_required"); + } + + return hasError ? err : null; +} + +export function PasswordFields({ + password, + confirm, + errors, + onChange, + isPasswordFieldFocused, + onPasswordFocusChange, +}: PasswordFieldsProps) { + const { t } = useTranslation(); + const passwordErrorId = "new-password-error"; + const confirmErrorId = "confirm-password-error"; + const passwordHintsId = "new-password-hints"; + + return ( +
+
+ + onPasswordFocusChange(true)} + onBlur={() => onPasswordFocusChange(false)} + aria-invalid={!!errors.password} + aria-describedby={ + [ + errors.password ? passwordErrorId : null, + isPasswordFieldFocused ? passwordHintsId : null, + ] + .filter(Boolean) + .join(" ") || undefined + } + /> + {errors.password && ( + + )} + {isPasswordFieldFocused && ( +
+ = 8, + }, + { + description: "password_lowercase_validation", + fulfilled: /[a-z]/.test(password), + }, + { + description: "password_uppercase_validation", + fulfilled: /[A-Z]/.test(password), + }, + { + description: "password_number_validation", + fulfilled: /\d/.test(password), + }, + ]} + /> +
+ )} +
+ +
+ + + {errors.confirm && ( + + )} +
+
+ ); +} diff --git a/src/components/Auth/ResetPassword.tsx b/src/components/Auth/ResetPassword.tsx index 46fa5470be8..641ee712875 100644 --- a/src/components/Auth/ResetPassword.tsx +++ b/src/components/Auth/ResetPassword.tsx @@ -5,12 +5,14 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { PasswordInput } from "@/components/ui/input-password"; -import { ValidationHelper } from "@/components/Users/UserFormValidations"; +import { + PasswordErrors, + PasswordFields, + validatePasswordFields, +} from "@/components/Auth/PasswordFields"; import { LocalStorageKeys } from "@/common/constants"; -import { validatePassword } from "@/common/validation"; import mutate from "@/Utils/request/mutate"; import query from "@/Utils/request/query"; @@ -21,77 +23,50 @@ interface ResetPasswordProps { } const ResetPassword = (props: ResetPasswordProps) => { - const initForm: any = { + const [form, setForm] = useState({ password: "", confirm: "", - }; - - const initErr: any = {}; - const [form, setForm] = useState(initForm); - const [errors, setErrors] = useState(initErr); + }); + const [errors, setErrors] = useState({}); const [isPasswordFieldFocused, setIsPasswordFieldFocused] = useState(false); const { t } = useTranslation(); - const handleChange = (e: any) => { + + const handleChange = (e: React.ChangeEvent) => { const { value, name } = e.target; - const fieldValue = Object.assign({}, form); - const errorField = Object.assign({}, errors); - if (errorField[name]) { - errorField[name] = null; + const fieldValue = { ...form, [name]: value }; + const errorField = { ...errors }; + if (errorField[name as keyof PasswordErrors]) { + errorField[name as keyof PasswordErrors] = null; setErrors(errorField); } - fieldValue[name] = value; setForm(fieldValue); }; - const validateData = () => { - let hasError = false; - const err = Object.assign({}, errors); - if (form.password !== form.confirm) { - hasError = true; - err.confirm = t("password_mismatch"); - } - - if (!validatePassword(form.password)) { - hasError = true; - err.password = t("invalid_password"); - } - - Object.keys(form).forEach((key) => { - if (!form[key]) { - hasError = true; - err[key] = t("field_required"); - } - }); - if (hasError) { - setErrors(err); - return false; - } else { - setErrors({}); - } - return form; - }; const { mutate: resetPassword } = useMutation({ mutationFn: mutate(authApi.resetPassword), onSuccess: () => { localStorage.removeItem(LocalStorageKeys.accessToken); + localStorage.removeItem(LocalStorageKeys.refreshToken); toast.success(t("password_reset_success")); navigate("/login"); }, onError: (error) => { if (error.cause) { - setErrors(error.cause); + setErrors(error.cause as PasswordErrors); } }, }); - const handleSubmit = async (e: any) => { + const handleSubmit = async (e: React.SubmitEvent) => { e.preventDefault(); - const valid = validateData(); - if (valid) { - valid.token = props.token; - resetPassword(valid); + const validationErrors = validatePasswordFields(form, t); + if (validationErrors) { + setErrors(validationErrors); + return; } + setErrors({}); + resetPassword({ token: props.token, password: form.password }); }; const { isError } = useQuery({ @@ -106,72 +81,20 @@ const ResetPassword = (props: ResetPasswordProps) => {
{ - handleSubmit(e); - }} + onSubmit={handleSubmit} >
{t("reset_password")}
-
-
- setIsPasswordFieldFocused(true)} - onBlur={() => setIsPasswordFieldFocused(false)} - /> - {errors.password && ( -
- {errors.password} -
- )} - {isPasswordFieldFocused && ( -
- = 8, - }, - { - description: "password_lowercase_validation", - fulfilled: /[a-z]/.test(form.password), - }, - { - description: "password_uppercase_validation", - fulfilled: /[A-Z]/.test(form.password), - }, - { - description: "password_number_validation", - fulfilled: /\d/.test(form.password), - }, - ]} - /> -
- )} -
- -
- - {errors.confirm && ( -
- {errors.confirm} -
- )} -
-
+
-
diff --git a/src/types/otp/otp.ts b/src/types/otp/otp.ts index abb28be5998..0357662b057 100644 --- a/src/types/otp/otp.ts +++ b/src/types/otp/otp.ts @@ -20,3 +20,14 @@ export interface LoginByOtpRequest { export interface LoginByOtpResponse { access: string; } + +export interface ConfirmPasswordResetOtpRequest { + phone_number: string; + otp: string; + password: string; + username?: string; +} + +export interface ConfirmPasswordResetOtpResponse { + message: string; +} diff --git a/src/types/otp/otpApi.ts b/src/types/otp/otpApi.ts index f74352cac7b..ec90d89bf38 100644 --- a/src/types/otp/otpApi.ts +++ b/src/types/otp/otpApi.ts @@ -1,5 +1,7 @@ import { HttpMethod, Type } from "@/Utils/request/types"; import { + ConfirmPasswordResetOtpRequest, + ConfirmPasswordResetOtpResponse, LoginByOtpRequest, LoginByOtpResponse, SendOtpRequest, @@ -19,4 +21,18 @@ export default { TBody: Type(), TRes: Type(), }, + sendPasswordResetOtp: { + path: "/api/v1/otp/password_reset/send/", + method: HttpMethod.POST, + noAuth: true, + TBody: Type(), + TRes: Type(), + }, + confirmPasswordResetOtp: { + path: "/api/v1/otp/password_reset/confirm/", + method: HttpMethod.POST, + noAuth: true, + TBody: Type(), + TRes: Type(), + }, } as const; diff --git a/tests/auth/passwordResetOtp.spec.ts b/tests/auth/passwordResetOtp.spec.ts new file mode 100644 index 00000000000..e328444e102 --- /dev/null +++ b/tests/auth/passwordResetOtp.spec.ts @@ -0,0 +1,122 @@ +import { faker } from "@faker-js/faker"; +import { expect, test, type Browser, type Page } from "@playwright/test"; +import { expectToast } from "tests/helper/ui"; +import { + createDisposableUserViaUi, + DEFAULT_DISPOSABLE_USER_PASSWORD, +} from "tests/helper/user"; + +const LOCAL_OTP = "45612"; + +async function openPhonePasswordReset(page: Page) { + await page.goto("/login"); + await page.getByRole("button", { name: /forgot password/i }).click(); + await page.getByText(/reset using phone number/i).click(); +} + +async function fillPhoneAndSendOtp(page: Page, phoneNumber: string) { + await page.getByPlaceholder(/enter phone number/i).fill(phoneNumber); + await page.getByRole("button", { name: /send otp/i }).click(); +} + +async function fillOtp(page: Page, otp: string) { + await page.locator("#reset_otp").click(); + await page.locator("#reset_otp").pressSequentially(otp); +} + +async function withAdminPage( + browser: Browser, + run: (page: Page) => Promise, +): Promise { + const context = await browser.newContext({ + storageState: "tests/.auth/user.json", + }); + const page = await context.newPage(); + try { + return await run(page); + } finally { + await context.close(); + } +} + +test.describe("OTP password reset", () => { + test("resets password using phone OTP for a user", async ({ browser }) => { + const user = await withAdminPage(browser, (page) => + createDisposableUserViaUi(page), + ); + + const page = await browser.newPage(); + + await test.step("Request password reset OTP", async () => { + await openPhonePasswordReset(page); + await fillPhoneAndSendOtp(page, user.phoneNumber); + await expectToast( + page, + /if this phone number is registered, you will receive an otp/i, + ); + }); + + await test.step("Confirm with OTP and same password", async () => { + await fillOtp(page, LOCAL_OTP); + await page.getByPlaceholder(/new password/i).fill(user.password); + await page.getByPlaceholder(/confirm password/i).fill(user.password); + await page.getByRole("button", { name: /^reset password$/i }).click(); + await expectToast(page, /password reset successfully/i); + }); + + await test.step("Return to staff login form", async () => { + await expect( + page.getByRole("textbox", { name: /username/i }), + ).toBeVisible(); + await expect(page.getByLabel(/password/i)).toBeVisible(); + await expect(page.getByRole("button", { name: /login/i })).toBeVisible(); + }); + + await page.close(); + }); + + test("shows error for invalid OTP", async ({ browser }) => { + const user = await withAdminPage(browser, (page) => + createDisposableUserViaUi(page), + ); + + const page = await browser.newPage(); + + await openPhonePasswordReset(page); + await fillPhoneAndSendOtp(page, user.phoneNumber); + await expectToast( + page, + /if this phone number is registered, you will receive an otp/i, + ); + + await fillOtp(page, "00000"); + await page + .getByPlaceholder(/new password/i) + .fill(DEFAULT_DISPOSABLE_USER_PASSWORD); + await page + .getByPlaceholder(/confirm password/i) + .fill(DEFAULT_DISPOSABLE_USER_PASSWORD); + await page.getByRole("button", { name: /^reset password$/i }).click(); + + await expectToast(page, /invalid otp/i); + await expect( + page.getByRole("textbox", { name: /username/i }), + ).not.toBeVisible(); + + await page.close(); + }); + + test("shows generic success toast for unknown phone number", async ({ + page, + }) => { + const unknownPhone = `${faker.helpers.arrayElement([7, 8, 9])}${faker.string.numeric(9)}`; + + await openPhonePasswordReset(page); + await fillPhoneAndSendOtp(page, unknownPhone); + + await expectToast( + page, + /if this phone number is registered, you will receive an otp/i, + ); + }); +}); diff --git a/tests/helper/user.ts b/tests/helper/user.ts new file mode 100644 index 00000000000..aeebb5c79aa --- /dev/null +++ b/tests/helper/user.ts @@ -0,0 +1,88 @@ +import { faker } from "@faker-js/faker"; +import { expect, type Page } from "@playwright/test"; + +export const DEFAULT_DISPOSABLE_USER_PASSWORD = "Ohcn@123"; + +export interface DisposableUser { + username: string; + phoneNumber: string; + password: string; +} + +export interface CreateDisposableUserOptions { + password?: string; + organizationSearch?: string; + organizationOption?: string; + roleSearch?: string; + roleOption?: string; +} + +/** + * Creates a disposable governance user through the Users UI. + * Caller must use an authenticated page (e.g. admin storageState). + */ +export async function createDisposableUserViaUi( + page: Page, + options: CreateDisposableUserOptions = {}, +): Promise { + const password = options.password ?? DEFAULT_DISPOSABLE_USER_PASSWORD; + const organizationSearch = options.organizationSearch ?? "Nurse"; + const organizationOption = options.organizationOption ?? "Nurse"; + const roleSearch = options.roleSearch ?? "Member"; + const roleOption = options.roleOption ?? "Member"; + + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const username = `${firstName.toLowerCase()}${faker.string.numeric(4)}`; + const email = faker.internet.email({ firstName, lastName }); + const phoneNumber = `${faker.helpers.arrayElement([7, 8, 9])}${faker.string.numeric(9)}`; + const gender = faker.helpers.arrayElement([ + "Male", + "Female", + "Non Binary", + "Transgender", + ]); + + await page.goto("/"); + await page.getByRole("tab", { name: "Governance" }).click(); + await page + .getByRole("link", { name: /Government$/ }) + .first() + .click(); + await page.getByRole("menuitem", { name: "Users" }).click(); + + await page.getByRole("button", { name: "Add User" }).click(); + await page.getByRole("textbox", { name: "First Name" }).fill(firstName); + await page.getByRole("textbox", { name: "Last Name" }).fill(lastName); + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page.locator('input[name="password"]').fill(password); + await page.locator('input[name="c_password"]').fill(password); + await page.getByRole("textbox", { name: "Email" }).fill(email); + await page.getByRole("textbox", { name: "Phone Number" }).fill(phoneNumber); + await page.getByRole("combobox", { name: "Gender" }).click(); + await page.getByRole("option", { name: gender, exact: true }).click(); + + await page + .getByRole("combobox") + .filter({ hasText: "Select organization" }) + .click(); + await page.getByPlaceholder("Search organization").fill(organizationSearch); + await page.getByRole("option", { name: organizationOption }).click(); + await page + .getByRole("combobox") + .filter({ hasText: "Select designation" }) + .click(); + await page.getByPlaceholder("Search Roles").fill(roleSearch); + await page.getByRole("option", { name: roleOption }).click(); + + const createUserResponse = page.waitForResponse( + (response) => + response.url().includes("/api/v1/users/") && + response.request().method() === "POST", + ); + await page.getByRole("button", { name: "Create User" }).click(); + const response = await createUserResponse; + expect(response.status()).toBe(200); + + return { username, phoneNumber, password }; +}