From 58f67acf0748b5239e49cefe3a3221266aea5e6e Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 11 Jul 2025 15:34:28 +0800 Subject: [PATCH] ownership transfer styling --- .../account-setting/members-page/index.tsx | 23 ++- .../operation/transfer-ownership.tsx | 54 ++++++ .../transfer-ownership-modal/index.tsx | 170 ++++++++++++++++++ .../member-selector.tsx | 112 ++++++++++++ web/i18n/en-US/common.ts | 20 +++ web/i18n/zh-Hans/common.ts | 20 +++ 6 files changed, 394 insertions(+), 5 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/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index df7aae6b62..fb5bbbd8ad 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' @@ -56,6 +58,7 @@ const MembersPage = () => { 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,15 @@ 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' && ( + setShowTransferOwnershipModal(true)}> + )} + {isCurrentWorkspaceOwner && account.role !== 'owner' && ( + + )} + {!isCurrentWorkspaceOwner && ( +
{RoleMap[account.role] || RoleMap.normal}
+ )}
)) @@ -173,6 +180,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..ddf64481a6 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -0,0 +1,170 @@ +import React, { useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { RiCloseLine } from '@remixicon/react' +import { useAppContext } from '@/context/app-context' +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 { 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 { currentWorkspace, userProfile } = useAppContext() + const [step, setStep] = useState(STEP.start) + const [code, setCode] = useState('') + const [time, setTime] = useState(0) + const [newOwner, setNewOwner] = useState('') + + const startCount = () => { + setTime(60) + const timer = setInterval(() => { + setTime((prev) => { + if (prev <= 0) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + } + 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/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: '登录方式',