dify
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import cn from '@/utils/classnames'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import s from './index.module.css'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { updateWorkspaceInfo } from '@/service/common'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type IEditWorkspaceModalProps = {
|
||||
onCancel: () => void
|
||||
}
|
||||
const EditWorkspaceModal = ({
|
||||
onCancel,
|
||||
}: IEditWorkspaceModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
|
||||
const [name, setName] = useState<string>(currentWorkspace.name)
|
||||
|
||||
const changeWorkspaceInfo = async (name: string) => {
|
||||
try {
|
||||
await updateWorkspaceInfo({
|
||||
url: '/workspaces/info',
|
||||
body: {
|
||||
name,
|
||||
},
|
||||
})
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
location.assign(`${location.origin}`)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(s.wrap)}>
|
||||
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
|
||||
<div className='mb-2 flex justify-between'>
|
||||
<div className='text-xl font-semibold text-text-primary'>{t('common.account.editWorkspaceInfo')}</div>
|
||||
<RiCloseLine className='h-4 w-4 cursor-pointer text-text-tertiary' onClick={onCancel} />
|
||||
</div>
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-text-primary'>{t('common.account.workspaceName')}</div>
|
||||
<Input
|
||||
className='mb-2'
|
||||
value={name}
|
||||
placeholder={t('common.account.workspaceNamePlaceholder')}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
}}
|
||||
onClear={() => {
|
||||
setName(currentWorkspace.name)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4'>
|
||||
<Button
|
||||
size='large'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size='large'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
changeWorkspaceInfo(name)
|
||||
onCancel()
|
||||
}}
|
||||
disabled={!isCurrentWorkspaceOwner}
|
||||
>
|
||||
{t('common.operation.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default EditWorkspaceModal
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiUserAddLine } from '@remixicon/react'
|
||||
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'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import Button from '@/app/components/base/button'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { RiPencilLine } from '@remixicon/react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
|
||||
const MembersPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const RoleMap = {
|
||||
owner: t('common.members.owner'),
|
||||
admin: t('common.members.admin'),
|
||||
editor: t('common.members.editor'),
|
||||
dataset_operator: t('common.members.datasetOperator'),
|
||||
normal: t('common.members.normal'),
|
||||
}
|
||||
const { locale } = useContext(I18n)
|
||||
|
||||
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
|
||||
const { data, mutate } = useSWR(
|
||||
{
|
||||
url: '/workspaces/current/members',
|
||||
params: {},
|
||||
},
|
||||
fetchMembers,
|
||||
)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false)
|
||||
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
|
||||
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
|
||||
const accounts = data?.accounts || []
|
||||
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 (
|
||||
<>
|
||||
<div className='flex flex-col'>
|
||||
<div className='mb-4 flex items-center gap-3 rounded-xl border-l-[0.5px] border-t-[0.5px] border-divider-subtle bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-3 pr-5'>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[20px]'>
|
||||
<span className='bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold uppercase text-shadow-shadow-1 opacity-90'>{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-md-semibold flex items-center gap-1 text-text-secondary'>
|
||||
<span>{currentWorkspace?.name}</span>
|
||||
{isCurrentWorkspaceOwner && <span>
|
||||
<Tooltip
|
||||
popupContent={t('common.account.editWorkspaceInfo')}
|
||||
>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 hover:bg-black/5'
|
||||
onClick={() => {
|
||||
setEditWorkspaceModalVisible(true)
|
||||
}}
|
||||
>
|
||||
<RiPencilLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</span>}
|
||||
</div>
|
||||
<div className='system-xs-medium mt-1 text-text-tertiary'>
|
||||
{enableBilling && isNotUnlimitedMemberPlan
|
||||
? (
|
||||
<div className='flex space-x-1'>
|
||||
<div>{t('billing.plansCommon.member')}{locale !== LanguagesSupported[1] && accounts.length > 1 && 's'}</div>
|
||||
<div className=''>{accounts.length}</div>
|
||||
<div>/</div>
|
||||
<div>{plan.total.teamMembers === NUM_INFINITE ? t('billing.plansCommon.unlimited') : plan.total.teamMembers}</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='flex space-x-1'>
|
||||
<div>{accounts.length}</div>
|
||||
<div>{t('billing.plansCommon.memberAfter')}{locale !== LanguagesSupported[1] && accounts.length > 1 && 's'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{isMemberFull && (
|
||||
<UpgradeBtn className='mr-2' loc='member-invite' />
|
||||
)}
|
||||
<Button variant='primary' className={cn('shrink-0')} disabled={!isCurrentWorkspaceManager || isMemberFull} onClick={() => setInviteModalVisible(true)}>
|
||||
<RiUserAddLine className='mr-1 h-4 w-4' />
|
||||
{t('common.members.invite')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='overflow-visible lg:overflow-visible'>
|
||||
<div className='flex min-w-[480px] items-center border-b border-divider-regular py-[7px]'>
|
||||
<div className='system-xs-medium-uppercase grow px-3 text-text-tertiary'>{t('common.members.name')}</div>
|
||||
<div className='system-xs-medium-uppercase w-[104px] shrink-0 text-text-tertiary'>{t('common.members.lastActive')}</div>
|
||||
<div className='system-xs-medium-uppercase w-[96px] shrink-0 px-3 text-text-tertiary'>{t('common.members.role')}</div>
|
||||
</div>
|
||||
<div className='relative min-w-[480px]'>
|
||||
{
|
||||
accounts.map(account => (
|
||||
<div key={account.id} className='flex border-b border-divider-subtle'>
|
||||
<div className='flex grow items-center px-3 py-2'>
|
||||
<Avatar avatar={account.avatar_url} size={24} className='mr-2' name={account.name} />
|
||||
<div className=''>
|
||||
<div className='system-sm-medium text-text-secondary'>
|
||||
{account.name}
|
||||
{account.status === 'pending' && <span className='system-xs-medium ml-1 text-text-warning'>{t('common.members.pending')}</span>}
|
||||
{userProfile.email === account.email && <span className='system-xs-regular text-text-tertiary'>{t('common.members.you')}</span>}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{account.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='system-sm-regular flex w-[104px] shrink-0 items-center py-2 text-text-secondary'>{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}</div>
|
||||
<div className='flex w-[96px] shrink-0 items-center'>
|
||||
{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>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
inviteModalVisible && (
|
||||
<InviteModal
|
||||
isEmailSetup={systemFeatures.is_email_setup}
|
||||
onCancel={() => setInviteModalVisible(false)}
|
||||
onSend={(invitationResults) => {
|
||||
setInvitedModalVisible(true)
|
||||
setInvitationResults(invitationResults)
|
||||
mutate()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
invitedModalVisible && (
|
||||
<InvitedModal
|
||||
invitationResults={invitationResults}
|
||||
onCancel={() => setInvitedModalVisible(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
editWorkspaceModalVisible && (
|
||||
<EditWorkspaceModal
|
||||
onCancel={() => setEditWorkspaceModalVisible(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{showTransferOwnershipModal && (
|
||||
<TransferOwnershipModal
|
||||
show={showTransferOwnershipModal}
|
||||
onClose={() => setShowTransferOwnershipModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MembersPage
|
||||
@@ -0,0 +1,12 @@
|
||||
.modal {
|
||||
padding: 24px 32px !important;
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.emailsInput {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
.emailBackground {
|
||||
background-color: white !important;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactMultiEmail } from 'react-multi-email'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
import RoleSelector from './role-selector'
|
||||
import s from './index.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import { emailRegex } from '@/config'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import I18n from '@/context/i18n'
|
||||
import 'react-multi-email/dist/style.css'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { useBoolean } from 'ahooks'
|
||||
|
||||
type IInviteModalProps = {
|
||||
isEmailSetup: boolean
|
||||
onCancel: () => void
|
||||
onSend: (invitationResults: InvitationResult[]) => void
|
||||
}
|
||||
|
||||
const InviteModal = ({
|
||||
isEmailSetup,
|
||||
onCancel,
|
||||
onSend,
|
||||
}: IInviteModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
|
||||
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
|
||||
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
|
||||
useEffect(() => {
|
||||
const limited = licenseLimit.workspace_members.limit > 0
|
||||
const used = emails.length + licenseLimit.workspace_members.size
|
||||
setIsLimited(limited)
|
||||
setUsedSize(used)
|
||||
setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit))
|
||||
}, [licenseLimit, emails])
|
||||
|
||||
const { locale } = useContext(I18n)
|
||||
const [role, setRole] = useState<string>('normal')
|
||||
|
||||
const [isSubmitting, {
|
||||
setTrue: setIsSubmitting,
|
||||
setFalse: setIsSubmitted,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (isLimitExceeded || isSubmitting)
|
||||
return
|
||||
setIsSubmitting()
|
||||
if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) {
|
||||
try {
|
||||
const { result, invitation_results } = await inviteMember({
|
||||
url: '/workspaces/current/members/invite-email',
|
||||
body: { emails, role, language: locale },
|
||||
})
|
||||
|
||||
if (result === 'success') {
|
||||
refreshLicenseLimit()
|
||||
onCancel()
|
||||
onSend(invitation_results)
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('common.members.emailInvalid') })
|
||||
}
|
||||
setIsSubmitted()
|
||||
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting])
|
||||
|
||||
return (
|
||||
<div className={cn(s.wrap)}>
|
||||
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
|
||||
<div className='mb-2 flex justify-between'>
|
||||
<div className='text-xl font-semibold text-text-primary'>{t('common.members.inviteTeamMember')}</div>
|
||||
<RiCloseLine className='h-4 w-4 cursor-pointer text-text-tertiary' onClick={onCancel} />
|
||||
</div>
|
||||
<div className='mb-3 text-[13px] text-text-tertiary'>{t('common.members.inviteTeamMemberTip')}</div>
|
||||
{!isEmailSetup && (
|
||||
<div className='grow basis-0 overflow-y-auto pb-4'>
|
||||
<div className='relative mb-1 rounded-xl border border-components-panel-border p-2 shadow-xs'>
|
||||
<div className='absolute left-0 top-0 h-full w-full rounded-xl opacity-40' style={{ background: 'linear-gradient(92deg, rgba(255, 171, 0, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)' }}></div>
|
||||
<div className='relative flex h-full w-full items-start'>
|
||||
<div className='mr-0.5 shrink-0 p-0.5'>
|
||||
<RiErrorWarningFill className='h-5 w-5 text-text-warning' />
|
||||
</div>
|
||||
<div className='system-xs-medium text-text-primary'>
|
||||
<span>{t('common.members.emailNotSetup')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-text-primary'>{t('common.members.email')}</div>
|
||||
<div className='mb-8 flex h-36 flex-col items-stretch'>
|
||||
<ReactMultiEmail
|
||||
className={cn('h-full w-full border-components-input-border-active !bg-components-input-bg-normal px-3 pt-2 outline-none',
|
||||
'appearance-none overflow-y-auto rounded-lg text-sm !text-text-primary',
|
||||
)}
|
||||
autoFocus
|
||||
emails={emails}
|
||||
inputClassName='bg-transparent'
|
||||
onChange={setEmails}
|
||||
getLabel={(email, index, removeEmail) =>
|
||||
<div data-tag key={index} className={cn('bg-components-button-secondary-bg')}>
|
||||
<div data-tag-item>{email}</div>
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
placeholder={t('common.members.emailPlaceholder') || ''}
|
||||
/>
|
||||
<div className={
|
||||
cn('system-xs-regular flex items-center justify-end text-text-tertiary',
|
||||
(isLimited && usedSize > licenseLimit.workspace_members.limit) ? 'text-text-destructive' : '')}
|
||||
>
|
||||
<span>{usedSize}</span>
|
||||
<span>/</span>
|
||||
<span>{isLimited ? licenseLimit.workspace_members.limit : t('common.license.unlimited')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<RoleSelector value={role} onChange={setRole} />
|
||||
</div>
|
||||
<Button
|
||||
tabIndex={0}
|
||||
className='w-full'
|
||||
onClick={handleSend}
|
||||
disabled={!emails.length || isLimitExceeded || isSubmitting}
|
||||
variant='primary'
|
||||
>
|
||||
{t('common.members.sendInvite')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InviteModal
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import React, { useState } from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
|
||||
export type RoleSelectorProps = {
|
||||
value: string
|
||||
onChange: (role: string) => void
|
||||
}
|
||||
|
||||
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { datasetOperatorEnabled } = useProviderContext()
|
||||
|
||||
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
>
|
||||
<div className='relative'>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className='block'
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<div className='mr-2 grow text-sm leading-5 text-text-primary'>{t('common.members.invitedAsRole', { role: t(`common.members.${toHump(value)}`) })}</div>
|
||||
<RiArrowDownSLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1002]'>
|
||||
<div className='relative w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='p-1'>
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('normal')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.normal')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.normalTip')}</div>
|
||||
{value === 'normal' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('editor')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.editor')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.editorTip')}</div>
|
||||
{value === 'editor' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('admin')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.admin')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.adminTip')}</div>
|
||||
{value === 'admin' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
{datasetOperatorEnabled && (
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('dataset_operator')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.datasetOperator')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.datasetOperatorTip')}</div>
|
||||
{value === 'dataset_operator' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleSelector
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66683C11.2865 2.66683 11.5965 2.66683 11.8508 2.73498C12.541 2.91991 13.0801 3.45901 13.265 4.14919C13.3332 4.40352 13.3332 4.71352 13.3332 5.3335V11.4668C13.3332 12.5869 13.3332 13.147 13.1152 13.5748C12.9234 13.9511 12.6175 14.2571 12.2412 14.4488C11.8133 14.6668 11.2533 14.6668 10.1332 14.6668H5.8665C4.7464 14.6668 4.18635 14.6668 3.75852 14.4488C3.3822 14.2571 3.07624 13.9511 2.88449 13.5748C2.6665 13.147 2.6665 12.5869 2.6665 11.4668V5.3335C2.6665 4.71352 2.6665 4.40352 2.73465 4.14919C2.91959 3.45901 3.45868 2.91991 4.14887 2.73498C4.4032 2.66683 4.71319 2.66683 5.33317 2.66683M5.99984 10.0002L7.33317 11.3335L10.3332 8.3335M6.39984 4.00016H9.59984C9.9732 4.00016 10.1599 4.00016 10.3025 3.9275C10.4279 3.86359 10.5299 3.7616 10.5938 3.63616C10.6665 3.49355 10.6665 3.30686 10.6665 2.9335V2.40016C10.6665 2.02679 10.6665 1.84011 10.5938 1.6975C10.5299 1.57206 10.4279 1.47007 10.3025 1.40616C10.1599 1.3335 9.97321 1.3335 9.59984 1.3335H6.39984C6.02647 1.3335 5.83978 1.3335 5.69718 1.40616C5.57174 1.47007 5.46975 1.57206 5.40583 1.6975C5.33317 1.84011 5.33317 2.02679 5.33317 2.40016V2.9335C5.33317 3.30686 5.33317 3.49355 5.40583 3.63616C5.46975 3.7616 5.57174 3.86359 5.69718 3.9275C5.83978 4.00016 6.02647 4.00016 6.39984 4.00016Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,21 @@
|
||||
.modal {
|
||||
padding: 32px !important;
|
||||
width: 480px !important;
|
||||
/* background: linear-gradient(180deg, rgba(3, 152, 85, 0.05) 0%, rgba(3, 152, 85, 0) 22.44%), #F9FAFB !important; */
|
||||
}
|
||||
|
||||
.copyIcon {
|
||||
background-image: url(./assets/copy.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon:hover {
|
||||
background-image: url(./assets/copy-hover.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon.copied {
|
||||
background-image: url(./assets/copied.svg);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import InvitationLink from './invitation-link'
|
||||
import s from './index.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
|
||||
export type FailedInvitationResult = Extract<InvitationResult, { status: 'failed' }>
|
||||
|
||||
type IInvitedModalProps = {
|
||||
invitationResults: InvitationResult[]
|
||||
onCancel: () => void
|
||||
}
|
||||
const InvitedModal = ({
|
||||
invitationResults,
|
||||
onCancel,
|
||||
}: IInvitedModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const successInvitationResults = useMemo<SuccessInvitationResult[]>(() => invitationResults?.filter(item => item.status === 'success') as SuccessInvitationResult[], [invitationResults])
|
||||
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
|
||||
|
||||
return (
|
||||
<div className={s.wrap}>
|
||||
<Modal isShow onClose={noop} className={s.modal}>
|
||||
<div className='mb-3 flex justify-between'>
|
||||
<div className='
|
||||
flex h-12 w-12 items-center justify-center rounded-xl
|
||||
border-[0.5px] border-components-panel-border bg-background-section-burn
|
||||
shadow-xl
|
||||
'>
|
||||
<CheckCircleIcon className='h-[22px] w-[22px] text-[#039855]' />
|
||||
</div>
|
||||
<XMarkIcon className='h-4 w-4 cursor-pointer' onClick={onCancel} />
|
||||
</div>
|
||||
<div className='mb-1 text-xl font-semibold text-text-primary'>{t('common.members.invitationSent')}</div>
|
||||
{!IS_CE_EDITION && (
|
||||
<div className='mb-10 text-sm text-text-tertiary'>{t('common.members.invitationSentTip')}</div>
|
||||
)}
|
||||
{IS_CE_EDITION && (
|
||||
<>
|
||||
<div className='mb-5 text-sm text-text-tertiary'>{t('common.members.invitationSentTip')}</div>
|
||||
<div className='mb-9 flex flex-col gap-2'>
|
||||
{
|
||||
!!successInvitationResults.length
|
||||
&& <>
|
||||
<div className='font-Medium py-2 text-sm text-text-primary'>{t('common.members.invitationLink')}</div>
|
||||
{successInvitationResults.map(item =>
|
||||
<InvitationLink key={item.email} value={item} />)}
|
||||
</>
|
||||
}
|
||||
{
|
||||
!!failedInvitationResults.length
|
||||
&& <>
|
||||
<div className='font-Medium py-2 text-sm text-text-primary'>{t('common.members.failedInvitationEmails')}</div>
|
||||
<div className='flex flex-wrap justify-between gap-y-1'>
|
||||
{
|
||||
failedInvitationResults.map(item =>
|
||||
<div key={item.email} className='flex justify-center rounded-md border border-red-300 bg-orange-50 px-1'>
|
||||
<Tooltip
|
||||
popupContent={item.message}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-1 text-sm'>
|
||||
{item.email}
|
||||
<RiQuestionLine className='h-4 w-4 text-red-300' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
className='w-[96px]'
|
||||
onClick={onCancel}
|
||||
variant='primary'
|
||||
>
|
||||
{t('common.members.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitedModal
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import s from './index.module.css'
|
||||
import type { SuccessInvitationResult } from '.'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type IInvitationLinkProps = {
|
||||
value: SuccessInvitationResult
|
||||
}
|
||||
|
||||
const InvitationLink = ({
|
||||
value,
|
||||
}: IInvitationLinkProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const copyHandle = useCallback(() => {
|
||||
// No prefix is needed here because the backend has already processed it
|
||||
copy(`${!value.url.startsWith('http') ? window.location.origin : ''}${value.url}`)
|
||||
setIsCopied(true)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
return (
|
||||
<div className='flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover'>
|
||||
<div className="flex h-5 grow items-center">
|
||||
<div className='relative h-full grow text-[13px]'>
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
|
||||
>
|
||||
<div className='r-0 absolute left-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary' onClick={copyHandle}>{value.url}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="h-4 shrink-0 border bg-divider-regular" />
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
|
||||
>
|
||||
<div className="shrink-0 px-0.5">
|
||||
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle}>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitationLink
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Member } from '@/models/common'
|
||||
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
|
||||
type IOperationProps = {
|
||||
member: Member
|
||||
operatorRole: string
|
||||
onOperate: () => void
|
||||
}
|
||||
|
||||
const Operation = ({
|
||||
member,
|
||||
operatorRole,
|
||||
onOperate,
|
||||
}: IOperationProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { datasetOperatorEnabled } = useProviderContext()
|
||||
const RoleMap = {
|
||||
owner: t('common.members.owner'),
|
||||
admin: t('common.members.admin'),
|
||||
editor: t('common.members.editor'),
|
||||
normal: t('common.members.normal'),
|
||||
dataset_operator: t('common.members.datasetOperator'),
|
||||
}
|
||||
const roleList = useMemo(() => {
|
||||
if (operatorRole === 'owner') {
|
||||
return [
|
||||
'admin', 'editor', 'normal',
|
||||
...(datasetOperatorEnabled ? ['dataset_operator'] : []),
|
||||
]
|
||||
}
|
||||
if (operatorRole === 'admin') {
|
||||
return [
|
||||
'editor', 'normal',
|
||||
...(datasetOperatorEnabled ? ['dataset_operator'] : []),
|
||||
]
|
||||
}
|
||||
return []
|
||||
}, [operatorRole, datasetOperatorEnabled])
|
||||
const { notify } = useContext(ToastContext)
|
||||
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
|
||||
const handleDeleteMemberOrCancelInvitation = async () => {
|
||||
try {
|
||||
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
|
||||
onOperate()
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
}
|
||||
const handleUpdateMemberRole = async (role: string) => {
|
||||
try {
|
||||
await updateMemberRole({
|
||||
url: `/workspaces/current/members/${member.id}/update-role`,
|
||||
body: { role },
|
||||
})
|
||||
onOperate()
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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')}>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<ChevronDownIcon 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">
|
||||
{
|
||||
roleList.map(role => (
|
||||
<MenuItem key={role}>
|
||||
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={() => handleUpdateMemberRole(role)}>
|
||||
{
|
||||
role === member.role
|
||||
? <CheckIcon className='mr-1 mt-[2px] h-4 w-4 text-text-accent' />
|
||||
: <div className='mr-1 mt-[2px] h-4 w-4 text-text-accent' />
|
||||
}
|
||||
<div>
|
||||
<div className='system-sm-semibold whitespace-nowrap text-text-secondary'>{t(`common.members.${toHump(role)}`)}</div>
|
||||
<div className='system-xs-regular whitespace-nowrap text-text-tertiary'>{t(`common.members.${toHump(role)}Tip`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<MenuItem>
|
||||
<div className='border-t border-divider-subtle p-1'>
|
||||
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={handleDeleteMemberOrCancelInvitation}>
|
||||
<div className='mr-1 mt-[2px] h-4 w-4 text-text-accent' />
|
||||
<div>
|
||||
<div className='system-sm-semibold whitespace-nowrap text-text-secondary'>{t('common.members.removeFromTeam')}</div>
|
||||
<div className='system-xs-regular whitespace-nowrap text-text-tertiary'>{t('common.members.removeFromTeamTip')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default Operation
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user