fix(web): auth form state management (#37003)

This commit is contained in:
yyh 2026-06-03 17:14:01 +08:00 committed by GitHub
parent 02e1a60cde
commit 5c7f05bd10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 145 additions and 132 deletions

View File

@ -11,7 +11,7 @@ When('I sign in as the default E2E admin', async function (this: DifyWorld) {
const page = this.getPage()
await page.getByLabel('Email address').fill(adminCredentials.email)
await page.getByLabel('Password').fill(adminCredentials.password)
await page.getByLabel('Password', { exact: true }).fill(adminCredentials.password)
await page.getByRole('button', { name: 'Sign in' }).click()
})

View File

@ -4992,22 +4992,6 @@
"count": 1
}
},
"web/app/signin/components/mail-and-code-auth.tsx": {
"no-restricted-globals": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/signin/components/mail-and-password-auth.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/signin/invite-settings/page.tsx": {
"no-restricted-imports": {
"count": 1

View File

@ -1,12 +1,13 @@
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useSetLocalStorage } from '@/hooks/use-local-storage'
import { useRouter, useSearchParams } from '@/next/navigation'
import { sendEMailLoginCode } from '@/service/common'
@ -20,8 +21,9 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
const searchParams = useSearchParams()
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [loading, setIsLoading] = useState(false)
const [loading, setLoading] = useState(false)
const locale = useLocale()
const setCountdownLeftTime = useSetLocalStorage<string>(COUNT_DOWN_KEY, { raw: true })
const handleGetEMailVerificationCode = async () => {
try {
@ -34,10 +36,10 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
toast.error(t('error.emailInValid', { ns: 'login' }))
return
}
setIsLoading(true)
setLoading(true)
const ret = await sendEMailLoginCode(email, locale)
if (ret.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`)
const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data))
@ -48,27 +50,31 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
console.error(error)
}
finally {
setIsLoading(false)
setLoading(false)
}
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
handleGetEMailVerificationCode()
}
return (
<form onSubmit={handleSubmit}>
<input type="text" className="hidden" />
<div className="mb-2">
<label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">{t('email', { ns: 'login' })}</label>
<div className="mt-1">
<Input id="email" type="email" disabled={isInvite} value={email} placeholder={t('emailPlaceholder', { ns: 'login' }) as string} onChange={e => setEmail(e.target.value)} />
</div>
<Form
onFormSubmit={() => {
void handleGetEMailVerificationCode()
}}
>
<FieldRoot name="email" disabled={isInvite} className="mb-2">
<FieldLabel className="my-2 py-0 system-md-semibold text-text-secondary">{t('email', { ns: 'login' })}</FieldLabel>
<FieldControl
type="email"
autoComplete="email"
spellCheck={false}
disabled={isInvite}
value={email}
placeholder={t('emailPlaceholder', { ns: 'login' }) as string}
onValueChange={setEmail}
/>
<div className="mt-3">
<Button type="submit" loading={loading} disabled={loading || !email} variant="primary" className="w-full">{t('signup.verifyMail', { ns: 'login' })}</Button>
</div>
</div>
</form>
</FieldRoot>
</Form>
)
}

View File

@ -1,12 +1,11 @@
import type { ResponseError } from '@/service/fetch'
import { Button } from '@langgenius/dify-ui/button'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Input from '@/app/components/base/input'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import Link from '@/next/link'
@ -20,10 +19,24 @@ import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
type MailAndPasswordAuthProps = {
isInvite: boolean
isEmailSetup: boolean
allowRegistration: boolean
}
export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegistration: _allowRegistration }: MailAndPasswordAuthProps) {
type LoginRequestBody = {
email: string
password: string
language: string
remember_me: boolean
invite_token?: string
}
function hasErrorCode(error: unknown, code: string) {
return typeof error === 'object'
&& error !== null
&& 'code' in error
&& error.code === code
}
export default function MailAndPasswordAuth({ isInvite, isEmailSetup }: MailAndPasswordAuthProps) {
const { t } = useTranslation()
const locale = useLocale()
const router = useRouter()
@ -52,7 +65,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
try {
setIsLoading(true)
const loginData: Record<string, any> = {
const loginData: LoginRequestBody = {
email,
password: encryptPassword(password),
language: locale,
@ -88,7 +101,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
}
}
catch (error) {
if ((error as ResponseError).code === 'authentication_failed') {
if (hasErrorCode(error, 'authentication_failed')) {
toast.error(t('error.invalidEmailOrPassword', { ns: 'login' }))
}
}
@ -98,28 +111,29 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
}
return (
<form onSubmit={noop}>
<div className="mb-3">
<label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">
<Form
onFormSubmit={() => {
void handleEmailPasswordLogin()
}}
>
<FieldRoot name="email" disabled={isInvite} className="mb-3">
<FieldLabel className="my-2 py-0 system-md-semibold text-text-secondary">
{t('email', { ns: 'login' })}
</label>
<div className="mt-1">
<Input
value={email}
onChange={e => setEmail(e.target.value)}
disabled={isInvite}
id="email"
type="email"
autoComplete="email"
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
tabIndex={1}
/>
</div>
</div>
</FieldLabel>
<FieldControl
value={email}
onValueChange={setEmail}
disabled={isInvite}
type="email"
autoComplete="email"
spellCheck={false}
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
/>
</FieldRoot>
<div className="mb-3">
<label htmlFor="password" className="my-2 flex items-center justify-between">
<span className="system-md-semibold text-text-secondary">{t('password', { ns: 'login' })}</span>
<FieldRoot name="password" className="mb-3">
<div className="my-2 flex items-center justify-between">
<FieldLabel className="py-0 system-md-semibold text-text-secondary">{t('password', { ns: 'login' })}</FieldLabel>
<Link
href={`/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'}`}
@ -128,44 +142,45 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
>
{t('forget', { ns: 'login' })}
</Link>
</label>
</div>
<div className="relative mt-1">
<Input
id="password"
<FieldControl
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleEmailPasswordLogin()
}}
onValueChange={setPassword}
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"
aria-label={t(showPassword ? 'hidePassword' : 'showPassword', { ns: 'login' })}
aria-pressed={showPassword}
className="mr-1 size-8 p-0 text-text-tertiary hover:text-text-secondary"
onClick={() => setShowPassword(!showPassword)}
>
{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>
</FieldRoot>
<div className="mb-2">
<Button
tabIndex={2}
type="submit"
loading={isLoading}
variant="primary"
onClick={handleEmailPasswordLogin}
disabled={isLoading || !email || !password}
className="w-full"
>
{t('signBtn', { ns: 'login' })}
</Button>
</div>
</form>
</Form>
)
}

