New Auth Methods (#8119)

This commit is contained in:
NFish 2024-10-21 09:23:20 +08:00 committed by GitHub
parent 853b0e84cc
commit 3898fe3311
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1568 additions and 787 deletions

View File

@ -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}
<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}
<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}
<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)

View File

@ -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
"

View File

@ -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,63 +24,19 @@ const ActivateForm = () => {
token,
},
}
const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, {
const { data: checkRes } = useSWR(checkParams, invitationCheck, {
revalidateOnFocus: false,
})
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
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()}`)
}
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={
@ -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>
)
}

View File

@ -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>

After

Width:  |  Height:  |  Size: 717 B

View File

@ -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>

View File

@ -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>
}

View File

@ -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,26 +22,59 @@ 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
}
catch (error) {
console.error(error)
return false
}
}, [])
const setRefreshToken = useCallback(async () => {
try {
if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage))
return Promise.reject(new Error('No token found'))
if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage)
getNewAccessToken()
await 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')
})
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
? (

View File

@ -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,34 +114,30 @@ 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
<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'}
className='mt-1'
/>
</div>
<div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
<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
<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'}
className='mt-1'
/>
</div>
</div>
<div>
<Button
variant='primary'
@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>
}

View File

@ -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>
</>
}

View File

@ -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>
}

View File

@ -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

View File

@ -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>
}

View File

@ -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>
)
}

View File

@ -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>
}

View File

@ -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>
</>
}

View File

@ -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

View File

@ -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

View File

@ -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
web/app/signin/layout.tsx Normal file
View File

@ -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>
</>
}

View File

@ -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 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 [state, dispatch] = useReducer(reducer, {
formValid: false,
github: false,
google: false,
})
const isInviteLink = Boolean(invite_token && invite_token !== 'null')
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 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,
})
}
}
finally {
setIsLoading(false)
}
}
const { data: github, error: github_error } = useSWR(state.github
? ({
url: '/oauth/login/github',
// params: {
// provider: 'github',
// },
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,
},
})
: null, oauth)
const { data: google, error: google_error } = useSWR(state.google
? ({
url: '/oauth/login/google',
// params: {
// provider: 'google',
// },
})
: null, oauth)
setWorkSpaceName(checkRes?.data?.workspace_name || '')
}
}
catch (error) {
console.error(error)
setAllMethodsAreDisabled(true)
setSystemFeatures(defaultSystemFeatures)
}
finally { setIsLoading(false) }
}, [consoleToken, refreshToken, message, router, invite_token, isInviteLink, getNewAccessToken])
useEffect(() => {
if (github_error !== undefined)
dispatch({ type: 'github_login_failed' })
if (github)
window.location.href = github.redirect_url
}, [github, github_error])
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 && (
{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">
<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>
{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>
<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>
)}
{
useEmailLogin && <>
{/* <div className="relative mt-6">
{showORLine && <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 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 text-sm">
<span className="px-2 text-gray-300 bg-white">OR</span>
<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> */}
<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>
</div>}
{
(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>}

View File

@ -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>

View File

@ -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)
const searchParams = useSearchParams()
const step = searchParams.get('step')
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>
</>
)
if (step === 'next')
return <OneMoreStep />
return <NormalForm />
}
export default SignIn

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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 })

View File

@ -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)
}

View File

@ -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,
}