diff --git a/client/shared/i18n/langs/en-US/translation.json b/client/shared/i18n/langs/en-US/translation.json index 29996289..5a0a13e6 100644 --- a/client/shared/i18n/langs/en-US/translation.json +++ b/client/shared/i18n/langs/en-US/translation.json @@ -27,6 +27,7 @@ "k1cbe2507": "Confirm", "k1d8e2fac": "View group details", "k1ea177bd": "Group privacy control to prevent malicious access to member information through groups", + "k1ea9f5ff": "Verified", "k206eff71": "Nickname can not be blank", "k21ee7a1f": "Roles", "k22856100": "Not found", @@ -53,6 +54,7 @@ "k323b5cc7": "Recall", "k3279c602": "Add now", "k32905632": "Unlimited invitation link", + "k335c71bf": "OTP code cannot be empty", "k34b5e3ab": "Send Message", "k34e357ee": "Group Summary", "k35abe359": "Lobby", @@ -61,6 +63,7 @@ "k3662c0d4": "Please select a panel", "k378f66fc": "Unmute", "k393892b6": "Upload original image", + "k3a31dae3": "Verification email sent", "k3ac17670": "An exception occurred, store create failed", "k3b4b656d": "About", "k3bbf3bbd": "Register Account", @@ -99,6 +102,7 @@ "k547a7a99": "This record was not found", "k551b0348": "Password", "k56f9469b": "No friends yet", + "k570b61fc": "Send verification email to {{email}}", "k57ab4d97": "Please select user", "k58a85592": "Is not a valid plugin configuration", "k5a0084e7": "Modify group configuration", @@ -186,6 +190,7 @@ "k9b91079c": "All readed", "k9bb01902": "Show Detail", "k9d5a843a": "Mail Service", + "k9d80acdf": "Verify email", "k9d901c20": "Meeting room", "k9dfa2c97": "never expires", "k9f3089ce": "Create", @@ -239,6 +244,7 @@ "kb76d94e0": "Refresh", "kb7a57f24": "Plugin Registry Service", "kb8185132": "Or", + "kb8ec7062": "Email verification passed", "kb96b79c5": "Allow management of invitation links", "kbc76781d": "No permission to send messages, please contact the group owner", "kbcacf812": "Are you sure to clear the inbox?", @@ -274,6 +280,7 @@ "kd455acf4": "Sent successfully, please check your email.", "kd4ff36fa": "Search Friends", "kd637a30": "Group Invite Service", + "kd7096593": "Unverified", "kd8d2b865": "Group Configuration", "kd955767f": "This invitation code never expires", "kd983a61a": "{{nickname}} recall a message", diff --git a/client/shared/i18n/langs/zh-CN/translation.json b/client/shared/i18n/langs/zh-CN/translation.json index fc381fa3..06fcabc2 100644 --- a/client/shared/i18n/langs/zh-CN/translation.json +++ b/client/shared/i18n/langs/zh-CN/translation.json @@ -27,6 +27,7 @@ "k1cbe2507": "确认", "k1d8e2fac": "查看群组详情", "k1ea177bd": "群组隐私控制,防止通过群组恶意获取成员信息", + "k1ea9f5ff": "已认证", "k206eff71": "昵称不能为空", "k21ee7a1f": "身份组", "k22856100": "未找到", @@ -53,6 +54,7 @@ "k323b5cc7": "撤回", "k3279c602": "立即添加", "k32905632": "不限时邀请链接", + "k335c71bf": "校验码不能为空", "k34b5e3ab": "发送消息", "k34e357ee": "群组概述", "k35abe359": "大厅", @@ -61,6 +63,7 @@ "k3662c0d4": "请选择面板", "k378f66fc": "解除禁言", "k393892b6": "上传原图", + "k3a31dae3": "已发送认证邮件", "k3ac17670": "出现异常, Store 创建失败", "k3b4b656d": "关于", "k3bbf3bbd": "注册账号", @@ -99,6 +102,7 @@ "k547a7a99": "没有找到该记录", "k551b0348": "密码", "k56f9469b": "暂无好友", + "k570b61fc": "向 {{email}} 发送认证邮件", "k57ab4d97": "请选择用户", "k58a85592": "不是一个合法的插件配置", "k5a0084e7": "修改群组配置", @@ -186,6 +190,7 @@ "k9b91079c": "所有已读", "k9bb01902": "显示详情", "k9d5a843a": "邮件服务", + "k9d80acdf": "认证邮箱", "k9d901c20": "会议室", "k9dfa2c97": "永不过期", "k9f3089ce": "创建", @@ -239,6 +244,7 @@ "kb76d94e0": "刷新", "kb7a57f24": "插件中心服务", "kb8185132": "或", + "kb8ec7062": "邮箱验证通过", "kb96b79c5": "允许管理邀请链接", "kbc76781d": "没有发送消息的权限, 请联系群组所有者", "kbcacf812": "确认清空收件箱么?", @@ -274,6 +280,7 @@ "kd455acf4": "发送成功, 请检查你的邮箱。", "kd4ff36fa": "查找好友", "kd637a30": "群组邀请服务", + "kd7096593": "未认证", "kd8d2b865": "群组配置", "kd955767f": "该邀请码永不过期", "kd983a61a": "{{nickname}} 撤回了一条消息", diff --git a/client/shared/model/user.ts b/client/shared/model/user.ts index ac34cf90..137d1223 100644 --- a/client/shared/model/user.ts +++ b/client/shared/model/user.ts @@ -14,6 +14,7 @@ export interface UserBaseInfo { discriminator: string; avatar: string | null; temporary: boolean; + emailVerified: boolean; extra?: Record; } @@ -110,6 +111,20 @@ export async function verifyEmail(email: string): Promise { return data; } +/** + * 检查邮箱校验码并更新用户字段 + * @param email 邮箱 + */ +export async function verifyEmailWithOTP( + emailOTP: string +): Promise { + const { data } = await request.post('/api/user/verifyEmailWithOTP', { + emailOTP, + }); + + return data; +} + /** * 邮箱注册账号 * @param email 邮箱 diff --git a/client/web/src/components/modals/EmailVerify.tsx b/client/web/src/components/modals/EmailVerify.tsx new file mode 100644 index 00000000..77c8f289 --- /dev/null +++ b/client/web/src/components/modals/EmailVerify.tsx @@ -0,0 +1,120 @@ +import { setUserJWT } from '@/utils/jwt-helper'; +import { setGlobalUserLoginInfo } from '@/utils/user-helper'; +import React, { useMemo, useState } from 'react'; +import { + model, + showErrorToasts, + showSuccessToasts, + t, + useAppDispatch, + useAsyncRequest, + userActions, + useUserInfo, +} from 'tailchat-shared'; +import { + createMetaFormSchema, + 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'; +import { Problem } from '../Problem'; + +interface Values { + emailOTP: string; + [key: string]: unknown; +} + +const fields: MetaFormFieldMeta[] = [ + { + type: 'text', + name: 'emailOTP', + placeholder: t('6位校验码'), + label: t('邮箱校验码'), + }, +]; + +const schema = createMetaFormSchema({ + emailOTP: metaFormFieldSchema + .string() + .length(6, t('校验码为6位')) + .required(t('校验码不能为空')), +}); + +export const EmailVerify: React.FC<{ + onSuccess?: () => void; +}> = React.memo((props) => { + const dispatch = useAppDispatch(); + const [sended, setSended] = useState(false); + const userInfo = useUserInfo(); + + const [{ loading }, handleSendEmail] = useAsyncRequest(async () => { + if (!userInfo) { + return; + } + + await model.user.verifyEmail(userInfo.email); + setSended(true); + }, [userInfo?.email]); + + const [, handleVerifyEmail] = useAsyncRequest( + async (values: Values) => { + const data = await model.user.verifyEmailWithOTP(values.emailOTP); + + setGlobalUserLoginInfo(data); + dispatch(userActions.setUserInfo(data)); + + showSuccessToasts(t('邮箱验证通过')); + + if (typeof props.onSuccess === 'function') { + props.onSuccess(); + } + }, + [userInfo?.email, props.onSuccess] + ); + + if (!userInfo) { + return ; + } + + return ( + + {!sended ? ( + <> + + + + ) : ( + + )} + + ); +}); +EmailVerify.displayName = 'EmailVerify'; diff --git a/client/web/src/components/modals/SettingsView/Account.tsx b/client/web/src/components/modals/SettingsView/Account.tsx index 1490f8ad..119ae53e 100644 --- a/client/web/src/components/modals/SettingsView/Account.tsx +++ b/client/web/src/components/modals/SettingsView/Account.tsx @@ -8,7 +8,7 @@ import { closeModal, pluginUserExtraInfo } from '@/plugin/common'; import { getGlobalSocket } from '@/utils/global-state-helper'; import { setUserJWT } from '@/utils/jwt-helper'; import { setGlobalUserLoginInfo } from '@/utils/user-helper'; -import { Button, Divider, Typography } from 'antd'; +import { Button, Divider, Tag, Typography } from 'antd'; import React, { useCallback } from 'react'; import { useNavigate } from 'react-router'; import { Avatar } from 'tailchat-design'; @@ -24,6 +24,7 @@ import { userActions, useUserInfo, } from 'tailchat-shared'; +import { EmailVerify } from '../EmailVerify'; import { ModifyPassword } from '../ModifyPassword'; export const SettingsAccount: React.FC = React.memo(() => { @@ -111,6 +112,36 @@ export const SettingsAccount: React.FC = React.memo(() => { onSave={handleUpdateNickName} /> + + {userInfo.email} + {userInfo.emailVerified ? ( + + {t('已认证')} + + ) : ( + { + const key = openModal( + { + closeModal(key); + }} + /> + ); + }} + > + {t('未认证')} + + )} + + } + /> + {pluginUserExtraInfo.map((item, i) => { if (item.component && item.component.editor) { const Component = item.component.editor; diff --git a/client/web/src/styles/antd/dark.less b/client/web/src/styles/antd/dark.less index 476423fc..0389db4b 100644 --- a/client/web/src/styles/antd/dark.less +++ b/client/web/src/styles/antd/dark.less @@ -11,6 +11,7 @@ border-color: #434343; background: transparent; + &:hover, &:focus { color: var(--antd-primary-border); @@ -58,6 +59,10 @@ } } } + + &.ant-btn-text{ + border-color: transparent; + } } .ant-switch { diff --git a/server/locales/en-US/translation.json b/server/locales/en-US/translation.json index 2e8da7f5..d1fbf10e 100644 --- a/server/locales/en-US/translation.json +++ b/server/locales/en-US/translation.json @@ -4,7 +4,9 @@ "k17f8532": "No message found", "k1b3d8c72": "Group not found", "k1bdc50f": "Please enter a username with a unique identifier such as: Nickname#0000", + "k206592b2": "Email has been verified", "k21e507de": "Unable to delete private message", + "k236bb718": "Email sending failed", "k2bb4fb6d": "OTP incorrect", "k313eb9b3": "User does not exist, please check your username", "k3b35c0b0": "No permission to create invitation codes", @@ -33,6 +35,7 @@ "kb5971793": "Username or email is empty", "kb8be9969": "Recall failed, no permission", "kba207c17": "No permission to view", + "kbb1ef795": "Verification failed, OTP has expired", "kbb96754b": "Group OP not allowed to be kicked out", "kc1e668f5": "Not allowed to kick yourself out", "kc4b77045": "{{nickname}} join this group with invite code from {{creator}}", diff --git a/server/locales/zh-CN/translation.json b/server/locales/zh-CN/translation.json index f68b0100..42f07a60 100644 --- a/server/locales/zh-CN/translation.json +++ b/server/locales/zh-CN/translation.json @@ -4,7 +4,9 @@ "k17f8532": "没有找到消息", "k1b3d8c72": "群组未找到", "k1bdc50f": "请输入带唯一标识的用户名 如: Nickname#0000", + "k206592b2": "邮箱已认证", "k21e507de": "无法删除私人信息", + "k236bb718": "邮件发送失败", "k2bb4fb6d": "OTP 不正确", "k313eb9b3": "用户不存在, 请检查您的用户名", "k3b35c0b0": "没有创建邀请码权限", @@ -33,6 +35,7 @@ "kb5971793": "用户名或邮箱为空", "kb8be9969": "撤回失败, 没有权限", "kba207c17": "没有查看权限", + "kbb1ef795": "校验失败, OTP已过期", "kbb96754b": "不允许踢出群组OP", "kc1e668f5": "不允许踢出自己", "kc4b77045": "{{nickname}} 通过 {{creator}} 的邀请码加入群组", diff --git a/server/packages/sdk/src/structs/user.ts b/server/packages/sdk/src/structs/user.ts index c7895c80..1423f57b 100644 --- a/server/packages/sdk/src/structs/user.ts +++ b/server/packages/sdk/src/structs/user.ts @@ -40,4 +40,6 @@ export interface UserStruct { avatar?: string; type: UserType[]; + + emailVerified: boolean; } diff --git a/server/services/core/user/mail.service.ts b/server/services/core/user/mail.service.ts index 2a40d45f..66cb1816 100644 --- a/server/services/core/user/mail.service.ts +++ b/server/services/core/user/mail.service.ts @@ -51,19 +51,24 @@ class MailService extends TcService { } const { to, subject, html } = ctx.params; + const { t } = ctx.meta; - const info = await this.adapter.model.sendMail({ - to, - subject, - html: await ejs.renderFile( - path.resolve(__dirname, '../../../views/mail.ejs'), - { - body: html, - } - ), - }); + try { + const info = await this.adapter.model.sendMail({ + to, + subject, + html: await ejs.renderFile( + path.resolve(__dirname, '../../../views/mail.ejs'), + { + body: html, + } + ), + }); - this.logger.info('sendMailSuccess:', info); + this.logger.info('sendMailSuccess:', info); + } catch (err) { + throw new Error(t('邮件发送失败')); + } } } diff --git a/server/services/core/user/user.service.ts b/server/services/core/user/user.service.ts index eaa6f798..6c531bcd 100644 --- a/server/services/core/user/user.service.ts +++ b/server/services/core/user/user.service.ts @@ -18,7 +18,7 @@ import { DataNotFoundError, EntityError, db, - t, + call, } from 'tailchat-server-sdk'; import { generateRandomNumStr, @@ -53,6 +53,7 @@ class UserService extends TcService { 'temporary', 'avatar', 'type', + 'emailVerified', 'extra', 'createdAt', ]); @@ -70,6 +71,11 @@ class UserService extends TcService { email: 'email', }, }); + this.registerAction('verifyEmailWithOTP', this.verifyEmailWithOTP, { + params: { + emailOTP: 'string', + }, + }); this.registerAction('register', this.register, { rest: 'POST /register', params: { @@ -287,6 +293,7 @@ class UserService extends TcService { */ async verifyEmail(ctx: TcPureContext<{ email: string }>) { const email = ctx.params.email; + const t = ctx.meta.t; const cacheKey = this.buildVerifyEmailKey(email); const c = await this.broker.cacher.get(cacheKey); @@ -296,19 +303,63 @@ class UserService extends TcService { } const otp = generateRandomNumStr(6); // 产生一次性6位数字密码 - await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟 const html = ` -

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

+

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

OTP: ${otp}

该 OTP 将会在 10分钟 后过期

-

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

`; +

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

`; await ctx.call('mail.sendMail', { to: email, - subject: 'Tailchat 邮箱验证', + subject: `Tailchat 邮箱验证: ${otp}`, html, }); + + await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟 + + return true; + } + + /** + * 通过用户邮件验证OTP, 并更新用户验证状态 + */ + async verifyEmailWithOTP(ctx: TcContext<{ emailOTP: string }>) { + const emailOTP = ctx.params.emailOTP; + const userId = ctx.meta.userId; + const t = ctx.meta.t; + + const userInfo = await call(ctx).getUserInfo(userId); + if (userInfo.emailVerified === true) { + throw new Error(t('邮箱已认证')); + } + + // 检查 + const cacheKey = this.buildVerifyEmailKey(userInfo.email); + const cachedOTP = await this.broker.cacher.get(cacheKey); + if (!cachedOTP) { + throw new Error(t('校验失败, OTP已过期')); + } + if (String(cachedOTP) !== emailOTP) { + throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP')); + } + + // 验证通过 + const user = await this.adapter.model.findOneAndUpdate( + { + _id: new Types.ObjectId(userId), + }, + { + emailVerified: true, + }, + { + new: true, + } + ); + + await this.cleanCurrentUserCache(ctx); + + return this.transformDocuments(ctx, {}, user); } /** @@ -340,9 +391,13 @@ class UserService extends TcService { if (config.emailVerification === true) { // 检查OTP const cacheKey = this.buildVerifyEmailKey(params.email); - const cachedOtp = await this.broker.cacher.get(cacheKey); + const cachedOTP = await this.broker.cacher.get(cacheKey); - if (String(cachedOtp) !== params.emailOTP) { + if (!cachedOTP) { + throw new Error(t('校验失败, OTP已过期')); + } + + if (String(cachedOTP) !== params.emailOTP) { throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP')); } @@ -448,9 +503,13 @@ class UserService extends TcService { if (config.emailVerification === true) { // 检查OTP const cacheKey = this.buildVerifyEmailKey(params.email); - const cachedOtp = await this.broker.cacher.get(cacheKey); + const cachedOTP = await this.broker.cacher.get(cacheKey); + + if (!cachedOTP) { + throw new Error(t('校验失败, OTP已过期')); + } - if (String(cachedOtp) !== params.emailOTP) { + if (String(cachedOTP) !== params.emailOTP) { throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP')); } @@ -492,7 +551,6 @@ class UserService extends TcService { } const otp = generateRandomNumStr(6); // 产生一次性6位数字密码 - await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟 const html = `

忘记密码了? 请使用以下 OTP 作为重置密码凭证:

@@ -502,9 +560,13 @@ class UserService extends TcService { await ctx.call('mail.sendMail', { to: email, - subject: 'Tailchat 忘记密码', + subject: `Tailchat 忘记密码: ${otp}`, html, }); + + await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟 + + return true; } /** @@ -521,8 +583,13 @@ class UserService extends TcService { const { t } = ctx.meta; const cacheKey = `forget-password:${email}`; - const cachedOtp = await this.broker.cacher.get(cacheKey); - if (String(cachedOtp) !== otp) { + const cachedOTP = await this.broker.cacher.get(cacheKey); + + if (!cachedOTP) { + throw new Error(t('校验失败, OTP已过期')); + } + + if (String(cachedOTP) !== otp) { throw new Error(t('OTP 不正确')); } @@ -988,6 +1055,9 @@ class UserService extends TcService { return `${botId}@tailchat-openapi.com`; } + /** + * 构建验证邮箱的缓存key + */ private buildVerifyEmailKey(email: string) { return `verify-email:${email}`; } diff --git a/server/views/mail.ejs b/server/views/mail.ejs index 9f51d9f1..ab1ea0b4 100644 --- a/server/views/mail.ejs +++ b/server/views/mail.ejs @@ -10,7 +10,7 @@
- +