Compare commits

...

4 Commits

Author SHA1 Message Date
yyh
005f84d55f refactor(web): tighten webapp auth test mocks 2026-08-09 12:59:16 +08:00
yyh
6184b2f291 fix(web): name the webapp password visibility action
Replace emoji-only visibility controls with the existing CSS eye icon composition, expose show and hide names, reserve input space, and verify the state transition.
2026-08-09 12:55:43 +08:00
yyh
5e382844f8 refactor(web): migrate webapp password fields to dify ui
Compose the existing Web App password login with documented Dify UI Form, Field, FieldLabel, and FieldControl primitives. Preserve owner state, layout wrappers, visible content, and the emoji visibility action while pruning the legacy Input suppression.
2026-08-09 12:45:17 +08:00
yyh
ea27125433 fix(web): restore webapp password form submission
Give the Web App password login one native submit owner, stable field names, and natural focus order. Remove the password keydown and button click submission paths, verify Enter submission, and prune the positive-tabindex suppression.
2026-08-09 12:42:28 +08:00
3 changed files with 84 additions and 55 deletions

View File

@@ -49,11 +49,6 @@
"count": 1
}
},
"web/__tests__/embedded-user-id-auth.test.tsx": {
"typescript/no-explicit-any": {
"count": 8
}
},
"web/__tests__/embedded-user-id-store.test.tsx": {
"typescript/no-explicit-any": {
"count": 1
@@ -146,14 +141,6 @@
"count": 1
}
},
"web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx": {
"jsx_a11y/tabindex-no-positive": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/(shareLayout)/webapp-signin/normalForm.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page'
import MailAndCodeAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-code-auth'
@@ -22,12 +23,12 @@ const mockStoreState = {
shareCode: 'test-app',
}
const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => any) => {
const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => unknown) => {
return selector ? selector(mockStoreState) : mockStoreState
})
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector?: (state: typeof mockStoreState) => any) =>
useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) =>
useWebAppStoreMock(selector),
}))
@@ -36,23 +37,23 @@ const webAppEmailLoginWithCodeMock = vi.fn()
const sendWebAppEMailLoginCodeMock = vi.fn()
vi.mock('@/service/common', () => ({
webAppLogin: (...args: any[]) => webAppLoginMock(...args),
webAppEmailLoginWithCode: (...args: any[]) => webAppEmailLoginWithCodeMock(...args),
sendWebAppEMailLoginCode: (...args: any[]) => sendWebAppEMailLoginCodeMock(...args),
webAppLogin: (...args: unknown[]) => webAppLoginMock(...args),
webAppEmailLoginWithCode: (...args: unknown[]) => webAppEmailLoginWithCodeMock(...args),
sendWebAppEMailLoginCode: (...args: unknown[]) => sendWebAppEMailLoginCodeMock(...args),
}))
const fetchAccessTokenMock = vi.fn()
vi.mock('@/service/share', () => ({
fetchAccessToken: (...args: any[]) => fetchAccessTokenMock(...args),
fetchAccessToken: (...args: unknown[]) => fetchAccessTokenMock(...args),
}))
const setWebAppAccessTokenMock = vi.fn()
const setWebAppPassportMock = vi.fn()
vi.mock('@/service/webapp-auth', () => ({
setWebAppAccessToken: (...args: any[]) => setWebAppAccessTokenMock(...args),
setWebAppPassport: (...args: any[]) => setWebAppPassportMock(...args),
setWebAppAccessToken: (...args: unknown[]) => setWebAppAccessTokenMock(...args),
setWebAppPassport: (...args: unknown[]) => setWebAppPassportMock(...args),
webAppLogout: vi.fn(),
}))
@@ -71,6 +72,7 @@ beforeEach(() => {
describe('embedded user id propagation in authentication flows', () => {
it('passes embedded user id when logging in with email and password', async () => {
const user = userEvent.setup()
const params = new URLSearchParams()
params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
useSearchParamsMock.mockReturnValue(params)
@@ -80,13 +82,21 @@ describe('embedded user id propagation in authentication flows', () => {
render(<MailAndPasswordAuth isEmailSetup />)
fireEvent.change(screen.getByLabelText('login.email'), {
target: { value: 'user@example.com' },
})
fireEvent.change(screen.getByLabelText(/login\.password/), {
target: { value: 'strong-password' },
})
fireEvent.click(screen.getByRole('button', { name: 'login.signBtn' }))
const emailInput = screen.getByLabelText('login.email')
const passwordInput = screen.getByLabelText(/login\.password/)
const submitButton = screen.getByRole('button', { name: 'login.signBtn' })
expect(emailInput).toHaveAttribute('name', 'email')
expect(emailInput).toHaveAttribute('autocomplete', 'email')
expect(emailInput).toHaveProperty('tabIndex', 0)
expect(passwordInput).toHaveAttribute('name', 'password')
expect(passwordInput).toHaveAttribute('autocomplete', 'current-password')
expect(passwordInput).toHaveProperty('tabIndex', 0)
expect(submitButton).toHaveAttribute('type', 'submit')
expect(submitButton).toHaveProperty('tabIndex', 0)
await user.type(emailInput, 'user@example.com')
await user.type(passwordInput, 'strong-password{Enter}')
await waitFor(() => {
expect(fetchAccessTokenMock).toHaveBeenCalledWith({
@@ -97,6 +107,25 @@ describe('embedded user id propagation in authentication flows', () => {
expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token')
expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token')
expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
expect(webAppLoginMock).toHaveBeenCalledTimes(1)
})
it('exposes a named password visibility action', async () => {
const user = userEvent.setup()
const params = new URLSearchParams()
params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
useSearchParamsMock.mockReturnValue(params)
render(<MailAndPasswordAuth isEmailSetup />)
const passwordInput = screen.getByLabelText('login.password')
expect(passwordInput).toHaveAttribute('type', 'password')
await user.click(screen.getByRole('button', { name: 'login.showPassword' }))
expect(passwordInput).toHaveAttribute('type', 'text')
await user.click(screen.getByRole('button', { name: 'login.hidePassword' }))
expect(passwordInput).toHaveAttribute('type', 'password')
})
it('does not call password login services when the redirect target is external', async () => {

View File

@@ -1,11 +1,11 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import Input from '@/app/components/base/input'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
@@ -101,29 +101,33 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
}
return (
<form onSubmit={noop}>
<div className="mb-3">
<label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">
<Form
onFormSubmit={() => {
void handleEmailPasswordLogin()
}}
>
<Field name="email" className="mb-3 block">
<FieldLabel className="my-2 py-0 system-md-semibold text-text-secondary">
{t(($) => $.email, { ns: 'login' })}
</label>
</FieldLabel>
<div className="mt-1">
<Input
<FieldControl
value={email}
onChange={(e) => setEmail(e.target.value)}
onValueChange={setEmail}
id="email"
type="email"
autoComplete="email"
spellCheck={false}
placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) || ''}
tabIndex={1}
/>
</div>
</div>
</Field>
<div className="mb-3">
<label htmlFor="password" className="my-2 flex items-center justify-between">
<span className="system-md-semibold text-text-secondary">
<Field name="password" className="mb-3 block">
<div className="my-2 flex items-center justify-between">
<FieldLabel className="py-0 system-md-semibold text-text-secondary">
{t(($) => $.password, { ns: 'login' })}
</span>
</FieldLabel>
<Link
href={`/webapp-reset-password?${searchParams.toString()}`}
className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`}
@@ -132,39 +136,48 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
>
{t(($) => $.forget, { ns: 'login' })}
</Link>
</label>
</div>
<div className="relative mt-1">
<Input
<FieldControl
value={password}
onChange={(e) => setPassword(e.target.value)}
onValueChange={setPassword}
id="password"
onKeyDown={(e) => {
if (e.key === 'Enter') handleEmailPasswordLogin()
}}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
spellCheck={false}
placeholder={t(($) => $.passwordPlaceholder, { ns: 'login' }) || ''}
tabIndex={2}
className="pr-10"
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button type="button" variant="ghost" onClick={() => setShowPassword(!showPassword)}>
{showPassword ? '👀' : '😝'}
<Button
type="button"
variant="ghost"
aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
className="mr-1 size-8 p-0 text-text-tertiary hover:text-text-secondary"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<span className="i-ri-eye-off-line size-4" aria-hidden="true" />
) : (
<span className="i-ri-eye-line size-4" aria-hidden="true" />
)}
</Button>
</div>
</div>
</div>
</Field>
<div className="mb-2">
<Button
tabIndex={2}
type="submit"
variant="primary"
onClick={handleEmailPasswordLogin}
disabled={isLoading || !email || !password}
className="w-full"
>
{t(($) => $.signBtn, { ns: 'login' })}
</Button>
</div>
</form>
</Form>
)
}