Compare commits

...

11 Commits

Author SHA1 Message Date
JzoNg e80ec701ac fix email reset params 10 months ago
JzoNg 17faf68fb8 fix i18n 10 months ago
JzoNg f5355b4e55 fix i18n 10 months ago
JzoNg 456ec7908d add ja-jp i18n 10 months ago
JzoNg 4ccfd15dff fix: new email value 10 months ago
JzoNg eec04ac5bd fix style of new email input 10 months ago
JzoNg 22232fd91d fix: api address of verify email 10 months ago
JzoNg 5afadb1540 add judgement of system feature 10 months ago
JzoNg c6bf5d1864 reset email 10 months ago
JzoNg fe6e3ac41e add api of email change 10 months ago
JzoNg 5fbc47b329 change email styling 10 months ago

@ -0,0 +1,371 @@
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>(STEP.start)
const [code, setCode] = useState<string>('')
const [mail, setMail] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
const [newEmailExited, setNewEmailExited] = useState<boolean>(false)
const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(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?: (data?: any) => void) => {
try {
const res = await verifyEmail({
email,
code,
token,
})
if (res.is_valid) {
setStepToken(res.token)
callback?.(res.token)
}
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) => {
setIsCheckingEmail(true)
try {
await checkEmailExisted({
email,
})
setNewEmailExited(false)
}
catch {
setNewEmailExited(true)
}
finally {
setIsCheckingEmail(false)
}
}
const handleNewEmailValueChange = (mailAddress: string) => {
setMail(mailAddress)
setNewEmailExited(false)
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 (lastToken: string) => {
try {
await resetEmail({
new_email: mail,
token: lastToken,
})
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 (
<Modal
isShow={show}
onClose={noop}
className='!w-[420px] !p-6'
>
<div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
{step === STEP.start && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.title')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-medium text-text-warning'>{t('common.account.changeEmail.authTip')}</div>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content1"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'></div>
<div className='space-y-2'>
<Button
className='!w-full'
variant='primary'
onClick={sendCodeToOriginEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verifyOrigin && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content2"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
onClick={handleVerifyOriginEmail}
>
{t('common.account.changeEmail.continue')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.newEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>{t('common.account.changeEmail.content3')}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.emailLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.emailPlaceholder')}
value={mail}
onChange={e => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited}
/>
{newEmailExited && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.existingEmail')}</div>
)}
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={!mail || newEmailExited || isCheckingEmail || !isValidEmail(mail)}
className='!w-full'
variant='primary'
onClick={sendCodeToNewEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verifyNew && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyNew')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content4"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
onClick={submitNewEmail}
>
{t('common.account.changeEmail.changeTo', { email: mail })}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
)}
</div>
</>
)}
</Modal>
)
}
export default EmailChangeModal

@ -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;
}

@ -6,7 +6,6 @@ import {
} from '@remixicon/react' } from '@remixicon/react'
import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import DeleteAccount from '../delete-account' import DeleteAccount from '../delete-account'
import s from './index.module.css'
import AvatarWithEdit from './AvatarWithEdit' import AvatarWithEdit from './AvatarWithEdit'
import Collapse from '@/app/components/header/account-setting/collapse' import Collapse from '@/app/components/header/account-setting/collapse'
import type { IItem } 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 Input from '@/app/components/base/input'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import EmailChangeModal from './email-change-modal'
const titleClassName = ` const titleClassName = `
system-sm-semibold text-text-secondary system-sm-semibold text-text-secondary
@ -48,6 +48,7 @@ export default function AccountPage() {
const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [showUpdateEmail, setShowUpdateEmail] = useState(false)
const handleEditName = () => { const handleEditName = () => {
setEditNameModalVisible(true) setEditNameModalVisible(true)
@ -123,10 +124,17 @@ export default function AccountPage() {
} }
const renderAppItem = (item: IItem) => { const renderAppItem = (item: IItem) => {
const { icon, icon_background, icon_type, icon_url } = item as any
return ( return (
<div className='flex px-3 py-1'> <div className='flex px-3 py-1'>
<div className='mr-3'> <div className='mr-3'>
<AppIcon size='tiny' /> <AppIcon
size='tiny'
iconType={icon_type}
icon={icon}
background={icon_background}
imageUrl={icon_url}
/>
</div> </div>
<div className='system-sm-medium mt-[3px] text-text-secondary'>{item.name}</div> <div className='system-sm-medium mt-[3px] text-text-secondary'>{item.name}</div>
</div> </div>
@ -170,6 +178,11 @@ export default function AccountPage() {
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '> <div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.email}</span> <span className='pl-1'>{userProfile.email}</span>
</div> </div>
{systemFeatures.enable_change_email && (
<div className='system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text' onClick={() => setShowUpdateEmail(true)}>
{t('common.operation.change')}
</div>
)}
</div> </div>
</div> </div>
{ {
@ -190,7 +203,7 @@ export default function AccountPage() {
{!!apps.length && ( {!!apps.length && (
<Collapse <Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`} title={`${t('common.account.showAppLength', { length: apps.length })}`}
items={apps.map(app => ({ key: app.id, name: app.name }))} items={apps.map(app => ({ ...app, key: app.id, name: app.name }))}
renderItem={renderAppItem} renderItem={renderAppItem}
wrapperClassName='mt-2' wrapperClassName='mt-2'
/> />
@ -202,7 +215,7 @@ export default function AccountPage() {
<Modal <Modal
isShow isShow
onClose={() => setEditNameModalVisible(false)} onClose={() => setEditNameModalVisible(false)}
className={s.modal} className='!w-[420px] !p-6'
> >
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{t('common.account.editName')}</div> <div className='title-2xl-semi-bold mb-6 text-text-primary'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div> <div className={titleClassName}>{t('common.account.name')}</div>
@ -231,7 +244,7 @@ export default function AccountPage() {
setEditPasswordModalVisible(false) setEditPasswordModalVisible(false)
resetPasswordForm() resetPasswordForm()
}} }}
className={s.modal} className='!w-[420px] !p-6'
> >
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div> <div className='title-2xl-semi-bold mb-6 text-text-primary'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
{userProfile.is_password_set && ( {userProfile.is_password_set && (
@ -316,6 +329,13 @@ export default function AccountPage() {
/> />
) )
} }
{showUpdateEmail && (
<EmailChangeModal
show={showUpdateEmail}
onClose={() => setShowUpdateEmail(false)}
email={userProfile.email}
/>
)}
</> </>
) )
} }

@ -233,6 +233,28 @@ const translation = {
editWorkspaceInfo: 'Edit Workspace Info', editWorkspaceInfo: 'Edit Workspace Info',
workspaceName: 'Workspace Name', workspaceName: 'Workspace Name',
workspaceIcon: 'Workspace Icon', 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>{{email}}</email> for re-authentication.',
content2: 'Your current email is <email>{{email}}</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>{{email}}</email>.',
codeLabel: 'Verification code',
codePlaceholder: 'Paste the 6-digit code',
emailLabel: 'New email',
emailPlaceholder: 'Enter a 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: { members: {
team: 'Team', team: 'Team',

@ -234,6 +234,28 @@ const translation = {
editWorkspaceInfo: 'ワークスペース情報を編集', editWorkspaceInfo: 'ワークスペース情報を編集',
workspaceName: 'ワークスペース名', workspaceName: 'ワークスペース名',
workspaceIcon: 'ワークスペースアイコン', workspaceIcon: 'ワークスペースアイコン',
changeEmail: {
title: 'メールアドレスを変更',
verifyEmail: '現在のメールアドレスを確認してください',
newEmail: '新しいメールアドレスを設定する',
verifyNew: '新しいメールアドレスを確認してください',
authTip: 'メールアドレスが変更されると、旧メールアドレスにリンクされている Google または GitHub アカウントは、このアカウントにログインできなくなります。',
content1: '変更を続ける場合、<email>{{email}}</email> に認証用の確認コードをお送りします。',
content2: '現在のメールアドレスは <email>{{email}}</email> です。認証コードはこのメールアドレスに送信されました。',
content3: '新しいメールアドレスを入力すると、確認コードが送信されます。',
content4: '一時確認コードを <email>{{email}}</email> に送信しました。',
codeLabel: 'コード',
codePlaceholder: 'コードを入力してください',
emailLabel: '新しいメール',
emailPlaceholder: '新しいメールを入力してください',
existingEmail: 'このメールアドレスのユーザーは既に存在します',
sendVerifyCode: '確認コードを送信',
continue: '続行',
changeTo: '{{email}} に変更',
resendTip: 'コードが届きませんか?',
resendCount: '{{count}} 秒後に再送信',
resend: '再送信',
},
}, },
members: { members: {
team: 'チーム', team: 'チーム',

@ -233,6 +233,28 @@ const translation = {
editWorkspaceInfo: '编辑工作空间信息', editWorkspaceInfo: '编辑工作空间信息',
workspaceName: '工作空间名称', workspaceName: '工作空间名称',
workspaceIcon: '工作空间图标', workspaceIcon: '工作空间图标',
changeEmail: {
title: '更改邮箱',
verifyEmail: '验证当前邮箱',
newEmail: '设置新邮箱',
verifyNew: '验证新邮箱',
authTip: '一旦您的电子邮件地址更改,链接到您旧电子邮件地址的 Google 或 GitHub 帐户将无法再登录该帐户。',
content1: '如果您继续,我们将向 <email>{{email}}</email> 发送验证码以进行重新验证。',
content2: '你的当前邮箱是 <email>{{email}}</email> 。验证码已发送至该邮箱。',
content3: '输入新的邮箱,我们将向您发送验证码。',
content4: '我们已将验证码发送至 <email>{{email}}</email> 。',
codeLabel: '验证码',
codePlaceholder: '输入 6 位数字验证码',
emailLabel: '新邮箱',
emailPlaceholder: '输入新邮箱',
existingEmail: '该邮箱已存在',
sendVerifyCode: '发送验证码',
continue: '继续',
changeTo: '更改为 {{email}}',
resendTip: '没有收到验证码?',
resendCount: '请在 {{count}} 秒后重新发送',
resend: '重新发送',
},
}, },
members: { members: {
team: '团队', team: '团队',

@ -376,3 +376,15 @@ export const submitDeleteAccountFeedback = (body: { feedback: string; email: str
export const getDocDownloadUrl = (doc_name: string) => export const getDocDownloadUrl = (doc_name: string) =>
get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true }) get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true })
export const sendVerifyCode = (body: { email: string; phase: string; token?: string }) =>
post<CommonResponse & { data: string }>('/account/change-email', { body })
export const verifyEmail = (body: { email: string; code: string; token: string }) =>
post<CommonResponse & { is_valid: boolean; email: string; token: string }>('/account/change-email/validity', { body })
export const resetEmail = (body: { new_email: string; token: string }) =>
post<CommonResponse>('/account/change-email/reset', { body })
export const checkEmailExisted = (body: { email: string }) =>
post<CommonResponse>('/account/change-email/check-email-unique', { body }, { silent: true })

@ -35,6 +35,7 @@ export type SystemFeatures = {
sso_enforced_for_web: boolean sso_enforced_for_web: boolean
sso_enforced_for_web_protocol: SSOProtocol | '' sso_enforced_for_web_protocol: SSOProtocol | ''
enable_marketplace: boolean enable_marketplace: boolean
enable_change_email: boolean
enable_email_code_login: boolean enable_email_code_login: boolean
enable_email_password_login: boolean enable_email_password_login: boolean
enable_social_oauth_login: boolean enable_social_oauth_login: boolean
@ -70,6 +71,7 @@ export const defaultSystemFeatures: SystemFeatures = {
sso_enforced_for_web: false, sso_enforced_for_web: false,
sso_enforced_for_web_protocol: '', sso_enforced_for_web_protocol: '',
enable_marketplace: false, enable_marketplace: false,
enable_change_email: false,
enable_email_code_login: false, enable_email_code_login: false,
enable_email_password_login: false, enable_email_password_login: false,
enable_social_oauth_login: false, enable_social_oauth_login: false,

Loading…
Cancel
Save