Browse Source

New Auth Methods (#8119)

NFish 5 months ago
parent
commit
3898fe3311

+ 57 - 22
web/app/account/account-page/index.tsx

@@ -15,6 +15,7 @@ import { ToastContext } from '@/app/components/base/toast'
 import AppIcon from '@/app/components/base/app-icon'
 import Avatar from '@/app/components/base/avatar'
 import { IS_CE_EDITION } from '@/config'
+import Input from '@/app/components/base/input'
 
 const titleClassName = `
   text-sm font-medium text-gray-900
@@ -31,6 +32,7 @@ const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
 
 export default function AccountPage() {
   const { t } = useTranslation()
+  const { systemFeatures } = useAppContext()
   const { mutateUserProfile, userProfile, apps } = useAppContext()
   const { notify } = useContext(ToastContext)
   const [editNameModalVisible, setEditNameModalVisible] = useState(false)
@@ -41,6 +43,9 @@ export default function AccountPage() {
   const [password, setPassword] = useState('')
   const [confirmPassword, setConfirmPassword] = useState('')
   const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
+  const [showCurrentPassword, setShowCurrentPassword] = useState(false)
+  const [showPassword, setShowPassword] = useState(false)
+  const [showConfirmPassword, setShowConfirmPassword] = useState(false)
 
   const handleEditName = () => {
     setEditNameModalVisible(true)
@@ -158,8 +163,8 @@ export default function AccountPage() {
         </div>
       </div>
       {
-        IS_CE_EDITION && (
-          <div className='mb-8 flex justify-between'>
+        systemFeatures.enable_email_password_login && (
+          <div className='mb-8 flex justify-between gap-2'>
             <div>
               <div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
               <div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>
@@ -191,8 +196,7 @@ export default function AccountPage() {
           >
             <div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
             <div className={titleClassName}>{t('common.account.name')}</div>
-            <input
-              className={inputClassName}
+            <Input className='mt-2'
               value={editName}
               onChange={e => setEditName(e.target.value)}
             />
@@ -223,30 +227,61 @@ export default function AccountPage() {
             {userProfile.is_password_set && (
               <>
                 <div className={titleClassName}>{t('common.account.currentPassword')}</div>
-                <input
-                  type="password"
-                  className={inputClassName}
-                  value={currentPassword}
-                  onChange={e => setCurrentPassword(e.target.value)}
-                />
+                <div className='relative mt-2'>
+                  <Input
+                    type={showCurrentPassword ? 'text' : 'password'}
+                    value={currentPassword}
+                    onChange={e => setCurrentPassword(e.target.value)}
+                  />
+
+                  <div className="absolute inset-y-0 right-0 flex items-center">
+                    <Button
+                      type="button"
+                      variant='ghost'
+                      onClick={() => setShowCurrentPassword(!showCurrentPassword)}
+                    >
+                      {showCurrentPassword ? '👀' : '😝'}
+                    </Button>
+                  </div>
+                </div>
               </>
             )}
             <div className='mt-8 text-sm font-medium text-gray-900'>
               {userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
             </div>
-            <input
-              type="password"
-              className={inputClassName}
-              value={password}
-              onChange={e => setPassword(e.target.value)}
-            />
+            <div className='relative mt-2'>
+              <Input
+                type={showPassword ? 'text' : 'password'}
+                value={password}
+                onChange={e => setPassword(e.target.value)}
+              />
+              <div className="absolute inset-y-0 right-0 flex items-center">
+                <Button
+                  type="button"
+                  variant='ghost'
+                  onClick={() => setShowPassword(!showPassword)}
+                >
+                  {showPassword ? '👀' : '😝'}
+                </Button>
+              </div>
+            </div>
             <div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div>
-            <input
-              type="password"
-              className={inputClassName}
-              value={confirmPassword}
-              onChange={e => setConfirmPassword(e.target.value)}
-            />
+            <div className='relative mt-2'>
+              <Input
+                type={showConfirmPassword ? 'text' : 'password'}
+                value={confirmPassword}
+                onChange={e => setConfirmPassword(e.target.value)}
+              />
+              <div className="absolute inset-y-0 right-0 flex items-center">
+                <Button
+                  type="button"
+                  variant='ghost'
+                  onClick={() => setShowConfirmPassword(!showConfirmPassword)}
+                >
+                  {showConfirmPassword ? '👀' : '😝'}
+                </Button>
+              </div>
+            </div>
             <div className='flex justify-end mt-10'>
               <Button className='mr-2' onClick={() => {
                 setEditPasswordModalVisible(false)

+ 1 - 1
web/app/account/avatar.tsx

@@ -58,7 +58,7 @@ export default function AppSelector() {
             >
               <Menu.Items
                 className="
-                    absolute -right-3 -top-3 w-60 max-w-80
+                    absolute -right-2 -top-1 w-60 max-w-80
                     divide-y divide-gray-100 origin-top-right rounded-lg bg-white
                     shadow-lg
                   "

+ 14 - 188
web/app/activate/activateForm.tsx

@@ -1,27 +1,16 @@
 'use client'
-import { useCallback, useState } from 'react'
-import { useContext } from 'use-context-selector'
 import { useTranslation } from 'react-i18next'
 import useSWR from 'swr'
-import { useSearchParams } from 'next/navigation'
-import Link from 'next/link'
-import { CheckCircleIcon } from '@heroicons/react/24/solid'
-import style from './style.module.css'
+import { useRouter, useSearchParams } from 'next/navigation'
 import cn from '@/utils/classnames'
 import Button from '@/app/components/base/button'
 
-import { SimpleSelect } from '@/app/components/base/select'
-import { timezones } from '@/utils/timezone'
-import { LanguagesSupported, languages } from '@/i18n/language'
-import { activateMember, invitationCheck } from '@/service/common'
-import Toast from '@/app/components/base/toast'
+import { invitationCheck } from '@/service/common'
 import Loading from '@/app/components/base/loading'
-import I18n from '@/context/i18n'
-const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
 
 const ActivateForm = () => {
+  const router = useRouter()
   const { t } = useTranslation()
-  const { locale, setLocaleOnClient } = useContext(I18n)
   const searchParams = useSearchParams()
   const workspaceID = searchParams.get('workspace_id')
   const email = searchParams.get('email')
@@ -35,64 +24,20 @@ const ActivateForm = () => {
       token,
     },
   }
-  const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, {
+  const { data: checkRes } = useSWR(checkParams, invitationCheck, {
     revalidateOnFocus: false,
+    onSuccess(data) {
+      if (data.is_valid) {
+        const params = new URLSearchParams(searchParams)
+        const { email, workspace_id } = data.data
+        params.set('email', encodeURIComponent(email))
+        params.set('workspace_id', encodeURIComponent(workspace_id))
+        params.set('invite_token', encodeURIComponent(token as string))
+        router.replace(`/signin?${params.toString()}`)
+      }
+    },
   })
 
-  const [name, setName] = useState('')
-  const [password, setPassword] = useState('')
-  const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
-  const [language, setLanguage] = useState(locale)
-  const [showSuccess, setShowSuccess] = useState(false)
-
-  const showErrorMessage = useCallback((message: string) => {
-    Toast.notify({
-      type: 'error',
-      message,
-    })
-  }, [])
-
-  const valid = useCallback(() => {
-    if (!name.trim()) {
-      showErrorMessage(t('login.error.nameEmpty'))
-      return false
-    }
-    if (!password.trim()) {
-      showErrorMessage(t('login.error.passwordEmpty'))
-      return false
-    }
-    if (!validPassword.test(password)) {
-      showErrorMessage(t('login.error.passwordInvalid'))
-      return false
-    }
-
-    return true
-  }, [name, password, showErrorMessage, t])
-
-  const handleActivate = useCallback(async () => {
-    if (!valid())
-      return
-    try {
-      await activateMember({
-        url: '/activate',
-        body: {
-          workspace_id: workspaceID,
-          email,
-          token,
-          name,
-          password,
-          interface_language: language,
-          timezone,
-        },
-      })
-      setLocaleOnClient(language, false)
-      setShowSuccess(true)
-    }
-    catch {
-      recheck()
-    }
-  }, [email, language, name, password, recheck, setLocaleOnClient, timezone, token, valid, workspaceID])
-
   return (
     <div className={
       cn(
@@ -115,125 +60,6 @@ const ActivateForm = () => {
           </div>
         </div>
       )}
-      {checkRes && checkRes.is_valid && !showSuccess && (
-        <div className='flex flex-col md:w-[400px]'>
-          <div className="w-full mx-auto">
-            <div className={`mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold ${style.logo}`}>
-            </div>
-            <h2 className="text-[32px] font-bold text-gray-900">
-              {`${t('login.join')} ${checkRes.workspace_name}`}
-            </h2>
-            <p className='mt-1 text-sm text-gray-600 '>
-              {`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`}
-            </p>
-          </div>
-
-          <div className="w-full mx-auto mt-6">
-            <div className="bg-white">
-              {/* username */}
-              <div className='mb-5'>
-                <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
-                  {t('login.name')}
-                </label>
-                <div className="mt-1 relative rounded-md shadow-sm">
-                  <input
-                    id="name"
-                    type="text"
-                    value={name}
-                    onChange={e => setName(e.target.value)}
-                    placeholder={t('login.namePlaceholder') || ''}
-                    className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
-                    tabIndex={1}
-                  />
-                </div>
-              </div>
-              {/* password */}
-              <div className='mb-5'>
-                <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
-                  {t('login.password')}
-                </label>
-                <div className="mt-1 relative rounded-md shadow-sm">
-                  <input
-                    id="password"
-                    type='password'
-                    value={password}
-                    onChange={e => setPassword(e.target.value)}
-                    placeholder={t('login.passwordPlaceholder') || ''}
-                    className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
-                    tabIndex={2}
-                  />
-                </div>
-                <div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
-              </div>
-              {/* language */}
-              <div className='mb-5'>
-                <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
-                  {t('login.interfaceLanguage')}
-                </label>
-                <div className="relative mt-1 rounded-md shadow-sm">
-                  <SimpleSelect
-                    defaultValue={LanguagesSupported[0]}
-                    items={languages.filter(item => item.supported)}
-                    onSelect={(item) => {
-                      setLanguage(item.value as string)
-                    }}
-                  />
-                </div>
-              </div>
-              {/* timezone */}
-              <div className='mb-4'>
-                <label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
-                  {t('login.timezone')}
-                </label>
-                <div className="relative mt-1 rounded-md shadow-sm">
-                  <SimpleSelect
-                    defaultValue={timezone}
-                    items={timezones}
-                    onSelect={(item) => {
-                      setTimezone(item.value as string)
-                    }}
-                  />
-                </div>
-              </div>
-              <div>
-                <Button
-                  variant='primary'
-                  className='w-full !text-sm'
-                  onClick={handleActivate}
-                >
-                  {`${t('login.join')} ${checkRes.workspace_name}`}
-                </Button>
-              </div>
-              <div className="block w-hull mt-2 text-xs text-gray-600">
-                {t('login.license.tip')}
-                &nbsp;
-                <Link
-                  className='text-primary-600'
-                  target='_blank' rel='noopener noreferrer'
-                  href={`https://docs.dify.ai/${language !== LanguagesSupported[1] ? 'user-agreement' : `v/${locale.toLowerCase()}/policies`}/open-source`}
-                >{t('login.license.link')}</Link>
-              </div>
-            </div>
-          </div>
-        </div>
-      )}
-      {checkRes && checkRes.is_valid && showSuccess && (
-        <div className="flex flex-col md:w-[400px]">
-          <div className="w-full mx-auto">
-            <div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">
-              <CheckCircleIcon className='w-10 h-10 text-[#039855]' />
-            </div>
-            <h2 className="text-[32px] font-bold text-gray-900">
-              {`${t('login.activatedTipStart')} ${checkRes.workspace_name} ${t('login.activatedTipEnd')}`}
-            </h2>
-          </div>
-          <div className="w-full mx-auto mt-6">
-            <Button variant='primary' className='w-full !text-sm'>
-              <a href="/signin">{t('login.activated')}</a>
-            </Button>
-          </div>
-        </div>
-      )}
     </div>
   )
 }

