diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index ab1b692927..942ae91302 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -49,46 +49,43 @@ class MemberInviteEmailApi(Resource): @account_initialization_required def post(self): parser = reqparse.RequestParser() - parser.add_argument('email', type=str, required=True, location='json') + parser.add_argument('emails', type=str, required=True, location='json', action='append') parser.add_argument('role', type=str, required=True, default='admin', location='json') args = parser.parse_args() - invitee_email = args['email'] + invitee_emails = args['emails'] invitee_role = args['role'] if invitee_role not in ['admin', 'normal']: return {'code': 'invalid-role', 'message': 'Invalid role'}, 400 inviter = current_user - - try: - token = RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role, - inviter=inviter) - account = db.session.query(Account, TenantAccountJoin.role).join( - TenantAccountJoin, Account.id == TenantAccountJoin.account_id - ).filter(Account.email == args['email']).first() - account, role = account - account = marshal(account, account_fields) - account['role'] = role - except services.errors.account.CannotOperateSelfError as e: - return {'code': 'cannot-operate-self', 'message': str(e)}, 400 - except services.errors.account.NoPermissionError as e: - return {'code': 'forbidden', 'message': str(e)}, 403 - except services.errors.account.AccountAlreadyInTenantError as e: - return {'code': 'email-taken', 'message': str(e)}, 409 - except Exception as e: - return {'code': 'unexpected-error', 'message': str(e)}, 500 - - # todo:413 + invitation_results = [] + console_web_url = current_app.config.get("CONSOLE_WEB_URL") + for invitee_email in invitee_emails: + try: + token = RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role, + inviter=inviter) + account = db.session.query(Account, TenantAccountJoin.role).join( + TenantAccountJoin, Account.id == TenantAccountJoin.account_id + ).filter(Account.email == invitee_email).first() + account, role = account + invitation_results.append({ + 'status': 'success', + 'email': invitee_email, + 'url': f'{console_web_url}/activate?workspace_id={current_user.current_tenant_id}&email={invitee_email}&token={token}' + }) + account = marshal(account, account_fields) + account['role'] = role + except Exception as e: + invitation_results.append({ + 'status': 'failed', + 'email': invitee_email, + 'message': str(e) + }) return { 'result': 'success', - 'account': account, - 'invite_url': '{}/activate?workspace_id={}&email={}&token={}'.format( - current_app.config.get("CONSOLE_WEB_URL"), - str(current_user.current_tenant_id), - invitee_email, - token - ) + 'invitation_results': invitation_results, }, 201 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 33b19707a4..be684a4ce0 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -16,6 +16,7 @@ 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' dayjs.extend(relativeTime) @@ -30,7 +31,7 @@ const MembersPage = () => { const { userProfile, currentWorkspace, isCurrentWorkspaceManager } = useAppContext() const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers) const [inviteModalVisible, setInviteModalVisible] = useState(false) - const [invitationLink, setInvitationLink] = useState('') + const [invitationResults, setInvitationResults] = useState([]) const [invitedModalVisible, setInvitedModalVisible] = useState(false) const accounts = data?.accounts || [] const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email @@ -78,7 +79,7 @@ const MembersPage = () => {
{ (owner && account.role !== 'owner') - ? mutate()} /> + ? :
{RoleMap[account.role] || RoleMap.normal}
}
@@ -92,9 +93,9 @@ const MembersPage = () => { inviteModalVisible && ( setInviteModalVisible(false)} - onSend={(url) => { + onSend={(invitationResults) => { setInvitedModalVisible(true) - setInvitationLink(url) + setInvitationResults(invitationResults) mutate() }} /> @@ -103,7 +104,7 @@ const MembersPage = () => { { invitedModalVisible && ( setInvitedModalVisible(false)} /> ) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.module.css b/web/app/components/header/account-setting/members-page/invite-modal/index.module.css index 2e21f3b619..932f35c44e 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.module.css +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.module.css @@ -1,4 +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; } \ No newline at end of file diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 541eb25f91..6033cc231c 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -1,35 +1,57 @@ 'use client' -import { useState } from 'react' +import { Fragment, useCallback, useMemo, useState } from 'react' import { useContext } from 'use-context-selector' import { XMarkIcon } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' +import { ReactMultiEmail } from 'react-multi-email' +import { Listbox, Transition } from '@headlessui/react' +import { CheckIcon } from '@heroicons/react/20/solid' +import cn from 'classnames' import s from './index.module.css' 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 'react-multi-email/dist/style.css' type IInviteModalProps = { onCancel: () => void - onSend: (url: string) => void + onSend: (invitationResults: InvitationResult[]) => void } + const InviteModal = ({ onCancel, onSend, }: IInviteModalProps) => { const { t } = useTranslation() - const [email, setEmail] = useState('') + const [emails, setEmails] = useState([]) const { notify } = useContext(ToastContext) - const handleSend = async () => { - if (emailRegex.test(email)) { - try { - const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin' } }) + const InvitingRoles = useMemo(() => [ + { + name: 'normal', + description: t('common.members.normalTip'), + }, + { + name: 'admin', + description: t('common.members.adminTip'), + }, + ], [t]) + const [role, setRole] = useState(InvitingRoles[0]) - if (res.result === 'success') { + const handleSend = useCallback(async () => { + if (emails.map(email => emailRegex.test(email)).every(Boolean)) { + try { + const { result, invitation_results } = await inviteMember({ + url: '/workspaces/current/members/invite-email', + body: { emails, role: role.name }, + }) + + if (result === 'success') { onCancel() - onSend(res.invite_url) + onSend(invitation_results) } } catch (e) {} @@ -37,11 +59,11 @@ const InviteModal = ({ else { notify({ type: 'error', message: t('common.members.emailInvalid') }) } - } + }, [role, emails, notify, onCancel, onSend, t]) return (
- {}} className={s.modal}> + {}} className={s.modal}>
{t('common.members.inviteTeamMember')}
@@ -49,18 +71,79 @@ const InviteModal = ({
{t('common.members.inviteTeamMemberTip')}
{t('common.members.email')}
- setEmail(e.target.value)} - placeholder={t('common.members.emailPlaceholder') || ''} - /> +
+ +
+
{email}
+ removeEmail(index)}> + × + +
+ } + placeholder={t('common.members.emailPlaceholder') || ''} + /> +
+ +
+ + {t('common.members.invitedAsRole', { role: t(`common.members.${role.name}`) })} + + + + {InvitingRoles.map(role => + + `${active ? ' bg-gray-50 rounded-xl' : ' bg-transparent'} + cursor-default select-none relative py-2 px-4 mx-2 flex flex-col` + } + value={role} + > + {({ selected }) => ( +
+ + {selected && ( +
+ + {t(`common.members.${role.name}`)} + + + {role.description} + +
+
+ )} +
, + )} +
+
+
+