| @@ -15,6 +15,7 @@ import { ToastContext } from '@/app/components/base/toast' | |||
| import AppIcon from '@/app/components/base/app-icon' | |||
| import Avatar from '@/app/components/base/avatar' | |||
| import { IS_CE_EDITION } from '@/config' | |||
| import Input from '@/app/components/base/input' | |||
| const titleClassName = ` | |||
| text-sm font-medium text-gray-900 | |||
| @@ -31,6 +32,7 @@ const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ | |||
| export default function AccountPage() { | |||
| const { t } = useTranslation() | |||
| const { systemFeatures } = useAppContext() | |||
| const { mutateUserProfile, userProfile, apps } = useAppContext() | |||
| const { notify } = useContext(ToastContext) | |||
| const [editNameModalVisible, setEditNameModalVisible] = useState(false) | |||
| @@ -41,6 +43,9 @@ export default function AccountPage() { | |||
| const [password, setPassword] = useState('') | |||
| const [confirmPassword, setConfirmPassword] = useState('') | |||
| const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) | |||
| const [showCurrentPassword, setShowCurrentPassword] = useState(false) | |||
| const [showPassword, setShowPassword] = useState(false) | |||
| const [showConfirmPassword, setShowConfirmPassword] = useState(false) | |||
| const handleEditName = () => { | |||
| setEditNameModalVisible(true) | |||
| @@ -158,8 +163,8 @@ export default function AccountPage() { | |||
| </div> | |||
| </div> | |||
| { | |||
| IS_CE_EDITION && ( | |||
| <div className='mb-8 flex justify-between'> | |||
| systemFeatures.enable_email_password_login && ( | |||
| <div className='mb-8 flex justify-between gap-2'> | |||
| <div> | |||
| <div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div> | |||
| <div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div> | |||
| @@ -191,8 +196,7 @@ export default function AccountPage() { | |||
| > | |||
| <div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div> | |||
| <div className={titleClassName}>{t('common.account.name')}</div> | |||
| <input | |||
| className={inputClassName} | |||
| <Input className='mt-2' | |||
| value={editName} | |||
| onChange={e => setEditName(e.target.value)} | |||
| /> | |||
| @@ -223,30 +227,61 @@ export default function AccountPage() { | |||
| {userProfile.is_password_set && ( | |||
| <> | |||
| <div className={titleClassName}>{t('common.account.currentPassword')}</div> | |||
| <input | |||
| type="password" | |||
| className={inputClassName} | |||
| value={currentPassword} | |||
| onChange={e => setCurrentPassword(e.target.value)} | |||
| /> | |||
| <div className='relative mt-2'> | |||
| <Input | |||
| type={showCurrentPassword ? 'text' : 'password'} | |||
| value={currentPassword} | |||
| onChange={e => setCurrentPassword(e.target.value)} | |||
| /> | |||
| <div className="absolute inset-y-0 right-0 flex items-center"> | |||
| <Button | |||
| type="button" | |||
| variant='ghost' | |||
| onClick={() => setShowCurrentPassword(!showCurrentPassword)} | |||
| > | |||
| {showCurrentPassword ? '👀' : '😝'} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| </> | |||
| )} | |||
| <div className='mt-8 text-sm font-medium text-gray-900'> | |||
| {userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')} | |||
| </div> | |||
| <input | |||
| type="password" | |||
| className={inputClassName} | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| /> | |||
| <div className='relative mt-2'> | |||
| <Input | |||
| type={showPassword ? 'text' : 'password'} | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| /> | |||
| <div className="absolute inset-y-0 right-0 flex items-center"> | |||
| <Button | |||
| type="button" | |||
| variant='ghost' | |||
| onClick={() => setShowPassword(!showPassword)} | |||
| > | |||
| {showPassword ? '👀' : '😝'} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| <div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div> | |||
| <input | |||
| type="password" | |||
| className={inputClassName} | |||
| value={confirmPassword} | |||
| onChange={e => setConfirmPassword(e.target.value)} | |||
| /> | |||
| <div className='relative mt-2'> | |||
| <Input | |||
| type={showConfirmPassword ? 'text' : 'password'} | |||
| value={confirmPassword} | |||
| onChange={e => setConfirmPassword(e.target.value)} | |||
| /> | |||
| <div className="absolute inset-y-0 right-0 flex items-center"> | |||
| <Button | |||
| type="button" | |||
| variant='ghost' | |||
| onClick={() => setShowConfirmPassword(!showConfirmPassword)} | |||
| > | |||
| {showConfirmPassword ? '👀' : '😝'} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| <div className='flex justify-end mt-10'> | |||
| <Button className='mr-2' onClick={() => { | |||
| setEditPasswordModalVisible(false) | |||
| @@ -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 | |||
| " | |||
| @@ -1,27 +1,16 @@ | |||
| 'use client' | |||
| import { useCallback, useState } from 'react' | |||
| import { useContext } from 'use-context-selector' | |||
| import { useTranslation } from 'react-i18next' | |||
| import useSWR from 'swr' | |||
| import { useSearchParams } from 'next/navigation' | |||
| import Link from 'next/link' | |||
| import { CheckCircleIcon } from '@heroicons/react/24/solid' | |||
| import style from './style.module.css' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import cn from '@/utils/classnames' | |||
| import Button from '@/app/components/base/button' | |||
| import { SimpleSelect } from '@/app/components/base/select' | |||
| import { timezones } from '@/utils/timezone' | |||
| import { LanguagesSupported, languages } from '@/i18n/language' | |||
| import { activateMember, invitationCheck } from '@/service/common' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { invitationCheck } from '@/service/common' | |||
| import Loading from '@/app/components/base/loading' | |||
| import I18n from '@/context/i18n' | |||
| const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ | |||
| const ActivateForm = () => { | |||
| const router = useRouter() | |||
| const { t } = useTranslation() | |||
| const { locale, setLocaleOnClient } = useContext(I18n) | |||
| const searchParams = useSearchParams() | |||
| const workspaceID = searchParams.get('workspace_id') | |||
| const email = searchParams.get('email') | |||
| @@ -35,64 +24,20 @@ const ActivateForm = () => { | |||
| token, | |||
| }, | |||
| } | |||
| const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, { | |||
| const { data: checkRes } = useSWR(checkParams, invitationCheck, { | |||
| revalidateOnFocus: false, | |||
| onSuccess(data) { | |||
| if (data.is_valid) { | |||
| const params = new URLSearchParams(searchParams) | |||
| const { email, workspace_id } = data.data | |||
| params.set('email', encodeURIComponent(email)) | |||
| params.set('workspace_id', encodeURIComponent(workspace_id)) | |||
| params.set('invite_token', encodeURIComponent(token as string)) | |||
| router.replace(`/signin?${params.toString()}`) | |||
| } | |||
| }, | |||
| }) | |||
| const [name, setName] = useState('') | |||
| const [password, setPassword] = useState('') | |||
| const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone) | |||
| const [language, setLanguage] = useState(locale) | |||
| const [showSuccess, setShowSuccess] = useState(false) | |||
| const showErrorMessage = useCallback((message: string) => { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message, | |||
| }) | |||
| }, []) | |||
| const valid = useCallback(() => { | |||
| if (!name.trim()) { | |||
| showErrorMessage(t('login.error.nameEmpty')) | |||
| return false | |||
| } | |||
| if (!password.trim()) { | |||
| showErrorMessage(t('login.error.passwordEmpty')) | |||
| return false | |||
| } | |||
| if (!validPassword.test(password)) { | |||
| showErrorMessage(t('login.error.passwordInvalid')) | |||
| return false | |||
| } | |||
| return true | |||
| }, [name, password, showErrorMessage, t]) | |||
| const handleActivate = useCallback(async () => { | |||
| if (!valid()) | |||
| return | |||
| try { | |||
| await activateMember({ | |||
| url: '/activate', | |||
| body: { | |||
| workspace_id: workspaceID, | |||
| email, | |||
| token, | |||
| name, | |||
| password, | |||
| interface_language: language, | |||
| timezone, | |||
| }, | |||
| }) | |||
| setLocaleOnClient(language, false) | |||
| setShowSuccess(true) | |||
| } | |||
| catch { | |||
| recheck() | |||
| } | |||
| }, [email, language, name, password, recheck, setLocaleOnClient, timezone, token, valid, workspaceID]) | |||
| return ( | |||
| <div className={ | |||
| cn( | |||
| @@ -115,125 +60,6 @@ const ActivateForm = () => { | |||
| </div> | |||
| </div> | |||
| )} | |||
| {checkRes && checkRes.is_valid && !showSuccess && ( | |||
| <div className='flex flex-col md:w-[400px]'> | |||
| <div className="w-full mx-auto"> | |||
| <div className={`mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold ${style.logo}`}> | |||
| </div> | |||
| <h2 className="text-[32px] font-bold text-gray-900"> | |||
| {`${t('login.join')} ${checkRes.workspace_name}`} | |||
| </h2> | |||
| <p className='mt-1 text-sm text-gray-600 '> | |||
| {`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`} | |||
| </p> | |||
| </div> | |||
| <div className="w-full mx-auto mt-6"> | |||
| <div className="bg-white"> | |||
| {/* username */} | |||
| <div className='mb-5'> | |||
| <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900"> | |||
| {t('login.name')} | |||
| </label> | |||
| <div className="mt-1 relative rounded-md shadow-sm"> | |||
| <input | |||
| id="name" | |||
| type="text" | |||
| value={name} | |||
| onChange={e => setName(e.target.value)} | |||
| placeholder={t('login.namePlaceholder') || ''} | |||
| className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} | |||
| tabIndex={1} | |||
| /> | |||
| </div> | |||
| </div> | |||
| {/* password */} | |||
| <div className='mb-5'> | |||
| <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900"> | |||
| {t('login.password')} | |||
| </label> | |||
| <div className="mt-1 relative rounded-md shadow-sm"> | |||
| <input | |||
| id="password" | |||
| type='password' | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| placeholder={t('login.passwordPlaceholder') || ''} | |||
| className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} | |||
| tabIndex={2} | |||
| /> | |||
| </div> | |||
| <div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div> | |||
| </div> | |||
| {/* language */} | |||
| <div className='mb-5'> | |||
| <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900"> | |||
| {t('login.interfaceLanguage')} | |||
| </label> | |||
| <div className="relative mt-1 rounded-md shadow-sm"> | |||
| <SimpleSelect | |||
| defaultValue={LanguagesSupported[0]} | |||
| items={languages.filter(item => item.supported)} | |||
| onSelect={(item) => { | |||
| setLanguage(item.value as string) | |||
| }} | |||
| /> | |||
| </div> | |||
| </div> | |||
| {/* timezone */} | |||
| <div className='mb-4'> | |||
| <label htmlFor="timezone" className="block text-sm font-medium text-gray-700"> | |||
| {t('login.timezone')} | |||
| </label> | |||
| <div className="relative mt-1 rounded-md shadow-sm"> | |||
| <SimpleSelect | |||
| defaultValue={timezone} | |||
| items={timezones} | |||
| onSelect={(item) => { | |||
| setTimezone(item.value as string) | |||
| }} | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div> | |||
| <Button | |||
| variant='primary' | |||
| className='w-full !text-sm' | |||
| onClick={handleActivate} | |||
| > | |||
| {`${t('login.join')} ${checkRes.workspace_name}`} | |||
| </Button> | |||
| </div> | |||
| <div className="block w-hull mt-2 text-xs text-gray-600"> | |||
| {t('login.license.tip')} | |||
|   | |||
| <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> | |||
| ) | |||
| } | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| } | |||
| @@ -1,10 +1,11 @@ | |||
| 'use client' | |||
| import { SWRConfig } from 'swr' | |||
| import { useEffect, useState } from 'react' | |||
| import { useCallback, useEffect, useState } from 'react' | |||
| import type { ReactNode } from 'react' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import useRefreshToken from '@/hooks/use-refresh-token' | |||
| import { fetchSetupStatus } from '@/service/common' | |||
| type SwrInitorProps = { | |||
| children: ReactNode | |||
| @@ -21,27 +22,60 @@ const SwrInitor = ({ | |||
| const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token') | |||
| const [init, setInit] = useState(false) | |||
| useEffect(() => { | |||
| if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) { | |||
| router.replace('/signin') | |||
| return | |||
| const isSetupFinished = useCallback(async () => { | |||
| try { | |||
| if (localStorage.getItem('setup_status') === 'finished') | |||
| return true | |||
| const setUpStatus = await fetchSetupStatus() | |||
| if (setUpStatus.step !== 'finished') { | |||
| localStorage.removeItem('setup_status') | |||
| return false | |||
| } | |||
| localStorage.setItem('setup_status', 'finished') | |||
| return true | |||
| } | |||
| if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage) | |||
| getNewAccessToken() | |||
| if (consoleToken && refreshToken) { | |||
| localStorage.setItem('console_token', consoleToken) | |||
| localStorage.setItem('refresh_token', refreshToken) | |||
| getNewAccessToken().then(() => { | |||
| router.replace('/apps', { forceOptimisticNavigation: false } as any) | |||
| }).catch(() => { | |||
| router.replace('/signin') | |||
| }) | |||
| catch (error) { | |||
| console.error(error) | |||
| return false | |||
| } | |||
| setInit(true) | |||
| }, []) | |||
| const setRefreshToken = useCallback(async () => { | |||
| try { | |||
| if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) | |||
| return Promise.reject(new Error('No token found')) | |||
| if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage) | |||
| await getNewAccessToken() | |||
| if (consoleToken && refreshToken) { | |||
| localStorage.setItem('console_token', consoleToken) | |||
| localStorage.setItem('refresh_token', refreshToken) | |||
| await getNewAccessToken() | |||
| } | |||
| } | |||
| catch (error) { | |||
| return Promise.reject(error) | |||
| } | |||
| }, [consoleToken, refreshToken, consoleTokenFromLocalStorage, refreshTokenFromLocalStorage, getNewAccessToken]) | |||
| useEffect(() => { | |||
| (async () => { | |||
| try { | |||
| const isFinished = await isSetupFinished() | |||
| if (!isFinished) { | |||
| router.replace('/install') | |||
| return | |||
| } | |||
| await setRefreshToken() | |||
| setInit(true) | |||
| } | |||
| catch (error) { | |||
| router.replace('/signin') | |||
| } | |||
| })() | |||
| }, [isSetupFinished, setRefreshToken, router]) | |||
| return init | |||
| ? ( | |||
| <SWRConfig value={{ | |||
| @@ -5,6 +5,7 @@ import useSWR from 'swr' | |||
| import { useSearchParams } from 'next/navigation' | |||
| import cn from 'classnames' | |||
| import { CheckCircleIcon } from '@heroicons/react/24/solid' | |||
| import Input from '../components/base/input' | |||
| import Button from '@/app/components/base/button' | |||
| import { changePasswordWithToken, verifyForgotPasswordToken } from '@/service/common' | |||
| import Toast from '@/app/components/base/toast' | |||
| @@ -113,33 +114,29 @@ const ChangePasswordForm = () => { | |||
| <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900"> | |||
| {t('common.account.newPassword')} | |||
| </label> | |||
| <div className="mt-1 relative rounded-md shadow-sm"> | |||
| <input | |||
| id="password" | |||
| type='password' | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| placeholder={t('login.passwordPlaceholder') || ''} | |||
| className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} | |||
| /> | |||
| </div> | |||
| <div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div> | |||
| <Input | |||
| id="password" | |||
| type='password' | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| placeholder={t('login.passwordPlaceholder') || ''} | |||
| className='mt-1' | |||
| /> | |||
| <div className='mt-1 text-xs text-text-secondary'>{t('login.error.passwordInvalid')}</div> | |||
| </div> | |||
| {/* Confirm Password */} | |||
| <div className='mb-5'> | |||
| <label htmlFor="confirmPassword" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900"> | |||
| {t('common.account.confirmPassword')} | |||
| </label> | |||
| <div className="mt-1 relative rounded-md shadow-sm"> | |||
| <input | |||
| id="confirmPassword" | |||
| type='password' | |||
| value={confirmPassword} | |||
| onChange={e => setConfirmPassword(e.target.value)} | |||
| placeholder={t('login.confirmPasswordPlaceholder') || ''} | |||
| className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} | |||
| /> | |||
| </div> | |||
| <Input | |||
| id="confirmPassword" | |||
| type='password' | |||
| value={confirmPassword} | |||
| onChange={e => setConfirmPassword(e.target.value)} | |||
| placeholder={t('login.confirmPasswordPlaceholder') || ''} | |||
| className='mt-1' | |||
| /> | |||
| </div> | |||
| <div> | |||
| <Button | |||
| @@ -165,7 +162,7 @@ const ChangePasswordForm = () => { | |||
| </h2> | |||
| </div> | |||
| <div className="w-full mx-auto mt-6"> | |||
| <Button variant='primary' className='w-full !text-sm'> | |||
| <Button variant='primary' className='w-full'> | |||
| <a href="/signin">{t('login.passwordChanged')}</a> | |||
| </Button> | |||
| </div> | |||
| @@ -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> | |||
| @@ -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')} | |||
|   | |||
| <Link | |||
| @@ -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> | |||
| } | |||
| @@ -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> | |||
| </> | |||
| } | |||
| @@ -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> | |||
| } | |||
| @@ -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 | |||
| @@ -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> | |||
| } | |||
| @@ -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> | |||
| ) | |||
| } | |||
| @@ -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> | |||
| } | |||
| @@ -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> | |||
| </> | |||
| } | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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')} | |||
|   | |||
| <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> | |||
| } | |||
| @@ -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> | |||
| </> | |||
| } | |||
| @@ -1,299 +1,170 @@ | |||
| 'use client' | |||
| import React, { useEffect, useReducer, useState } from 'react' | |||
| import React, { useCallback, useEffect, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useRouter } from 'next/navigation' | |||
| import useSWR from 'swr' | |||
| import Link from 'next/link' | |||
| import Toast from '../components/base/toast' | |||
| import style from './page.module.css' | |||
| import classNames from '@/utils/classnames' | |||
| import { IS_CE_EDITION, SUPPORT_MAIL_LOGIN, apiPrefix, emailRegex } from '@/config' | |||
| import Button from '@/app/components/base/button' | |||
| import { login, oauth } from '@/service/common' | |||
| import { getPurifyHref } from '@/utils' | |||
| import { useRouter, useSearchParams } from 'next/navigation' | |||
| import { RiDoorLockLine } from '@remixicon/react' | |||
| import Loading from '../components/base/loading' | |||
| import MailAndCodeAuth from './components/mail-and-code-auth' | |||
| import MailAndPasswordAuth from './components/mail-and-password-auth' | |||
| import SocialAuth from './components/social-auth' | |||
| import SSOAuth from './components/sso-auth' | |||
| import cn from '@/utils/classnames' | |||
| import { getSystemFeatures, invitationCheck } from '@/service/common' | |||
| import { defaultSystemFeatures } from '@/types/feature' | |||
| import Toast from '@/app/components/base/toast' | |||
| import useRefreshToken from '@/hooks/use-refresh-token' | |||
| type IState = { | |||
| formValid: boolean | |||
| github: boolean | |||
| google: boolean | |||
| } | |||
| type IAction = { | |||
| type: 'login' | 'login_failed' | 'github_login' | 'github_login_failed' | 'google_login' | 'google_login_failed' | |||
| } | |||
| function reducer(state: IState, action: IAction) { | |||
| switch (action.type) { | |||
| case 'login': | |||
| return { | |||
| ...state, | |||
| formValid: true, | |||
| } | |||
| case 'login_failed': | |||
| return { | |||
| ...state, | |||
| formValid: true, | |||
| } | |||
| case 'github_login': | |||
| return { | |||
| ...state, | |||
| github: true, | |||
| } | |||
| case 'github_login_failed': | |||
| return { | |||
| ...state, | |||
| github: false, | |||
| } | |||
| case 'google_login': | |||
| return { | |||
| ...state, | |||
| google: true, | |||
| } | |||
| case 'google_login_failed': | |||
| return { | |||
| ...state, | |||
| google: false, | |||
| } | |||
| default: | |||
| throw new Error('Unknown action.') | |||
| } | |||
| } | |||
| import { IS_CE_EDITION } from '@/config' | |||
| const NormalForm = () => { | |||
| const { t } = useTranslation() | |||
| const { getNewAccessToken } = useRefreshToken() | |||
| const useEmailLogin = IS_CE_EDITION || SUPPORT_MAIL_LOGIN | |||
| const { t } = useTranslation() | |||
| const router = useRouter() | |||
| const [state, dispatch] = useReducer(reducer, { | |||
| formValid: false, | |||
| github: false, | |||
| google: false, | |||
| }) | |||
| const [showPassword, setShowPassword] = useState(false) | |||
| const [email, setEmail] = useState('') | |||
| const [password, setPassword] = useState('') | |||
| const [isLoading, setIsLoading] = useState(false) | |||
| const handleEmailPasswordLogin = async () => { | |||
| if (!emailRegex.test(email)) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('login.error.emailInValid'), | |||
| }) | |||
| return | |||
| } | |||
| const searchParams = useSearchParams() | |||
| const consoleToken = decodeURIComponent(searchParams.get('access_token') || '') | |||
| const refreshToken = decodeURIComponent(searchParams.get('refresh_token') || '') | |||
| const message = decodeURIComponent(searchParams.get('message') || '') | |||
| const invite_token = decodeURIComponent(searchParams.get('invite_token') || '') | |||
| const [isLoading, setIsLoading] = useState(true) | |||
| const [systemFeatures, setSystemFeatures] = useState(defaultSystemFeatures) | |||
| const [authType, updateAuthType] = useState<'code' | 'password'>('password') | |||
| const [showORLine, setShowORLine] = useState(false) | |||
| const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) | |||
| const [workspaceName, setWorkSpaceName] = useState('') | |||
| const isInviteLink = Boolean(invite_token && invite_token !== 'null') | |||
| const init = useCallback(async () => { | |||
| try { | |||
| setIsLoading(true) | |||
| const res = await login({ | |||
| url: '/login', | |||
| body: { | |||
| email, | |||
| password, | |||
| remember_me: true, | |||
| }, | |||
| }) | |||
| if (res.result === 'success') { | |||
| localStorage.setItem('console_token', res.data.access_token) | |||
| localStorage.setItem('refresh_token', res.data.refresh_token) | |||
| if (consoleToken && refreshToken) { | |||
| localStorage.setItem('console_token', consoleToken) | |||
| localStorage.setItem('refresh_token', refreshToken) | |||
| getNewAccessToken() | |||
| router.replace('/apps') | |||
| return | |||
| } | |||
| else { | |||
| if (message) { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: res.data, | |||
| message, | |||
| }) | |||
| } | |||
| const features = await getSystemFeatures() | |||
| const allFeatures = { ...defaultSystemFeatures, ...features } | |||
| setSystemFeatures(allFeatures) | |||
| setAllMethodsAreDisabled(!allFeatures.enable_social_oauth_login && !allFeatures.enable_email_code_login && !allFeatures.enable_email_password_login && !allFeatures.sso_enforced_for_signin) | |||
| setShowORLine((allFeatures.enable_social_oauth_login || allFeatures.sso_enforced_for_signin) && (allFeatures.enable_email_code_login || allFeatures.enable_email_password_login)) | |||
| updateAuthType(allFeatures.enable_email_password_login ? 'password' : 'code') | |||
| if (isInviteLink) { | |||
| const checkRes = await invitationCheck({ | |||
| url: '/activate/check', | |||
| params: { | |||
| token: invite_token, | |||
| }, | |||
| }) | |||
| setWorkSpaceName(checkRes?.data?.workspace_name || '') | |||
| } | |||
| } | |||
| finally { | |||
| setIsLoading(false) | |||
| catch (error) { | |||
| console.error(error) | |||
| setAllMethodsAreDisabled(true) | |||
| setSystemFeatures(defaultSystemFeatures) | |||
| } | |||
| } | |||
| const { data: github, error: github_error } = useSWR(state.github | |||
| ? ({ | |||
| url: '/oauth/login/github', | |||
| // params: { | |||
| // provider: 'github', | |||
| // }, | |||
| }) | |||
| : null, oauth) | |||
| const { data: google, error: google_error } = useSWR(state.google | |||
| ? ({ | |||
| url: '/oauth/login/google', | |||
| // params: { | |||
| // provider: 'google', | |||
| // }, | |||
| }) | |||
| : null, oauth) | |||
| useEffect(() => { | |||
| if (github_error !== undefined) | |||
| dispatch({ type: 'github_login_failed' }) | |||
| if (github) | |||
| window.location.href = github.redirect_url | |||
| }, [github, github_error]) | |||
| finally { setIsLoading(false) } | |||
| }, [consoleToken, refreshToken, message, router, invite_token, isInviteLink, getNewAccessToken]) | |||
| useEffect(() => { | |||
| if (google_error !== undefined) | |||
| dispatch({ type: 'google_login_failed' }) | |||
| if (google) | |||
| window.location.href = google.redirect_url | |||
| }, [google, google_error]) | |||
| init() | |||
| }, [init]) | |||
| if (isLoading || consoleToken) { | |||
| return <div className={ | |||
| cn( | |||
| 'flex flex-col items-center w-full grow justify-center', | |||
| 'px-6', | |||
| 'md:px-[108px]', | |||
| ) | |||
| }> | |||
| <Loading type='area' /> | |||
| </div> | |||
| } | |||
| return ( | |||
| <> | |||
| <div className="w-full mx-auto"> | |||
| <h2 className="text-[32px] font-bold text-gray-900">{t('login.pageTitle')}</h2> | |||
| <p className='mt-1 text-sm text-gray-600'>{t('login.welcome')}</p> | |||
| </div> | |||
| <div className="w-full mx-auto mt-8"> | |||
| <div className="bg-white "> | |||
| {!useEmailLogin && ( | |||
| <div className="flex flex-col gap-3 mt-6"> | |||
| <div className='w-full'> | |||
| <a href={getPurifyHref(`${apiPrefix}/oauth/login/github`)}> | |||
| <Button | |||
| disabled={isLoading} | |||
| className='w-full hover:!bg-gray-50' | |||
| > | |||
| <> | |||
| <span className={ | |||
| classNames( | |||
| style.githubIcon, | |||
| 'w-5 h-5 mr-2', | |||
| ) | |||
| } /> | |||
| <span className="truncate text-gray-800">{t('login.withGitHub')}</span> | |||
| </> | |||
| </Button> | |||
| </a> | |||
| </div> | |||
| <div className='w-full'> | |||
| <a href={getPurifyHref(`${apiPrefix}/oauth/login/google`)}> | |||
| <Button | |||
| disabled={isLoading} | |||
| className='w-full hover:!bg-gray-50' | |||
| > | |||
| <> | |||
| <span className={ | |||
| classNames( | |||
| style.googleIcon, | |||
| 'w-5 h-5 mr-2', | |||
| ) | |||
| } /> | |||
| <span className="truncate text-gray-800">{t('login.withGoogle')}</span> | |||
| </> | |||
| </Button> | |||
| </a> | |||
| </div> | |||
| </div> | |||
| )} | |||
| {isInviteLink | |||
| ? <div className="w-full mx-auto"> | |||
| <h2 className="title-4xl-semi-bold text-text-primary">{t('login.join')}{workspaceName}</h2> | |||
| <p className='mt-2 body-md-regular text-text-tertiary'>{t('login.joinTipStart')}{workspaceName}{t('login.joinTipEnd')}</p> | |||
| </div> | |||
| : <div className="w-full mx-auto"> | |||
| <h2 className="title-4xl-semi-bold text-text-primary">{t('login.pageTitle')}</h2> | |||
| <p className='mt-2 body-md-regular text-text-tertiary'>{t('login.welcome')}</p> | |||
| </div>} | |||
| <div className="bg-white"> | |||
| <div className="flex flex-col gap-3 mt-6"> | |||
| {systemFeatures.enable_social_oauth_login && <SocialAuth />} | |||
| {systemFeatures.sso_enforced_for_signin && <div className='w-full'> | |||
| <SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} /> | |||
| </div>} | |||
| </div> | |||
| {showORLine && <div className="relative mt-6"> | |||
| <div className="absolute inset-0 flex items-center" aria-hidden="true"> | |||
| <div className='bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent h-px w-full'></div> | |||
| </div> | |||
| <div className="relative flex justify-center"> | |||
| <span className="px-2 text-text-tertiary system-xs-medium-uppercase bg-white">{t('login.or')}</span> | |||
| </div> | |||
| </div>} | |||
| { | |||
| useEmailLogin && <> | |||
| {/* <div className="relative mt-6"> | |||
| <div className="absolute inset-0 flex items-center" aria-hidden="true"> | |||
| <div className="w-full border-t border-gray-300" /> | |||
| </div> | |||
| <div className="relative flex justify-center text-sm"> | |||
| <span className="px-2 text-gray-300 bg-white">OR</span> | |||
| </div> | |||
| </div> */} | |||
| <form onSubmit={() => { }}> | |||
| <div className='mb-5'> | |||
| <label htmlFor="email" className="my-2 block text-sm font-medium text-gray-900"> | |||
| {t('login.email')} | |||
| </label> | |||
| <div className="mt-1"> | |||
| <input | |||
| value={email} | |||
| onChange={e => setEmail(e.target.value)} | |||
| id="email" | |||
| type="email" | |||
| autoComplete="email" | |||
| placeholder={t('login.emailPlaceholder') || ''} | |||
| className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'} | |||
| tabIndex={1} | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div className='mb-4'> | |||
| <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900"> | |||
| <span>{t('login.password')}</span> | |||
| <Link href='/forgot-password' className='text-primary-600'> | |||
| {t('login.forget')} | |||
| </Link> | |||
| </label> | |||
| <div className="relative mt-1"> | |||
| <input | |||
| id="password" | |||
| value={password} | |||
| onChange={e => setPassword(e.target.value)} | |||
| onKeyDown={(e) => { | |||
| if (e.key === 'Enter') | |||
| handleEmailPasswordLogin() | |||
| }} | |||
| type={showPassword ? 'text' : 'password'} | |||
| autoComplete="current-password" | |||
| placeholder={t('login.passwordPlaceholder') || ''} | |||
| className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} | |||
| tabIndex={2} | |||
| /> | |||
| <div className="absolute inset-y-0 right-0 flex items-center pr-3"> | |||
| <button | |||
| type="button" | |||
| onClick={() => setShowPassword(!showPassword)} | |||
| className="text-gray-400 hover:text-gray-500 focus:outline-none focus:text-gray-500" | |||
| > | |||
| {showPassword ? '👀' : '😝'} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className='mb-2'> | |||
| <Button | |||
| tabIndex={0} | |||
| variant='primary' | |||
| onClick={handleEmailPasswordLogin} | |||
| disabled={isLoading} | |||
| className="w-full" | |||
| >{t('login.signBtn')}</Button> | |||
| </div> | |||
| </form> | |||
| (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <> | |||
| {systemFeatures.enable_email_code_login && authType === 'code' && <> | |||
| <MailAndCodeAuth isInvite={isInviteLink} /> | |||
| {systemFeatures.enable_email_password_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('password') }}> | |||
| <span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.usePassword')}</span> | |||
| </div>} | |||
| </>} | |||
| {systemFeatures.enable_email_password_login && authType === 'password' && <> | |||
| <MailAndPasswordAuth isInvite={isInviteLink} allowRegistration={systemFeatures.is_allow_register} /> | |||
| {systemFeatures.enable_email_code_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('code') }}> | |||
| <span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.useVerificationCode')}</span> | |||
| </div>} | |||
| </>} | |||
| </> | |||
| } | |||
| {/* agree to our Terms and Privacy Policy. */} | |||
| <div className="w-hull text-center block mt-2 text-xs text-gray-600"> | |||
| {allMethodsAreDisabled && <> | |||
| <div className="p-4 rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2"> | |||
| <div className='flex items-center justify-center w-10 h-10 rounded-xl bg-components-card-bg shadow shadows-shadow-lg mb-2'> | |||
| <RiDoorLockLine className='w-5 h-5' /> | |||
| </div> | |||
| <p className='system-sm-medium text-text-primary'>{t('login.noLoginMethod')}</p> | |||
| <p className='system-xs-regular text-text-tertiary mt-1'>{t('login.noLoginMethodTip')}</p> | |||
| </div> | |||
| <div className="relative my-2 py-2"> | |||
| <div className="absolute inset-0 flex items-center" aria-hidden="true"> | |||
| <div className='bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent h-px w-full'></div> | |||
| </div> | |||
| </div> | |||
| </>} | |||
| <div className="w-full block mt-2 system-xs-regular text-text-tertiary"> | |||
| {t('login.tosDesc')} | |||
|   | |||
| <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> | |||
|  &  | |||
| <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')} | |||
|   | |||
| <Link | |||
| className='text-primary-600' | |||
| className='system-xs-medium text-text-secondary hover:underline' | |||
| href='/install' | |||
| >{t('login.setAdminAccount')}</Link> | |||
| </div>} | |||
| @@ -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')} | |||
|   | |||
| <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> | |||
| @@ -1,94 +1,15 @@ | |||
| 'use client' | |||
| import React, { useEffect, useState } from 'react' | |||
| import Script from 'next/script' | |||
| import Loading from '../components/base/loading' | |||
| import Forms from './forms' | |||
| import Header from './_header' | |||
| import style from './page.module.css' | |||
| import UserSSOForm from './userSSOForm' | |||
| import cn from '@/utils/classnames' | |||
| import { IS_CE_EDITION } from '@/config' | |||
| import type { SystemFeatures } from '@/types/feature' | |||
| import { defaultSystemFeatures } from '@/types/feature' | |||
| import { getSystemFeatures } from '@/service/common' | |||
| import { useSearchParams } from 'next/navigation' | |||
| import OneMoreStep from './oneMoreStep' | |||
| import NormalForm from './normalForm' | |||
| const SignIn = () => { | |||
| const [loading, setLoading] = useState<boolean>(true) | |||
| const [systemFeatures, setSystemFeatures] = useState<SystemFeatures>(defaultSystemFeatures) | |||
| useEffect(() => { | |||
| getSystemFeatures().then((res) => { | |||
| setSystemFeatures(res) | |||
| }).finally(() => { | |||
| setLoading(false) | |||
| }) | |||
| }, []) | |||
| return ( | |||
| <> | |||
| {!IS_CE_EDITION && ( | |||
| <> | |||
| <Script strategy="beforeInteractive" async src={'https://www.googletagmanager.com/gtag/js?id=AW-11217955271'}></Script> | |||
| <Script | |||
| id="ga-monitor-register" | |||
| dangerouslySetInnerHTML={{ | |||
| __html: ` | |||
| window.dataLayer2 = window.dataLayer2 || []; | |||
| function gtag(){dataLayer2.push(arguments);} | |||
| gtag('js', new Date()); | |||
| gtag('config', 'AW-11217955271"'); | |||
| `, | |||
| }} | |||
| > | |||
| </Script> | |||
| </> | |||
| )} | |||
| <div className={cn( | |||
| style.background, | |||
| 'flex w-full min-h-screen', | |||
| 'sm:p-4 lg:p-8', | |||
| 'gap-x-20', | |||
| 'justify-center lg:justify-start', | |||
| )}> | |||
| <div className={ | |||
| cn( | |||
| 'flex w-full flex-col bg-white shadow rounded-2xl shrink-0', | |||
| 'space-between', | |||
| ) | |||
| }> | |||
| <Header /> | |||
| {loading && ( | |||
| <div className={ | |||
| cn( | |||
| 'flex flex-col items-center w-full grow justify-center', | |||
| 'px-6', | |||
| 'md:px-[108px]', | |||
| ) | |||
| }> | |||
| <Loading type='area' /> | |||
| </div> | |||
| )} | |||
| {!loading && !systemFeatures.sso_enforced_for_signin && ( | |||
| <> | |||
| <Forms /> | |||
| <div className='px-8 py-6 text-sm font-normal text-gray-500'> | |||
| © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. | |||
| </div> | |||
| </> | |||
| )} | |||
| {!loading && systemFeatures.sso_enforced_for_signin && ( | |||
| <UserSSOForm protocol={systemFeatures.sso_enforced_for_signin_protocol} /> | |||
| )} | |||
| </div> | |||
| </div> | |||
| const searchParams = useSearchParams() | |||
| const step = searchParams.get('step') | |||
| </> | |||
| ) | |||
| if (step === 'next') | |||
| return <OneMoreStep /> | |||
| return <NormalForm /> | |||
| } | |||
| export default SignIn | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -1,6 +1,6 @@ | |||
| const translation = { | |||
| pageTitle: '嗨,近來可好 👋', | |||
| welcome: '歡迎來到 Dify, 登入以繼續', | |||
| pageTitle: '嗨,近來可好', | |||
| welcome: '👋 歡迎來到 Dify, 登入以繼續', | |||
| email: '郵箱', | |||
| emailPlaceholder: '輸入郵箱地址', | |||
| password: '密碼', | |||
| @@ -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 }) | |||
| @@ -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) | |||
| } | |||
| @@ -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, | |||
| } | |||