View File

@ -2,8 +2,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CE_EDITION } from '@/config'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
@ -20,7 +19,9 @@ import SSOAuth from './components/sso-auth'
import Split from './split'
import { resolvePostLoginRedirect } from './utils/post-login-redirect'
const NormalForm = () => {
type AuthType = 'code' | 'password'
function NormalForm() {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
@ -30,55 +31,58 @@ const NormalForm = () => {
const { isPending: isCheckLoading, data: userResp, error: probeError } = useQuery({
...userProfileQueryOptions(),
throwOnError: err => !isLegacyBase401(err),
refetchOnWindowFocus: false,
})
const isLoggedIn = !!userResp && !probeError
const message = decodeURIComponent(searchParams.get('message') || '')
const invite_token = decodeURIComponent(searchParams.get('invite_token') || '')
const [isInitCheckLoading, setInitCheckLoading] = useState(true)
const [isRedirecting, setIsRedirecting] = useState(false)
const isLoading = isCheckLoading || isInitCheckLoading || isRedirecting
const inviteToken = decodeURIComponent(searchParams.get('invite_token') || '')
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
const [showORLine, setShowORLine] = useState(false)
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
const [workspaceName, setWorkSpaceName] = useState('')
const [selectedAuthType, setSelectedAuthType] = useState<AuthType | null>(null)
const isInviteLink = Boolean(invite_token && invite_token !== 'null')
const isInviteLink = Boolean(inviteToken && inviteToken !== 'null')
const { data: invitationCheckResp, isPending: isInviteCheckLoading, isError: isInviteCheckError } = useQuery({
queryKey: ['signin', 'invite-check', inviteToken],
queryFn: () => invitationCheck({
url: '/activate/check',
params: {
token: inviteToken,
},
}),
enabled: isInviteLink,
retry: false,
refetchOnWindowFocus: false,
})
const init = useCallback(async () => {
try {
if (isLoggedIn) {
setIsRedirecting(true)
const redirectUrl = resolvePostLoginRedirect(searchParams)
router.replace(redirectUrl || '/')
return
}
const workspaceName = invitationCheckResp?.data?.workspace_name || ''
const hasSocialLogin = systemFeatures.enable_social_oauth_login
const hasSsoLogin = Boolean(systemFeatures.sso_enforced_for_signin)
const hasEmailCodeLogin = systemFeatures.enable_email_code_login
const hasEmailPasswordLogin = systemFeatures.enable_email_password_login
const hasEmailLogin = hasEmailCodeLogin || hasEmailPasswordLogin
const defaultAuthType: AuthType = hasEmailPasswordLogin ? 'password' : 'code'
const authType = selectedAuthType === 'password' && hasEmailPasswordLogin
? 'password'
: selectedAuthType === 'code' && hasEmailCodeLogin
? 'code'
: defaultAuthType
const showORLine = (hasSocialLogin || hasSsoLogin) && hasEmailLogin
const noLoginMethodsConfigured = !hasSocialLogin && !hasEmailCodeLogin && !hasEmailPasswordLogin && !hasSsoLogin
const allMethodsAreDisabled = noLoginMethodsConfigured || isInviteCheckError
const isLoading = isCheckLoading || isLoggedIn || (isInviteLink && isInviteCheckLoading)
if (message) {
toast.error(message)
}
setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin)
setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login))
updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code')
if (isInviteLink) {
const checkRes = await invitationCheck({
url: '/activate/check',
params: {
token: invite_token,
},
})
setWorkSpaceName(checkRes?.data?.workspace_name || '')
}
}
catch (error) {
console.error(error)
setAllMethodsAreDisabled(true)
}
finally { setInitCheckLoading(false) }
}, [isLoggedIn, message, router, searchParams, invite_token, isInviteLink, systemFeatures])
useEffect(() => {
init()
}, [init])
if (!isLoggedIn)
return
const redirectUrl = resolvePostLoginRedirect(searchParams)
router.replace(redirectUrl || '/')
}, [isLoggedIn, router, searchParams])
useEffect(() => {
if (message)
toast.error(message)
}, [message])
if (isLoading) {
return (
<div className={
@ -169,8 +173,8 @@ const NormalForm = () => {
)}
<div className="relative">
<div className="mt-6 flex flex-col gap-3">
{systemFeatures.enable_social_oauth_login && <SocialAuth />}
{systemFeatures.sso_enforced_for_signin && (
{hasSocialLogin && <SocialAuth />}
{hasSsoLogin && (
<div className="w-full">
<SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} />
</div>
@ -187,23 +191,23 @@ const NormalForm = () => {
</div>
)}
{
(systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && (
hasEmailLogin && (
<>
{systemFeatures.enable_email_code_login && authType === 'code' && (
{hasEmailCodeLogin && authType === 'code' && (
<>
<MailAndCodeAuth isInvite={isInviteLink} />
{systemFeatures.enable_email_password_login && (
<div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('password') }}>
{hasEmailPasswordLogin && (
<div className="cursor-pointer py-1 text-center" onClick={() => { setSelectedAuthType('password') }}>
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('usePassword', { ns: 'login' })}</span>
</div>
)}
</>
)}
{systemFeatures.enable_email_password_login && authType === 'password' && (
{hasEmailPasswordLogin && authType === 'password' && (
<>
<MailAndPasswordAuth isInvite={isInviteLink} isEmailSetup={systemFeatures.is_email_setup} allowRegistration={systemFeatures.is_allow_register} />
{systemFeatures.enable_email_code_login && (
<div className="cursor-pointer py-1 text-center" onClick={() => { updateAuthType('code') }}>
<MailAndPasswordAuth isInvite={isInviteLink} isEmailSetup={systemFeatures.is_email_setup} />
{hasEmailCodeLogin && (
<div className="cursor-pointer py-1 text-center" onClick={() => { setSelectedAuthType('code') }}>
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('useVerificationCode', { ns: 'login' })}</span>
</div>
)}

View File

@ -49,6 +49,7 @@
"forgotPasswordDesc": "Please enter your email address to reset your password. We will send you an email with instructions on how to reset your password.",
"go": "Go to Dify",
"goToInit": "If you have not initialized the account, please go to the initialization page",
"hidePassword": "Hide password",
"installBtn": "Set up",
"interfaceLanguage": "Interface Language",
"invalid": "The link has expired",
@ -92,6 +93,7 @@
"setAdminAccount": "Setting up an admin account",
"setAdminAccountDesc": "Maximum privileges for admin account, which can be used to create applications and manage LLM providers, etc.",
"setYourAccount": "Set Your Account",
"showPassword": "Show password",
"signBtn": "Sign in",
"signup.createAccount": "Create your account",
"signup.haveAccount": "Already have an account? ",

View File

@ -49,6 +49,7 @@
"forgotPasswordDesc": "请输入您的电子邮件地址以重置密码。我们将向您发送一封电子邮件,包含如何重置密码的说明。",
"go": "跳转至 Dify",
"goToInit": "如果您还没有初始化账户,请前往初始化页面",
"hidePassword": "隐藏密码",
"installBtn": "设置",
"interfaceLanguage": "界面语言",
"invalid": "链接已失效",
@ -92,6 +93,7 @@
"setAdminAccount": "设置管理员账户",
"setAdminAccountDesc": "管理员拥有的最大权限,可用于创建应用和管理 LLM 供应商等。",
"setYourAccount": "设置您的账户",
"showPassword": "显示密码",
"signBtn": "登录",
"signup.createAccount": "创建您的账户",
"signup.haveAccount": "已有账户?",