diff --git a/web/app/account/account-page/index.tsx b/web/app/account/account-page/index.tsx index 53f7692e6c..b42b481eba 100644 --- a/web/app/account/account-page/index.tsx +++ b/web/app/account/account-page/index.tsx @@ -15,6 +15,7 @@ import { ToastContext } from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' import Avatar from '@/app/components/base/avatar' import { IS_CE_EDITION } from '@/config' +import Input from '@/app/components/base/input' const titleClassName = ` text-sm font-medium text-gray-900 @@ -31,6 +32,7 @@ const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ export default function AccountPage() { const { t } = useTranslation() + const { systemFeatures } = useAppContext() const { mutateUserProfile, userProfile, apps } = useAppContext() const { notify } = useContext(ToastContext) const [editNameModalVisible, setEditNameModalVisible] = useState(false) @@ -41,6 +43,9 @@ export default function AccountPage() { const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) + const [showCurrentPassword, setShowCurrentPassword] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) const handleEditName = () => { setEditNameModalVisible(true) @@ -158,8 +163,8 @@ export default function AccountPage() { { - IS_CE_EDITION && ( -
+ systemFeatures.enable_email_password_login && ( +
{t('common.account.password')}
{t('common.account.passwordTip')}
@@ -191,8 +196,7 @@ export default function AccountPage() { >
{t('common.account.editName')}
{t('common.account.name')}
- setEditName(e.target.value)} /> @@ -223,30 +227,61 @@ export default function AccountPage() { {userProfile.is_password_set && ( <>
{t('common.account.currentPassword')}
- setCurrentPassword(e.target.value)} - /> +
+ setCurrentPassword(e.target.value)} + /> + +
+ +
+
)}
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
- setPassword(e.target.value)} - /> +
+ setPassword(e.target.value)} + /> +
+ +
+
{t('common.account.confirmPassword')}
- setConfirmPassword(e.target.value)} - /> +
+ setConfirmPassword(e.target.value)} + /> +
+ +
+
)} - {checkRes && checkRes.is_valid && !showSuccess && ( -
-
-
-
-

- {`${t('login.join')} ${checkRes.workspace_name}`} -

-

- {`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`} -

-
- -
-
- {/* username */} -
- -
- 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} - /> -
-
- {/* password */} -
- -
- 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} - /> -
-
{t('login.error.passwordInvalid')}
-
- {/* language */} -
- -
- item.supported)} - onSelect={(item) => { - setLanguage(item.value as string) - }} - /> -
-
- {/* timezone */} -
- -
- { - setTimezone(item.value as string) - }} - /> -
-
-
- -
-
- {t('login.license.tip')} -   - {t('login.license.link')} -
-
-
-
- )} - {checkRes && checkRes.is_valid && showSuccess && ( -
-
-
- -
-

- {`${t('login.activatedTipStart')} ${checkRes.workspace_name} ${t('login.activatedTipEnd')}`} -

-
-
- -
-
- )}
) } diff --git a/web/app/components/base/icons/assets/public/common/lock.svg b/web/app/components/base/icons/assets/public/common/lock.svg new file mode 100644 index 0000000000..a6987846f7 --- /dev/null +++ b/web/app/components/base/icons/assets/public/common/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/header/account-setting/account-page/index.tsx b/web/app/components/header/account-setting/account-page/index.tsx index eecd275b35..7b400dd50d 100644 --- a/web/app/components/header/account-setting/account-page/index.tsx +++ b/web/app/components/header/account-setting/account-page/index.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' +import { useContext, useContextSelector } from 'use-context-selector' import Collapse from '../collapse' import type { IItem } from '../collapse' import s from './index.module.css' @@ -11,7 +11,7 @@ import Modal from '@/app/components/base/modal' import Confirm from '@/app/components/base/confirm' import Button from '@/app/components/base/button' import { updateUserProfile } from '@/service/common' -import { useAppContext } from '@/context/app-context' +import AppContext, { useAppContext } from '@/context/app-context' import { ToastContext } from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' import Avatar from '@/app/components/base/avatar' @@ -42,6 +42,7 @@ export default function AccountPage() { const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) + const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures) const handleEditName = () => { setEditNameModalVisible(true) @@ -144,7 +145,7 @@ export default function AccountPage() {
{t('common.account.email')}
{userProfile.email}
- {IS_CE_EDITION && ( + {systemFeatures.enable_email_password_login && (
{t('common.account.password')}
{t('common.account.passwordTip')}
diff --git a/web/app/components/signin/countdown.tsx b/web/app/components/signin/countdown.tsx new file mode 100644 index 0000000000..6282480d10 --- /dev/null +++ b/web/app/components/signin/countdown.tsx @@ -0,0 +1,41 @@ +'use client' +import { useCountDown } from 'ahooks' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export const COUNT_DOWN_TIME_MS = 59000 +export const COUNT_DOWN_KEY = 'leftTime' + +type CountdownProps = { + onResend?: () => void +} + +export default function Countdown({ onResend }: CountdownProps) { + const { t } = useTranslation() + const [leftTime, setLeftTime] = useState(Number(localStorage.getItem(COUNT_DOWN_KEY) || COUNT_DOWN_TIME_MS)) + const [time] = useCountDown({ + leftTime, + onEnd: () => { + setLeftTime(0) + localStorage.removeItem(COUNT_DOWN_KEY) + }, + }) + + const resend = async function () { + setLeftTime(COUNT_DOWN_TIME_MS) + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + onResend?.() + } + + useEffect(() => { + localStorage.setItem(COUNT_DOWN_KEY, `${time}`) + }, [time]) + + return

+ {t('login.checkCode.didNotReceiveCode')} + {time > 0 && {Math.round(time / 1000)}s} + { + time <= 0 && {t('login.checkCode.resend')} + } +

+} diff --git a/web/app/components/swr-initor.tsx b/web/app/components/swr-initor.tsx index ce126512fa..89141359d6 100644 --- a/web/app/components/swr-initor.tsx +++ b/web/app/components/swr-initor.tsx @@ -1,10 +1,11 @@ 'use client' import { SWRConfig } from 'swr' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import type { ReactNode } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import useRefreshToken from '@/hooks/use-refresh-token' +import { fetchSetupStatus } from '@/service/common' type SwrInitorProps = { children: ReactNode @@ -21,27 +22,60 @@ const SwrInitor = ({ const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token') const [init, setInit] = useState(false) - useEffect(() => { - if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) { - router.replace('/signin') - return + const isSetupFinished = useCallback(async () => { + try { + if (localStorage.getItem('setup_status') === 'finished') + return true + const setUpStatus = await fetchSetupStatus() + if (setUpStatus.step !== 'finished') { + localStorage.removeItem('setup_status') + return false + } + localStorage.setItem('setup_status', 'finished') + return true } - if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage) - getNewAccessToken() - - if (consoleToken && refreshToken) { - localStorage.setItem('console_token', consoleToken) - localStorage.setItem('refresh_token', refreshToken) - getNewAccessToken().then(() => { - router.replace('/apps', { forceOptimisticNavigation: false } as any) - }).catch(() => { - router.replace('/signin') - }) + catch (error) { + console.error(error) + return false } - - setInit(true) }, []) + const setRefreshToken = useCallback(async () => { + try { + if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) + return Promise.reject(new Error('No token found')) + + if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage) + await getNewAccessToken() + + if (consoleToken && refreshToken) { + localStorage.setItem('console_token', consoleToken) + localStorage.setItem('refresh_token', refreshToken) + await getNewAccessToken() + } + } + catch (error) { + return Promise.reject(error) + } + }, [consoleToken, refreshToken, consoleTokenFromLocalStorage, refreshTokenFromLocalStorage, getNewAccessToken]) + + useEffect(() => { + (async () => { + try { + const isFinished = await isSetupFinished() + if (!isFinished) { + router.replace('/install') + return + } + await setRefreshToken() + setInit(true) + } + catch (error) { + router.replace('/signin') + } + })() + }, [isSetupFinished, setRefreshToken, router]) + return init ? ( { -
- 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'} - /> -
-
{t('login.error.passwordInvalid')}
+ setPassword(e.target.value)} + placeholder={t('login.passwordPlaceholder') || ''} + className='mt-1' + /> +
{t('login.error.passwordInvalid')}
{/* Confirm Password */}
-
- 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'} - /> -
+ setConfirmPassword(e.target.value)} + placeholder={t('login.confirmPasswordPlaceholder') || ''} + className='mt-1' + />
-
diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index 6fd69a3638..df744924da 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import Loading from '../components/base/loading' +import Input from '../components/base/input' import Button from '@/app/components/base/button' import { @@ -78,7 +79,7 @@ const ForgotPasswordForm = () => { return ( loading - ? + ? : <>

@@ -98,10 +99,9 @@ const ForgotPasswordForm = () => { {t('login.email')}
- {errors.email && {t(`${errors.email?.message}`)}}
diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index 0db88c8e25..abf377e389 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -65,6 +65,7 @@ const InstallForm = () => { useEffect(() => { fetchSetupStatus().then((res: SetupStatusResponse) => { if (res.step === 'finished') { + localStorage.setItem('setup_status', 'finished') window.location.href = '/signin' } else { @@ -153,7 +154,7 @@ const InstallForm = () => {

-
+
{t('login.license.tip')}   { + 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
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex items-center justify-center h-9 text-text-tertiary cursor-pointer'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/reset-password/layout.tsx b/web/app/reset-password/layout.tsx new file mode 100644 index 0000000000..16d8642ed2 --- /dev/null +++ b/web/app/reset-password/layout.tsx @@ -0,0 +1,39 @@ +import Header from '../signin/_header' +import style from '../signin/page.module.css' + +import cn from '@/utils/classnames' + +export default async function SignInLayout({ children }: any) { + return <> +
+
+
+
+
+ {children} +
+
+
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
+
+
+ +} diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx new file mode 100644 index 0000000000..65f1db3fb5 --- /dev/null +++ b/web/app/reset-password/page.tsx @@ -0,0 +1,101 @@ +'use client' +import Link from 'next/link' +import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown' +import { emailRegex } from '@/config' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendResetPasswordCode } from '@/service/common' +import I18NContext from '@/context/i18n' + +export default function CheckCode() { + const { t } = useTranslation() + const searchParams = useSearchParams() + const router = useRouter() + const [email, setEmail] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const res = await sendResetPasswordCode(email, locale) + if (res.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(res.data)) + params.set('email', encodeURIComponent(email)) + router.push(`/reset-password/check-code?${params.toString()}`) + } + else if (res.code === 'account_not_found') { + Toast.notify({ + type: 'error', + message: t('login.error.registrationNotAllowed'), + }) + } + else { + Toast.notify({ + type: 'error', + message: res.data, + }) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return
+
+ +
+
+

{t('login.resetPassword')}

+

+ {t('login.resetPasswordDesc')} +

+
+ +
{ }}> + +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+
+
+
+ +
+ +
+ {t('login.backToLogin')} + +
+} diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx new file mode 100644 index 0000000000..7948c59a9a --- /dev/null +++ b/web/app/reset-password/set-password/page.tsx @@ -0,0 +1,193 @@ +'use client' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import cn from 'classnames' +import { RiCheckboxCircleFill } from '@remixicon/react' +import { useCountDown } from 'ahooks' +import Button from '@/app/components/base/button' +import { changePasswordWithToken } from '@/service/common' +import Toast from '@/app/components/base/toast' +import Input from '@/app/components/base/input' + +const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +const ChangePasswordForm = () => { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const token = decodeURIComponent(searchParams.get('token') || '') + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showSuccess, setShowSuccess] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const showErrorMessage = useCallback((message: string) => { + Toast.notify({ + type: 'error', + message, + }) + }, []) + + const getSignInUrl = () => { + if (searchParams.has('invite_token')) { + const params = new URLSearchParams() + params.set('token', searchParams.get('invite_token') as string) + return `/activate?${params.toString()}` + } + return '/signin' + } + + const AUTO_REDIRECT_TIME = 5000 + const [leftTime, setLeftTime] = useState(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 ( +
+ {!showSuccess && ( +
+
+

+ {t('login.changePassword')} +

+

+ {t('login.changePasswordTip')} +

+
+ +
+
+ {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder={t('login.passwordPlaceholder') || ''} + /> + +
+ +
+
+
{t('login.error.passwordInvalid')}
+
+ {/* Confirm Password */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder={t('login.confirmPasswordPlaceholder') || ''} + /> +
+ +
+
+
+
+ +
+
+
+
+ )} + {showSuccess && ( +
+
+
+ +
+

+ {t('login.passwordChangedTip')} +

+
+
+ +
+
+ )} +
+ ) +} + +export default ChangePasswordForm diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx new file mode 100644 index 0000000000..4767308f72 --- /dev/null +++ b/web/app/signin/check-code/page.tsx @@ -0,0 +1,96 @@ +'use client' +import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Countdown from '@/app/components/signin/countdown' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' +import I18NContext from '@/context/i18n' + +export default function CheckCode() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const email = decodeURIComponent(searchParams.get('email') as string) + const token = decodeURIComponent(searchParams.get('token') as string) + const invite_token = decodeURIComponent(searchParams.get('invite_token') || '') + const [code, setVerifyCode] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const verify = async () => { + try { + if (!code.trim()) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.emptyCode'), + }) + return + } + if (!/\d{6}/.test(code)) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.invalidCode'), + }) + return + } + setIsLoading(true) + const ret = await emailLoginWithCode({ email, code, token }) + if (ret.result === 'success') { + localStorage.setItem('console_token', ret.data.access_token) + localStorage.setItem('refresh_token', ret.data.refresh_token) + router.replace(invite_token ? `/signin/invite-settings?${searchParams.toString()}` : '/apps') + } + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + } + + const resendCode = async () => { + try { + const ret = await sendEMailLoginCode(email, locale) + if (ret.result === 'success') { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(ret.data)) + router.replace(`/signin/check-code?${params.toString()}`) + } + } + catch (error) { console.error(error) } + } + + return
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex items-center justify-center h-9 text-text-tertiary cursor-pointer'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx new file mode 100644 index 0000000000..7225b094d4 --- /dev/null +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Input from '@/app/components/base/input' +import Button from '@/app/components/base/button' +import { emailRegex } from '@/config' +import Toast from '@/app/components/base/toast' +import { sendEMailLoginCode } from '@/service/common' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import I18NContext from '@/context/i18n' + +type MailAndCodeAuthProps = { + isInvite: boolean +} + +export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const ret = await sendEMailLoginCode(email, locale) + if (ret.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('email', encodeURIComponent(email)) + params.set('token', encodeURIComponent(ret.data)) + router.push(`/signin/check-code?${params.toString()}`) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return (
{ }}> + +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+ ) +} diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx new file mode 100644 index 0000000000..210c877bb7 --- /dev/null +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -0,0 +1,167 @@ +import Link from 'next/link' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Button from '@/app/components/base/button' +import Toast from '@/app/components/base/toast' +import { emailRegex } from '@/config' +import { login } from '@/service/common' +import Input from '@/app/components/base/input' +import I18NContext from '@/context/i18n' + +type MailAndPasswordAuthProps = { + isInvite: boolean + allowRegistration: boolean +} + +const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +export default function MailAndPasswordAuth({ isInvite, allowRegistration }: MailAndPasswordAuthProps) { + const { t } = useTranslation() + const { locale } = useContext(I18NContext) + const router = useRouter() + const searchParams = useSearchParams() + const [showPassword, setShowPassword] = useState(false) + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [password, setPassword] = useState('') + + const [isLoading, setIsLoading] = useState(false) + const handleEmailPasswordLogin = async () => { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + if (!password?.trim()) { + Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') }) + return + } + if (!passwordRegex.test(password)) { + Toast.notify({ + type: 'error', + message: t('login.error.passwordInvalid'), + }) + return + } + try { + setIsLoading(true) + const loginData: Record = { + 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
{ }}> +
+ +
+ setEmail(e.target.value)} + disabled={isInvite} + id="email" + type="email" + autoComplete="email" + placeholder={t('login.emailPlaceholder') || ''} + tabIndex={1} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') + handleEmailPasswordLogin() + }} + type={showPassword ? 'text' : 'password'} + autoComplete="current-password" + placeholder={t('login.passwordPlaceholder') || ''} + tabIndex={2} + /> +
+ +
+
+
+ +
+ +
+
+} diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx new file mode 100644 index 0000000000..39d7ceaa40 --- /dev/null +++ b/web/app/signin/components/social-auth.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'next/navigation' +import style from '../page.module.css' +import Button from '@/app/components/base/button' +import { apiPrefix } from '@/config' +import classNames from '@/utils/classnames' +import { getPurifyHref } from '@/utils' + +type SocialAuthProps = { + disabled?: boolean +} + +export default function SocialAuth(props: SocialAuthProps) { + const { t } = useTranslation() + const searchParams = useSearchParams() + + const getOAuthLink = (href: string) => { + const url = getPurifyHref(`${apiPrefix}${href}`) + if (searchParams.has('invite_token')) + return `${url}?${searchParams.toString()}` + + return url + } + return <> + + + +} diff --git a/web/app/signin/components/sso-auth.tsx b/web/app/signin/components/sso-auth.tsx new file mode 100644 index 0000000000..fb303b93e2 --- /dev/null +++ b/web/app/signin/components/sso-auth.tsx @@ -0,0 +1,73 @@ +'use client' +import { useRouter, useSearchParams } from 'next/navigation' +import type { FC } from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' +import Toast from '@/app/components/base/toast' +import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso' +import Button from '@/app/components/base/button' +import { SSOProtocol } from '@/types/feature' + +type SSOAuthProps = { + protocol: SSOProtocol | '' +} + +const SSOAuth: FC = ({ + 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 ( + + ) +} + +export default SSOAuth diff --git a/web/app/signin/forms.tsx b/web/app/signin/forms.tsx deleted file mode 100644 index 70a34c26fa..0000000000 --- a/web/app/signin/forms.tsx +++ /dev/null @@ -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 - default: - return - } - } - return
-
- {getForm()} -
-
-} - -export default Forms diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx new file mode 100644 index 0000000000..2138399ec3 --- /dev/null +++ b/web/app/signin/invite-settings/page.tsx @@ -0,0 +1,154 @@ +'use client' +import { useTranslation } from 'react-i18next' +import { useCallback, useState } from 'react' +import Link from 'next/link' +import { useContext } from 'use-context-selector' +import { useRouter, useSearchParams } from 'next/navigation' +import useSWR from 'swr' +import { RiAccountCircleLine } from '@remixicon/react' +import Input from '@/app/components/base/input' +import { SimpleSelect } from '@/app/components/base/select' +import Button from '@/app/components/base/button' +import { timezones } from '@/utils/timezone' +import { LanguagesSupported, languages } from '@/i18n/language' +import I18n from '@/context/i18n' +import { activateMember, invitationCheck } from '@/service/common' +import Loading from '@/app/components/base/loading' +import Toast from '@/app/components/base/toast' + +export default function InviteSettingsPage() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const token = decodeURIComponent(searchParams.get('invite_token') as string) + const { locale, setLocaleOnClient } = useContext(I18n) + const [name, setName] = useState('') + const [language, setLanguage] = useState(LanguagesSupported[0]) + const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles') + + const checkParams = { + url: '/activate/check', + params: { + token, + }, + } + const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, { + revalidateOnFocus: false, + }) + + const handleActivate = useCallback(async () => { + try { + if (!name) { + Toast.notify({ type: 'error', message: t('login.enterYourName') }) + return + } + const res = await activateMember({ + url: '/activate', + body: { + token, + name, + interface_language: language, + timezone, + }, + }) + if (res.result === 'success') { + localStorage.setItem('console_token', res.data.access_token) + localStorage.setItem('refresh_token', res.data.refresh_token) + setLocaleOnClient(language, false) + router.replace('/apps') + } + } + catch { + recheck() + } + }, [language, name, recheck, setLocaleOnClient, timezone, token, router, t]) + + if (!checkRes) + return + if (!checkRes.is_valid) { + return
+
+
🤷‍♂️
+

{t('login.invalid')}

+
+ +
+ } + + return
+
+ +
+
+

{t('login.setYourAccount')}

+
+
+ +
+ +
+ setName(e.target.value)} + placeholder={t('login.namePlaceholder') || ''} + /> +
+
+
+ +
+ item.supported)} + onSelect={(item) => { + setLanguage(item.value as string) + }} + /> +
+
+ {/* timezone */} +
+ +
+ { + setTimezone(item.value as string) + }} + /> +
+
+
+ +
+
+
+ {t('login.license.tip')} +   + {t('login.license.link')} +
+
+} diff --git a/web/app/signin/layout.tsx b/web/app/signin/layout.tsx new file mode 100644 index 0000000000..342876bc53 --- /dev/null +++ b/web/app/signin/layout.tsx @@ -0,0 +1,54 @@ +import Script from 'next/script' +import Header from './_header' +import style from './page.module.css' + +import cn from '@/utils/classnames' +import { IS_CE_EDITION } from '@/config' + +export default async function SignInLayout({ children }: any) { + return <> + {!IS_CE_EDITION && ( + <> + + + + )} + +
+
+
+
+
+ {children} +
+
+
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
+
+
+ +} diff --git a/web/app/signin/normalForm.tsx b/web/app/signin/normalForm.tsx index 113ed64b57..c0f2d89b37 100644 --- a/web/app/signin/normalForm.tsx +++ b/web/app/signin/normalForm.tsx @@ -1,299 +1,170 @@ -'use client' -import React, { useEffect, useReducer, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useRouter } from 'next/navigation' -import useSWR from 'swr' import Link from 'next/link' -import Toast from '../components/base/toast' -import style from './page.module.css' -import classNames from '@/utils/classnames' -import { IS_CE_EDITION, SUPPORT_MAIL_LOGIN, apiPrefix, emailRegex } from '@/config' -import Button from '@/app/components/base/button' -import { login, oauth } from '@/service/common' -import { getPurifyHref } from '@/utils' +import { useRouter, useSearchParams } from 'next/navigation' +import { RiDoorLockLine } from '@remixicon/react' +import Loading from '../components/base/loading' +import MailAndCodeAuth from './components/mail-and-code-auth' +import MailAndPasswordAuth from './components/mail-and-password-auth' +import SocialAuth from './components/social-auth' +import SSOAuth from './components/sso-auth' +import cn from '@/utils/classnames' +import { getSystemFeatures, invitationCheck } from '@/service/common' +import { defaultSystemFeatures } from '@/types/feature' +import Toast from '@/app/components/base/toast' import useRefreshToken from '@/hooks/use-refresh-token' - -type IState = { - formValid: boolean - github: boolean - google: boolean -} - -type IAction = { - type: 'login' | 'login_failed' | 'github_login' | 'github_login_failed' | 'google_login' | 'google_login_failed' -} - -function reducer(state: IState, action: IAction) { - switch (action.type) { - case 'login': - return { - ...state, - formValid: true, - } - case 'login_failed': - return { - ...state, - formValid: true, - } - case 'github_login': - return { - ...state, - github: true, - } - case 'github_login_failed': - return { - ...state, - github: false, - } - case 'google_login': - return { - ...state, - google: true, - } - case 'google_login_failed': - return { - ...state, - google: false, - } - default: - throw new Error('Unknown action.') - } -} +import { IS_CE_EDITION } from '@/config' const NormalForm = () => { - const { t } = useTranslation() const { getNewAccessToken } = useRefreshToken() - const useEmailLogin = IS_CE_EDITION || SUPPORT_MAIL_LOGIN - + const { t } = useTranslation() const router = useRouter() + const 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, }) } + const features = await getSystemFeatures() + const allFeatures = { ...defaultSystemFeatures, ...features } + setSystemFeatures(allFeatures) + setAllMethodsAreDisabled(!allFeatures.enable_social_oauth_login && !allFeatures.enable_email_code_login && !allFeatures.enable_email_password_login && !allFeatures.sso_enforced_for_signin) + setShowORLine((allFeatures.enable_social_oauth_login || allFeatures.sso_enforced_for_signin) && (allFeatures.enable_email_code_login || allFeatures.enable_email_password_login)) + updateAuthType(allFeatures.enable_email_password_login ? 'password' : 'code') + if (isInviteLink) { + const checkRes = await invitationCheck({ + url: '/activate/check', + params: { + token: invite_token, + }, + }) + setWorkSpaceName(checkRes?.data?.workspace_name || '') + } } - finally { - setIsLoading(false) + catch (error) { + console.error(error) + setAllMethodsAreDisabled(true) + setSystemFeatures(defaultSystemFeatures) } + finally { setIsLoading(false) } + }, [consoleToken, refreshToken, message, router, invite_token, isInviteLink, getNewAccessToken]) + useEffect(() => { + init() + }, [init]) + if (isLoading || consoleToken) { + return
+ +
} - const { data: github, error: github_error } = useSWR(state.github - ? ({ - url: '/oauth/login/github', - // params: { - // provider: 'github', - // }, - }) - : null, oauth) - - const { data: google, error: google_error } = useSWR(state.google - ? ({ - url: '/oauth/login/google', - // params: { - // provider: 'google', - // }, - }) - : null, oauth) - - useEffect(() => { - if (github_error !== undefined) - dispatch({ type: 'github_login_failed' }) - if (github) - window.location.href = github.redirect_url - }, [github, github_error]) - - useEffect(() => { - if (google_error !== undefined) - dispatch({ type: 'google_login_failed' }) - if (google) - window.location.href = google.redirect_url - }, [google, google_error]) - return ( <> -
-

{t('login.pageTitle')}

-

{t('login.welcome')}

-
-
-
- {!useEmailLogin && ( -
- - + {isInviteLink + ?
+

{t('login.join')}{workspaceName}

+

{t('login.joinTipStart')}{workspaceName}{t('login.joinTipEnd')}

+
+ :
+

{t('login.pageTitle')}

+

{t('login.welcome')}

+
} +
+
+ {systemFeatures.enable_social_oauth_login && } + {systemFeatures.sso_enforced_for_signin &&
+ +
} +
+ + {showORLine &&
+ - )} - +
+ {t('login.or')} +
+
} { - useEmailLogin && <> - {/*
- */} - -
{ }}> -
- -
- 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} - /> -
-
- -
- -
- 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} - /> -
- -
-
-
- -
- -
-
+ (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <> + {systemFeatures.enable_email_code_login && authType === 'code' && <> + + {systemFeatures.enable_email_password_login &&
{ updateAuthType('password') }}> + {t('login.usePassword')} +
} + } + {systemFeatures.enable_email_password_login && authType === 'password' && <> + + {systemFeatures.enable_email_code_login &&
{ updateAuthType('code') }}> + {t('login.useVerificationCode')} +
} + } } - {/* agree to our Terms and Privacy Policy. */} -
+ {allMethodsAreDisabled && <> +
+
+ +
+

{t('login.noLoginMethod')}

+

{t('login.noLoginMethodTip')}

+
+
+ +
+ } +
{t('login.tosDesc')}   {t('login.tos')}  &  {t('login.pp')}
- - {IS_CE_EDITION &&
+ {IS_CE_EDITION &&
{t('login.goToInit')}   {t('login.setAdminAccount')}
} diff --git a/web/app/signin/oneMoreStep.tsx b/web/app/signin/oneMoreStep.tsx index a4324517a5..8554b364c0 100644 --- a/web/app/signin/oneMoreStep.tsx +++ b/web/app/signin/oneMoreStep.tsx @@ -3,8 +3,8 @@ import React, { useEffect, useReducer } from 'react' import { useTranslation } from 'react-i18next' import Link from 'next/link' import useSWR from 'swr' -import { useRouter } from 'next/navigation' -// import { useContext } from 'use-context-selector' +import { useRouter, useSearchParams } from 'next/navigation' +import Input from '../components/base/input' import Button from '@/app/components/base/button' import Tooltip from '@/app/components/base/tooltip' import { SimpleSelect } from '@/app/components/base/select' @@ -12,7 +12,6 @@ import { timezones } from '@/utils/timezone' import { LanguagesSupported, languages } from '@/i18n/language' import { oneMoreStep } from '@/service/common' import Toast from '@/app/components/base/toast' -// import I18n from '@/context/i18n' type IState = { formState: 'processing' | 'error' | 'success' | 'initial' @@ -46,11 +45,11 @@ const reducer = (state: IState, action: any) => { const OneMoreStep = () => { const { t } = useTranslation() const router = useRouter() - // const { locale } = useContext(I18n) + const searchParams = useSearchParams() const [state, dispatch] = useReducer(reducer, { formState: 'initial', - invitation_code: '', + invitation_code: searchParams.get('invitation_code') || '', interface_language: 'en-US', timezone: 'Asia/Shanghai', }) @@ -77,36 +76,35 @@ const OneMoreStep = () => { return ( <>
-

{t('login.oneMoreStep')}

-

{t('login.createSample')}

+

{t('login.oneMoreStep')}

+

{t('login.createSample')}

-
-