From 21756f1110cd1fa44cf47ebf0263e909d47068fc Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 15 Jul 2025 13:43:34 +0800 Subject: [PATCH 1/2] Temp feat change user email enterprise frontend (#22401) Co-authored-by: JzoNg --- .../account-page/email-change-modal.tsx | 374 ++++++++++++++++++ web/app/account/account-page/index.module.css | 9 - web/app/account/account-page/index.tsx | 30 +- web/i18n/en-US/common.ts | 22 ++ web/i18n/zh-Hans/common.ts | 22 ++ web/service/common.ts | 12 + web/types/feature.ts | 2 + 7 files changed, 457 insertions(+), 14 deletions(-) create mode 100644 web/app/account/account-page/email-change-modal.tsx delete mode 100644 web/app/account/account-page/index.module.css diff --git a/web/app/account/account-page/email-change-modal.tsx b/web/app/account/account-page/email-change-modal.tsx new file mode 100644 index 0000000000..8d26ca66f3 --- /dev/null +++ b/web/app/account/account-page/email-change-modal.tsx @@ -0,0 +1,374 @@ +import React, { useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useRouter } from 'next/navigation' +import { useContext } from 'use-context-selector' +import { ToastContext } from '@/app/components/base/toast' +import { RiCloseLine } from '@remixicon/react' +import Modal from '@/app/components/base/modal' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import { + checkEmailExisted, + logout, + resetEmail, + sendVerifyCode, + verifyEmail, +} from '@/service/common' +import { noop } from 'lodash-es' + +type Props = { + show: boolean + onClose: () => void + email: string +} + +enum STEP { + start = 'start', + verifyOrigin = 'verifyOrigin', + newEmail = 'newEmail', + verifyNew = 'verifyNew', +} + +const EmailChangeModal = ({ onClose, email, show }: Props) => { + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + const router = useRouter() + const [step, setStep] = useState(STEP.start) + const [code, setCode] = useState('') + const [mail, setMail] = useState('') + const [time, setTime] = useState(0) + const [stepToken, setStepToken] = useState('') + const [newEmailExited, setNewEmailExited] = useState(false) + + const startCount = () => { + setTime(60) + const timer = setInterval(() => { + setTime((prev) => { + if (prev <= 0) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + } + + const sendEmail = async (email: string, isOrigin: boolean, token?: string) => { + try { + const res = await sendVerifyCode({ + email, + phase: isOrigin ? 'old_email' : 'new_email', + token, + }) + startCount() + if (res.data) + setStepToken(res.data) + } + catch (error) { + notify({ + type: 'error', + message: `Error sending verification code: ${error ? (error as any).message : ''}`, + }) + } + } + + const verifyEmailAddress = async (email: string, code: string, token: string, callback?: () => void) => { + try { + const res = await verifyEmail({ + email, + code, + token, + }) + if (res.is_valid) { + setStepToken(res.token) + callback?.() + } + else { + notify({ + type: 'error', + message: 'Verifying email failed', + }) + } + } + catch (error) { + notify({ + type: 'error', + message: `Error verifying email: ${error ? (error as any).message : ''}`, + }) + } + } + + const sendCodeToOriginEmail = async () => { + await sendEmail( + email, + true, + ) + setStep(STEP.verifyOrigin) + } + + const handleVerifyOriginEmail = async () => { + await verifyEmailAddress(email, code, stepToken, () => setStep(STEP.newEmail)) + setCode('') + } + + const isValidEmail = (email: string): boolean => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ + return emailRegex.test(email) + } + + const checkNewEmailExisted = async (email: string) => { + try { + await checkEmailExisted({ + email, + }) + setNewEmailExited(false) + } + catch (error) { + setNewEmailExited(false) + if ((error as any)?.code === 'email_already_in_use') { + setNewEmailExited(true) + } + else { + notify({ + type: 'error', + message: `Error checking email existence: ${error ? (error as any).message : ''}`, + }) + } + } + } + + const handleNewEmailValueChange = (mailAddress: string) => { + setMail(mailAddress) + if (isValidEmail(mailAddress)) + checkNewEmailExisted(mailAddress) + } + + const sendCodeToNewEmail = async () => { + if (!isValidEmail(mail)) { + notify({ + type: 'error', + message: 'Invalid email format', + }) + return + } + await sendEmail( + mail, + false, + stepToken, + ) + setStep(STEP.verifyNew) + } + + const handleLogout = async () => { + await logout({ + url: '/logout', + params: {}, + }) + + localStorage.removeItem('setup_status') + localStorage.removeItem('console_token') + localStorage.removeItem('refresh_token') + + router.push('/signin') + } + + const updateEmail = async () => { + try { + await resetEmail({ + new_email: mail, + token: stepToken, + }) + handleLogout() + } + catch (error) { + notify({ + type: 'error', + message: `Error changing email: ${error ? (error as any).message : ''}`, + }) + } + } + + const submitNewEmail = async () => { + await verifyEmailAddress(mail, code, stepToken, () => updateEmail()) + } + + return ( + +
+ +
+ {step === STEP.start && ( + <> +
{t('common.account.changeEmail.title')}
+
+
{t('common.account.changeEmail.authTip')}
+
+ }} + values={{ email }} + /> +
+
+
+
+ + +
+ + )} + {step === STEP.verifyOrigin && ( + <> +
{t('common.account.changeEmail.verifyEmail')}
+
+
+ }} + values={{ email }} + /> +
+
+
+
{t('common.account.changeEmail.codeLabel')}
+ setCode(e.target.value)} + maxLength={6} + /> +
+
+ + +
+
+ {t('common.account.changeEmail.resendTip')} + {time > 0 && ( + {t('common.account.changeEmail.resendCount', { count: time })} + )} + {!time && ( + {t('common.account.changeEmail.resend')} + )} +
+ + )} + {step === STEP.newEmail && ( + <> +
{t('common.account.changeEmail.newEmail')}
+
+
{t('common.account.changeEmail.content3')}
+
+
+
{t('common.account.changeEmail.emailLabel')}
+ handleNewEmailValueChange(e.target.value)} + destructive={newEmailExited} + /> + {newEmailExited && ( +
{t('common.account.changeEmail.existingEmail')}
+ )} +
+
+ + +
+ + )} + {step === STEP.verifyNew && ( + <> +
{t('common.account.changeEmail.verifyNew')}
+
+
+ }} + values={{ email: mail }} + /> +
+
+
+
{t('common.account.changeEmail.codeLabel')}
+ setCode(e.target.value)} + maxLength={6} + /> +
+
+ + +
+
+ {t('common.account.changeEmail.resendTip')} + {time > 0 && ( + {t('common.account.changeEmail.resendCount', { count: time })} + )} + {!time && ( + {t('common.account.changeEmail.resend')} + )} +
+ + )} +
+ ) +} + +export default EmailChangeModal diff --git a/web/app/account/account-page/index.module.css b/web/app/account/account-page/index.module.css deleted file mode 100644 index 949d1257e9..0000000000 --- a/web/app/account/account-page/index.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.modal { - padding: 24px 32px !important; - width: 400px !important; -} - -.bg { - background: linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0.00) 24.02%), #F9FAFB; -} - diff --git a/web/app/account/account-page/index.tsx b/web/app/account/account-page/index.tsx index 19c1e44236..a469286900 100644 --- a/web/app/account/account-page/index.tsx +++ b/web/app/account/account-page/index.tsx @@ -6,7 +6,6 @@ import { } from '@remixicon/react' import { useContext } from 'use-context-selector' import DeleteAccount from '../delete-account' -import s from './index.module.css' import AvatarWithEdit from './AvatarWithEdit' import Collapse from '@/app/components/header/account-setting/collapse' import type { IItem } from '@/app/components/header/account-setting/collapse' @@ -21,6 +20,7 @@ import { IS_CE_EDITION } from '@/config' import Input from '@/app/components/base/input' import PremiumBadge from '@/app/components/base/premium-badge' import { useGlobalPublicStore } from '@/context/global-public-context' +import EmailChangeModal from './email-change-modal' const titleClassName = ` system-sm-semibold text-text-secondary @@ -48,6 +48,7 @@ export default function AccountPage() { const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [showUpdateEmail, setShowUpdateEmail] = useState(false) const handleEditName = () => { setEditNameModalVisible(true) @@ -123,10 +124,17 @@ export default function AccountPage() { } const renderAppItem = (item: IItem) => { + const { icon, icon_background, icon_type, icon_url } = item as any return (
- +
{item.name}
@@ -170,6 +178,11 @@ export default function AccountPage() {
{userProfile.email}
+ {systemFeatures.enable_change_email && ( +
setShowUpdateEmail(true)}> + {t('common.operation.change')} +
+ )} { @@ -190,7 +203,7 @@ export default function AccountPage() { {!!apps.length && ( ({ key: app.id, name: app.name }))} + items={apps.map(app => ({ ...app, key: app.id, name: app.name }))} renderItem={renderAppItem} wrapperClassName='mt-2' /> @@ -202,7 +215,7 @@ export default function AccountPage() { setEditNameModalVisible(false)} - className={s.modal} + className='!w-[420px] !p-6' >
{t('common.account.editName')}
{t('common.account.name')}
@@ -231,7 +244,7 @@ export default function AccountPage() { setEditPasswordModalVisible(false) resetPasswordForm() }} - className={s.modal} + className='!w-[420px] !p-6' >
{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}
{userProfile.is_password_set && ( @@ -316,6 +329,13 @@ export default function AccountPage() { /> ) } + {showUpdateEmail && ( + setShowUpdateEmail(false)} + email={userProfile.email} + /> + )} ) } diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 7bb37c1cc9..0ea80b8368 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -233,6 +233,28 @@ const translation = { editWorkspaceInfo: 'Edit Workspace Info', workspaceName: 'Workspace Name', workspaceIcon: 'Workspace Icon', + changeEmail: { + title: 'Change Email', + verifyEmail: 'Verify your current email', + newEmail: 'Set up a new email address', + verifyNew: 'Verify your new email', + authTip: 'Once your email is changed, Google or GitHub accounts linked to your old email will no longer be able to log in to this account.', + content1: 'If you continue, we\'ll send a verification code to {{email}} for re-authentication.', + content2: 'Your current email is {{email}} . Verification code has been sent to this email address.', + content3: 'Enter a new email and we will send you a verification code.', + content4: 'We just sent you a temporary verification code to {{email}}.', + codeLabel: 'Verification code', + codePlaceholder: 'Paste the 6-digit code', + emailLabel: 'New email', + emailPlaceholder: 'Enter new email', + existingEmail: 'A user with this email already exists.', + sendVerifyCode: 'Send verification code', + continue: 'Continue', + changeTo: 'Change to {{email}}', + resendTip: 'Didn\'t receive a code?', + resendCount: 'Resend in {{count}}s', + resend: 'Resend', + }, }, members: { team: 'Team', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 61407652cf..dd37bb9325 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -233,6 +233,28 @@ const translation = { editWorkspaceInfo: '编辑工作空间信息', workspaceName: '工作空间名称', workspaceIcon: '工作空间图标', + changeEmail: { + title: '更改邮箱', + verifyEmail: '验证当前邮箱', + newEmail: '设置新邮箱', + verifyNew: '验证新邮箱', + authTip: '一旦您的电子邮件地址更改,链接到您旧电子邮件地址的 Google 或 GitHub 帐户将无法再登录该帐户。', + content1: '如果您继续,我们将向 {{email}} 发送验证码以进行重新验证。', + content2: '你的当前邮箱是 {{email}} 。验证码已发送至该邮箱。', + content3: '输入新的电子邮件,我们将向您发送验证码。', + content4: '我们已将验证码发送至 {{email}} 。', + codeLabel: '验证码', + codePlaceholder: '输入 6 位数字验证码', + emailLabel: '新邮箱', + emailPlaceholder: '输入新邮箱', + existingEmail: '该邮箱已存在', + sendVerifyCode: '发送验证码', + continue: '继续', + changeTo: '更改为 {{email}}', + resendTip: '没有收到验证码?', + resendCount: '请在 {{count}} 秒后重新发送', + resend: '重新发送', + }, }, members: { team: '团队', diff --git a/web/service/common.ts b/web/service/common.ts index cc7b71bee5..aa8b7ff2d4 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -385,3 +385,15 @@ export const submitDeleteAccountFeedback = (body: { feedback: string; email: str export const getDocDownloadUrl = (doc_name: string) => get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true }) + +export const sendVerifyCode = (body: { email: string; phase: string; token?: string }) => + post('/account/change-email', { body }) + +export const verifyEmail = (body: { email: string; code: string; token: string }) => + post('/account/change-email/validity', { body }) + +export const resetEmail = (body: { new_email: string; token: string }) => + post('/account/change-email/reset', { body }) + +export const checkEmailExisted = (body: { email: string }) => + post('/account/change-email/check-email-unique', { body }) diff --git a/web/types/feature.ts b/web/types/feature.ts index 5787c2661f..088317d7fd 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -35,6 +35,7 @@ export type SystemFeatures = { sso_enforced_for_web: boolean sso_enforced_for_web_protocol: SSOProtocol | '' enable_marketplace: boolean + enable_change_email: boolean enable_email_code_login: boolean enable_email_password_login: boolean enable_social_oauth_login: boolean @@ -70,6 +71,7 @@ export const defaultSystemFeatures: SystemFeatures = { sso_enforced_for_web: false, sso_enforced_for_web_protocol: '', enable_marketplace: false, + enable_change_email: false, enable_email_code_login: false, enable_email_password_login: false, enable_social_oauth_login: false, From 28fca88b359b24a0be62778b296dcb812e90877b Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 15 Jul 2025 13:45:03 +0800 Subject: [PATCH 2/2] Temp feat owner transfer enterprise frontend (#22400) Co-authored-by: JzoNg --- web/i18n/zh-Hans/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index dd37bb9325..a3a92b92d3 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -299,7 +299,7 @@ const translation = { transferOwnership: '转移所有权', transferModal: { title: '转移工作空间所有权', - warning: '您即将转让“{{workspace}”的所有权。此操作立即生效,且无法撤消。', + warning: '您即将转让“{{workspace}}”的所有权。此操作立即生效,且无法撤消。', warningTip: '您将成为管理员成员,新所有者将拥有完全控制权。', sendTip: '如果您继续,我们将向 {{email}} 发送验证码以进行重新验证。', verifyEmail: '验证你的邮箱',