+ 5 - 0
web/app/components/base/icons/assets/public/common/lock.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="lock">
+<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M8 1.75C6.27411 1.75 4.875 3.14911 4.875 4.875V6.125C3.83947 6.125 3 6.96444 3 8V12.375C3 13.4106 3.83947 14.25 4.875 14.25H11.125C12.1606 14.25 13 13.4106 13 12.375V8C13 6.96444 12.1606 6.125 11.125 6.125V4.875C11.125 3.14911 9.72587 1.75 8 1.75ZM9.875 6.125V4.875C9.875 3.83947 9.03556 3 8 3C6.96444 3 6.125 3.83947 6.125 4.875V6.125H9.875ZM8 8.625C8.34519 8.625 8.625 8.90481 8.625 9.25V11.125C8.625 11.4702 8.34519 11.75 8 11.75C7.65481 11.75 7.375 11.4702 7.375 11.125V9.25C7.375 8.90481 7.65481 8.625 8 8.625Z" fill="#155AEF"/>
+</g>
+</svg>

+ 4 - 3
web/app/components/header/account-setting/account-page/index.tsx

@@ -2,7 +2,7 @@
 import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
 
-import { useContext } from 'use-context-selector'
+import { useContext, useContextSelector } from 'use-context-selector'
 import Collapse from '../collapse'
 import type { IItem } from '../collapse'
 import s from './index.module.css'
@@ -11,7 +11,7 @@ import Modal from '@/app/components/base/modal'
 import Confirm from '@/app/components/base/confirm'
 import Button from '@/app/components/base/button'
 import { updateUserProfile } from '@/service/common'
-import { useAppContext } from '@/context/app-context'
+import AppContext, { useAppContext } from '@/context/app-context'
 import { ToastContext } from '@/app/components/base/toast'
 import AppIcon from '@/app/components/base/app-icon'
 import Avatar from '@/app/components/base/avatar'
