diff --git a/client/packages/design/components/index.ts b/client/packages/design/components/index.ts index d852e73a..71061365 100644 --- a/client/packages/design/components/index.ts +++ b/client/packages/design/components/index.ts @@ -15,5 +15,9 @@ export { createFastifyFormSchema as createMetaFormSchema, fieldSchema as metaFormFieldSchema, useFastifyFormContext as useMetaFormContext, + useFastifyFormContext, +} from 'react-fastify-form'; +export type { + FastifyFormFieldMeta as MetaFormFieldMeta, + FastifyFormFieldProps, } from 'react-fastify-form'; -export type { FastifyFormFieldMeta as MetaFormFieldMeta } from 'react-fastify-form'; diff --git a/client/shared/model/config.ts b/client/shared/model/config.ts index 56726c44..1374245f 100644 --- a/client/shared/model/config.ts +++ b/client/shared/model/config.ts @@ -13,6 +13,7 @@ export interface GlobalConfig { let globalConfig = { uploadFileLimit: 1 * 1024 * 1024, + emailVerification: false, // 是否在注册时校验邮箱 }; export function getGlobalConfig() { diff --git a/client/shared/model/user.ts b/client/shared/model/user.ts index a6eb4a9a..ac34cf90 100644 --- a/client/shared/model/user.ts +++ b/client/shared/model/user.ts @@ -98,6 +98,18 @@ export async function loginWithToken(token: string): Promise { return data; } +/** + * 发送邮箱校验码 + * @param email 邮箱 + */ +export async function verifyEmail(email: string): Promise { + const { data } = await request.post('/api/user/verifyEmail', { + email, + }); + + return data; +} + /** * 邮箱注册账号 * @param email 邮箱 @@ -105,11 +117,13 @@ export async function loginWithToken(token: string): Promise { */ export async function registerWithEmail( email: string, - password: string + password: string, + emailOTP?: string ): Promise { const { data } = await request.post('/api/user/register', { email, password, + emailOTP, }); return data; @@ -174,12 +188,14 @@ export async function createTemporaryUser( export async function claimTemporaryUser( userId: string, email: string, - password: string + password: string, + emailOTP?: string ): Promise { const { data } = await request.post('/api/user/claimTemporaryUser', { userId, email, password, + emailOTP, }); return data; diff --git a/client/web/src/components/modals/ClaimTemporaryUser.tsx b/client/web/src/components/modals/ClaimTemporaryUser.tsx index 9ae5d201..308568b0 100644 --- a/client/web/src/components/modals/ClaimTemporaryUser.tsx +++ b/client/web/src/components/modals/ClaimTemporaryUser.tsx @@ -1,8 +1,10 @@ import { setUserJWT } from '@/utils/jwt-helper'; import { setGlobalUserLoginInfo } from '@/utils/user-helper'; -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { claimTemporaryUser, + model, + showErrorToasts, t, useAppDispatch, useAsyncRequest, @@ -13,33 +15,88 @@ import { MetaFormFieldMeta, metaFormFieldSchema, WebMetaForm, + FastifyFormFieldProps, + useFastifyFormContext, } from 'tailchat-design'; import { ModalWrapper } from '../Modal'; +import { Button, Input } from 'antd'; +import _compact from 'lodash/compact'; +import { getGlobalConfig } from 'tailchat-shared/model/config'; interface Values { email: string; password: string; + emailOTP?: string; [key: string]: unknown; } -const fields: MetaFormFieldMeta[] = [ - { type: 'text', name: 'email', label: t('邮箱') }, - { - type: 'password', - name: 'password', - label: t('密码'), - }, -]; +const getFields = (): MetaFormFieldMeta[] => + _compact([ + { type: 'text', name: 'email', label: t('邮箱') }, + getGlobalConfig().emailVerification && { + type: 'custom', + name: 'emailOTP', + label: t('邮箱校验码'), + render: (props: FastifyFormFieldProps) => { + const context = useFastifyFormContext(); + const email = context?.values?.['email']; + const [sended, setSended] = useState(false); + + const [{ loading }, handleVerifyEmail] = useAsyncRequest(async () => { + if (!email) { + showErrorToasts(t('邮箱不能为空')); + return; + } + await model.user.verifyEmail(email); + setSended(true); + }, [email]); + + return ( + + props.onChange(e.target.value)} + /> + + {!sended && ( + + )} + + ); + }, + }, + { + type: 'password', + name: 'password', + label: t('密码'), + }, + ]); const schema = createMetaFormSchema({ email: metaFormFieldSchema .string() - .required(t('邮箱不能为空')) - .email(t('邮箱格式不正确')), + .email(t('邮箱格式不正确')) + .required(t('邮箱不能为空')), password: metaFormFieldSchema .string() .min(6, t('密码不能低于6位')) .required(t('密码不能为空')), + emailOTP: metaFormFieldSchema.string().length(6, t('校验码为6位')), }); interface ClaimTemporaryUserProps { @@ -50,13 +107,15 @@ export const ClaimTemporaryUser: React.FC = React.memo( (props) => { const userId = props.userId; const dispatch = useAppDispatch(); + const fields = useMemo(() => getFields(), []); const [{}, handleClaim] = useAsyncRequest( async (values: Values) => { const data = await claimTemporaryUser( userId, values.email, - values.password + values.password, + values.emailOTP ); setGlobalUserLoginInfo(data); diff --git a/client/web/src/routes/Entry/ForgetPasswordView.tsx b/client/web/src/routes/Entry/ForgetPasswordView.tsx index 108f527d..fee0b3f5 100644 --- a/client/web/src/routes/Entry/ForgetPasswordView.tsx +++ b/client/web/src/routes/Entry/ForgetPasswordView.tsx @@ -7,7 +7,6 @@ import { useAsyncRequest, } from 'tailchat-shared'; import React, { useState } from 'react'; -import { Spinner } from '../../components/Spinner'; import { string } from 'yup'; import { useNavToView } from './utils'; import { EntryInput } from './components/Input'; diff --git a/client/web/src/routes/Entry/RegisterView.tsx b/client/web/src/routes/Entry/RegisterView.tsx index 855dc2eb..69297b24 100644 --- a/client/web/src/routes/Entry/RegisterView.tsx +++ b/client/web/src/routes/Entry/RegisterView.tsx @@ -1,6 +1,13 @@ -import { isValidStr, registerWithEmail, t, useAsyncFn } from 'tailchat-shared'; +import { + isValidStr, + model, + registerWithEmail, + showSuccessToasts, + t, + useAsyncFn, + useAsyncRequest, +} from 'tailchat-shared'; import React, { useState } from 'react'; -import { Spinner } from '../../components/Spinner'; import { string } from 'yup'; import { Icon } from 'tailchat-design'; import { useNavigate } from 'react-router'; @@ -11,6 +18,7 @@ import { useNavToView } from './utils'; import { EntryInput } from './components/Input'; import { SecondaryBtn } from './components/SecondaryBtn'; import { PrimaryBtn } from './components/PrimaryBtn'; +import { getGlobalConfig } from 'tailchat-shared/model/config'; /** * 注册视图 @@ -19,6 +27,7 @@ export const RegisterView: React.FC = React.memo(() => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [emailOTP, setEmailOTP] = useState(''); + const [sendedEmail, setSendedEmail] = useState(false); const navigate = useNavigate(); const navRedirect = useSearchParam('redirect'); @@ -45,6 +54,13 @@ export const RegisterView: React.FC = React.memo(() => { } }, [email, password, emailOTP, navRedirect]); + const [{ loading: sendEmailLoading }, handleSendEmail] = + useAsyncRequest(async () => { + await model.user.verifyEmail(email); + showSuccessToasts(t('发送成功, 请检查你的邮箱。')); + setSendedEmail(true); + }, [email]); + const navToView = useNavToView(); return ( @@ -63,6 +79,27 @@ export const RegisterView: React.FC = React.memo(() => { /> + {getGlobalConfig().emailVerification && ( + <> + {!sendedEmail && ( + + {t('向邮箱发送校验码')} + + )} + +
+
{t('邮箱校验码')}
+ setEmailOTP(e.target.value)} + /> +
+ + )} +
{t('密码')}
) { + const email = ctx.params.email; + const cacheKey = this.buildVerifyEmailKey(email); + + const c = await this.broker.cacher.get(cacheKey); + if (!!c) { + // 如果有一个忘记密码请求未到期 + throw new Error(t('过于频繁的请求,10 分钟内可以共用同一OTP')); + } + + const otp = generateRandomNumStr(6); // 产生一次性6位数字密码 + await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟 + + const html = ` +

您正在尝试注册 Tailchat, 请使用以下 OTP 作为邮箱验证凭证:

+

OTP: ${otp}

+

该 OTP 将会在 10分钟 后过期

+

如果并不是您触发的注册操作,请忽略此电子邮件。

`; + + await ctx.call('mail.sendMail', { + to: email, + subject: 'Tailchat 邮箱验证', + html, + }); + } + /** * 用户注册 */ async register( ctx: TcPureContext< - { username?: string; email?: string; password: string }, + { + username?: string; + email?: string; + password: string; + emailOTP?: string; + }, any > ) { @@ -289,6 +336,16 @@ class UserService extends TcService { nickname ); + if (config.emailVerification === true) { + // 检查OTP + const cacheKey = this.buildVerifyEmailKey(params.email); + const cachedOtp = await this.broker.cacher.get(cacheKey); + + if (String(cachedOtp) !== params.emailOTP) { + throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP')); + } + } + const password = await this.hashPassword(params.password); const doc = await this.adapter.insert({ ...params, @@ -370,6 +427,7 @@ class UserService extends TcService { username?: string; email: string; password: string; + emailOTP?: string; }> ) { const params = ctx.params; @@ -383,6 +441,16 @@ class UserService extends TcService { throw new Error(t('该用户不是临时用户')); } + if (config.emailVerification === true) { + // 检查OTP + const cacheKey = this.buildVerifyEmailKey(params.email); + const cachedOtp = await this.broker.cacher.get(cacheKey); + + if (String(cachedOtp) !== params.emailOTP) { + throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP')); + } + } + await this.validateRegisterParams(params, t); const password = await this.hashPassword(params.password); @@ -414,7 +482,7 @@ class UserService extends TcService { const c = await this.broker.cacher.get(cacheKey); if (!!c) { // 如果有一个忘记密码请求未到期 - throw new Error(t('过于频繁的请求,请 10 分钟以后再试')); + throw new Error(t('过于频繁的请求,10 分钟内可以共用同一OTP')); } const otp = generateRandomNumStr(6); // 产生一次性6位数字密码 @@ -913,6 +981,10 @@ class UserService extends TcService { private buildOpenapiBotEmail(botId: string) { return `${botId}@tailchat-openapi.com`; } + + private buildVerifyEmailKey(email: string) { + return `verify-email:${email}`; + } } export default UserService; diff --git a/server/views/mail.ejs b/server/views/mail.ejs index 55e52000..9f51d9f1 100644 --- a/server/views/mail.ejs +++ b/server/views/mail.ejs @@ -10,7 +10,7 @@
- +