From 9c4f24c6d74702e5f925d233de1ae874469837d4 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 15 Jul 2025 11:45:04 +0800 Subject: [PATCH] Temp feat owner transfer enterprise frontend (#22393) Co-authored-by: JzoNg --- web/app/components/billing/type.ts | 3 +- .../account-setting/members-page/index.tsx | 28 +- .../operation/transfer-ownership.tsx | 54 ++++ .../transfer-ownership-modal/index.tsx | 255 ++++++++++++++++++ .../member-selector.tsx | 112 ++++++++ web/context/provider-context.tsx | 6 + web/i18n/en-US/common.ts | 20 ++ web/i18n/zh-Hans/common.ts | 20 ++ web/service/common.ts | 9 + 9 files changed, 500 insertions(+), 7 deletions(-) create mode 100644 web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx diff --git a/web/app/components/billing/type.ts b/web/app/components/billing/type.ts index 506ef46799..9090cec78a 100644 --- a/web/app/components/billing/type.ts +++ b/web/app/components/billing/type.ts @@ -99,7 +99,8 @@ export type CurrentPlanInfoBackend = { workspace_members: { size: number limit: number - } + }, + is_allow_transfer_workspace: boolean } export type SubscriptionItem = { diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index df7aae6b62..ad04371d4a 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -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([]) 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 ( <> @@ -133,11 +136,18 @@ const MembersPage = () => {
{dayjs(Number((account.last_active_at || account.created_at)) * 1000).locale(locale === 'zh-Hans' ? 'zh-cn' : 'en').fromNow()}
- { - isCurrentWorkspaceOwner && account.role !== 'owner' - ? - :
{RoleMap[account.role] || RoleMap.normal}
- } + {isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && ( + setShowTransferOwnershipModal(true)}> + )} + {isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && ( +
{RoleMap[account.role] || RoleMap.normal}
+ )} + {isCurrentWorkspaceOwner && account.role !== 'owner' && ( + + )} + {!isCurrentWorkspaceOwner && ( +
{RoleMap[account.role] || RoleMap.normal}
+ )}
)) @@ -173,6 +183,12 @@ const MembersPage = () => { /> ) } + {showTransferOwnershipModal && ( + setShowTransferOwnershipModal(false)} + /> + )} ) } diff --git a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx new file mode 100644 index 0000000000..bbf1a0351a --- /dev/null +++ b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx @@ -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 ( + + { + ({ open }) => ( + <> + + {t('common.members.owner')} + + + + +
+ +
+
{t('common.members.transferOwnership')}
+
+
+
+
+
+ + ) + } +
+ ) +} + +export default TransferOwnership diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx new file mode 100644 index 0000000000..1811d799a3 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -0,0 +1,255 @@ +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.start) + const [code, setCode] = useState('') + const [time, setTime] = useState(0) + const [stepToken, setStepToken] = useState('') + const [newOwner, setNewOwner] = useState('') + const [isTransfer, setIsTransfer] = 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 () => { + try { + const res = await sendOwnerEmail({ + language: userProfile.interface_language, + }) + 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 ( + +
+ +
+ {step === STEP.start && ( + <> +
{t('common.members.transferModal.title')}
+
+
{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}
+
{t('common.members.transferModal.warningTip')}
+
+ }} + values={{ email: userProfile.email }} + /> +
+
+
+
+ + +
+ + )} + {step === STEP.verify && ( + <> +
{t('common.members.transferModal.verifyEmail')}
+
+
+ }} + values={{ email: userProfile.email }} + /> +
+
{t('common.members.transferModal.verifyContent2')}
+
+
+
{t('common.members.transferModal.codeLabel')}
+ setCode(e.target.value)} + maxLength={6} + /> +
+
+ + +
+
+ {t('common.members.transferModal.resendTip')} + {time > 0 && ( + {t('common.members.transferModal.resendCount', { count: time })} + )} + {!time && ( + {t('common.members.transferModal.resend')} + )} +
+ + )} + {step === STEP.transfer && ( + <> +
{t('common.members.transferModal.title')}
+
+
{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}
+
{t('common.members.transferModal.warningTip')}
+
+
+
{t('common.members.transferModal.transferLabel')}
+ +
+
+ + +
+ + )} +
+ ) +} + +export default TransferOwnershipModal diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx new file mode 100644 index 0000000000..5c3e69b790 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -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 = ({ + 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 ( + + setOpen(v => !v)} + > +
+ {!currentValue && ( +
{t('common.members.transferModal.transferPlaceholder')}
+ )} + {currentValue && ( + <> + +
{currentValue.name}
+
{currentValue.email}
+ + )} + +
+
+ +
+
+ setSearchValue(e.target.value)} + /> +
+
+ {filteredList.map(account => ( +
{ + onSelect(account.id) + setOpen(false) + }} + > + +
{account.name}
+
{account.email}
+
+ ))} +
+
+
+
+ ) +} +export default MemberSelector diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index aaec900fd8..70917f2cf6 100644 --- a/web/context/provider-context.tsx +++ b/web/context/provider-context.tsx @@ -56,6 +56,7 @@ type ProviderContextState = { } }, refreshLicenseLimit: () => void + isAllowTransferWorkspace: boolean } const ProviderContext = createContext({ modelProviders: [], @@ -97,6 +98,7 @@ const ProviderContext = createContext({ }, }, 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} diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 0823c6129c..7bb37c1cc9 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -274,6 +274,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}} for re-authentication.', + verifyEmail: 'Verify your email', + verifyContent: 'Your current email is {{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', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 39964bb6b0..61407652cf 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -274,6 +274,26 @@ const translation = { builderTip: '可以构建和编辑自己的应用程序', setBuilder: 'Set as builder(设置为构建器)', builder: '构建器', + transferOwnership: '转移所有权', + transferModal: { + title: '转移工作空间所有权', + warning: '您即将转让“{{workspace}”的所有权。此操作立即生效,且无法撤消。', + warningTip: '您将成为管理员成员,新所有者将拥有完全控制权。', + sendTip: '如果您继续,我们将向 {{email}} 发送验证码以进行重新验证。', + verifyEmail: '验证你的邮箱', + verifyContent: '您当前的电子邮件地址是 {{email}}。', + verifyContent2: '我们将向此电子邮件发送临时验证码,以便重新进行身份验证。', + codeLabel: '验证码', + codePlaceholder: '输入 6 位数字验证码', + resendTip: '没有收到验证码?', + resendCount: '请在 {{count}} 秒后重新发送', + resend: '重新发送', + transferLabel: '新所有者', + transferPlaceholder: '选择一个成员', + sendVerifyCode: '发送验证码', + continue: '继续', + transfer: '转移工作空间所有权', + }, }, integrations: { connected: '登录方式', diff --git a/web/service/common.ts b/web/service/common.ts index 700cd4bf51..cc7b71bee5 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -131,6 +131,15 @@ export const deleteMemberOrCancelInvitation: Fetcher(url) } +export const sendOwnerEmail = (body: { language?: string }) => + post('/workspaces/current/members/send-owner-transfer-confirm-email', { body }) + +export const verifyOwnerEmail = (body: { code: string; token: string }) => + post('/workspaces/current/members/owner-transfer-check', { body }) + +export const ownershipTransfer = (memberID: string, body: { token: string }) => + post(`/workspaces/current/members/${memberID}/owner-transfer`, { body }) + export const fetchFilePreview: Fetcher<{ content: string }, { fileID: string }> = ({ fileID }) => { return get<{ content: string }>(`/files/${fileID}/preview`) }