diff --git a/web/src/api/user.ts b/web/src/api/user.ts index f37e685b..72a3ad73 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,11 +1,11 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 14:00:23 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 14:00:23 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-25 11:17:44 */ import { request } from '@/utils/request' -import type { CreateModalData } from '@/views/UserManagement/types' +import type { CreateModalData, ChangeEmailModalForm } from '@/views/UserManagement/types' import { cookieUtils } from '@/utils/request' // User info @@ -28,6 +28,10 @@ export const refreshToken = () => { export const changePassword = (data: { user_id: string; new_password: string }) => { return request.put('/users/admin/change-password', data) } +// Verify password +export const verifyPassword = (data: { password: string }) => { + return request.post('/users/verify_pwd', data) +} // Disable user export const deleteUser = (user_id: string) => { return request.delete(`/users/${user_id}`) @@ -44,4 +48,12 @@ export const addUser = (data: CreateModalData) => { export const logoutUrl = '/logout' export const logout = () => { return request.post(logoutUrl) +} +// Send email verification code +export const sendEmailCode = (data: { email: string }) => { + return request.post('/users/send-email-code', data) +} +// Verify code and change email +export const changeEmail = (data: ChangeEmailModalForm) => { + return request.put('/users/change-email', data) } \ No newline at end of file diff --git a/web/src/components/Empty/index.tsx b/web/src/components/Empty/index.tsx index fbf57767..48bfa33c 100644 --- a/web/src/components/Empty/index.tsx +++ b/web/src/components/Empty/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:03:25 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-02 15:47:31 + * @Last Modified time: 2026-02-25 11:14:25 */ /** * Empty Component @@ -13,7 +13,7 @@ * @component */ -import { type FC } from 'react'; +import { type FC, type ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import emptyIcon from '@/assets/images/empty/empty.svg'; @@ -24,7 +24,7 @@ interface EmptyProps { /** Icon size - single number or [width, height] array */ size?: number | number[]; /** Main title text */ - title?: string; + title?: string | ReactElement; /** Whether to show subtitle */ isNeedSubTitle?: boolean; /** Custom subtitle text */ diff --git a/web/src/components/Header/UserInfoModal.tsx b/web/src/components/Header/UserInfoModal.tsx index ac187fb8..94a4db7c 100644 --- a/web/src/components/Header/UserInfoModal.tsx +++ b/web/src/components/Header/UserInfoModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:09:47 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-02 15:51:54 + * @Last Modified time: 2026-02-25 11:40:47 */ /** * UserInfoModal Component @@ -15,7 +15,7 @@ */ import { forwardRef, useImperativeHandle, useState, useRef } from 'react'; -import { Button } from 'antd'; +import { Button, Space } from 'antd'; import { UnlockOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; @@ -23,7 +23,9 @@ import { useUser } from '@/store/user'; import RbModal from '@/components/RbModal' import { formatDateTime } from '@/utils/format'; import ResetPasswordModal from '@/views/UserManagement/components/ResetPasswordModal' -import type { ResetPasswordModalRef } from '@/views/UserManagement/types' +import type { ResetPasswordModalRef, VerifyPasswordModalRef, ChangeEmailModalRef } from '@/views/UserManagement/types' +import VerifyPasswordModal from '@/views/UserManagement/components/VerifyPasswordModal' +import ChangeEmailModal from '@/views/UserManagement/components/ChangeEmailModal' /** Interface for UserInfoModal ref methods exposed to parent components */ export interface UserInfoModalRef { @@ -37,8 +39,10 @@ export interface UserInfoModalRef { const UserInfoModal = forwardRef((_props, ref) => { const { t } = useTranslation(); const resetPasswordModalRef = useRef(null) - const { user } = useUser(); + const { user, getUserInfo } = useUser(); const [visible, setVisible] = useState(false); + const verifyPasswordModalRef = useRef(null) + const changeEmailModalRef = useRef(null) /** Close the modal */ const handleClose = () => { @@ -50,6 +54,17 @@ const UserInfoModal = forwardRef((_props, ref) => { setVisible(true); }; + /** Open password verification modal before editing email */ + const handleEditEmail = () => { + verifyPasswordModalRef.current?.handleOpen() + } + + /** Update user information after email change */ + const updateUserInfo = () => { + localStorage.removeItem('user') + getUserInfo() + } + /** Expose handleOpen and handleClose methods to parent component via ref */ useImperativeHandle(ref, () => ({ handleOpen, @@ -74,7 +89,13 @@ const UserInfoModal = forwardRef((_props, ref) => { {/* Email */}
{t('user.email')} - {user.email} + + {user.email} +
+
{/* Role */}
@@ -106,6 +127,14 @@ const UserInfoModal = forwardRef((_props, ref) => { ref={resetPasswordModalRef} source="changePassword" /> + changeEmailModalRef.current?.handleOpen()} + /> + ); }); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5fcdf0ed..1cac2648 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -274,6 +274,28 @@ export const en = { createdAt: 'Creation Time', member: 'Member', passwordRule: 'password should have at least 6 characters', + authVerify: 'Identity Verification', + authVerifyDesc: 'For security reasons, please verify your login password first', + verify: 'Verify', + loginPassword: 'Login Password', + loginPasswordPlaceholder: 'Please enter the login password for the current account', + loginPasswordVerifyFailed: 'Incorrect password, please try again', + bindNewEmail: 'Bind New Email', + sureChange: 'Confirm Change', + sendEmailCode: 'Send Verification Code', + currentEmail: 'Current Email', + newEmail: 'New Email Address', + emailCode: 'Verification Code', + emailCodePlaceholder: 'Please enter the verification code received by the new email', + sureChangeEmail: 'Confirm to change the bound email to', + sureChangeEmailDesc: '?', + changeSuccess: 'Changed successfully', + sendSuccess: 'Verification code has been sent, please check', + newEmailSameAsOld: 'New email cannot be the same as current email', + emailCodeLengthRule: 'Please enter a 6-digit verification code', + emailFormatError: 'Incorrect email format', + sendCodeTooFrequent: 'Please resend after {{seconds}}s', + retrySend: 'Can resend after {{seconds}}s', }, timezones: { 'Asia/Shanghai': 'China Standard Time (UTC+8)', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6b880426..4b5fe798 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -946,18 +946,29 @@ export const zh = { email: '邮箱', createdAt: '创建时间', member: '成员', - batchImport: '批量导入', - batchImportUser: '批量导入用户', - downloadTemplate: '下载导入模板', - templateDownloadSuccess: '模板下载成功', - startImport: '开始导入', - batchImportSuccess: '批量导入成功', - importFailed: '导入失败,请检查文件格式', - noFileSelected: '请选择要导入的文件', - onlyXlsxOrCsv: '只能上传 .xlsx 或 .csv 格式的文件', - reselect: '重新选择', - noFileSelectedTip: '未选择任何文件', - downloadTemplateTip: '请下载模板,填写用户信息后上传。' + passwordRule: '密码至少需要6个字符', + authVerify: '身份验证', + authVerifyDesc: '出于安全考虑,请先验证您的登录密码', + verify: '验证', + loginPassword: '登录密码', + loginPasswordPlaceholder: '请输入当前账号的登录密码', + loginPasswordVerifyFailed: '密码错误,请重新输入', + bindNewEmail: '绑定新邮箱', + sureChange: '确认修改', + sendEmailCode: '发送验证码', + currentEmail: '当前邮箱', + newEmail: '新邮箱地址', + emailCode: '验证码', + emailCodePlaceholder: '请输入新邮箱收到的验证码', + sureChangeEmail: '确认将绑定邮箱修改为', + sureChangeEmailDesc: '吗?', + changeSuccess: '修改成功', + sendSuccess: '验证码已发送,请查收', + newEmailSameAsOld: '新邮箱不能与当前邮箱相同', + emailCodeLengthRule: '请输入6位的验证码', + emailFormatError: '邮箱格式不正确', + sendCodeTooFrequent: '请在{{seconds}}s后重新发送', + retrySend: '{{seconds}}s后可重发', }, common: { search: '搜索', diff --git a/web/src/views/UserManagement/components/ChangeEmailModal.tsx b/web/src/views/UserManagement/components/ChangeEmailModal.tsx new file mode 100644 index 00000000..fbf93480 --- /dev/null +++ b/web/src/views/UserManagement/components/ChangeEmailModal.tsx @@ -0,0 +1,219 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-25 11:45:07 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-25 11:45:07 + */ +/** + * ChangeEmailModal Component + * + * A two-step modal for changing user email address with verification code. + * Step 1: Enter new email and send verification code + * Step 2: Confirm the email change + */ + +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, App, Row, Col, Button, Steps } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { ChangeEmailModalRef, ChangeEmailModalForm } from '../types' +import RbModal from '@/components/RbModal' +import { changeEmail, sendEmailCode } from '@/api/user' +import { useUser } from '@/store/user'; +import RbAlert from '@/components/RbAlert'; +import Empty from '@/components/Empty'; +import EmailIcon from '@/assets/images/login/email.svg' + +const FormItem = Form.Item; + +/** + * Component props interface + */ +interface ChangeEmailModalProps { + /** Callback function to refresh user data after email change */ + refresh: () => void; +} + +const steps = [ + 'bindNewEmail', + 'sureChange', +] + +const ChangeEmailModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const [current, setCurrent] = useState(0); + const { user } = useUser(); + const [codeLoading, setCodeLoading] = useState(false) + const [countdown, setCountdown] = useState(0) + const newEmail = Form.useWatch(['new_email'], form) + + /** Close modal and reset form */ + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + setCurrent(0) + setCountdown(0) + }; + /** Handle cancel button click - go back to previous step or close modal */ + const handleCancel = () => { + if (current === 0) { + handleClose() + } else { + setCurrent(0) + } + } + + /** Open modal */ + const handleOpen = () => { + form.resetFields(); + setVisible(true); + }; + /** Handle save/next button click - proceed to next step or submit email change */ + const handleSave = () => { + form + .validateFields() + .then((values) => { + if (current === 0) { + setCurrent(1) + } else { + setLoading(true) + changeEmail(values) + .then(() => { + setLoading(false) + refresh() + handleClose() + message.success(t('user.changeSuccess')) + }) + .catch(() => { + setLoading(false) + }); + } + }) + .catch((err) => { + console.log('err', err) + }); + } + + /** Send verification code to new email with countdown timer */ + const handleSendCode = () => { + if (countdown > 0) { + message.warning(t('user.sendCodeTooFrequent', { seconds: countdown })); + return; + } + form + .validateFields(['new_email']) + .then((values) => { + setCodeLoading(true) + sendEmailCode({ email: values.new_email }) + .then(() => { + message.success(t('user.sendSuccess')) + setCountdown(300) + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + }) + .finally(() => { + setCodeLoading(false) + }) + }) + .catch((err) => { + console.log('err', err) + }); + } + + /** Expose methods to parent component */ + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + {current === 1 ? t('common.prevStep') : t('common.cancel')}, + , + ]} + > +
+ ({ title: t(`user.${key}`) }))} + /> +
+ {current === 0 && {t('user.currentEmail')}: {user.email}} + {current === 1 && + {t('user.sureChangeEmail')}
+
{newEmail}
+ {t('user.sureChangeEmailDesc')} +
} />} + + + ); +}); + +export default ChangeEmailModal; \ No newline at end of file diff --git a/web/src/views/UserManagement/components/VerifyPasswordModal.tsx b/web/src/views/UserManagement/components/VerifyPasswordModal.tsx new file mode 100644 index 00000000..4b2552ad --- /dev/null +++ b/web/src/views/UserManagement/components/VerifyPasswordModal.tsx @@ -0,0 +1,111 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-25 10:51:17 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-25 11:46:11 + */ +/** + * VerifyPasswordModal Component + * + * A modal dialog for verifying user's current login password before performing + * sensitive operations (e.g., changing email address). + */ + +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { ExclamationCircleFilled } from '@ant-design/icons'; + +import type { VerifyPasswordModalRef } from '../types' +import RbModal from '@/components/RbModal' +import { verifyPassword } from '@/api/user' +import RbAlert from '@/components/RbAlert'; + +/** + * VerifyPasswordModal component props + */ +interface VerifyPasswordModalProps { + /** Callback function executed after successful password verification */ + refresh: () => void; +} + +const VerifyPasswordModal = forwardRef(({ refresh }, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm<{ password: string }>(); + const [loading, setLoading] = useState(false) + + /** Close modal and reset form */ + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + }; + + /** Open modal */ + const handleOpen = () => { + form.resetFields(); + setVisible(true); + }; + /** Verify password and execute callback on success */ + const handleSave = () => { + form + .validateFields() + .then((values) => { + setLoading(true) + verifyPassword(values) + .then(() => { + refresh() + handleClose() + }) + .catch(() => { + form.setFields([{ + name: 'password', + errors: [t('user.loginPasswordVerifyFailed')] + }]) + }) + .finally(() => { + setLoading(false) + }) + }) + .catch((err) => { + console.log('err', err) + }); + } + + /** Expose methods to parent component */ + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + + } className="rb:mb-4!">{ t('user.authVerifyDesc') } +
+ + + +
+
+ ); +}); + +export default VerifyPasswordModal; \ No newline at end of file diff --git a/web/src/views/UserManagement/types.ts b/web/src/views/UserManagement/types.ts index e86f2e49..0250e925 100644 --- a/web/src/views/UserManagement/types.ts +++ b/web/src/views/UserManagement/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:50:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 17:51:17 + * @Last Modified time: 2026-02-25 11:44:02 */ /** * User data type @@ -49,4 +49,33 @@ export interface CreateModalRef { */ export interface ResetPasswordModalRef { handleOpen: (user: User) => void; +} +/** + * Verify password modal ref interface + */ +export interface VerifyPasswordModalRef { + handleOpen: () => void; +} + +/** + * Check password modal ref interface + */ +export interface CheckPasswordModalRef { + handleOpen: () => void; + handleClose: () => void; +} + +/** + * Change email modal ref interface + */ +export interface ChangeEmailModalRef { + handleOpen: () => void; +} + +/** + * Change email form data type + */ +export interface ChangeEmailModalForm { + new_email: string; + code: string; } \ No newline at end of file