fix(auth): use validity-returned token in ChangePasswordForm reset submit [cherry-pick → hotfix/1.14.1-fix.3]

Cherry-pick of #36415.

The two-phase token flow (T1 → /validity → T2 with phase="reset" → /resets)
was broken because ForgotPasswordValidity type was missing the `token` field,
so T2 was discarded and T1 (from URL) was re-sent to /resets. Backend rejects
T1 at /resets because it requires phase="reset". Fix: add `token` to the type
and use verifyTokenRes.token in handleChangePassword.

Adds a test that asserts T2 (validity response token) is submitted, not T1
(URL token).

Fixes ENG-423.
This commit is contained in:
GareArc
2026-05-20 01:00:59 -07:00
committed by Yunlu Wen
parent a79ee941c4
commit 2f73bb8c0e
3 changed files with 90 additions and 4 deletions

View File

@ -0,0 +1,86 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { changePasswordWithToken } from '@/service/common'
import { useVerifyForgotPasswordToken } from '@/service/use-common'
import ChangePasswordForm from './ChangePasswordForm'
const mockReplace = vi.fn()
vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams('token=url-token-t1'),
useRouter: () => ({ replace: mockReplace }),
}))
vi.mock('@/service/use-common', () => ({
useVerifyForgotPasswordToken: vi.fn(),
}))
vi.mock('@/service/common', () => ({
changePasswordWithToken: vi.fn(),
}))
vi.mock('@/utils/var', () => ({ basePath: '' }))
type UseVerifyResult = ReturnType<typeof useVerifyForgotPasswordToken>
const mockUseVerify = vi.mocked(useVerifyForgotPasswordToken)
const mockChangePassword = vi.mocked(changePasswordWithToken)
const VALID_PASSWORD = 'ValidPass123!'
describe('ChangePasswordForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('when token is valid', () => {
const T2 = 'verified-token-t2'
beforeEach(() => {
mockUseVerify.mockReturnValue({
data: { result: 'success', is_valid: true, email: 'user@example.com', token: T2 },
refetch: vi.fn(),
} as unknown as UseVerifyResult)
})
it('renders the password form', () => {
render(<ChangePasswordForm />)
expect(screen.getByText('login.changePassword')).toBeInTheDocument()
})
it('submits with T2 (from validity response), NOT T1 (from URL)', async () => {
mockChangePassword.mockResolvedValue({ result: 'success' })
render(<ChangePasswordForm />)
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="password"]')) as [HTMLInputElement, HTMLInputElement]
fireEvent.change(inputs[0], { target: { value: VALID_PASSWORD } })
fireEvent.change(inputs[1], { target: { value: VALID_PASSWORD } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.reset/ }))
await waitFor(() => {
expect(mockChangePassword).toHaveBeenCalledWith({
url: '/forgot-password/resets',
body: {
token: T2,
new_password: VALID_PASSWORD,
password_confirm: VALID_PASSWORD,
},
})
})
})
})
describe('when token is invalid', () => {
beforeEach(() => {
mockUseVerify.mockReturnValue({
data: { result: 'success', is_valid: false, email: '', token: '' },
refetch: vi.fn(),
} as unknown as UseVerifyResult)
})
it('shows invalid token state and no form', () => {
render(<ChangePasswordForm />)
expect(screen.getByText('login.invalid')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common\.operation\.reset/ })).not.toBeInTheDocument()
})
})
})

View File

@ -49,7 +49,7 @@ const ChangePasswordForm = () => {
}, [password, confirmPassword, showErrorMessage, t])
const handleChangePassword = useCallback(async () => {
const token = searchParams.get('token') || ''
const resetToken = verifyTokenRes?.token ?? ''
if (!valid())
return
@ -57,7 +57,7 @@ const ChangePasswordForm = () => {
await changePasswordWithToken({
url: '/forgot-password/resets',
body: {
token,
token: resetToken,
new_password: password,
password_confirm: confirmPassword,
},
@ -67,7 +67,7 @@ const ChangePasswordForm = () => {
catch {
await revalidateToken()
}
}, [confirmPassword, password, revalidateToken, searchParams, valid])
}, [confirmPassword, password, revalidateToken, verifyTokenRes?.token, valid])
return (
<div className={

View File

@ -246,7 +246,7 @@ export const useLogout = () => {
})
}
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string }
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string, token: string }
export const useVerifyForgotPasswordToken = (token?: string | null) => {
return useQuery<ForgotPasswordValidity>({
queryKey: commonQueryKeys.forgotPasswordValidity(token),