@@ -42,6 +42,7 @@ export default function AccountPage() {
   const [password, setPassword] = useState('')
   const [confirmPassword, setConfirmPassword] = useState('')
   const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
+  const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
 
   const handleEditName = () => {
     setEditNameModalVisible(true)
@@ -144,7 +145,7 @@ export default function AccountPage() {
         <div className={titleClassName}>{t('common.account.email')}</div>
         <div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
       </div>
-      {IS_CE_EDITION && (
+      {systemFeatures.enable_email_password_login && (
         <div className='mb-8'>
           <div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
           <div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>

+ 41 - 0
web/app/components/signin/countdown.tsx

@@ -0,0 +1,41 @@
+'use client'
+import { useCountDown } from 'ahooks'
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+export const COUNT_DOWN_TIME_MS = 59000
+export const COUNT_DOWN_KEY = 'leftTime'
+
+type CountdownProps = {
+  onResend?: () => void
+}
+
+export default function Countdown({ onResend }: CountdownProps) {
+  const { t } = useTranslation()
+  const [leftTime, setLeftTime] = useState(Number(localStorage.getItem(COUNT_DOWN_KEY) || COUNT_DOWN_TIME_MS))
+  const [time] = useCountDown({
+    leftTime,
+    onEnd: () => {
+      setLeftTime(0)
+      localStorage.removeItem(COUNT_DOWN_KEY)
+    },
+  })
+
+  const resend = async function () {
+    setLeftTime(COUNT_DOWN_TIME_MS)
+    localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
+    onResend?.()
+  }
+
+  useEffect(() => {
+    localStorage.setItem(COUNT_DOWN_KEY, `${time}`)
+  }, [time])
+
+  return <p className='system-xs-regular text-text-tertiary'>
+    <span>{t('login.checkCode.didNotReceiveCode')}</span>
+    {time > 0 && <span>{Math.round(time / 1000)}s</span>}
+    {
+      time <= 0 && <span className='system-xs-medium text-text-accent-secondary cursor-pointer' onClick={resend}>{t('login.checkCode.resend')}</span>
+    }
+  </p>
+}

+ 52 - 18
web/app/components/swr-initor.tsx

@@ -1,10 +1,11 @@
 'use client'
 
 import { SWRConfig } from 'swr'
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
 import type { ReactNode } from 'react'
 import { useRouter, useSearchParams } from 'next/navigation'
 import useRefreshToken from '@/hooks/use-refresh-token'
+import { fetchSetupStatus } from '@/service/common'
 
 type SwrInitorProps = {
   children: ReactNode
@@ -21,27 +22,60 @@ const SwrInitor = ({
   const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token')
   const [init, setInit] = useState(false)
 
-  useEffect(() => {
-    if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) {
-      router.replace('/signin')
-      return
+  const isSetupFinished = useCallback(async () => {
+    try {
+      if (localStorage.getItem('setup_status') === 'finished')
+        return true
+      const setUpStatus = await fetchSetupStatus()
+      if (setUpStatus.step !== 'finished') {
+        localStorage.removeItem('setup_status')
+        return false
+      }
+      localStorage.setItem('setup_status', 'finished')
+      return true
     }
-    if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage)
-      getNewAccessToken()
-
-    if (consoleToken && refreshToken) {
-      localStorage.setItem('console_token', consoleToken)
-      localStorage.setItem('refresh_token', refreshToken)
-      getNewAccessToken().then(() => {
-        router.replace('/apps', { forceOptimisticNavigation: false } as any)
-      }).catch(() => {
-        router.replace('/signin')
-      })
+    catch (error) {
+      console.error(error)
+      return false
     }
-
-    setInit(true)
   }, [])
 
+  const setRefreshToken = useCallback(async () => {
+    try {
+      if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage))
+        return Promise.reject(new Error('No token found'))
+
+      if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage)
+        await getNewAccessToken()
+
+      if (consoleToken && refreshToken) {
+        localStorage.setItem('console_token', consoleToken)
+        localStorage.setItem('refresh_token', refreshToken)
+        await getNewAccessToken()
+      }
+    }
+    catch (error) {
+      return Promise.reject(error)
+    }
+  }, [consoleToken, refreshToken, consoleTokenFromLocalStorage, refreshTokenFromLocalStorage, getNewAccessToken])
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const isFinished = await isSetupFinished()
+        if (!isFinished) {
+          router.replace('/install')
+          return
+        }
+        await setRefreshToken()
+        setInit(true)
+      }
+      catch (error) {
+        router.replace('/signin')
+      }
+    })()
+  }, [isSetupFinished, setRefreshToken, router])
+
   return init
     ? (
       <SWRConfig value={{

+ 19 - 22
web/app/forgot-password/ChangePasswordForm.tsx

@@ -5,6 +5,7 @@ import useSWR from 'swr'
 import { useSearchParams } from 'next/navigation'
 import cn from 'classnames'
 import { CheckCircleIcon } from '@heroicons/react/24/solid'
+import Input from '../components/base/input'
 import Button from '@/app/components/base/button'
 import { changePasswordWithToken, verifyForgotPasswordToken } from '@/service/common'
 import Toast from '@/app/components/base/toast'
@@ -113,33 +114,29 @@ const ChangePasswordForm = () => {
                 <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
                   {t('common.account.newPassword')}
                 </label>
-                <div className="mt-1 relative rounded-md shadow-sm">
-                  <input
-                    id="password"
-                    type='password'
-                    value={password}
-                    onChange={e => setPassword(e.target.value)}
-                    placeholder={t('login.passwordPlaceholder') || ''}
-                    className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
-                  />
-                </div>
-                <div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
+                <Input
+                  id="password"
+                  type='password'
+                  value={password}
+                  onChange={e => setPassword(e.target.value)}
+                  placeholder={t('login.passwordPlaceholder') || ''}
+                  className='mt-1'
+                />
+                <div className='mt-1 text-xs text-text-secondary'>{t('login.error.passwordInvalid')}</div>
               </div>
               {/* Confirm Password */}
               <div className='mb-5'>
                 <label htmlFor="confirmPassword" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
                   {t('common.account.confirmPassword')}
                 </label>
-                <div className="mt-1 relative rounded-md shadow-sm">
-                  <input
-                    id="confirmPassword"
-                    type='password'
-                    value={confirmPassword}
-                    onChange={e => setConfirmPassword(e.target.value)}
-                    placeholder={t('login.confirmPasswordPlaceholder') || ''}
-                    className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
-                  />
-                </div>
+                <Input
+                  id="confirmPassword"
+                  type='password'
+                  value={confirmPassword}
+                  onChange={e => setConfirmPassword(e.target.value)}
+                  placeholder={t('login.confirmPasswordPlaceholder') || ''}
+                  className='mt-1'
+                />
               </div>
               <div>
                 <Button
@@ -165,7 +162,7 @@ const ChangePasswordForm = () => {
             </h2>
           </div>
           <div className="w-full mx-auto mt-6">
-            <Button variant='primary' className='w-full !text-sm'>
+            <Button variant='primary' className='w-full'>
               <a href="/signin">{t('login.passwordChanged')}</a>
             </Button>
           </div>

+ 3 - 3
web/app/forgot-password/ForgotPasswordForm.tsx

@@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form'
 import { z } from 'zod'
 import { zodResolver } from '@hookform/resolvers/zod'
 import Loading from '../components/base/loading'
+import Input from '../components/base/input'
 import Button from '@/app/components/base/button'
 
 import {
@@ -78,7 +79,7 @@ const ForgotPasswordForm = () => {
 
   return (
     loading
-      ? <Loading/>
+      ? <Loading />
       : <>
         <div className="sm:mx-auto sm:w-full sm:max-w-md">
           <h2 className="text-[32px] font-bold text-gray-900">
@@ -98,10 +99,9 @@ const ForgotPasswordForm = () => {
                     {t('login.email')}
                   </label>
                   <div className="mt-1">
-                    <input
+                    <Input
                       {...register('email')}
                       placeholder={t('login.emailPlaceholder') || ''}
-                      className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
                     />
                     {errors.email && <span className='text-red-400 text-sm'>{t(`${errors.email?.message}`)}</span>}
                   </div>

+ 2 - 1
web/app/install/installForm.tsx

@@ -65,6 +65,7 @@ const InstallForm = () => {
   useEffect(() => {
     fetchSetupStatus().then((res: SetupStatusResponse) => {
       if (res.step === 'finished') {
+        localStorage.setItem('setup_status', 'finished')
         window.location.href = '/signin'
       }
       else {
@@ -153,7 +154,7 @@ const InstallForm = () => {
                 </Button>
               </div>
             </form>
-            <div className="block w-hull mt-2 text-xs text-gray-600">
+            <div className="block w-full mt-2 text-xs text-gray-600">
               {t('login.license.tip')}
               &nbsp;
               <Link

+ 92 - 0
web/app/reset-password/check-code/page.tsx

@@ -0,0 +1,92 @@
+'use client'
+import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useContext } from 'use-context-selector'
+import Countdown from '@/app/components/signin/countdown'
+import Button from '@/app/components/base/button'
+import Input from '@/app/components/base/input'
+import Toast from '@/app/components/base/toast'
+import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common'
+import I18NContext from '@/context/i18n'
+
+export default function CheckCode() {
+  const { t } = useTranslation()
+  const router = useRouter()
+  const searchParams = useSearchParams()
+  const email = decodeURIComponent(searchParams.get('email') as string)
+  const token = decodeURIComponent(searchParams.get('token') as string)
+  const [code, setVerifyCode] = useState('')
+  const [loading, setIsLoading] = useState(false)
+  const { locale } = useContext(I18NContext)
+
+  const verify = async () => {
+    try {
+      if (!code.trim()) {
+        Toast.notify({
+          type: 'error',
+          message: t('login.checkCode.emptyCode'),
+        })
+        return
+      }
+      if (!/\d{6}/.test(code)) {
+        Toast.notify({
+          type: 'error',
+          message: t('login.checkCode.invalidCode'),
+        })
+        return
+      }
+      setIsLoading(true)
+      const ret = await verifyResetPasswordCode({ email, code, token })
+      ret.is_valid && router.push(`/reset-password/set-password?${searchParams.toString()}`)
+    }
+    catch (error) { console.error(error) }
+    finally {
+      setIsLoading(false)
+    }
+  }
+
+  const resendCode = async () => {
+    try {
+      const res = await sendResetPasswordCode(email, locale)
+      if (res.result === 'success') {
+        const params = new URLSearchParams(searchParams)
+        params.set('token', encodeURIComponent(res.data))
+        router.replace(`/reset-password/check-code?${params.toString()}`)
+      }
+    }
+    catch (error) { console.error(error) }
+  }
+
+  return <div className='flex flex-col gap-3'>
+    <div className='bg-background-default-dodge text-text-accent-light-mode-only border border-components-panel-border-subtle shadow-lg inline-flex w-14 h-14 justify-center items-center rounded-2xl'>
+      <RiMailSendFill className='w-6 h-6 text-2xl' />
+    </div>
+    <div className='pt-2 pb-4'>
+      <h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
+      <p className='mt-2 body-md-regular text-text-secondary'>
+        <span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span>
+        <br />
+        {t('login.checkCode.validTime')}
+      </p>
+    </div>
+
+    <form action="">
+      <input type='text' className='hidden' />
+      <label htmlFor="code" className='system-md-semibold text-text-secondary mb-1'>{t('login.checkCode.verificationCode')}</label>
+      <Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
+      <Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
+      <Countdown onResend={resendCode} />
+    </form>
+    <div className='py-2'>
+      <div className='bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent h-px'></div>
+    </div>
+    <div onClick={() => router.back()} className='flex items-center justify-center h-9 text-text-tertiary cursor-pointer'>
+      <div className='inline-block p-1 rounded-full bg-background-default-dimm'>
+        <RiArrowLeftLine size={12} />
+      </div>
+      <span className='ml-2 system-xs-regular'>{t('login.back')}</span>
+    </div>
+  </div>
+}

+ 39 - 0
web/app/reset-password/layout.tsx

@@ -0,0 +1,39 @@
+import Header from '../signin/_header'
+import style from '../signin/page.module.css'
+
+import cn from '@/utils/classnames'
+
+export default async function SignInLayout({ children }: any) {
+  return <>
+    <div className={cn(
+      style.background,
+      'flex w-full min-h-screen',
+      'sm:p-4 lg:p-8',
+      'gap-x-20',
+      'justify-center lg:justify-start',
+    )}>
+      <div className={
+        cn(
+          'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
+          'space-between',
+        )
+      }>
+        <Header />
+        <div className={
+          cn(
+            'flex flex-col items-center w-full grow justify-center',
+            'px-6',
+            'md:px-[108px]',
+          )
+        }>
+          <div className='flex flex-col md:w-[400px]'>
+            {children}
+          </div>
+        </div>
+        <div className='px-8 py-6 system-xs-regular text-text-tertiary'>
+          © {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
+        </div>
+      </div>
+    </div>
+  </>
+}

+ 101 - 0
web/app/reset-password/page.tsx

@@ -0,0 +1,101 @@
+'use client'
+import Link from 'next/link'
+import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useContext } from 'use-context-selector'
+import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown'
+import { emailRegex } from '@/config'
+import Button from '@/app/components/base/button'
+import Input from '@/app/components/base/input'
+import Toast from '@/app/components/base/toast'
+import { sendResetPasswordCode } from '@/service/common'
+import I18NContext from '@/context/i18n'
+
+export default function CheckCode() {
+  const { t } = useTranslation()
+  const searchParams = useSearchParams()
+  const router = useRouter()
+  const [email, setEmail] = useState('')
+  const [loading, setIsLoading] = useState(false)
+  const { locale } = useContext(I18NContext)
+
+  const handleGetEMailVerificationCode = async () => {
+    try {
+      if (!email) {
+        Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
+        return
+      }
+
+      if (!emailRegex.test(email)) {
+        Toast.notify({
+          type: 'error',
+          message: t('login.error.emailInValid'),
+        })
+        return
+      }
+      setIsLoading(true)
+      const res = await sendResetPasswordCode(email, locale)
+      if (res.result === 'success') {
+        localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
+        const params = new URLSearchParams(searchParams)
+        params.set('token', encodeURIComponent(res.data))
+        params.set('email', encodeURIComponent(email))
+        router.push(`/reset-password/check-code?${params.toString()}`)
+      }
+      else if (res.code === 'account_not_found') {
+        Toast.notify({
+          type: 'error',
+          message: t('login.error.registrationNotAllowed'),
+        })
+      }
+      else {
+        Toast.notify({
+          type: 'error',
+          message: res.data,
+        })
+      }
+    }
+    catch (error) {
+      console.error(error)
+    }
+    finally {
+      setIsLoading(false)
+    }
+  }
+
+  return <div className='flex flex-col gap-3'>
+    <div className='bg-background-default-dodge border border-components-panel-border-subtle shadow-lg inline-flex w-14 h-14 justify-center items-center rounded-2xl'>
+      <RiLockPasswordLine className='w-6 h-6 text-2xl text-text-accent-light-mode-only' />
+    </div>
+    <div className='pt-2 pb-4'>
+      <h2 className='title-4xl-semi-bold text-text-primary'>{t('login.resetPassword')}</h2>
+      <p className='body-md-regular mt-2 text-text-secondary'>
+        {t('login.resetPasswordDesc')}
+      </p>
+    </div>
+
+    <form onSubmit={() => { }}>
+      <input type='text' className='hidden' />
+      <div className='mb-2'>
+        <label htmlFor="email" className='my-2 system-md-semibold text-text-secondary'>{t('login.email')}</label>
+        <div className='mt-1'>
+          <Input id='email' type="email" disabled={loading} value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
+        </div>
+        <div className='mt-3'>
+          <Button loading={loading} disabled={loading} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.sendVerificationCode')}</Button>
+        </div>
+      </div>
+    </form>
+    <div className='py-2'>
+      <div className='bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent h-px'></div>
+    </div>
+    <Link href={`/signin?${searchParams.toString()}`} className='flex items-center justify-center h-9 text-text-tertiary'>
+      <div className='inline-block p-1 rounded-full bg-background-default-dimm'>
+        <RiArrowLeftLine size={12} />
+      </div>
+      <span className='ml-2 system-xs-regular'>{t('login.backToLogin')}</span>
+    </Link>
+  </div>
+}

+ 193 - 0
web/app/reset-password/set-password/page.tsx

@@ -0,0 +1,193 @@
+'use client'
+import { useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useRouter, useSearchParams } from 'next/navigation'
+import cn from 'classnames'
+import { RiCheckboxCircleFill } from '@remixicon/react'
+import { useCountDown } from 'ahooks'
+import Button from '@/app/components/base/button'
+import { changePasswordWithToken } from '@/service/common'
+import Toast from '@/app/components/base/toast'
+import Input from '@/app/components/base/input'
+
+const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
+
+const ChangePasswordForm = () => {
+  const { t } = useTranslation()
+  const router = useRouter()
+  const searchParams = useSearchParams()
+  const token = decodeURIComponent(searchParams.get('token') || '')
+
+  const [password, setPassword] = useState('')
+  const [confirmPassword, setConfirmPassword] = useState('')
+  const [showSuccess, setShowSuccess] = useState(false)
+  const [showPassword, setShowPassword] = useState(false)
+  const [showConfirmPassword, setShowConfirmPassword] = useState(false)
+
+  const showErrorMessage = useCallback((message: string) => {
+    Toast.notify({
+      type: 'error',
+      message,
+    })
+  }, [])
+
+  const getSignInUrl = () => {
+    if (searchParams.has('invite_token')) {
+      const params = new URLSearchParams()
+      params.set('token', searchParams.get('invite_token') as string)
+      return `/activate?${params.toString()}`
+    }
+    return '/signin'
+  }
+
+  const AUTO_REDIRECT_TIME = 5000
+  const [leftTime, setLeftTime] = useState<number | undefined>(undefined)
+  const [countdown] = useCountDown({
+    leftTime,
+    onEnd: () => {
+      router.replace(getSignInUrl())
+    },
+  })
+
+  const valid = useCallback(() => {
+    if (!password.trim()) {
+      showErrorMessage(t('login.error.passwordEmpty'))
+      return false
+    }
+    if (!validPassword.test(password)) {
+      showErrorMessage(t('login.error.passwordInvalid'))
+      return false
+    }
+    if (password !== confirmPassword) {
+      showErrorMessage(t('common.account.notEqual'))
+      return false
+    }
+    return true
+  }, [password, confirmPassword, showErrorMessage, t])
+
+  const handleChangePassword = useCallback(async () => {
+    if (!valid())
+      return
+    try {
+      await changePasswordWithToken({
+        url: '/forgot-password/resets',
+        body: {
+          token,
+          new_password: password,
+          password_confirm: confirmPassword,
+        },
+      })
+      setShowSuccess(true)
+      setLeftTime(AUTO_REDIRECT_TIME)
+    }
+    catch (error) {
+      console.error(error)
+    }
+  }, [password, token, valid, confirmPassword])
+
+  return (
+    <div className={
+      cn(
+        'flex flex-col items-center w-full grow justify-center',
+        'px-6',
+        'md:px-[108px]',
+      )
+    }>
+      {!showSuccess && (
+        <div className='flex flex-col md:w-[400px]'>
+          <div className="w-full mx-auto">
+            <h2 className="title-4xl-semi-bold text-text-primary">
+              {t('login.changePassword')}
+            </h2>
+            <p className='mt-2 body-md-regular text-text-secondary'>
+              {t('login.changePasswordTip')}
+            </p>
+          </div>
+
+          <div className="w-full mx-auto mt-6">
+            <div className="bg-white">
+              {/* Password */}
+              <div className='mb-5'>
+                <label htmlFor="password" className="my-2 system-md-semibold text-text-secondary">
+                  {t('common.account.newPassword')}
+                </label>
+                <div className='relative mt-1'>
+                  <Input
+                    id="password" type={showPassword ? 'text' : 'password'}
+                    value={password}
+                    onChange={e => setPassword(e.target.value)}
+                    placeholder={t('login.passwordPlaceholder') || ''}
+                  />
+
+                  <div className="absolute inset-y-0 right-0 flex items-center">
+                    <Button
+                      type="button"
+                      variant='ghost'
+                      onClick={() => setShowPassword(!showPassword)}
+                    >
+                      {showPassword ? '👀' : '😝'}
+                    </Button>
+                  </div>
+                </div>
+                <div className='mt-1 body-xs-regular text-text-secondary'>{t('login.error.passwordInvalid')}</div>
+              </div>
+              {/* Confirm Password */}
+              <div className='mb-5'>
+                <label htmlFor="confirmPassword" className="my-2 system-md-semibold text-text-secondary">
+                  {t('common.account.confirmPassword')}
+                </label>
+                <div className='relative mt-1'>
+                  <Input
+                    id="confirmPassword"
+                    type={showConfirmPassword ? 'text' : 'password'}
+                    value={confirmPassword}
+                    onChange={e => setConfirmPassword(e.target.value)}
+                    placeholder={t('login.confirmPasswordPlaceholder') || ''}
+                  />
+                  <div className="absolute inset-y-0 right-0 flex items-center">
+                    <Button
+                      type="button"
+                      variant='ghost'
+                      onClick={() => setShowConfirmPassword(!showConfirmPassword)}
+                    >
+                      {showConfirmPassword ? '👀' : '😝'}
+                    </Button>
+                  </div>
+                </div>
+              </div>
+              <div>
+                <Button
+                  variant='primary'
+                  className='w-full'
+                  onClick={handleChangePassword}
+                >
+                  {t('login.changePasswordBtn')}
+                </Button>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+      {showSuccess && (
+        <div className="flex flex-col md:w-[400px]">
+          <div className="w-full mx-auto">
+            <div className="mb-3 flex justify-center items-center w-14 h-14 rounded-2xl border border-components-panel-border-subtle shadow-lg font-bold">
+              <RiCheckboxCircleFill className='w-6 h-6 text-text-success' />
+            </div>
+            <h2 className="title-4xl-semi-bold text-text-primary">
+              {t('login.passwordChangedTip')}
+            </h2>
+          </div>
+          <div className="w-full mx-auto mt-6">
+            <Button variant='primary' className='w-full' onClick={() => {
+              setLeftTime(undefined)
+              router.replace(getSignInUrl())
+            }}>{t('login.passwordChanged')} ({Math.round(countdown / 1000)}) </Button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default ChangePasswordForm

+ 96 - 0
web/app/signin/check-code/page.tsx

@@ -0,0 +1,96 @@
+'use client'
+import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useContext } from 'use-context-selector'
+import Countdown from '@/app/components/signin/countdown'
+import Button from '@/app/components/base/button'
+import Input from '@/app/components/base/input'
+import Toast from '@/app/components/base/toast'
+import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
+import I18NContext from '@/context/i18n'
+
+export default function CheckCode() {
+  const { t } = useTranslation()
+  const router = useRouter()
+  const searchParams = useSearchParams()
+  const email = decodeURIComponent(searchParams.get('email') as string)
+  const token = decodeURIComponent(searchParams.get('token') as string)
+  const invite_token = decodeURIComponent(searchParams.get('invite_token') || '')
+  const [code, setVerifyCode] = useState('')
+  const [loading, setIsLoading] = useState(false)
+  const { locale } = useContext(I18NContext)
+
+  const verify = async () => {
+    try {
+      if (!code.trim()) {
+        Toast.notify({
+          type: 'error',
+          message: t('login.checkCode.emptyCode'),
+        })
+        return
+      }
+      if (!/\d{6}/.test(code)) {
+        Toast.notify({
+          type: 'error',
+          message: t('login.checkCode.invalidCode'),
+        })
+        return
+      }
+      setIsLoading(true)
+      const ret = await emailLoginWithCode({ email, code, token })
+      if (ret.result === 'success') {
+        localStorage.setItem('console_token', ret.data.access_token)
+        localStorage.setItem('refresh_token', ret.data.refresh_token)
+        router.replace(invite_token ? `/signin/invite-settings?${searchParams.toString()}` : '/apps')
+      }
+    }
+    catch (error) { console.error(error) }
+    finally {
+      setIsLoading(false)
+    }
+  }
+
+  const resendCode = async () => {
+    try {
+      const ret = await sendEMailLoginCode(email, locale)
+      if (ret.result === 'success') {
+        const params = new URLSearchParams(searchParams)
+        params.set('token', encodeURIComponent(ret.data))
+        router.replace(`/signin/check-code?${params.toString()}`)
+      }
+    }
+    catch (error) { console.error(error) }
+  }
+
+  return <div className='flex flex-col gap-3'>
+    <div className='bg-background-default-dodge border border-components-panel-border-subtle shadow-lg inline-flex w-14 h-14 justify-center items-center rounded-2xl'>
+      <RiMailSendFill className='w-6 h-6 text-2xl text-text-accent-light-mode-only' />
+    </div>
+    <div className='pt-2 pb-4'>
+      <h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
+      <p className='body-md-regular mt-2 text-text-secondary'>
+        <span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span>
+        <br />
+        {t('login.checkCode.validTime')}
+      </p>
+    </div>
+
+    <form action="">
+      <label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
+      <Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
+      <Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
+      <Countdown onResend={resendCode} />
+    </form>
+    <div className='py-2'>
+      <div className='bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent h-px'></div>
+    </div>
+    <div onClick={() => router.back()} className='flex items-center justify-center h-9 text-text-tertiary cursor-pointer'>
+      <div className='inline-block p-1 rounded-full bg-background-default-dimm'>
+        <RiArrowLeftLine size={12} />
+      </div>
+      <span className='ml-2 system-xs-regular'>{t('login.back')}</span>
+    </div>
+  </div>
+}

+ 71 - 0
web/app/signin/components/mail-and-code-auth.tsx

@@ -0,0 +1,71 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useContext } from 'use-context-selector'
+import Input from '@/app/components/base/input'
+import Button from '@/app/components/base/button'
+import { emailRegex } from '@/config'
+import Toast from '@/app/components/base/toast'
+import { sendEMailLoginCode } from '@/service/common'
+import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
+import I18NContext from '@/context/i18n'
+
+type MailAndCodeAuthProps = {
+  isInvite: boolean
+}
+
+export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
+  const { t } = useTranslation()
+  const router = useRouter()
+  const searchParams = useSearchParams()
+  const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
+  const [email, setEmail] = useState(emailFromLink)
+  const [loading, setIsLoading] = useState(false)
+  const { locale } = useContext(I18NContext)
+
+  const handleGetEMailVerificationCode = async () => {
+    try {
+      if (!email) {
+        Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
+        return
+      }
+
+      if (!emailRegex.test(email)) {
+        Toast.notify({
+          type: 'error',
+          message: t('login.error.emailInValid'),
+        })
+        return
+      }
+      setIsLoading(true)
+      const ret = await sendEMailLoginCode(email, locale)
+      if (ret.result === 'success') {
+        localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
+        const params = new URLSearchParams(searchParams)
+        params.set('email', encodeURIComponent(email))
+        params.set('token', encodeURIComponent(ret.data))
+        router.push(`/signin/check-code?${params.toString()}`)
+      }
+    }
+    catch (error) {
+      console.error(error)
+    }
+    finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (<form onSubmit={() => { }}>
+    <input type='text' className='hidden' />
+    <div className='mb-2'>
+      <label htmlFor="email" className='my-2 system-md-semibold text-text-secondary'>{t('login.email')}</label>
+      <div className='mt-1'>
+        <Input id='email' type="email" disabled={isInvite} value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
+      </div>
+      <div className='mt-3'>
+        <Button loading={loading} disabled={loading || !email} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.continueWithCode')}</Button>
+      </div>
+    </div>
+  </form>
+  )
+}

+ 167 - 0
web/app/signin/components/mail-and-password-auth.tsx

@@ -0,0 +1,167 @@
+import Link from 'next/link'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useContext } from 'use-context-selector'
+import Button from '@/app/components/base/button'
+import Toast from '@/app/components/base/toast'
+import { emailRegex } from '@/config'
+import { login } from '@/service/common'
+import Input from '@/app/components/base/input'
+import I18NContext from '@/context/i18n'
+
+type MailAndPasswordAuthProps = {
+  isInvite: boolean
+  allowRegistration: boolean
+}
+
+const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
+
+export default function MailAndPasswordAuth({ isInvite, allowRegistration }: MailAndPasswordAuthProps) {
+  const { t } = useTranslation()
+  const { locale } = useContext(I18NContext)
+  const router = useRouter()
+  const searchParams = useSearchParams()
+  const [showPassword, setShowPassword] = useState(false)
+  const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
+  const [email, setEmail] = useState(emailFromLink)
+  const [password, setPassword] = useState('')
+
+  const [isLoading, setIsLoading] = useState(false)
+  const handleEmailPasswordLogin = async () => {
+    if (!email) {
+      Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
+      return
+    }
+    if (!emailRegex.test(email)) {
+      Toast.notify({
+        type: 'error',
+        message: t('login.error.emailInValid'),
+      })
+      return
+    }
+    if (!password?.trim()) {
+      Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') })
+      return
+    }
+    if (!passwordRegex.test(password)) {
+      Toast.notify({
+        type: 'error',
+        message: t('login.error.passwordInvalid'),
+      })
+      return
+    }
+    try {
+      setIsLoading(true)
+      const loginData: Record<string, any> = {
+        email,
+        password,
+        language: locale,
+        remember_me: true,
+      }
+      if (isInvite)
+        loginData.invite_token = decodeURIComponent(searchParams.get('invite_token') as string)
+      const res = await login({
+        url: '/login',
+        body: loginData,
+      })
+      if (res.result === 'success') {
+        if (isInvite) {
+          router.replace(`/signin/invite-settings?${searchParams.toString()}`)
+        }
+        else {
+          localStorage.setItem('console_token', res.data.access_token)
+          localStorage.setItem('refresh_token', res.data.refresh_token)
+          router.replace('/apps')
+        }
+      }
+      else if (res.code === 'account_not_found') {
+        if (allowRegistration) {
+          const params = new URLSearchParams()
+          params.append('email', encodeURIComponent(email))
+          params.append('token', encodeURIComponent(res.data))
+          router.replace(`/reset-password/check-code?${params.toString()}`)
+        }
+        else {
+          Toast.notify({
+            type: 'error',
+            message: t('login.error.registrationNotAllowed'),
+          })
+        }
+      }
+      else {
+        Toast.notify({
+          type: 'error',
+          message: res.data,
+        })
+      }
+    }
+
+    finally {
+      setIsLoading(false)
+    }
+  }
+
+  return <form onSubmit={() => { }}>
+    <div className='mb-3'>
+      <label htmlFor="email" className="my-2 system-md-semibold text-text-secondary">
+        {t('login.email')}
+      </label>
+      <div className="mt-1">
+        <Input
+          value={email}
+          onChange={e => setEmail(e.target.value)}
+          disabled={isInvite}
+          id="email"
+          type="email"
+          autoComplete="email"
+          placeholder={t('login.emailPlaceholder') || ''}
+          tabIndex={1}
+        />
+      </div>
+    </div>
+
+    <div className='mb-3'>
+      <label htmlFor="password" className="my-2 flex items-center justify-between">
+        <span className='system-md-semibold text-text-secondary'>{t('login.password')}</span>
+        <Link href={`/reset-password?${searchParams.toString()}`} className='system-xs-regular text-components-button-secondary-accent-text'>
+          {t('login.forget')}
+        </Link>
+      </label>
+      <div className="relative mt-1">
+        <Input
+          id="password"
+          value={password}
+          onChange={e => setPassword(e.target.value)}
+          onKeyDown={(e) => {
+            if (e.key === 'Enter')
+              handleEmailPasswordLogin()
+          }}
+          type={showPassword ? 'text' : 'password'}
+          autoComplete="current-password"
+          placeholder={t('login.passwordPlaceholder') || ''}
+          tabIndex={2}
+        />
+        <div className="absolute inset-y-0 right-0 flex items-center">
+          <Button
+            type="button"
+            variant='ghost'
+            onClick={() => setShowPassword(!showPassword)}
+          >
+            {showPassword ? '👀' : '😝'}
+          </Button>
+        </div>
+      </div>
+    </div>
+
+    <div className='mb-2'>
+      <Button
+        tabIndex={2}
+        variant='primary'
+        onClick={handleEmailPasswordLogin}
+        disabled={isLoading || !email || !password}
+        className="w-full"
+      >{t('login.signBtn')}</Button>
+    </div>
+  </form>
+}

+ 62 - 0
web/app/signin/components/social-auth.tsx

@@ -0,0 +1,62 @@
+import { useTranslation } from 'react-i18next'
+import { useSearchParams } from 'next/navigation'
+import style from '../page.module.css'
+import Button from '@/app/components/base/button'
+import { apiPrefix } from '@/config'
+import classNames from '@/utils/classnames'
+import { getPurifyHref } from '@/utils'
+
+type SocialAuthProps = {
+  disabled?: boolean
+}
+
+export default function SocialAuth(props: SocialAuthProps) {
+  const { t } = useTranslation()
+  const searchParams = useSearchParams()
+
+  const getOAuthLink = (href: string) => {
+    const url = getPurifyHref(`${apiPrefix}${href}`)
+    if (searchParams.has('invite_token'))
+      return `${url}?${searchParams.toString()}`
+
+    return url
+  }
+  return <>
+    <div className='w-full'>
+      <a href={getOAuthLink('/oauth/login/github')}>
+        <Button
+          disabled={props.disabled}
+          className='w-full'
+        >
+          <>
+            <span className={
+              classNames(
+                style.githubIcon,
+                'w-5 h-5 mr-2',
+              )
+            } />
+            <span className="truncate">{t('login.withGitHub')}</span>
+          </>
+        </Button>
+      </a>
+    </div>
+    <div className='w-full'>
+      <a href={getOAuthLink('/oauth/login/google')}>
+        <Button
+          disabled={props.disabled}
+          className='w-full'
+        >
+          <>
+            <span className={
+              classNames(
+                style.googleIcon,
+                'w-5 h-5 mr-2',
+              )
+            } />
+            <span className="truncate">{t('login.withGoogle')}</span>
+          </>
+        </Button>
+      </a>
+    </div>
+  </>
+}

+ 73 - 0
web/app/signin/components/sso-auth.tsx

@@ -0,0 +1,73 @@
+'use client'
+import { useRouter, useSearchParams } from 'next/navigation'
+import type { FC } from 'react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
+import Toast from '@/app/components/base/toast'
+import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso'
+import Button from '@/app/components/base/button'
+import { SSOProtocol } from '@/types/feature'
+
+type SSOAuthProps = {
+  protocol: SSOProtocol | ''
+}
+
+const SSOAuth: FC<SSOAuthProps> = ({
+  protocol,
+}) => {
+  const router = useRouter()
+  const { t } = useTranslation()
+  const searchParams = useSearchParams()
+  const invite_token = decodeURIComponent(searchParams.get('invite_token') || '')
+
+  const [isLoading, setIsLoading] = useState(false)
+
+  const handleSSOLogin = () => {
+    setIsLoading(true)
+    if (protocol === SSOProtocol.SAML) {
+      getUserSAMLSSOUrl(invite_token).then((res) => {
+        router.push(res.url)
+      }).finally(() => {
+        setIsLoading(false)
+      })
+    }
+    else if (protocol === SSOProtocol.OIDC) {
+      getUserOIDCSSOUrl(invite_token).then((res) => {
+        document.cookie = `user-oidc-state=${res.state}`
+        router.push(res.url)
+      }).finally(() => {
+        setIsLoading(false)
+      })
+    }
+    else if (protocol === SSOProtocol.OAuth2) {
+      getUserOAuth2SSOUrl(invite_token).then((res) => {
+        document.cookie = `user-oauth2-state=${res.state}`
+        router.push(res.url)
+      }).finally(() => {
+        setIsLoading(false)
+      })
+    }
+    else {
+      Toast.notify({
+        type: 'error',
+        message: 'invalid SSO protocol',
+      })
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <Button
+      tabIndex={0}
+      onClick={() => { handleSSOLogin() }}
+      disabled={isLoading}
+      className="w-full"
+    >
+      <Lock01 className='mr-2 w-5 h-5 text-text-accent-light-mode-only' />
+      <span className="truncate">{t('login.withSSO')}</span>
+    </Button>
+  )
+}
+
+export default SSOAuth

+ 0 - 34
web/app/signin/forms.tsx

@@ -1,34 +0,0 @@
-'use client'
-import React from 'react'
-import { useSearchParams } from 'next/navigation'
-
-import NormalForm from './normalForm'
-import OneMoreStep from './oneMoreStep'
-import cn from '@/utils/classnames'
-
-const Forms = () => {
-  const searchParams = useSearchParams()
-  const step = searchParams.get('step')
-
-  const getForm = () => {
-    switch (step) {
-      case 'next':
-        return <OneMoreStep />
-      default:
-        return <NormalForm />
-    }
-  }
-  return <div className={
-    cn(
-      'flex flex-col items-center w-full grow justify-center',
-      'px-6',
-      'md:px-[108px]',
-    )
-  }>
-    <div className='flex flex-col md:w-[400px]'>
-      {getForm()}
-    </div>
-  </div>
-}
-
-export default Forms

+ 154 - 0
web/app/signin/invite-settings/page.tsx

@@ -0,0 +1,154 @@
+'use client'
+import { useTranslation } from 'react-i18next'
+import { useCallback, useState } from 'react'
+import Link from 'next/link'
+import { useContext } from 'use-context-selector'
+import { useRouter, useSearchParams } from 'next/navigation'
+import useSWR from 'swr'
+import { RiAccountCircleLine } from '@remixicon/react'
+import Input from '@/app/components/base/input'
+import { SimpleSelect } from '@/app/components/base/select'
+import Button from '@/app/components/base/button'
+import { timezones } from '@/utils/timezone'
+import { LanguagesSupported, languages } from '@/i18n/language'
+import I18n from '@/context/i18n'
+import { activateMember, invitationCheck } from '@/service/common'
+import Loading from '@/app/components/base/loading'
+import Toast from '@/app/components/base/toast'
+
+export default function InviteSettingsPage() {
+  const { t } = useTranslation()
+  const router = useRouter()
+  const searchParams = useSearchParams()
+  const token = decodeURIComponent(searchParams.get('invite_token') as string)
+  const { locale, setLocaleOnClient } = useContext(I18n)
+  const [name, setName] = useState('')
+  const [language, setLanguage] = useState(LanguagesSupported[0])
+  const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles')
+
+  const checkParams = {
+    url: '/activate/check',
+    params: {
+      token,
+    },
+  }
+  const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, {
+    revalidateOnFocus: false,
+  })
+
+  const handleActivate = useCallback(async () => {
+    try {
+      if (!name) {
+        Toast.notify({ type: 'error', message: t('login.enterYourName') })
+        return
+      }
+      const res = await activateMember({
+        url: '/activate',
+        body: {
+          token,
+          name,
+          interface_language: language,
+          timezone,
+        },
+      })
+      if (res.result === 'success') {
+        localStorage.setItem('console_token', res.data.access_token)
+        localStorage.setItem('refresh_token', res.data.refresh_token)
+        setLocaleOnClient(language, false)
+        router.replace('/apps')
+      }
+    }
+    catch {
+      recheck()
+    }
+  }, [language, name, recheck, setLocaleOnClient, timezone, token, router, t])
+
+  if (!checkRes)
+    return <Loading />
+  if (!checkRes.is_valid) {
+    return <div className="flex flex-col md:w-[400px]">
+      <div className="w-full mx-auto">
+        <div className="mb-3 flex justify-center items-center w-14 h-14 rounded-2xl border border-components-panel-border-subtle shadow-lg text-2xl font-bold">🤷‍♂️</div>
+        <h2 className="title-4xl-semi-bold">{t('login.invalid')}</h2>
+      </div>
+      <div className="w-full mx-auto mt-6">
+        <Button variant='primary' className='w-full !text-sm'>
+          <a href="https://dify.ai">{t('login.explore')}</a>
+        </Button>
+      </div>
+    </div>
+  }
+
+  return <div className='flex flex-col gap-3'>
+    <div className='bg-background-default-dodge border border-components-panel-border-subtle shadow-lg inline-flex w-14 h-14 justify-center items-center rounded-2xl'>
+      <RiAccountCircleLine className='w-6 h-6 text-2xl text-text-accent-light-mode-only' />
+    </div>
+    <div className='pt-2 pb-4'>
+      <h2 className='title-4xl-semi-bold'>{t('login.setYourAccount')}</h2>
+    </div>
+    <form action=''>
+
+      <div className='mb-5'>
+        <label htmlFor="name" className="my-2 system-md-semibold">
+          {t('login.name')}
+        </label>
+        <div className="mt-1">
+          <Input
+            id="name"
+            type="text"
+            value={name}
+            onChange={e => setName(e.target.value)}
+            placeholder={t('login.namePlaceholder') || ''}
+          />
+        </div>
+      </div>
+      <div className='mb-5'>
+        <label htmlFor="name" className="my-2 system-md-semibold">
+          {t('login.interfaceLanguage')}
+        </label>
+        <div className="mt-1">
+          <SimpleSelect
+            defaultValue={LanguagesSupported[0]}
+            items={languages.filter(item => item.supported)}
+            onSelect={(item) => {
+              setLanguage(item.value as string)
+            }}
+          />
+        </div>
+      </div>
+      {/* timezone */}
+      <div className='mb-5'>
+        <label htmlFor="timezone" className="system-md-semibold">
+          {t('login.timezone')}
+        </label>
+        <div className="mt-1">
+          <SimpleSelect
+            defaultValue={timezone}
+            items={timezones}
+            onSelect={(item) => {
+              setTimezone(item.value as string)
+            }}
+          />
+        </div>
+      </div>
+      <div>
+        <Button
+          variant='primary'
+          className='w-full'
+          onClick={handleActivate}
+        >
+          {`${t('login.join')} ${checkRes?.data?.workspace_name}`}
+        </Button>
+      </div>
+    </form>
+    <div className="block w-full mt-2 system-xs-regular">
+      {t('login.license.tip')}
+      &nbsp;
+      <Link
+        className='system-xs-medium text-text-accent-secondary'
+        target='_blank' rel='noopener noreferrer'
+        href={`https://docs.dify.ai/${language !== LanguagesSupported[1] ? 'user-agreement' : `v/${locale.toLowerCase()}/policies`}/open-source`}
+      >{t('login.license.link')}</Link>
+    </div>
+  </div>
+}

+ 54 - 0
web/app/signin/layout.tsx

@@ -0,0 +1,54 @@
+import Script from 'next/script'
+import Header from './_header'
+import style from './page.module.css'
+
+import cn from '@/utils/classnames'
+import { IS_CE_EDITION } from '@/config'
+
+export default async function SignInLayout({ children }: any) {
+  return <>
+    {!IS_CE_EDITION && (
+      <>
+        <Script strategy="beforeInteractive" async src={'https://www.googletagmanager.com/gtag/js?id=AW-11217955271'}></Script>
+        <Script
+          id="ga-monitor-register"
+          dangerouslySetInnerHTML={{
+            __html: 'window.dataLayer2 = window.dataLayer2 || [];function gtag(){dataLayer2.push(arguments);}gtag(\'js\', new Date());gtag(\'config\', \'AW-11217955271"\');',
+          }}
+        >
+        </Script>
+      </>
+    )}
+
+    <div className={cn(
+      style.background,
+      'flex w-full min-h-screen',
+      'sm:p-4 lg:p-8',
+      'gap-x-20',
+      'justify-center lg:justify-start',
+    )}>
+      <div className={
+        cn(
+          'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
+          'space-between',
+        )
+      }>
+        <Header />
+        <div className={
+          cn(
+            'flex flex-col items-center w-full grow justify-center',
+            'px-6',
+            'md:px-[108px]',
+          )
+        }>
+          <div className='flex flex-col md:w-[400px]'>
+            {children}
+          </div>
+        </div>
+        <div className='px-8 py-6 system-xs-regular text-text-tertiary'>
+          © {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
+        </div>
+      </div>
+    </div>
+  </>
+}

+ 126 - 255
web/app/signin/normalForm.tsx

@@ -1,299 +1,170 @@
-'use client'
-import React, { useEffect, useReducer, useState } from 'react'
+import React, { useCallback, useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { useRouter } from 'next/navigation'
-import useSWR from 'swr'
 import Link from 'next/link'
-import Toast from '../components/base/toast'
-import style from './page.module.css'
-import classNames from '@/utils/classnames'
-import { IS_CE_EDITION, SUPPORT_MAIL_LOGIN, apiPrefix, emailRegex } from '@/config'
-import Button from '@/app/components/base/button'
-import { login, oauth } from '@/service/common'
-import { getPurifyHref } from '@/utils'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { RiDoorLockLine } from '@remixicon/react'
+import Loading from '../components/base/loading'
+import MailAndCodeAuth from './components/mail-and-code-auth'
+import MailAndPasswordAuth from './components/mail-and-password-auth'
+import SocialAuth from './components/social-auth'
+import SSOAuth from './components/sso-auth'
+import cn from '@/utils/classnames'
+import { getSystemFeatures, invitationCheck } from '@/service/common'
+import { defaultSystemFeatures } from '@/types/feature'
+import Toast from '@/app/components/base/toast'
 import useRefreshToken from '@/hooks/use-refresh-token'
-
-type IState = {
-  formValid: boolean
-  github: boolean
-  google: boolean
-}
-
-type IAction = {
-  type: 'login' | 'login_failed' | 'github_login' | 'github_login_failed' | 'google_login' | 'google_login_failed'
-}
-
-function reducer(state: IState, action: IAction) {
-  switch (action.type) {
-    case 'login':
-      return {
-        ...state,
-        formValid: true,
-      }
-    case 'login_failed':
-      return {
-        ...state,
-        formValid: true,
-      }
-    case 'github_login':
-      return {
-        ...state,
-        github: true,
-      }
-    case 'github_login_failed':
-      return {
-        ...state,
-        github: false,
-      }
-    case 'google_login':
-      return {
-        ...state,
-        google: true,
-      }
-    case 'google_login_failed':
-      return {
-        ...state,
-        google: false,
-      }
-    default:
-      throw new Error('Unknown action.')
-  }
-}
+import { IS_CE_EDITION } from '@/config'
 
 const NormalForm = () => {
-  const { t } = useTranslation()
   const { getNewAccessToken } = useRefreshToken()
-  const useEmailLogin = IS_CE_EDITION || SUPPORT_MAIL_LOGIN
-
+  const { t } = useTranslation()
   const router = useRouter()
-
-  const [state, dispatch] = useReducer(reducer, {
-    formValid: false,
-    github: false,
-    google: false,
-  })
-
-  const [showPassword, setShowPassword] = useState(false)
-  const [email, setEmail] = useState('')
-  const [password, setPassword] = useState('')
-
-  const [isLoading, setIsLoading] = useState(false)
-  const handleEmailPasswordLogin = async () => {
-    if (!emailRegex.test(email)) {
-      Toast.notify({
-        type: 'error',
-        message: t('login.error.emailInValid'),
-      })
-      return
-    }
+  const searchParams = useSearchParams()
+  const consoleToken = decodeURIComponent(searchParams.get('access_token') || '')
+  const refreshToken = decodeURIComponent(searchParams.get('refresh_token') || '')
+  const message = decodeURIComponent(searchParams.get('message') || '')
+  const invite_token = decodeURIComponent(searchParams.get('invite_token') || '')
+  const [isLoading, setIsLoading] = useState(true)
+  const [systemFeatures, setSystemFeatures] = useState(defaultSystemFeatures)
+  const [authType, updateAuthType] = useState<'code' | 'password'>('password')
+  const [showORLine, setShowORLine] = useState(false)
+  const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
+  const [workspaceName, setWorkSpaceName] = useState('')
+
+  const isInviteLink = Boolean(invite_token && invite_token !== 'null')
+
+  const init = useCallback(async () => {
     try {
-      setIsLoading(true)
-      const res = await login({
-        url: '/login',
-        body: {
-          email,
-          password,
-          remember_me: true,
-        },
-      })
-      if (res.result === 'success') {
-        localStorage.setItem('console_token', res.data.access_token)
-        localStorage.setItem('refresh_token', res.data.refresh_token)
+      if (consoleToken && refreshToken) {
+        localStorage.setItem('console_token', consoleToken)
+        localStorage.setItem('refresh_token', refreshToken)
         getNewAccessToken()
         router.replace('/apps')
+        return
       }
-      else {
+
+      if (message) {
         Toast.notify({
           type: 'error',
-          message: res.data,
+          message,
+        })
+      }
+      const features = await getSystemFeatures()
+      const allFeatures = { ...defaultSystemFeatures, ...features }
+      setSystemFeatures(allFeatures)
+      setAllMethodsAreDisabled(!allFeatures.enable_social_oauth_login && !allFeatures.enable_email_code_login && !allFeatures.enable_email_password_login && !allFeatures.sso_enforced_for_signin)
+      setShowORLine((allFeatures.enable_social_oauth_login || allFeatures.sso_enforced_for_signin) && (allFeatures.enable_email_code_login || allFeatures.enable_email_password_login))
+      updateAuthType(allFeatures.enable_email_password_login ? 'password' : 'code')
+      if (isInviteLink) {
+        const checkRes = await invitationCheck({
+          url: '/activate/check',
+          params: {
+            token: invite_token,
+          },
         })
+        setWorkSpaceName(checkRes?.data?.workspace_name || '')
       }
     }
-    finally {
-      setIsLoading(false)
+    catch (error) {
+      console.error(error)
+      setAllMethodsAreDisabled(true)
+      setSystemFeatures(defaultSystemFeatures)
     }
-  }
-
-  const { data: github, error: github_error } = useSWR(state.github
-    ? ({
-      url: '/oauth/login/github',
-      // params: {
-      //   provider: 'github',
-      // },
-    })
-    : null, oauth)
-
-  const { data: google, error: google_error } = useSWR(state.google
-    ? ({
-      url: '/oauth/login/google',
-      // params: {
-      //   provider: 'google',
-      // },
-    })
-    : null, oauth)
-
-  useEffect(() => {
-    if (github_error !== undefined)
-      dispatch({ type: 'github_login_failed' })
-    if (github)
-      window.location.href = github.redirect_url
-  }, [github, github_error])
-
+    finally { setIsLoading(false) }
+  }, [consoleToken, refreshToken, message, router, invite_token, isInviteLink, getNewAccessToken])
   useEffect(() => {
-    if (google_error !== undefined)
-      dispatch({ type: 'google_login_failed' })
-    if (google)
-      window.location.href = google.redirect_url
-  }, [google, google_error])
+    init()
+  }, [init])
+  if (isLoading || consoleToken) {
+    return <div className={
+      cn(
+        'flex flex-col items-center w-full grow justify-center',
+        'px-6',
+        'md:px-[108px]',
+      )
+    }>
+      <Loading type='area' />
+    </div>
+  }
 
   return (
     <>
-      <div className="w-full mx-auto">
-        <h2 className="text-[32px] font-bold text-gray-900">{t('login.pageTitle')}</h2>
-        <p className='mt-1 text-sm text-gray-600'>{t('login.welcome')}</p>
-      </div>
-
       <div className="w-full mx-auto mt-8">
-        <div className="bg-white ">
-          {!useEmailLogin && (
-            <div className="flex flex-col gap-3 mt-6">
-              <div className='w-full'>
-                <a href={getPurifyHref(`${apiPrefix}/oauth/login/github`)}>
-                  <Button
-                    disabled={isLoading}
-                    className='w-full hover:!bg-gray-50'
-                  >
-                    <>
-                      <span className={
-                        classNames(
-                          style.githubIcon,
-                          'w-5 h-5 mr-2',
-                        )
-                      } />
-                      <span className="truncate text-gray-800">{t('login.withGitHub')}</span>
-                    </>
-                  </Button>
-                </a>
-              </div>
-              <div className='w-full'>
-                <a href={getPurifyHref(`${apiPrefix}/oauth/login/google`)}>
-                  <Button
-                    disabled={isLoading}
-                    className='w-full hover:!bg-gray-50'
-                  >
-                    <>
-                      <span className={
-                        classNames(
-                          style.googleIcon,
-                          'w-5 h-5 mr-2',
-                        )
-                      } />
-                      <span className="truncate text-gray-800">{t('login.withGoogle')}</span>
-                    </>
-                  </Button>
-                </a>
-              </div>
-            </div>
-          )}
+        {isInviteLink
+          ? <div className="w-full mx-auto">
+            <h2 className="title-4xl-semi-bold text-text-primary">{t('login.join')}{workspaceName}</h2>
+            <p className='mt-2 body-md-regular text-text-tertiary'>{t('login.joinTipStart')}{workspaceName}{t('login.joinTipEnd')}</p>
+          </div>
+          : <div className="w-full mx-auto">
+            <h2 className="title-4xl-semi-bold text-text-primary">{t('login.pageTitle')}</h2>
+            <p className='mt-2 body-md-regular text-text-tertiary'>{t('login.welcome')}</p>
+          </div>}
+        <div className="bg-white">
+          <div className="flex flex-col gap-3 mt-6">
+            {systemFeatures.enable_social_oauth_login && <SocialAuth />}
+            {systemFeatures.sso_enforced_for_signin && <div className='w-full'>
+              <SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} />
+            </div>}
+          </div>
 
+          {showORLine && <div className="relative mt-6">
+            <div className="absolute inset-0 flex items-center" aria-hidden="true">
+              <div className='bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent h-px w-full'></div>
+            </div>
+            <div className="relative flex justify-center">
+              <span className="px-2 text-text-tertiary system-xs-medium-uppercase bg-white">{t('login.or')}</span>
+            </div>
+          </div>}
           {
-            useEmailLogin && <>
-              {/* <div className="relative mt-6">
-                <div className="absolute inset-0 flex items-center" aria-hidden="true">
-                  <div className="w-full border-t border-gray-300" />
-                </div>
-                <div className="relative flex justify-center text-sm">
-                  <span className="px-2 text-gray-300 bg-white">OR</span>
-                </div>
-              </div> */}
-
-              <form onSubmit={() => { }}>
-                <div className='mb-5'>
-                  <label htmlFor="email" className="my-2 block text-sm font-medium text-gray-900">
-                    {t('login.email')}
-                  </label>
-                  <div className="mt-1">
-                    <input
-                      value={email}
-                      onChange={e => setEmail(e.target.value)}
-                      id="email"
-                      type="email"
-                      autoComplete="email"
-                      placeholder={t('login.emailPlaceholder') || ''}
-                      className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
-                      tabIndex={1}
-                    />
-                  </div>
-                </div>
-
-                <div className='mb-4'>
-                  <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
-                    <span>{t('login.password')}</span>
-                    <Link href='/forgot-password' className='text-primary-600'>
-                      {t('login.forget')}
-                    </Link>
-                  </label>
-                  <div className="relative mt-1">
-                    <input
-                      id="password"
-                      value={password}
-                      onChange={e => setPassword(e.target.value)}
-                      onKeyDown={(e) => {
-                        if (e.key === 'Enter')
-                          handleEmailPasswordLogin()
-                      }}
-                      type={showPassword ? 'text' : 'password'}
-                      autoComplete="current-password"
-                      placeholder={t('login.passwordPlaceholder') || ''}
-                      className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
-                      tabIndex={2}
-                    />
-                    <div className="absolute inset-y-0 right-0 flex items-center pr-3">
-                      <button
-                        type="button"
-                        onClick={() => setShowPassword(!showPassword)}
-                        className="text-gray-400 hover:text-gray-500 focus:outline-none focus:text-gray-500"
-                      >
-                        {showPassword ? '👀' : '😝'}
-                      </button>
-                    </div>
-                  </div>
-                </div>
-
-                <div className='mb-2'>
-                  <Button
-                    tabIndex={0}
-                    variant='primary'
-                    onClick={handleEmailPasswordLogin}
-                    disabled={isLoading}
-                    className="w-full"
-                  >{t('login.signBtn')}</Button>
-                </div>
-              </form>
+            (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <>
+              {systemFeatures.enable_email_code_login && authType === 'code' && <>
+                <MailAndCodeAuth isInvite={isInviteLink} />
+                {systemFeatures.enable_email_password_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('password') }}>
+                  <span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.usePassword')}</span>
+                </div>}
+              </>}
+              {systemFeatures.enable_email_password_login && authType === 'password' && <>
+                <MailAndPasswordAuth isInvite={isInviteLink} allowRegistration={systemFeatures.is_allow_register} />
+                {systemFeatures.enable_email_code_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('code') }}>
+                  <span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.useVerificationCode')}</span>
+                </div>}
+              </>}
             </>
           }
-          {/*  agree to our Terms and Privacy Policy. */}
-          <div className="w-hull text-center block mt-2 text-xs text-gray-600">
+          {allMethodsAreDisabled && <>
+            <div className="p-4 rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2">
+              <div className='flex items-center justify-center w-10 h-10 rounded-xl bg-components-card-bg shadow shadows-shadow-lg mb-2'>
+                <RiDoorLockLine className='w-5 h-5' />
+              </div>
+              <p className='system-sm-medium text-text-primary'>{t('login.noLoginMethod')}</p>
+              <p className='system-xs-regular text-text-tertiary mt-1'>{t('login.noLoginMethodTip')}</p>
+            </div>
+            <div className="relative my-2 py-2">
+              <div className="absolute inset-0 flex items-center" aria-hidden="true">
+                <div className='bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent h-px w-full'></div>
+              </div>
+            </div>
+          </>}
+          <div className="w-full block mt-2 system-xs-regular text-text-tertiary">
             {t('login.tosDesc')}
             &nbsp;
             <Link
-              className='text-primary-600'
+              className='system-xs-medium text-text-secondary hover:underline'
               target='_blank' rel='noopener noreferrer'
               href='https://dify.ai/terms'
             >{t('login.tos')}</Link>
             &nbsp;&&nbsp;
             <Link
-              className='text-primary-600'
+              className='system-xs-medium text-text-secondary hover:underline'
               target='_blank' rel='noopener noreferrer'
               href='https://dify.ai/privacy'
             >{t('login.pp')}</Link>
           </div>
-
-          {IS_CE_EDITION && <div className="w-hull text-center block mt-2 text-xs text-gray-600">
+          {IS_CE_EDITION && <div className="w-hull block mt-2 system-xs-regular text-text-tertiary">
             {t('login.goToInit')}
             &nbsp;
             <Link
-              className='text-primary-600'
+              className='system-xs-medium text-text-secondary hover:underline'
               href='/install'
             >{t('login.setAdminAccount')}</Link>
           </div>}

+ 16 - 18
web/app/signin/oneMoreStep.tsx

@@ -3,8 +3,8 @@ import React, { useEffect, useReducer } from 'react'
 import { useTranslation } from 'react-i18next'
 import Link from 'next/link'
 import useSWR from 'swr'
-import { useRouter } from 'next/navigation'
-// import { useContext } from 'use-context-selector'
+import { useRouter, useSearchParams } from 'next/navigation'
+import Input from '../components/base/input'
 import Button from '@/app/components/base/button'
 import Tooltip from '@/app/components/base/tooltip'
 import { SimpleSelect } from '@/app/components/base/select'
@@ -12,7 +12,6 @@ import { timezones } from '@/utils/timezone'
 import { LanguagesSupported, languages } from '@/i18n/language'
 import { oneMoreStep } from '@/service/common'
 import Toast from '@/app/components/base/toast'
-// import I18n from '@/context/i18n'
 
 type IState = {
   formState: 'processing' | 'error' | 'success' | 'initial'
@@ -46,11 +45,11 @@ const reducer = (state: IState, action: any) => {
 const OneMoreStep = () => {
   const { t } = useTranslation()
   const router = useRouter()
-  // const { locale } = useContext(I18n)
+  const searchParams = useSearchParams()
 
   const [state, dispatch] = useReducer(reducer, {
     formState: 'initial',
-    invitation_code: '',
+    invitation_code: searchParams.get('invitation_code') || '',
     interface_language: 'en-US',
     timezone: 'Asia/Shanghai',
   })
@@ -77,36 +76,35 @@ const OneMoreStep = () => {
   return (
     <>
       <div className="w-full mx-auto">
-        <h2 className="text-[32px] font-bold text-gray-900">{t('login.oneMoreStep')}</h2>
-        <p className='mt-1 text-sm text-gray-600 '>{t('login.createSample')}</p>
+        <h2 className="title-4xl-semi-bold text-text-secondary">{t('login.oneMoreStep')}</h2>
+        <p className='mt-1 body-md-regular text-text-tertiary'>{t('login.createSample')}</p>
       </div>
 
       <div className="w-full mx-auto mt-6">
         <div className="bg-white">
           <div className="mb-5">
-            <label className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
+            <label className="my-2 flex items-center justify-between system-md-semibold text-text-secondary">
               {t('login.invitationCode')}
               <Tooltip
                 popupContent={
                   <div className='w-[256px] text-xs font-medium'>
                     <div className='font-medium'>{t('login.sendUsMail')}</div>
-                    <div className='text-xs font-medium cursor-pointer text-primary-600'>
+                    <div className='text-xs font-medium cursor-pointer text-text-accent-secondary'>
                       <a href="mailto:request-invitation@langgenius.ai">request-invitation@langgenius.ai</a>
                     </div>
                   </div>
                 }
                 needsDelay
               >
-                <span className='cursor-pointer text-primary-600'>{t('login.dontHave')}</span>
+                <span className='cursor-pointer text-text-accent-secondary'>{t('login.dontHave')}</span>
               </Tooltip>
             </label>
             <div className="mt-1">
-              <input
+              <Input
                 id="invitation_code"
                 value={state.invitation_code}
                 type="text"
                 placeholder={t('login.invitationCodePlaceholder') || ''}
-                className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
                 onChange={(e) => {
                   dispatch({ type: 'invitation_code', value: e.target.value.trim() })
                 }}
@@ -114,10 +112,10 @@ const OneMoreStep = () => {
             </div>
           </div>
           <div className='mb-5'>
-            <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
+            <label htmlFor="name" className="my-2 system-md-semibold text-text-secondary">
               {t('login.interfaceLanguage')}
             </label>
-            <div className="relative mt-1 rounded-md shadow-sm">
+            <div className="mt-1">
               <SimpleSelect
                 defaultValue={LanguagesSupported[0]}
                 items={languages.filter(item => item.supported)}
@@ -128,10 +126,10 @@ const OneMoreStep = () => {
             </div>
           </div>
           <div className='mb-4'>
-            <label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
+            <label htmlFor="timezone" className="system-md-semibold text-text-tertiary">
               {t('login.timezone')}
             </label>
-            <div className="relative mt-1 rounded-md shadow-sm">
+            <div className="mt-1">
               <SimpleSelect
                 defaultValue={state.timezone}
                 items={timezones}
@@ -153,11 +151,11 @@ const OneMoreStep = () => {
               {t('login.go')}
             </Button>
           </div>
-          <div className="block w-hull mt-2 text-xs text-gray-600">
+          <div className="block w-full mt-2 system-xs-regular text-text-tertiary">
             {t('login.license.tip')}
             &nbsp;
             <Link
-              className='text-primary-600'
+              className='system-xs-medium text-text-accent-secondary'
               target='_blank' rel='noopener noreferrer'
               href={'https://docs.dify.ai/user-agreement/open-source'}
             >{t('login.license.link')}</Link>

+ 8 - 87
web/app/signin/page.tsx

@@ -1,94 +1,15 @@
 'use client'
-import React, { useEffect, useState } from 'react'
-import Script from 'next/script'
-import Loading from '../components/base/loading'
-import Forms from './forms'
-import Header from './_header'
-import style from './page.module.css'
-import UserSSOForm from './userSSOForm'
-import cn from '@/utils/classnames'
-import { IS_CE_EDITION } from '@/config'
-
-import type { SystemFeatures } from '@/types/feature'
-import { defaultSystemFeatures } from '@/types/feature'
-import { getSystemFeatures } from '@/service/common'
+import { useSearchParams } from 'next/navigation'
+import OneMoreStep from './oneMoreStep'
+import NormalForm from './normalForm'
 
 const SignIn = () => {
-  const [loading, setLoading] = useState<boolean>(true)
-  const [systemFeatures, setSystemFeatures] = useState<SystemFeatures>(defaultSystemFeatures)
-
-  useEffect(() => {
-    getSystemFeatures().then((res) => {
-      setSystemFeatures(res)
-    }).finally(() => {
-      setLoading(false)
-    })
-  }, [])
-
-  return (
-    <>
-      {!IS_CE_EDITION && (
-        <>
-          <Script strategy="beforeInteractive" async src={'https://www.googletagmanager.com/gtag/js?id=AW-11217955271'}></Script>
-          <Script
-            id="ga-monitor-register"
-            dangerouslySetInnerHTML={{
-              __html: `
-window.dataLayer2 = window.dataLayer2 || [];
-function gtag(){dataLayer2.push(arguments);}
-gtag('js', new Date());
-gtag('config', 'AW-11217955271"');
-        `,
-            }}
-          >
-          </Script>
-        </>
-      )}
-      <div className={cn(
-        style.background,
-        'flex w-full min-h-screen',
-        'sm:p-4 lg:p-8',
-        'gap-x-20',
-        'justify-center lg:justify-start',
-      )}>
-        <div className={
-          cn(
-            'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
-            'space-between',
-          )
-        }>
-          <Header />
-
-          {loading && (
-            <div className={
-              cn(
-                'flex flex-col items-center w-full grow justify-center',
-                'px-6',
-                'md:px-[108px]',
-              )
-            }>
-              <Loading type='area' />
-            </div>
-          )}
-
-          {!loading && !systemFeatures.sso_enforced_for_signin && (
-            <>
-              <Forms />
-              <div className='px-8 py-6 text-sm font-normal text-gray-500'>
-                © {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
-              </div>
-            </>
-          )}
-
-          {!loading && systemFeatures.sso_enforced_for_signin && (
-            <UserSSOForm protocol={systemFeatures.sso_enforced_for_signin_protocol} />
-          )}
-        </div>
-
-      </div>
+  const searchParams = useSearchParams()
+  const step = searchParams.get('step')
 
-    </>
-  )
+  if (step === 'next')
+    return <OneMoreStep />
+  return <NormalForm />
 }
 
 export default SignIn

+ 0 - 107
web/app/signin/userSSOForm.tsx

@@ -1,107 +0,0 @@
-'use client'
-import { useRouter, useSearchParams } from 'next/navigation'
-import type { FC } from 'react'
-import { useEffect, useState } from 'react'
-import { useTranslation } from 'react-i18next'
-import cn from '@/utils/classnames'
-import Toast from '@/app/components/base/toast'
-import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso'
-import Button from '@/app/components/base/button'
-import useRefreshToken from '@/hooks/use-refresh-token'
-
-type UserSSOFormProps = {
-  protocol: string
-}
-
-const UserSSOForm: FC<UserSSOFormProps> = ({
-  protocol,
-}) => {
-  const { getNewAccessToken } = useRefreshToken()
-  const searchParams = useSearchParams()
-  const consoleToken = searchParams.get('access_token')
-  const refreshToken = searchParams.get('refresh_token')
-  const message = searchParams.get('message')
-
-  const router = useRouter()
-  const { t } = useTranslation()
-
-  const [isLoading, setIsLoading] = useState(false)
-
-  useEffect(() => {
-    if (refreshToken && consoleToken) {
-      localStorage.setItem('console_token', consoleToken)
-      localStorage.setItem('refresh_token', refreshToken)
-      getNewAccessToken()
-      router.replace('/apps')
-    }
-
-    if (message) {
-      Toast.notify({
-        type: 'error',
-        message,
-      })
-    }
-  }, [consoleToken, refreshToken, message, router])
-
-  const handleSSOLogin = () => {
-    setIsLoading(true)
-    if (protocol === 'saml') {
-      getUserSAMLSSOUrl().then((res) => {
-        router.push(res.url)
-      }).finally(() => {
-        setIsLoading(false)
-      })
-    }
-    else if (protocol === 'oidc') {
-      getUserOIDCSSOUrl().then((res) => {
-        document.cookie = `user-oidc-state=${res.state}`
-        router.push(res.url)
-      }).finally(() => {
-        setIsLoading(false)
-      })
-    }
-    else if (protocol === 'oauth2') {
-      getUserOAuth2SSOUrl().then((res) => {
-        document.cookie = `user-oauth2-state=${res.state}`
-        router.push(res.url)
-      }).finally(() => {
-        setIsLoading(false)
-      })
-    }
-    else {
-      Toast.notify({
-        type: 'error',
-        message: 'invalid SSO protocol',
-      })
-      setIsLoading(false)
-    }
-  }
-
-  return (
-    <div className={
-      cn(
-        'flex flex-col items-center w-full grow justify-center',
-        'px-6',
-        'md:px-[108px]',
-      )
-    }>
-      <div className='flex flex-col md:w-[400px]'>
-        <div className="w-full mx-auto">
-          <h2 className="text-[32px] font-bold text-gray-900">{t('login.pageTitle')}</h2>
-        </div>
-        <div className="w-full mx-auto mt-10">
-          <Button
-            tabIndex={0}
-            variant='primary'
-            onClick={() => { handleSSOLogin() }}
-            disabled={isLoading}
-            className="w-full"
-          >{t('login.sso')}
-          </Button>
-        </div>
-      </div>
-    </div>
-  )
-}
-
-export default UserSSOForm

+ 35 - 7
web/i18n/en-US/login.ts

@@ -1,6 +1,6 @@
 const translation = {
-  pageTitle: 'Hey, let\'s get started!👋',
-  welcome: 'Welcome to Dify, please log in to continue.',
+  pageTitle: 'Hey, let\'s get started!',
+  welcome: '👋 Welcome to Dify, please log in to continue.',
   email: 'Email address',
   emailPlaceholder: 'Your email',
   password: 'Password',
@@ -9,7 +9,11 @@ const translation = {
   namePlaceholder: 'Your username',
   forget: 'Forgot your password?',
   signBtn: 'Sign in',
-  sso: 'Continue with SSO',
+  continueWithCode: 'Continue With Code',
+  sendVerificationCode: 'Send Verification Code',
+  usePassword: 'Use Password',
+  useVerificationCode: 'Use Verification Code',
+  or: 'OR',
   installBtn: 'Set up',
   setAdminAccount: 'Setting up an admin account',
   setAdminAccountDesc: 'Maximum privileges for admin account, which can be used to create applications and manage LLM providers, etc.',
@@ -26,6 +30,7 @@ const translation = {
   reset: 'Please run following command to reset your password',
   withGitHub: 'Continue with GitHub',
   withGoogle: 'Continue with Google',
+  withSSO: 'Continue with SSO',
   rightTitle: 'Unlock the full potential of LLM',
   rightDesc: 'Effortlessly build visually captivating, operable, and improvable AI applications.',
   tos: 'Terms of Service',
@@ -42,8 +47,9 @@ const translation = {
   forgotPasswordDesc: 'Please enter your email address to reset your password. We will send you an email with instructions on how to reset your password.',
   checkEmailForResetLink: 'Please check your email for a link to reset your password. If it doesn\'t appear within a few minutes, make sure to check your spam folder.',
   passwordChanged: 'Sign in now',
-  changePassword: 'Change Password',
+  changePassword: 'Set a password',
   changePasswordTip: 'Please enter a new password for your account',
+  changePasswordBtn: 'Set a password',
   invalidToken: 'Invalid or expired token',
   confirmPassword: 'Confirm Password',
   confirmPasswordPlaceholder: 'Confirm your new password',
@@ -55,14 +61,15 @@ const translation = {
     passwordEmpty: 'Password is required',
     passwordLengthInValid: 'Password must be at least 8 characters',
     passwordInvalid: 'Password must contain letters and numbers, and the length must be greater than 8',
+    registrationNotAllowed: 'Account not found. Please contact the system admin to register.',
   },
   license: {
     tip: 'Before starting Dify Community Edition, read the GitHub',
     link: 'Open-source License',
   },
-  join: 'Join',
-  joinTipStart: 'Invite you join',
-  joinTipEnd: 'team on Dify',
+  join: 'Join ',
+  joinTipStart: 'Invite you join ',
+  joinTipEnd: ' team on Dify',
   invalid: 'The link has expired',
   explore: 'Explore Dify',
   activatedTipStart: 'You have joined the',
@@ -70,6 +77,27 @@ const translation = {
   activated: 'Sign in now',
   adminInitPassword: 'Admin initialization password',
   validate: 'Validate',
+  checkCode: {
+    checkYourEmail: 'Check your email',
+    tips: 'We send a verification code to <strong>{{email}}</strong>',
+    validTime: 'Bear in mind that the code is valid for 5 minutes',
+    verificationCode: 'Verification code',
+    verificationCodePlaceholder: 'Enter 6-digit code',
+    verify: 'Verify',
+    didNotReceiveCode: 'Didn\'t receive the code? ',
+    resend: 'Resend',
+    useAnotherMethod: 'Use another method',
+    emptyCode: 'Code is required',
+    invalidCode: 'Invalid code',
+  },
+  resetPassword: 'Reset Password',
+  resetPasswordDesc: 'Type the email you used to sign up on Dify and we will send you a password reset email.',
+  backToLogin: 'Back to login',
+  setYourAccount: 'Set Your Account',
+  enterYourName: 'Please enter your username',
+  back: 'Back',
+  noLoginMethod: 'Authentication method not configured',
+  noLoginMethodTip: 'Please contact the system admin to add an authentication method.',
 }
 
 export default translation

+ 36 - 7
web/i18n/zh-Hans/login.ts

@@ -1,6 +1,6 @@
 const translation = {
-  pageTitle: '嗨,近来可好 👋',
-  welcome: '欢迎来到 Dify, 登录以继续',
+  pageTitle: '嗨,近来可好',
+  welcome: '👋 欢迎来到 Dify, 登录以继续',
   email: '邮箱',
   emailPlaceholder: '输入邮箱地址',
   password: '密码',
@@ -9,6 +9,11 @@ const translation = {
   namePlaceholder: '输入用户名',
   forget: '忘记密码?',
   signBtn: '登录',
+  continueWithCode: '发送验证码',
+  sendVerificationCode: '发送验证码',
+  usePassword: '使用密码登录',
+  useVerificationCode: '使用验证码登录',
+  or: '或',
   installBtn: '设置',
   setAdminAccount: '设置管理员账户',
   setAdminAccountDesc: '管理员拥有的最大权限,可用于创建应用和管理 LLM 供应商等。',
@@ -25,11 +30,12 @@ const translation = {
   reset: '请运行以下命令重置密码',
   withGitHub: '使用 GitHub 登录',
   withGoogle: '使用 Google 登录',
+  withSSO: '使用 SSO 登录',
   rightTitle: '释放大型语言模型的全部潜能',
   rightDesc: '简单构建可视化、可运营、可改进的 AI 应用',
   tos: '使用协议',
   pp: '隐私政策',
-  tosDesc: '使用即代表你并同意我们的',
+  tosDesc: '使用即代表同意我们的',
   goToInit: '如果您还没有初始化账户,请前往初始化页面',
   dontHave: '还没有邀请码?',
   invalidInvitationCode: '无效的邀请码',
@@ -41,8 +47,9 @@ const translation = {
   forgotPasswordDesc: '请输入您的电子邮件地址以重置密码。我们将向您发送一封电子邮件,包含如何重置密码的说明。',
   checkEmailForResetLink: '请检查您的电子邮件以获取重置密码的链接。如果几分钟内没有收到,请检查您的垃圾邮件文件夹。',
   passwordChanged: '立即登录',
-  changePassword: '更改密码',
+  changePassword: '设置密码',
   changePasswordTip: '请输入您的新密码',
+  changePasswordBtn: '设置密码',
   invalidToken: '无效或已过期的令牌',
   confirmPassword: '确认密码',
   confirmPasswordPlaceholder: '确认您的新密码',
@@ -54,14 +61,15 @@ const translation = {
     passwordEmpty: '密码不能为空',
     passwordInvalid: '密码必须包含字母和数字,且长度不小于8位',
     passwordLengthInValid: '密码必须至少为 8 个字符',
+    registrationNotAllowed: '账户不存在,请联系系统管理员注册账户',
   },
   license: {
     tip: '启动 Dify 社区版之前, 请阅读 GitHub 上的',
     link: '开源协议',
   },
-  join: '加入',
-  joinTipStart: '邀请你加入',
-  joinTipEnd: '团队',
+  join: '加入 ',
+  joinTipStart: '邀请你加入 ',
+  joinTipEnd: ' 团队',
   invalid: '链接已失效',
   explore: '探索 Dify',
   activatedTipStart: '您已加入',
@@ -70,6 +78,27 @@ const translation = {
   adminInitPassword: '管理员初始化密码',
   validate: '验证',
   sso: '使用 SSO 继续',
+  checkCode: {
+    checkYourEmail: '验证您的电子邮件',
+    tips: '验证码已经发送到您的邮箱 <strong>{{email}}</strong>',
+    validTime: '请注意验证码 5 分钟内有效',
+    verificationCode: '验证码',
+    verificationCodePlaceholder: '输入 6 位验证码',
+    verify: '验证',
+    didNotReceiveCode: '没有收到验证码?',
+    resend: '重新发送',
+    useAnotherMethod: '使用其他方式登录',
+    emptyCode: '验证码不能为空',
+    invalidCode: '验证码无效',
+  },
+  resetPassword: '重置密码',
+  resetPasswordDesc: '请输入您的电子邮件地址以重置密码。我们将向您发送一封电子邮件。',
+  backToLogin: '返回登录',
+  setYourAccount: '设置您的账户',
+  enterYourName: '请输入用户名',
+  back: '返回',
+  noLoginMethod: '未配置身份认证方式',
+  noLoginMethodTip: '请联系系统管理员添加身份认证方式',
 }
 
 export default translation

+ 2 - 2
web/i18n/zh-Hant/login.ts

@@ -1,6 +1,6 @@
 const translation = {
-  pageTitle: '嗨,近來可好 👋',
-  welcome: '歡迎來到 Dify, 登入以繼續',
+  pageTitle: '嗨,近來可好',
+  welcome: '👋 歡迎來到 Dify, 登入以繼續',
   email: '郵箱',
   emailPlaceholder: '輸入郵箱地址',
   password: '密碼',

+ 20 - 6
web/service/common.ts

@@ -45,6 +45,8 @@ type LoginSuccess = {
 type LoginFail = {
   result: 'fail'
   data: string
+  code: string
+  message: string
 }
 type LoginResponse = LoginSuccess | LoginFail
 export const login: Fetcher<LoginResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => {
@@ -169,12 +171,12 @@ export const updatePluginProviderAIKey: Fetcher<UpdateOpenAIKeyResponse, { url:
   return post<UpdateOpenAIKeyResponse>(url, { body })
 }
 
-export const invitationCheck: Fetcher<CommonResponse & { is_valid: boolean; workspace_name: string }, { url: string; params: { workspace_id: string; email: string; token: string } }> = ({ url, params }) => {
-  return get<CommonResponse & { is_valid: boolean; workspace_name: string }>(url, { params })
+export const invitationCheck: Fetcher<CommonResponse & { is_valid: boolean; data: { workspace_name: string; email: string; workspace_id: string } }, { url: string; params: { workspace_id?: string; email?: string; token: string } }> = ({ url, params }) => {
+  return get<CommonResponse & { is_valid: boolean; data: { workspace_name: string; email: string; workspace_id: string } }>(url, { params })
 }
 
-export const activateMember: Fetcher<CommonResponse, { url: string; body: any }> = ({ url, body }) => {
-  return post<CommonResponse>(url, { body })
+export const activateMember: Fetcher<LoginResponse, { url: string; body: any }> = ({ url, body }) => {
+  return post<LoginResponse>(url, { body })
 }
 
 export const fetchModelProviders: Fetcher<{ data: ModelProvider[] }, string> = (url) => {
@@ -312,8 +314,8 @@ export const enableModel = (url: string, body: { model: string; model_type: Mode
 export const disableModel = (url: string, body: { model: string; model_type: ModelTypeEnum }) =>
   patch<CommonResponse>(url, { body })
 
-export const sendForgotPasswordEmail: Fetcher<CommonResponse, { url: string; body: { email: string } }> = ({ url, body }) =>
-  post<CommonResponse>(url, { body })
+export const sendForgotPasswordEmail: Fetcher<CommonResponse & { data: string }, { url: string; body: { email: string } }> = ({ url, body }) =>
+  post<CommonResponse & { data: string }>(url, { body })
 
 export const verifyForgotPasswordToken: Fetcher<CommonResponse & { is_valid: boolean; email: string }, { url: string; body: { token: string } }> = ({ url, body }) => {
   return post(url, { body }) as Promise<CommonResponse & { is_valid: boolean; email: string }>
@@ -321,3 +323,15 @@ export const verifyForgotPasswordToken: Fetcher<CommonResponse & { is_valid: boo
 
 export const changePasswordWithToken: Fetcher<CommonResponse, { url: string; body: { token: string; new_password: string; password_confirm: string } }> = ({ url, body }) =>
   post<CommonResponse>(url, { body })
+
+export const sendEMailLoginCode = (email: string, language = 'en-US') =>
+  post<CommonResponse & { data: string }>('/email-code-login', { body: { email, language } })
+
+export const emailLoginWithCode = (data: { email: string;code: string;token: string }) =>
+  post<LoginResponse>('/email-code-login/validity', { body: data })
+
+export const sendResetPasswordCode = (email: string, language = 'en-US') =>
+  post<CommonResponse & { data: string;message?: string ;code?: string }>('/forgot-password', { body: { email, language } })
+
+export const verifyResetPasswordCode = (body: { email: string;code: string;token: string }) =>
+  post<CommonResponse & { is_valid: boolean }>('/forgot-password/validity', { body })

+ 9 - 6
web/service/sso.ts

@@ -1,13 +1,16 @@
 import { get } from './base'
 
-export const getUserSAMLSSOUrl = () => {
-  return get<{ url: string }>('/enterprise/sso/saml/login')
+export const getUserSAMLSSOUrl = (invite_token?: string) => {
+  const url = invite_token ? `/enterprise/sso/saml/login?invite_token=${invite_token}` : '/enterprise/sso/saml/login'
+  return get<{ url: string }>(url)
 }
 
-export const getUserOIDCSSOUrl = () => {
-  return get<{ url: string; state: string }>('/enterprise/sso/oidc/login')
+export const getUserOIDCSSOUrl = (invite_token?: string) => {
+  const url = invite_token ? `/enterprise/sso/oidc/login?invite_token=${invite_token}` : '/enterprise/sso/oidc/login'
+  return get<{ url: string; state: string }>(url)
 }
 
-export const getUserOAuth2SSOUrl = () => {
-  return get<{ url: string; state: string }>('/enterprise/sso/oauth2/login')
+export const getUserOAuth2SSOUrl = (invite_token?: string) => {
+  const url = invite_token ? `/enterprise/sso/oauth2/login?invite_token=${invite_token}` : '/enterprise/sso/oauth2/login'
+  return get<{ url: string; state: string }>(url)
 }

+ 18 - 2
web/types/feature.ts

@@ -1,9 +1,20 @@
+export enum SSOProtocol {
+  SAML = 'saml',
+  OIDC = 'oidc',
+  OAuth2 = 'oauth2',
+}
+
 export type SystemFeatures = {
   sso_enforced_for_signin: boolean
-  sso_enforced_for_signin_protocol: string
+  sso_enforced_for_signin_protocol: SSOProtocol | ''
   sso_enforced_for_web: boolean
-  sso_enforced_for_web_protocol: string
+  sso_enforced_for_web_protocol: SSOProtocol | ''
   enable_web_sso_switch_component: boolean
+  enable_email_code_login: boolean
+  enable_email_password_login: boolean
+  enable_social_oauth_login: boolean
+  is_allow_create_workspace: boolean
+  is_allow_register: boolean
 }
 
 export const defaultSystemFeatures: SystemFeatures = {
@@ -12,4 +23,9 @@ export const defaultSystemFeatures: SystemFeatures = {
   sso_enforced_for_web: false,
   sso_enforced_for_web_protocol: '',
   enable_web_sso_switch_component: false,
+  enable_email_code_login: false,
+  enable_email_password_login: false,
+  enable_social_oauth_login: false,
+  is_allow_create_workspace: false,
+  is_allow_register: false,
 }