wip: add login page for web app signin
parent
70bfeb78df
commit
c1672c465a
@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
type LoginLogoProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LoginLogo: FC<LoginLogoProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { theme } = useTheme()
|
||||
|
||||
let src = theme === 'light' ? '/logo/logo-site.png' : `/logo/logo-site-${theme}.png`
|
||||
if (systemFeatures.branding.enabled)
|
||||
src = systemFeatures.branding.login_page_logo
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
className={classNames('block w-auto h-10', className)}
|
||||
alt='logo'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginLogo
|
||||
@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Select from '@/app/components/base/select/locale'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { languages } from '@/i18n/language'
|
||||
import type { Locale } from '@/i18n'
|
||||
import I18n from '@/context/i18n'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// Avoid rendering the logo and theme selector on the server
|
||||
const DifyLogo = dynamic(() => import('@/app/components/base/logo/dify-logo'), {
|
||||
ssr: false,
|
||||
loading: () => <div className='h-7 w-16 bg-transparent' />,
|
||||
})
|
||||
const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector'), {
|
||||
ssr: false,
|
||||
loading: () => <div className='size-8 bg-transparent' />,
|
||||
})
|
||||
|
||||
const Header = () => {
|
||||
const { locale, setLocaleOnClient } = useContext(I18n)
|
||||
|
||||
return (
|
||||
<div className='flex w-full items-center justify-between p-6'>
|
||||
<DifyLogo size='large' />
|
||||
<div className='flex items-center gap-1'>
|
||||
<Select
|
||||
value={locale}
|
||||
items={languages.filter(item => item.supported)}
|
||||
onChange={(value) => {
|
||||
setLocaleOnClient(value as Locale)
|
||||
}}
|
||||
/>
|
||||
<Divider type='vertical' className='mx-0 ml-2 h-4' />
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
@ -0,0 +1,17 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_131_1011)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0003 0.5C9.15149 0.501478 6.39613 1.51046 4.22687 3.34652C2.05761 5.18259 0.615903 7.72601 0.159545 10.522C-0.296814 13.318 0.261927 16.1842 1.73587 18.6082C3.20981 21.0321 5.50284 22.8558 8.20493 23.753C8.80105 23.8636 9.0256 23.4941 9.0256 23.18C9.0256 22.8658 9.01367 21.955 9.0097 20.9592C5.6714 21.6804 4.96599 19.5505 4.96599 19.5505C4.42152 18.1674 3.63464 17.8039 3.63464 17.8039C2.54571 17.065 3.71611 17.0788 3.71611 17.0788C4.92227 17.1637 5.55616 18.3097 5.55616 18.3097C6.62521 20.1333 8.36389 19.6058 9.04745 19.2976C9.15475 18.5251 9.46673 17.9995 9.8105 17.7012C7.14383 17.4008 4.34204 16.3774 4.34204 11.8054C4.32551 10.6197 4.76802 9.47305 5.57801 8.60268C5.45481 8.30236 5.04348 7.08923 5.69524 5.44143C5.69524 5.44143 6.7027 5.12135 8.9958 6.66444C10.9627 6.12962 13.0379 6.12962 15.0047 6.66444C17.2958 5.12135 18.3013 5.44143 18.3013 5.44143C18.9551 7.08528 18.5437 8.29841 18.4205 8.60268C19.2331 9.47319 19.6765 10.6218 19.6585 11.8094C19.6585 16.3912 16.8507 17.4008 14.1801 17.6952C14.6093 18.0667 14.9928 18.7918 14.9928 19.9061C14.9928 21.5026 14.9789 22.7868 14.9789 23.18C14.9789 23.4981 15.1955 23.8695 15.8035 23.753C18.5059 22.8557 20.7992 21.0317 22.2731 18.6073C23.747 16.183 24.3055 13.3163 23.8486 10.5201C23.3917 7.7238 21.9493 5.18035 19.7793 3.34461C17.6093 1.50886 14.8533 0.500541 12.0042 0.5H12.0003Z" fill="#191717"/>
|
||||
<path d="M4.54444 17.6321C4.5186 17.6914 4.42322 17.7092 4.34573 17.6677C4.26823 17.6262 4.21061 17.5491 4.23843 17.4879C4.26625 17.4266 4.35964 17.4108 4.43714 17.4523C4.51463 17.4938 4.57424 17.5729 4.54444 17.6321Z" fill="#191717"/>
|
||||
<path d="M5.03123 18.1714C4.99008 18.192 4.943 18.1978 4.89805 18.1877C4.8531 18.1776 4.81308 18.1523 4.78483 18.1161C4.70734 18.0331 4.69143 17.9185 4.75104 17.8671C4.81066 17.8157 4.91797 17.8395 4.99546 17.9224C5.07296 18.0054 5.09084 18.12 5.03123 18.1714Z" fill="#191717"/>
|
||||
<path d="M5.50425 18.857C5.43072 18.9084 5.30553 18.857 5.23598 18.7543C5.21675 18.7359 5.20146 18.7138 5.19101 18.6893C5.18056 18.6649 5.17517 18.6386 5.17517 18.612C5.17517 18.5855 5.18056 18.5592 5.19101 18.5347C5.20146 18.5103 5.21675 18.4882 5.23598 18.4698C5.3095 18.4204 5.4347 18.4698 5.50425 18.5705C5.57379 18.6713 5.57578 18.8057 5.50425 18.857V18.857Z" fill="#191717"/>
|
||||
<path d="M6.14612 19.5207C6.08054 19.5939 5.94741 19.5741 5.83812 19.4753C5.72883 19.3765 5.70299 19.2422 5.76857 19.171C5.83414 19.0999 5.96727 19.1197 6.08054 19.2165C6.1938 19.3133 6.21566 19.4496 6.14612 19.5207V19.5207Z" fill="#191717"/>
|
||||
<path d="M7.04617 19.9081C7.01637 20.001 6.88124 20.0425 6.74612 20.003C6.611 19.9635 6.52158 19.8528 6.54741 19.758C6.57325 19.6631 6.71036 19.6197 6.84747 19.6631C6.98457 19.7066 7.07201 19.8113 7.04617 19.9081Z" fill="#191717"/>
|
||||
<path d="M8.02783 19.9752C8.02783 20.072 7.91656 20.155 7.77349 20.1569C7.63042 20.1589 7.51318 20.0799 7.51318 19.9831C7.51318 19.8863 7.62445 19.8033 7.76752 19.8013C7.91059 19.7993 8.02783 19.8764 8.02783 19.9752Z" fill="#191717"/>
|
||||
<path d="M8.9419 19.8232C8.95978 19.92 8.86042 20.0207 8.71735 20.0445C8.57428 20.0682 8.4491 20.0109 8.43121 19.916C8.41333 19.8212 8.51666 19.7185 8.65576 19.6928C8.79485 19.6671 8.92401 19.7264 8.9419 19.8232Z" fill="#191717"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_131_1011">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@ -0,0 +1,13 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_729_2080)">
|
||||
<path d="M20.3052 10.2302C20.3052 9.55044 20.2501 8.86699 20.1325 8.19824H10.7002V12.0491H16.1016C15.8775 13.291 15.1573 14.3897 14.1027 15.0878V17.5864H17.3252C19.2176 15.8448 20.3052 13.2726 20.3052 10.2302Z" fill="#4285F4"/>
|
||||
<path d="M10.6999 20.0008C13.397 20.0008 15.6714 19.1152 17.3286 17.5867L14.1061 15.088C13.2096 15.698 12.0521 16.0434 10.7036 16.0434C8.09474 16.0434 5.88272 14.2833 5.08904 11.917H1.76367V14.4928C3.46127 17.8696 6.91892 20.0008 10.6999 20.0008Z" fill="#34A853"/>
|
||||
<path d="M5.08564 11.9172C4.66676 10.6753 4.66676 9.33044 5.08564 8.08848V5.5127H1.76395C0.345611 8.33834 0.345611 11.6674 1.76395 14.493L5.08564 11.9172Z" fill="#FBBC04"/>
|
||||
<path d="M10.6999 3.95805C12.1256 3.936 13.5035 4.47247 14.536 5.45722L17.3911 2.60218C15.5833 0.904587 13.1838 -0.0287217 10.6999 0.000673888C6.91892 0.000673888 3.46126 2.13185 1.76367 5.51234L5.08537 8.08813C5.87537 5.71811 8.09106 3.95805 10.6999 3.95805Z" fill="#EA4335"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_729_2080">
|
||||
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, 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'
|
||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||
|
||||
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 redirectUrl = searchParams.get('redirect_url')
|
||||
|
||||
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||
const appCode = redirectUrl?.split('/').pop()
|
||||
if (!appCode)
|
||||
return null
|
||||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
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
|
||||
}
|
||||
if (!redirectUrl || !appCode) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.error.redirectUrlMissing'),
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
const ret = await emailLoginWithCode({ email, code, token })
|
||||
if (ret.result === 'success') {
|
||||
localStorage.setItem('webAppAccessToken', ret.data.access_token)
|
||||
await checkOrSetAccessToken()
|
||||
router.replace(redirectUrl)
|
||||
}
|
||||
}
|
||||
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(`/webapp-signin/check-code?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}
|
||||
|
||||
return <div className='flex flex-col gap-3'>
|
||||
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
|
||||
<RiMailSendFill className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
|
||||
</div>
|
||||
<div className='pb-4 pt-2'>
|
||||
<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='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
|
||||
</div>
|
||||
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
|
||||
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
|
||||
<RiArrowLeftLine size={12} />
|
||||
</div>
|
||||
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { SSOProtocol } from '@/types/feature'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
const ExternalMemberSSOAuth = () => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
|
||||
const showErrorToast = (message: string) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||
const appCode = redirectUrl?.split('/').pop()
|
||||
if (!appCode)
|
||||
return null
|
||||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
|
||||
const handleSSOLogin = useCallback(async () => {
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
if (!appCode || !redirectUrl) {
|
||||
showErrorToast('redirect url or app code is invalid.')
|
||||
return
|
||||
}
|
||||
|
||||
switch (systemFeatures.webapp_auth.sso_config.protocol) {
|
||||
case SSOProtocol.SAML: {
|
||||
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
|
||||
router.push(samlRes.url)
|
||||
break
|
||||
}
|
||||
case SSOProtocol.OIDC: {
|
||||
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
|
||||
router.push(oidcRes.url)
|
||||
break
|
||||
}
|
||||
case SSOProtocol.OAuth2: {
|
||||
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
|
||||
router.push(oauth2Res.url)
|
||||
break
|
||||
}
|
||||
case '':
|
||||
break
|
||||
default:
|
||||
showErrorToast('SSO protocol is not supported.')
|
||||
}
|
||||
}, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol])
|
||||
|
||||
useEffect(() => {
|
||||
handleSSOLogin()
|
||||
}, [handleSSOLogin])
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ExternalMemberSSOAuth)
|
||||
@ -0,0 +1,68 @@
|
||||
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'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export default function MailAndCodeAuth() {
|
||||
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(`/webapp-signin/check-code?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (<form onSubmit={noop}>
|
||||
<input type='text' className='hidden' />
|
||||
<div className='mb-2'>
|
||||
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
|
||||
<div className='mt-1'>
|
||||
<Input id='email' type="email" 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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
import Link from 'next/link'
|
||||
import { useCallback, 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 { webAppLogin } from '@/service/common'
|
||||
import Input from '@/app/components/base/input'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { noop } from 'lodash-es'
|
||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||
|
||||
type MailAndPasswordAuthProps = {
|
||||
isEmailSetup: boolean
|
||||
}
|
||||
|
||||
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
||||
|
||||
export default function MailAndPasswordAuth({ isEmailSetup }: 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 redirectUrl = searchParams.get('redirect_url')
|
||||
|
||||
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||
const appCode = redirectUrl?.split('/').pop()
|
||||
if (!appCode)
|
||||
return null
|
||||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
const handleEmailPasswordLogin = async () => {
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
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
|
||||
}
|
||||
if (!redirectUrl || !appCode) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.error.redirectUrlMissing'),
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const loginData: Record<string, any> = {
|
||||
email,
|
||||
password,
|
||||
language: locale,
|
||||
remember_me: true,
|
||||
}
|
||||
|
||||
const res = await webAppLogin({
|
||||
url: '/login',
|
||||
body: loginData,
|
||||
})
|
||||
if (res.result === 'success') {
|
||||
localStorage.setItem('webAppAccessToken', res.data.access_token)
|
||||
await checkOrSetAccessToken()
|
||||
router.replace(redirectUrl)
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: res.data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
const getResetPasswordParams = () => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('from', 'webapp-signin')
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
return <form onSubmit={noop}>
|
||||
<div className='mb-3'>
|
||||
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">
|
||||
{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') || ''}
|
||||
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?${getResetPasswordParams()}`}
|
||||
className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`}
|
||||
tabIndex={isEmailSetup ? 0 : -1}
|
||||
aria-disabled={!isEmailSetup}
|
||||
>
|
||||
{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>
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback } 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 Button from '@/app/components/base/button'
|
||||
import { SSOProtocol } from '@/types/feature'
|
||||
import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share'
|
||||
|
||||
type SSOAuthProps = {
|
||||
protocol: SSOProtocol | ''
|
||||
}
|
||||
|
||||
const SSOAuth: FC<SSOAuthProps> = ({
|
||||
protocol,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||
const appCode = redirectUrl?.split('/').pop()
|
||||
if (!appCode)
|
||||
return null
|
||||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
if (!redirectUrl || !appCode) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'invalid redirect URL or app code',
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
if (protocol === SSOProtocol.SAML) {
|
||||
fetchMembersSAMLSSOUrl(appCode, redirectUrl).then((res) => {
|
||||
router.push(res.url)
|
||||
}).finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
else if (protocol === SSOProtocol.OIDC) {
|
||||
fetchMembersOIDCSSOUrl(appCode, redirectUrl).then((res) => {
|
||||
router.push(res.url)
|
||||
}).finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
else if (protocol === SSOProtocol.OAuth2) {
|
||||
fetchMembersOAuth2SSOUrl(appCode, redirectUrl).then((res) => {
|
||||
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 h-5 w-5 text-text-accent-light-mode-only' />
|
||||
<span className="truncate">{t('login.withSSO')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SSOAuth
|
||||
@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
import Header from './_header'
|
||||
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
useDocumentTitle('')
|
||||
return <>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
|
||||
<div className='flex flex-col md:w-[400px] lg:w-[600px]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{systemFeatures.branding.enabled === false && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import MailAndCodeAuth from './components/mail-and-code-auth'
|
||||
import MailAndPasswordAuth from './components/mail-and-password-auth'
|
||||
import SSOAuth from './components/sso-auth'
|
||||
import cn from '@/utils/classnames'
|
||||
import { LicenseStatus } from '@/types/feature'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
const NormalForm = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
|
||||
const [showORLine, setShowORLine] = useState(false)
|
||||
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
|
||||
|
||||
const init = useCallback(async () => {
|
||||
try {
|
||||
setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin)
|
||||
setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login))
|
||||
updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code')
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
setAllMethodsAreDisabled(true)
|
||||
}
|
||||
finally { setIsLoading(false) }
|
||||
}, [systemFeatures])
|
||||
useEffect(() => {
|
||||
init()
|
||||
}, [init])
|
||||
if (isLoading) {
|
||||
return <div className={
|
||||
cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-[108px]',
|
||||
)
|
||||
}>
|
||||
<Loading type='area' />
|
||||
</div>
|
||||
}
|
||||
if (systemFeatures.license?.status === LicenseStatus.LOST) {
|
||||
return <div className='mx-auto mt-8 w-full'>
|
||||
<div className='relative'>
|
||||
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
|
||||
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
|
||||
<RiContractLine className='h-5 w-5' />
|
||||
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<p className='system-sm-medium text-text-primary'>{t('login.licenseLost')}</p>
|
||||
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseLostTip')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if (systemFeatures.license?.status === LicenseStatus.EXPIRED) {
|
||||
return <div className='mx-auto mt-8 w-full'>
|
||||
<div className='relative'>
|
||||
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
|
||||
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
|
||||
<RiContractLine className='h-5 w-5' />
|
||||
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<p className='system-sm-medium text-text-primary'>{t('login.licenseExpired')}</p>
|
||||
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseExpiredTip')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if (systemFeatures.license?.status === LicenseStatus.INACTIVE) {
|
||||
return <div className='mx-auto mt-8 w-full'>
|
||||
<div className='relative'>
|
||||
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
|
||||
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
|
||||
<RiContractLine className='h-5 w-5' />
|
||||
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
<p className='system-sm-medium text-text-primary'>{t('login.licenseInactive')}</p>
|
||||
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseInactiveTip')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mt-8 w-full">
|
||||
<div className="mx-auto w-full">
|
||||
<h2 className="title-4xl-semi-bold text-text-primary">{t('login.pageTitle')}</h2>
|
||||
{!systemFeatures.branding.enabled && <p className='body-md-regular mt-2 text-text-tertiary'>{t('login.welcome')}</p>}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{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='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="system-xs-medium-uppercase px-2 text-text-tertiary">{t('login.or')}</span>
|
||||
</div>
|
||||
</div>}
|
||||
{
|
||||
(systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <>
|
||||
{systemFeatures.enable_email_code_login && authType === 'code' && <>
|
||||
<MailAndCodeAuth />
|
||||
{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 isEmailSetup={systemFeatures.is_email_setup} />
|
||||
{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>}
|
||||
</>}
|
||||
</>
|
||||
}
|
||||
{allMethodsAreDisabled && <>
|
||||
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
|
||||
<div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
|
||||
<RiDoorLockLine className='h-5 w-5' />
|
||||
</div>
|
||||
<p className='system-sm-medium text-text-primary'>{t('login.noLoginMethod')}</p>
|
||||
<p className='system-xs-regular mt-1 text-text-tertiary'>{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='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
{!systemFeatures.branding.enabled && <>
|
||||
<div className="system-xs-regular mt-2 block w-full text-text-tertiary">
|
||||
{t('login.tosDesc')}
|
||||
|
||||
<Link
|
||||
className='system-xs-medium text-text-secondary hover:underline'
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
href='https://dify.ai/terms'
|
||||
>{t('login.tos')}</Link>
|
||||
&
|
||||
<Link
|
||||
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 system-xs-regular mt-2 block text-text-tertiary">
|
||||
{t('login.goToInit')}
|
||||
|
||||
<Link
|
||||
className='system-xs-medium text-text-secondary hover:underline'
|
||||
href='/install'
|
||||
>{t('login.setAdminAccount')}</Link>
|
||||
</div>}
|
||||
</>}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NormalForm
|
||||
@ -0,0 +1,7 @@
|
||||
.githubIcon {
|
||||
background: center/contain url('./assets/github.svg');
|
||||
}
|
||||
|
||||
.googleIcon {
|
||||
background: center/contain url('./assets/google.svg');
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getAppAccessModeByAppCode } from './share'
|
||||
|
||||
const NAME_SPACE = 'webapp'
|
||||
|
||||
export const useAppAccessModeByCode = (code: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appAccessMode', code],
|
||||
queryFn: () => {
|
||||
if (!code)
|
||||
return null
|
||||
|
||||
return getAppAccessModeByAppCode(code)
|
||||
},
|
||||
enabled: !!code,
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue