refactor(web): compose reset password fields (#40952)

This commit is contained in:
yyh 2026-08-19 06:30:37 +00:00 committed by GitHub
parent 90a6b8c998
commit 73b15fc562
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 149 additions and 108 deletions

View File

@ -103,11 +103,6 @@
"count": 1
}
},
"web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(shareLayout)/webapp-signin/check-code/page.tsx": {
"no-restricted-imports": {
"count": 1
@ -5184,11 +5179,6 @@
"count": 1
}
},
"web/app/reset-password/set-password/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/signin/layout.tsx": {
"typescript/no-explicit-any": {
"count": 1

View File

@ -1,12 +1,15 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Field, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter, useSearchParams } from '@/next/navigation'
@ -96,68 +99,75 @@ const ChangePasswordForm = () => {
</div>
<div className="mx-auto mt-6 w-full">
<div className="bg-white">
{/* Password */}
<div className="mb-5">
<label htmlFor="password" className="my-2 system-md-semibold text-text-secondary">
<Form className="bg-white" onFormSubmit={() => void handleChangePassword()}>
<Field name="password" className="mb-5">
<FieldLabel className="py-0 system-md-semibold text-text-secondary">
{t(($) => $['account.newPassword'], { ns: 'common' })}
</label>
<div className="relative mt-1">
<Input
id="password"
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
spellCheck={false}
value={password}
onChange={(e) => setPassword(e.target.value)}
onValueChange={setPassword}
placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
<InputGroupAddon align="inline-end">
<IconButton
aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className="mt-1 body-xs-regular text-text-secondary">
<span
className={
showPassword ? 'i-ri-eye-off-line size-4' : 'i-ri-eye-line size-4'
}
aria-hidden="true"
/>
</IconButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription className="py-0 body-xs-regular text-text-secondary">
{t(($) => $['error.passwordInvalid'], { ns: 'login' })}
</div>
</div>
{/* Confirm Password */}
<div className="mb-5">
<label
htmlFor="confirmPassword"
className="my-2 system-md-semibold text-text-secondary"
>
</FieldDescription>
</Field>
<Field name="confirmPassword" className="mb-5">
<FieldLabel className="py-0 system-md-semibold text-text-secondary">
{t(($) => $['account.confirmPassword'], { ns: 'common' })}
</label>
<div className="relative mt-1">
<Input
id="confirmPassword"
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
spellCheck={false}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onValueChange={setConfirmPassword}
placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
<InputGroupAddon align="inline-end">
<IconButton
aria-label={t(
($) => $[showConfirmPassword ? 'hidePassword' : 'showPassword'],
{ ns: 'login' },
)}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</div>
<div>
<Button variant="primary" className="w-full" onClick={handleChangePassword}>
{t(($) => $.changePasswordBtn, { ns: 'login' })}
</Button>
</div>
</div>
<span
className={
showConfirmPassword ? 'i-ri-eye-off-line size-4' : 'i-ri-eye-line size-4'
}
aria-hidden="true"
/>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
<Button type="submit" variant="primary" className="w-full">
{t(($) => $.changePasswordBtn, { ns: 'login' })}
</Button>
</Form>
</div>
</div>
)}
@ -165,7 +175,7 @@ const ChangePasswordForm = () => {
<div className="flex flex-col md:w-100">
<div className="mx-auto w-full">
<div className="mb-3 flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
<RiCheckboxCircleFill className="size-6 text-text-success" />
<RiCheckboxCircleFill aria-hidden="true" className="size-6 text-text-success" />
</div>
<h1 className="title-4xl-semi-bold text-text-primary">
{t(($) => $.passwordChangedTip, { ns: 'login' })}

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter, useSearchParams } from '@/next/navigation'
import { changePasswordWithToken } from '@/service/common'
@ -81,6 +82,36 @@ describe('Reset Password Set Password Page', () => {
expect(mockUseDocumentTitle).toHaveBeenCalledWith('login.changePassword')
})
it('supports password reveal and native form submission', async () => {
const user = userEvent.setup()
render(<ChangePasswordForm />)
const passwordInput = screen.getByLabelText('common.account.newPassword')
const confirmPasswordInput = screen.getByLabelText('common.account.confirmPassword')
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
expect(confirmPasswordInput).toHaveAttribute('autocomplete', 'new-password')
await user.type(passwordInput, 'ValidPass123!')
await user.click(screen.getAllByRole('button', { name: 'login.showPassword' })[0]!)
expect(passwordInput).toHaveAttribute('type', 'text')
expect(screen.getByRole('button', { name: 'login.hidePassword' })).toBeInTheDocument()
await user.type(confirmPasswordInput, 'ValidPass123!{Enter}')
await waitFor(() => {
expect(mockChangePasswordWithToken).toHaveBeenCalledWith({
url: '/forgot-password/resets',
body: {
token: 'reset-token',
new_password: 'ValidPass123!',
password_confirm: 'ValidPass123!',
},
})
})
})
describe('Post-reset navigation', () => {
it('should preserve redirect_url when the user returns to sign in manually', async () => {
setSearchParams({ token: 'reset-token', redirect_url: redirectUrl })

View File

@ -1,12 +1,15 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Field, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter, useSearchParams } from '@/next/navigation'
@ -109,68 +112,75 @@ const ChangePasswordForm = () => {
</div>
<div className="mx-auto mt-6 w-full">
<div>
{/* Password */}
<div className="mb-5">
<label htmlFor="password" className="my-2 system-md-semibold text-text-secondary">
<Form onFormSubmit={() => void handleChangePassword()}>
<Field name="password" className="mb-5">
<FieldLabel className="py-0 system-md-semibold text-text-secondary">
{t(($) => $['account.newPassword'], { ns: 'common' })}
</label>
<div className="relative mt-1">
<Input
id="password"
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
spellCheck={false}
value={password}
onChange={(e) => setPassword(e.target.value)}
onValueChange={setPassword}
placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
<InputGroupAddon align="inline-end">
<IconButton
aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className="mt-1 body-xs-regular text-text-secondary">
<span
className={
showPassword ? 'i-ri-eye-off-line size-4' : 'i-ri-eye-line size-4'
}
aria-hidden="true"
/>
</IconButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription className="py-0 body-xs-regular text-text-secondary">
{t(($) => $['error.passwordInvalid'], { ns: 'login' })}
</div>
</div>
{/* Confirm Password */}
<div className="mb-5">
<label
htmlFor="confirmPassword"
className="my-2 system-md-semibold text-text-secondary"
>
</FieldDescription>
</Field>
<Field name="confirmPassword" className="mb-5">
<FieldLabel className="py-0 system-md-semibold text-text-secondary">
{t(($) => $['account.confirmPassword'], { ns: 'common' })}
</label>
<div className="relative mt-1">
<Input
id="confirmPassword"
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
spellCheck={false}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onValueChange={setConfirmPassword}
placeholder={t(($) => $.confirmPasswordPlaceholder, { ns: 'login' }) || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
<InputGroupAddon align="inline-end">
<IconButton
aria-label={t(
($) => $[showConfirmPassword ? 'hidePassword' : 'showPassword'],
{ ns: 'login' },
)}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</div>
<div>
<Button variant="primary" className="w-full" onClick={handleChangePassword}>
{t(($) => $.changePasswordBtn, { ns: 'login' })}
</Button>
</div>
</div>
<span
className={
showConfirmPassword ? 'i-ri-eye-off-line size-4' : 'i-ri-eye-line size-4'
}
aria-hidden="true"
/>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
<Button type="submit" variant="primary" className="w-full">
{t(($) => $.changePasswordBtn, { ns: 'login' })}
</Button>
</Form>
</div>
</div>
)}
@ -178,7 +188,7 @@ const ChangePasswordForm = () => {
<div className="flex flex-col md:w-100">
<div className="mx-auto w-full">
<div className="mb-3 flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
<RiCheckboxCircleFill className="size-6 text-text-success" />
<RiCheckboxCircleFill aria-hidden="true" className="size-6 text-text-success" />
</div>
<h1 className="title-4xl-semi-bold text-text-primary">
{t(($) => $.passwordChangedTip, { ns: 'login' })}