Browse Source

Feat/support to invite multiple users (#1011)

Matri 1 year ago
parent
commit
d3f8ea2df0

+ 26 - 29
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
 
 

+ 6 - 5
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<InvitationResult[]>([])
   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 = () => {
                   <div className='shrink-0 w-[96px] flex items-center'>
                     {
                       (owner && account.role !== 'owner')
-                        ? <Operation member={account} onOperate={() => mutate()} />
+                        ? <Operation member={account} onOperate={mutate} />
                         : <div className='px-3 text-[13px] text-gray-700'>{RoleMap[account.role] || RoleMap.normal}</div>
                     }
                   </div>
@@ -92,9 +93,9 @@ const MembersPage = () => {
         inviteModalVisible && (
           <InviteModal
             onCancel={() => setInviteModalVisible(false)}
-            onSend={(url) => {
+            onSend={(invitationResults) => {
               setInvitedModalVisible(true)
-              setInvitationLink(url)
+              setInvitationResults(invitationResults)
               mutate()
             }}
           />
@@ -103,7 +104,7 @@ const MembersPage = () => {
       {
         invitedModalVisible && (
           <InvitedModal
-            invitationLink={invitationLink}
+            invitationResults={invitationResults}
             onCancel={() => setInvitedModalVisible(false)}
           />
         )

+ 8 - 0
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;
 }

+ 102 - 19
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<string[]>([])
   const { notify } = useContext(ToastContext)
 
-  const handleSend = async () => {
-    if (emailRegex.test(email)) {
+  const InvitingRoles = useMemo(() => [
+    {
+      name: 'normal',
+      description: t('common.members.normalTip'),
+    },
+    {
+      name: 'admin',
+      description: t('common.members.adminTip'),
+    },
+  ], [t])
+  const [role, setRole] = useState(InvitingRoles[0])
+
+  const handleSend = useCallback(async () => {
+    if (emails.map(email => emailRegex.test(email)).every(Boolean)) {
       try {
-        const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin' } })
+        const { result, invitation_results } = await inviteMember({
+          url: '/workspaces/current/members/invite-email',
+          body: { emails, role: role.name },
+        })
 
-        if (res.result === 'success') {
+        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 (
     <div className={s.wrap}>
-      <Modal isShow onClose={() => {}} className={s.modal}>
+      <Modal overflowVisible isShow onClose={() => {}} className={s.modal}>
         <div className='flex justify-between mb-2'>
           <div className='text-xl font-semibold text-gray-900'>{t('common.members.inviteTeamMember')}</div>
           <XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
@@ -49,18 +71,79 @@ const InviteModal = ({
         <div className='mb-7 text-[13px] text-gray-500'>{t('common.members.inviteTeamMemberTip')}</div>
         <div>
           <div className='mb-2 text-sm font-medium text-gray-900'>{t('common.members.email')}</div>
-          <input
-            className='
-              block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none
-              appearance-none text-sm text-gray-900 rounded-lg
-            '
-            value={email}
-            onChange={e => setEmail(e.target.value)}
-            placeholder={t('common.members.emailPlaceholder') || ''}
-          />
+          <div className='mb-8 h-36 flex items-stretch'>
+            <ReactMultiEmail
+              className={cn('w-full pt-2 px-3 outline-none border-none',
+                'appearance-none text-sm text-gray-900 rounded-lg overflow-y-auto',
+                s.emailsInput,
+              )}
+              autoFocus
+              emails={emails}
+              inputClassName='bg-transparent'
+              onChange={setEmails}
+              getLabel={(email, index, removeEmail) =>
+                <div data-tag key={index} className={cn(s.emailBackground)}>
+                  <div data-tag-item>{email}</div>
+                  <span data-tag-handle onClick={() => removeEmail(index)}>
+                      ×
+                  </span>
+                </div>
+              }
+              placeholder={t('common.members.emailPlaceholder') || ''}
+            />
+          </div>
+          <Listbox value={role} onChange={setRole}>
+            <div className="relative pb-6">
+              <Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-gray-100 outline-none border-none appearance-none text-sm text-gray-900 rounded-lg">
+                <span className="block truncate capitalize">{t('common.members.invitedAsRole', { role: t(`common.members.${role.name}`) })}</span>
+              </Listbox.Button>
+              <Transition
+                as={Fragment}
+                leave="transition ease-in duration-200"
+                leaveFrom="opacity-200"
+                leaveTo="opacity-0"
+              >
+                <Listbox.Options className="absolute w-full py-1 my-2 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
+                  {InvitingRoles.map(role =>
+                    <Listbox.Option
+                      key={role.name}
+                      className={({ active }) =>
+                        `${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 }) => (
+                        <div className='flex flex-row'>
+                          <span
+                            className={cn(
+                              'text-indigo-600 w-8',
+                              'flex items-center',
+                            )}
+                          >
+                            {selected && (<CheckIcon className="h-5 w-5" aria-hidden="true" />)}
+                          </span>
+                          <div className=' flex flex-col flex-grow'>
+                            <span className={`${selected ? 'font-medium' : 'font-normal'} capitalize block truncate`}>
+                              {t(`common.members.${role.name}`)}
+                            </span>
+                            <span className={`${selected ? 'font-medium' : 'font-normal'} capitalize block truncate`}>
+                              {role.description}
+                            </span>
+                          </div>
+                        </div>
+                      )}
+                    </Listbox.Option>,
+                  )}
+                </Listbox.Options>
+              </Transition>
+            </div>
+          </Listbox>
           <Button
+            tabIndex={0}
             className='w-full text-sm font-medium'
             onClick={handleSend}
+            disabled={!emails.length}
             type='primary'
           >
             {t('common.members.sendInvite')}

+ 44 - 6
web/app/components/header/account-setting/members-page/invited-modal/index.tsx

@@ -1,22 +1,31 @@
 import { CheckCircleIcon } from '@heroicons/react/24/solid'
-import { XMarkIcon } from '@heroicons/react/24/outline'
+import { QuestionMarkCircleIcon, XMarkIcon } from '@heroicons/react/24/outline'
 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'
+
+export type SuccessInvationResult = Extract<InvitationResult, { status: 'success' }>
+export type FailedInvationResult = Extract<InvitationResult, { status: 'failed' }>
 
 type IInvitedModalProps = {
-  invitationLink: string
+  invitationResults: InvitationResult[]
   onCancel: () => void
 }
 const InvitedModal = ({
-  invitationLink,
+  invitationResults,
   onCancel,
 }: IInvitedModalProps) => {
   const { t } = useTranslation()
 
+  const successInvationResults = useMemo<SuccessInvationResult[]>(() => invitationResults?.filter(item => item.status === 'success') as SuccessInvationResult[], [invitationResults])
+  const failedInvationResults = useMemo<FailedInvationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvationResult[], [invitationResults])
+
   return (
     <div className={s.wrap}>
       <Modal isShow onClose={() => {}} className={s.modal}>
@@ -37,9 +46,38 @@ const InvitedModal = ({
         {IS_CE_EDITION && (
           <>
             <div className='mb-5 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
-            <div className='mb-9'>
-              <div className='py-2 text-sm font-Medium text-gray-900'>{t('common.members.invitationLink')}</div>
-              <InvitationLink value={invitationLink} />
+            <div className='flex flex-col gap-2 mb-9'>
+              {
+                !!successInvationResults.length
+                  && <>
+                    <div className='py-2 text-sm font-Medium text-gray-900'>{t('common.members.invitationLink')}</div>
+                    {successInvationResults.map(item =>
+                      <InvitationLink key={item.email} value={item} />)}
+                  </>
+              }
+              {
+                !!failedInvationResults.length
+                  && <>
+                    <div className='py-2 text-sm font-Medium text-gray-900'>{t('common.members.failedinvitationEmails')}</div>
+                    <div className='flex flex-wrap justify-between gap-y-1'>
+                      {
+                        failedInvationResults.map(item =>
+                          <div key={item.email} className='flex justify-center border border-red-300 rounded-md px-1 bg-orange-50'>
+                            <Tooltip
+                              selector={`invitation-tag-${item.email}`}
+                              htmlContent={item.message}
+                            >
+                              <div className='flex justify-center items-center text-sm gap-1'>
+                                {item.email}
+                                <QuestionMarkCircleIcon className='w-4 h-4 text-red-300' />
+                              </div>
+                            </Tooltip>
+                          </div>,
+                        )
+                      }
+                    </div>
+                  </>
+              }
             </div>
           </>
         )}

+ 5 - 4
web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx

@@ -3,21 +3,22 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
 import { t } from 'i18next'
 import copy from 'copy-to-clipboard'
 import s from './index.module.css'
+import type { SuccessInvationResult } from '.'
 import Tooltip from '@/app/components/base/tooltip'
 import { randomString } from '@/utils'
 
 type IInvitationLinkProps = {
-  value?: string
+  value: SuccessInvationResult
 }
 
 const InvitationLink = ({
-  value = '',
+  value,
 }: IInvitationLinkProps) => {
   const [isCopied, setIsCopied] = useState(false)
   const selector = useRef(`invite-link-${randomString(4)}`)
 
   const copyHandle = useCallback(() => {
-    copy(value)
+    copy(value.url)
     setIsCopied(true)
   }, [value])
 
@@ -42,7 +43,7 @@ const InvitationLink = ({
             content={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
             className='z-10'
           >
-            <div className='absolute top-0 left-0 w-full pl-2 pr-2 truncate cursor-pointer r-0' onClick={copyHandle}>{value}</div>
+            <div className='absolute top-0 left-0 w-full pl-2 pr-2 truncate cursor-pointer r-0' onClick={copyHandle}>{value.url}</div>
           </Tooltip>
         </div>
         <div className="flex-shrink-0 h-4 bg-gray-200 border" />

+ 4 - 2
web/i18n/lang/common.en.ts

@@ -135,11 +135,13 @@ const translation = {
     inviteTeamMemberTip: 'They can access your team data directly after signing in.',
     email: 'Email',
     emailInvalid: 'Invalid Email Format',
-    emailPlaceholder: 'Input Email',
-    sendInvite: 'Add',
+    emailPlaceholder: 'Please input emails',
+    sendInvite: 'Send Invite',
+    invitedAsRole: 'Invited as {{role}} user',
     invitationSent: 'Invitation sent',
     invitationSentTip: 'Invitation sent, and they can sign in to Dify to access your team data.',
     invitationLink: 'Invitation Link',
+    failedinvitationEmails: 'Below users were not invited successfully',
     ok: 'OK',
     removeFromTeam: 'Remove from team',
     removeFromTeamTip: 'Will remove team access',

+ 3 - 1
web/i18n/lang/common.zh.ts

@@ -136,10 +136,12 @@ const translation = {
     email: '邮箱',
     emailInvalid: '邮箱格式无效',
     emailPlaceholder: '输入邮箱',
-    sendInvite: '添加',
+    sendInvite: '发送邀请',
+    invitedAsRole: '邀请为{{role}}用户',
     invitationSent: '邀请已发送',
     invitationSentTip: '邀请已发送,对方登录 Dify 后即可访问你的团队数据。',
     invitationLink: '邀请链接',
+    failedinvitationEmails: '邀请以下邮箱失败',
     ok: '好的',
     removeFromTeam: '移除团队',
     removeFromTeamTip: '将取消团队访问',

+ 14 - 0
web/models/common.ts

@@ -182,3 +182,17 @@ export type DocumentsLimitResponse = {
   documents_count: number
   documents_limit: number
 }
+
+export type InvitationResult = {
+  status: 'success'
+  email: string
+  url: string
+} | {
+  status: 'failed'
+  email: string
+  message: string
+}
+
+export type InvitationResponse = CommonResponse & {
+  invitation_results: InvitationResult[]
+}

+ 1 - 0
web/package.json

@@ -54,6 +54,7 @@
     "react-i18next": "^12.2.0",
     "react-infinite-scroll-component": "^6.1.0",
     "react-markdown": "^8.0.6",
+    "react-multi-email": "^1.0.14",
     "react-papaparse": "^4.1.0",
     "react-slider": "^2.0.4",
     "react-sortablejs": "^6.1.4",

+ 3 - 3
web/service/common.ts

@@ -5,7 +5,7 @@ import type {
   DocumentsLimitResponse,
   FileUploadConfigResponse,
   ICurrentWorkspace,
-  IWorkspace, LangGeniusVersionResponse, Member,
+  IWorkspace, InvitationResponse, LangGeniusVersionResponse, Member,
   OauthResponse, PluginProvider, Provider, ProviderAnthropicToken, ProviderAzureToken,
   SetupStatusResponse, UserProfileOriginResponse,
 } from '@/models/common'
@@ -70,8 +70,8 @@ export const fetchAccountIntegrates: Fetcher<{ data: AccountIntegrate[] | null }
   return get(url, { params }) as Promise<{ data: AccountIntegrate[] | null }>
 }
 
-export const inviteMember: Fetcher<CommonResponse & { account: Member; invite_url: string }, { url: string; body: Record<string, any> }> = ({ url, body }) => {
-  return post(url, { body }) as Promise<CommonResponse & { account: Member; invite_url: string }>
+export const inviteMember: Fetcher<InvitationResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => {
+  return post(url, { body }) as Promise<InvitationResponse>
 }
 
 export const updateMemberRole: Fetcher<CommonResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => {

+ 5 - 0
web/yarn.lock

@@ -4618,6 +4618,11 @@ react-markdown@^8.0.6:
     unist-util-visit "^4.0.0"
     vfile "^5.0.0"
 
+react-multi-email@^1.0.14:
+  version "1.0.16"
+  resolved "https://registry.yarnpkg.com/react-multi-email/-/react-multi-email-1.0.16.tgz#126f78011d02cc27d7462f47befbafff2a53d683"
+  integrity sha512-dgg4TY3P5FWz6c4ghgxH1bjZOgYL3S/HN+EUNe6dqHbLMVzeyud1ztDUlqvft4NX1sUxKx2IF2zDq1yAJQA5yQ==
+
 react-papaparse@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/react-papaparse/-/react-papaparse-4.1.0.tgz#09e1cdae55a0f3e36650aaf7ff9bb5ee2aed164a"