Feat/change user email (#22213)

Co-authored-by: NFish <douxc512@gmail.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: Garfield Dai <dai.hai@foxmail.com>
This commit is contained in:
zyssyz123 2025-07-17 10:55:59 +08:00 • committed by GitHub
commit a4f421028c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4726 additions and 327 deletions

View file

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

View file

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

View file

@ -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'
import { validPassword } from '@/config'
const titleClassName = `
@ -47,6 +47,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)
@ -122,10 +123,17 @@ export default function AccountPage() {
}
const renderAppItem = (item: IItem) => {
const { icon, icon_background, icon_type, icon_url } = item as any
return (
<div className='flex px-3 py-1'>
<div className='mr-3'>
<AppIcon size='tiny' />
<AppIcon
size='tiny'
iconType={icon_type}
icon={icon}
background={icon_background}
imageUrl={icon_url}
/>
</div>
<div className='system-sm-medium mt-[3px] text-text-secondary'>{item.name}</div>
</div>
@ -169,6 +177,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 '>
<span className='pl-1'>{userProfile.email}</span>
</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>
{
@ -189,7 +202,7 @@ export default function AccountPage() {
{!!apps.length && (
<Collapse
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}
wrapperClassName='mt-2'
/>
@ -201,7 +214,7 @@ export default function AccountPage() {
<Modal
isShow
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={titleClassName}>{t('common.account.name')}</div>
@ -230,7 +243,7 @@ export default function AccountPage() {
setEditPasswordModalVisible(false)
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>
{userProfile.is_password_set && (
@ -315,6 +328,13 @@ export default function AccountPage() {
/>
)
}
{showUpdateEmail && (
<EmailChangeModal
show={showUpdateEmail}
onClose={() => setShowUpdateEmail(false)}
email={userProfile.email}
/>
)}
</>
)
}

View file

@ -99,7 +99,8 @@ export type CurrentPlanInfoBackend = {
workspace_members: {
size: number
limit: number
}
},
is_allow_transfer_workspace: boolean
}
export type SubscriptionItem = {

View file

@ -10,7 +10,9 @@ import { useTranslation } from 'react-i18next'
import InviteModal from './invite-modal'
import InvitedModal from './invited-modal'
import EditWorkspaceModal from './edit-workspace-modal'
import TransferOwnershipModal from './transfer-ownership-modal'
import Operation from './operation'
import TransferOwnership from './operation/transfer-ownership'
import { fetchMembers } from '@/service/common'
import I18n from '@/context/i18n'
import { useAppContext } from '@/context/app-context'
@ -52,10 +54,11 @@ const MembersPage = () => {
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
const accounts = data?.accounts || []
const { plan, enableBilling } = useProviderContext()
const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext()
const isNotUnlimitedMemberPlan = enableBilling && plan.type !== Plan.team && plan.type !== Plan.enterprise
const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
return (
<>
@ -132,11 +135,18 @@ const MembersPage = () => {
</div>
<div className='system-sm-regular flex w-[104px] shrink-0 items-center py-2 text-text-secondary'>{dayjs(Number((account.last_active_at || account.created_at)) * 1000).locale(locale === 'zh-Hans' ? 'zh-cn' : 'en').fromNow()}</div>
<div className='flex w-[96px] shrink-0 items-center'>
{
isCurrentWorkspaceOwner && account.role !== 'owner'
? <Operation member={account} operatorRole={currentWorkspace.role} onOperate={mutate} />
: <div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
}
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
)}
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
<div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
)}
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={mutate} />
)}
{!isCurrentWorkspaceOwner && (
<div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
)}
</div>
</div>
))
@ -172,6 +182,12 @@ const MembersPage = () => {
/>
)
}
{showTransferOwnershipModal && (
<TransferOwnershipModal
show={showTransferOwnershipModal}
onClose={() => setShowTransferOwnershipModal(false)}
/>
)}
</>
)
}

View file

@ -0,0 +1,54 @@
'use client'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import cn from '@/utils/classnames'
type Props = {
onOperate: () => void
}
const TransferOwnership = ({ onOperate }: Props) => {
const { t } = useTranslation()
return (
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{t('common.members.owner')}
<RiArrowDownSLine className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className={cn('absolute right-0 top-[52px] z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}
>
<div className="p-1">
<MenuItem>
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={onOperate}>
<div className='system-md-regular whitespace-nowrap text-text-secondary'>{t('common.members.transferOwnership')}</div>
</div>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
)
}
export default TransferOwnership

View file

@ -0,0 +1,253 @@
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiCloseLine } from '@remixicon/react'
import { useAppContext } from '@/context/app-context'
import { ToastContext } from '@/app/components/base/toast'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import MemberSelector from './member-selector'
import {
ownershipTransfer,
sendOwnerEmail,
verifyOwnerEmail,
} from '@/service/common'
import { noop } from 'lodash-es'
type Props = {
show: boolean
onClose: () => void
}
enum STEP {
start = 'start',
verify = 'verify',
transfer = 'transfer',
}
const TransferOwnershipModal = ({ onClose, show }: Props) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { currentWorkspace, userProfile } = useAppContext()
const [step, setStep] = useState<STEP>(STEP.start)
const [code, setCode] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
const [newOwner, setNewOwner] = useState<string>('')
const [isTransfer, setIsTransfer] = 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 () => {
try {
const res = await sendOwnerEmail({})
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 (code: string, token: string, callback?: () => void) => {
try {
const res = await verifyOwnerEmail({
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()
setStep(STEP.verify)
}
const handleVerifyOriginEmail = async () => {
await verifyEmailAddress(code, stepToken, () => setStep(STEP.transfer))
setCode('')
}
const handleTransfer = async () => {
setIsTransfer(true)
try {
await ownershipTransfer(
newOwner,
{
token: stepToken,
},
)
globalThis.location.reload()
}
catch (error) {
notify({
type: 'error',
message: `Error ownership transfer: ${error ? (error as any).message : ''}`,
})
}
finally {
setIsTransfer(false)
}
}
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.members.transferModal.title')}</div>
<div className='space-y-1 pb-2 pt-1'>
<div className='body-md-medium text-text-destructive'>{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}</div>
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.warningTip')}</div>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.members.transferModal.sendTip"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email: userProfile.email }}
/>
</div>
</div>
<div className='pt-3'></div>
<div className='space-y-2'>
<Button
className='!w-full'
variant='primary'
onClick={sendCodeToOriginEmail}
>
{t('common.members.transferModal.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verify && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.verifyEmail')}</div>
<div className='pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.members.transferModal.verifyContent"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email: userProfile.email }}
/>
</div>
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.verifyContent2')}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.members.transferModal.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.members.transferModal.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.members.transferModal.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.members.transferModal.resendTip')}</span>
{time > 0 && (
<span>{t('common.members.transferModal.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.members.transferModal.resend')}</span>
)}
</div>
</>
)}
{step === STEP.transfer && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.title')}</div>
<div className='space-y-1 pb-2 pt-1'>
<div className='body-md-medium text-text-destructive'>{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}</div>
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.warningTip')}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.members.transferModal.transferLabel')}</div>
<MemberSelector
exclude={[userProfile.id]}
value={newOwner}
onSelect={setNewOwner}
/>
</div>
<div className='mt-4 space-y-2'>
<Button
disabled={!newOwner || isTransfer}
className='!w-full'
variant='warning'
onClick={handleTransfer}
>
{t('common.members.transferModal.transfer')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
</Modal>
)
}
export default TransferOwnershipModal

View file

@ -0,0 +1,112 @@
'use client'
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Avatar from '@/app/components/base/avatar'
import Input from '@/app/components/base/input'
import { fetchMembers } from '@/service/common'
import cn from '@/utils/classnames'
type Props = {
value?: any
onSelect: (value: any) => void
exclude?: string[]
}
const MemberSelector: FC<Props> = ({
value,
onSelect,
exclude = [],
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const { data } = useSWR(
{
url: '/workspaces/current/members',
params: {},
},
fetchMembers,
)
const currentValue = useMemo(() => {
if (!data?.accounts) return null
const accounts = data.accounts || []
if (!value) return null
return accounts.find(account => account.id === value)
}, [data, value])
const filteredList = useMemo(() => {
if (!data?.accounts) return []
const accounts = data.accounts
if (!searchValue) return accounts.filter(account => !exclude.includes(account.id))
return accounts.filter((account) => {
const name = account.name || ''
const email = account.email || ''
return name.toLowerCase().includes(searchValue.toLowerCase())
|| email.toLowerCase().includes(searchValue.toLowerCase())
}).filter(account => !exclude.includes(account.id))
}, [data, searchValue, exclude])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom'
offset={4}
>
<PortalToFollowElemTrigger
className='w-full'
onClick={() => setOpen(v => !v)}
>
<div className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}>
{!currentValue && (
<div className='system-sm-regular grow p-1 text-components-input-text-placeholder'>{t('common.members.transferModal.transferPlaceholder')}</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size={24} name={currentValue.name} />
<div className='system-sm-medium grow truncate text-text-secondary'>{currentValue.name}</div>
<div className='system-xs-regular text-text-quaternary'>{currentValue.email}</div>
</>
)}
<RiArrowDownSLine className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-2 pb-1'>
<Input
showLeftIcon
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
/>
</div>
<div className='p-1'>
{filteredList.map(account => (
<div
key={account.id}
className='flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover'
onClick={() => {
onSelect(account.id)
setOpen(false)
}}
>
<Avatar avatar={account.avatar_url} size={24} name={account.name} />
<div className='system-sm-medium grow truncate text-text-secondary'>{account.name}</div>
<div className='system-xs-regular text-text-quaternary'>{account.email}</div>
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default MemberSelector

View file

@ -56,6 +56,7 @@ type ProviderContextState = {
}
},
refreshLicenseLimit: () => void
isAllowTransferWorkspace: boolean
}
const ProviderContext = createContext<ProviderContextState>({
modelProviders: [],
@ -97,6 +98,7 @@ const ProviderContext = createContext<ProviderContextState>({
},
},
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
})
export const useProviderContext = () => useContext(ProviderContext)
@ -134,6 +136,7 @@ export const ProviderContextProvider = ({
const [enableEducationPlan, setEnableEducationPlan] = useState(false)
const [isEducationWorkspace, setIsEducationWorkspace] = useState(false)
const { data: isEducationAccount } = useEducationStatus(!enableEducationPlan)
const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false)
const fetchPlan = async () => {
try {
@ -162,6 +165,8 @@ export const ProviderContextProvider = ({
setWebappCopyrightEnabled(true)
if (data.workspace_members)
setLicenseLimit({ workspace_members: data.workspace_members })
if (data.is_allow_transfer_workspace)
setIsAllowTransferWorkspace(data.is_allow_transfer_workspace)
}
catch (error) {
console.error('Failed to fetch plan info:', error)
@ -222,6 +227,7 @@ export const ProviderContextProvider = ({
webappCopyrightEnabled,
licenseLimit,
refreshLicenseLimit: fetchPlan,
isAllowTransferWorkspace,
}}>
{children}
</ProviderContext.Provider>

View file

@ -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>{{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: {
team: 'Team',
@ -274,6 +296,26 @@ const translation = {
disInvite: 'Cancel the invitation',
deleteMember: 'Delete Member',
you: '(You)',
transferOwnership: 'Transfer Ownership',
transferModal: {
title: 'Transfer workspace ownership',
warning: 'You\'re about to transfer ownership of “{{workspace}}”. This takes effect immediately and can\'t be undone.',
warningTip: 'You\'ll become an admin member, and the new owner will have full control.',
sendTip: 'If you continue, we\'ll send a verification code to <email>{{email}}</email> for re-authentication.',
verifyEmail: 'Verify your current email',
verifyContent: 'Your current email is <email>{{email}}</email>.',
verifyContent2: 'We\'ll send a temporary verification code to this email for re-authentication.',
codeLabel: 'Verification code',
codePlaceholder: 'Paste the 6-digit code',
resendTip: 'Didn\'t receive a code?',
resendCount: 'Resend in {{count}}s',
resend: 'Resend',
transferLabel: 'Transfer workspace ownership to',
transferPlaceholder: 'Select a workspace member…',
sendVerifyCode: 'Send verification code',
continue: 'Continue',
transfer: 'Transfer workspace ownership',
},
},
integrations: {
connected: 'Connected',

View file

@ -234,6 +234,28 @@ const translation = {
editWorkspaceInfo: 'ワークスペース情報を編集',
workspaceName: 'ワークスペース名',
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: {
team: 'チーム',
@ -275,6 +297,26 @@ const translation = {
disInvite: '招待をキャンセル',
deleteMember: 'メンバーを削除',
you: '(あなた)',
transferOwnership: '所有権の移転',
transferModal: {
title: 'ワークスペースの所有権を移する',
warning: '「{{workspace}}」の所有権を移しようとしています。この操作は即時に有効となり、元に戻すことはできません。',
warningTip: 'あなたは管理者メンバーになり、新しいオーナーがすべての権限を持つことになります。',
sendTip: '続行する場合は、本人確認のため <email>{{email}}</email> に認証コードを送信します。',
verifyEmail: '現在のメールアドレスを確認',
verifyContent: '現在のメールアドレスは <email>{{email}}</email>。',
verifyContent2: 'このメールアドレスに一時的な認証コードを送信し、再認証を行います。',
codeLabel: '認証コード',
codePlaceholder: '6 桁のコードを入力してください',
resendTip: '認証コードを受け取れない場合は、',
resendCount: '{{count}} 秒後に再送信',
resend: '認証コードを再送信',
transferLabel: 'ワークスペースの所有権を転移する相手は',
transferPlaceholder: 'メールアドレスを入力してください',
sendVerifyCode: '認証コードを送信',
continue: '続行する',
transfer: 'ワークスペースの所有権を移する',
},
},
integrations: {
connected: '接続済み',

View file

@ -233,6 +233,28 @@ const translation = {
editWorkspaceInfo: '编辑工作空间信息',
workspaceName: '工作空间名称',
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: {
team: '团队',
@ -274,6 +296,26 @@ const translation = {
builderTip: '可以构建和编辑自己的应用程序',
setBuilder: 'Set as builder(设置为构建器)',
builder: '构建器',
transferOwnership: '转移所有权',
transferModal: {
title: '转移工作空间所有权',
warning: '您即将转移 “{{workspace}}”的所有权。该操作将立即生效,且无法撤销。',
warningTip: '您将成为管理员成员,新所有者将拥有完全控制权。',
sendTip: '如果您继续,我们将向 <email>{{email}}</email> 发送验证码以进行身份认证。',
verifyEmail: '验证您当前的邮箱',
verifyContent: '您当前的邮箱是 <email>{{email}}</email>。',
verifyContent2: '我们将向该邮箱发送临时验证码以完成身份验证。',
codeLabel: '验证码',
codePlaceholder: '输入 6 位数字验证码',
resendTip: '没有收到验证码?',
resendCount: '请在 {{count}} 秒后重新发送',
resend: '重新发送',
transferLabel: '新所有者',
transferPlaceholder: '选择一个成员',
sendVerifyCode: '发送验证码',
continue: '继续',
transfer: '转移工作空间所有权',
},
},
integrations: {
connected: '登录方式',

View file

@ -131,6 +131,15 @@ export const deleteMemberOrCancelInvitation: Fetcher<CommonResponse, { url: stri
return del<CommonResponse>(url)
}
export const sendOwnerEmail = (body: { language?: string }) =>
post<CommonResponse & { data: string }>('/workspaces/current/members/send-owner-transfer-confirm-email', { body })
export const verifyOwnerEmail = (body: { code: string; token: string }) =>
post<CommonResponse & { is_valid: boolean; email: string; token: string }>('/workspaces/current/members/owner-transfer-check', { body })
export const ownershipTransfer = (memberID: string, body: { token: string }) =>
post<CommonResponse & { is_valid: boolean; email: string; token: string }>(`/workspaces/current/members/${memberID}/owner-transfer`, { body })
export const fetchFilePreview: Fetcher<{ content: string }, { fileID: string }> = ({ fileID }) => {
return get<{ content: string }>(`/files/${fileID}/preview`)
}
@ -376,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<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 })

View file

@ -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,