From c301dafca7430d30fa2717ddf8816b41bd4cf657 Mon Sep 17 00:00:00 2001 From: Finley Ge <32237950+FinleyGe@users.noreply.github.com> Date: Wed, 12 Mar 2025 13:47:15 +0800 Subject: [PATCH] feat: invitation link (#3979) * feat: invitation link schema and apis * feat: add invitation link * feat: member status: active, leave, forbidden * fix: expires show hours and minutes * feat: invalid invitation link hint * fix: typo * chore: fix typo & i18n * fix * pref: fe * feat: add ttl index for 30-day-clean-up --- packages/global/common/error/code/team.ts | 17 +- packages/global/support/user/team/constant.ts | 23 +- .../global/support/user/team/controller.d.ts | 5 - .../user/team/invitationLink/constants.ts | 1 + .../user/team/invitationLink/controllers.ts | 3 + .../user/team/invitationLink/schema.ts | 54 ++++ .../support/user/team/invitationLink/type.ts | 37 +++ .../components/common/Avatar/AvatarGroup.tsx | 10 +- .../web/components/common/Icon/constants.ts | 2 + .../common/Icon/icons/common/lineStop.svg | 10 + .../common/Icon/icons/common/link.svg | 3 + packages/web/i18n/en/account_team.json | 24 +- packages/web/i18n/en/common.json | 3 + packages/web/i18n/zh-CN/account_team.json | 24 +- packages/web/i18n/zh-CN/common.json | 3 + packages/web/i18n/zh-Hant/account_team.json | 24 +- packages/web/i18n/zh-Hant/common.json | 3 + projects/app/src/components/Layout/index.tsx | 2 - .../user/team/UpdateInviteModal/index.tsx | 132 --------- .../account/team/CreateInvitationModal.tsx | 99 +++++++ .../account/team/GroupManage/index.tsx | 4 +- .../account/team/HandleInviteModal.tsx | 106 +++++++ .../account/team/InviteModal.tsx | 276 +++++++++++++++--- .../account/team/MemberTable.tsx | 59 ++-- projects/app/src/pages/account/team/index.tsx | 12 + projects/app/src/web/support/user/team/api.ts | 34 ++- 26 files changed, 719 insertions(+), 251 deletions(-) create mode 100644 packages/service/support/user/team/invitationLink/constants.ts create mode 100644 packages/service/support/user/team/invitationLink/controllers.ts create mode 100644 packages/service/support/user/team/invitationLink/schema.ts create mode 100644 packages/service/support/user/team/invitationLink/type.ts create mode 100644 packages/web/components/common/Icon/icons/common/lineStop.svg create mode 100644 packages/web/components/common/Icon/icons/common/link.svg delete mode 100644 projects/app/src/components/support/user/team/UpdateInviteModal/index.tsx create mode 100644 projects/app/src/pageComponents/account/team/CreateInvitationModal.tsx create mode 100644 projects/app/src/pageComponents/account/team/HandleInviteModal.tsx diff --git a/packages/global/common/error/code/team.ts b/packages/global/common/error/code/team.ts index 7c2ab6790..f60d08cca 100644 --- a/packages/global/common/error/code/team.ts +++ b/packages/global/common/error/code/team.ts @@ -24,7 +24,10 @@ export enum TeamErrEnum { cannotModifyRootOrg = 'cannotModifyRootOrg', cannotDeleteNonEmptyOrg = 'cannotDeleteNonEmptyOrg', cannotDeleteDefaultGroup = 'cannotDeleteDefaultGroup', - userNotActive = 'userNotActive' + userNotActive = 'userNotActive', + invitationLinkInvalid = 'invitationLinkInvalid', + youHaveBeenInTheTeam = 'youHaveBeenInTheTeam', + tooManyInvitations = 'tooManyInvitations' } const teamErr = [ @@ -112,6 +115,18 @@ const teamErr = [ { statusText: TeamErrEnum.cannotDeleteNonEmptyOrg, message: i18nT('common:code_error.team_error.cannot_delete_non_empty_org') + }, + { + statusText: TeamErrEnum.invitationLinkInvalid, + message: i18nT('common:code_error.team_error.invitation_link_invalid') + }, + { + statusText: TeamErrEnum.youHaveBeenInTheTeam, + message: i18nT('common:code_error.team_error.you_have_been_in_the_team') + }, + { + statusText: TeamErrEnum.tooManyInvitations, + message: i18nT('common:code_error.team_error.too_many_invitations') } ]; diff --git a/packages/global/support/user/team/constant.ts b/packages/global/support/user/team/constant.ts index ea40229bd..f596cfcd4 100644 --- a/packages/global/support/user/team/constant.ts +++ b/packages/global/support/user/team/constant.ts @@ -14,29 +14,28 @@ export const TeamMemberRoleMap = { }; export enum TeamMemberStatusEnum { - waiting = 'waiting', active = 'active', - reject = 'reject', - leave = 'leave' + leave = 'leave', + forbidden = 'forbidden' } export const TeamMemberStatusMap = { - [TeamMemberStatusEnum.waiting]: { - label: 'user.team.member.waiting', - color: 'orange.600' - }, [TeamMemberStatusEnum.active]: { label: 'user.team.member.active', color: 'green.600' }, - [TeamMemberStatusEnum.reject]: { - label: 'user.team.member.reject', - color: 'red.600' - }, [TeamMemberStatusEnum.leave]: { label: 'user.team.member.leave', color: 'red.600' + }, + [TeamMemberStatusEnum.forbidden]: { + label: 'user.team.member.forbidden', + color: 'red.600' } }; -export const notLeaveStatus = { $ne: TeamMemberStatusEnum.leave }; +export const notLeaveStatus = { + $not: { + $in: [TeamMemberStatusEnum.leave, TeamMemberStatusEnum.forbidden] + } +}; diff --git a/packages/global/support/user/team/controller.d.ts b/packages/global/support/user/team/controller.d.ts index cf17b7add..8d25986c0 100644 --- a/packages/global/support/user/team/controller.d.ts +++ b/packages/global/support/user/team/controller.d.ts @@ -40,11 +40,6 @@ export type UpdateInviteProps = { status: TeamMemberSchema['status']; }; -export type UpdateStatusProps = { - tmbId: string; - status: TeamMemberSchema['status']; -}; - export type InviteMemberResponse = Record< 'invite' | 'inValid' | 'inTeam', { username: string; userId: string }[] diff --git a/packages/service/support/user/team/invitationLink/constants.ts b/packages/service/support/user/team/invitationLink/constants.ts new file mode 100644 index 000000000..bf5427449 --- /dev/null +++ b/packages/service/support/user/team/invitationLink/constants.ts @@ -0,0 +1 @@ +export const MaxInvitationLinksAmount = 10; diff --git a/packages/service/support/user/team/invitationLink/controllers.ts b/packages/service/support/user/team/invitationLink/controllers.ts new file mode 100644 index 000000000..4eecab463 --- /dev/null +++ b/packages/service/support/user/team/invitationLink/controllers.ts @@ -0,0 +1,3 @@ +export function isForbidden({ expires, forbidden }: { expires: Date; forbidden?: boolean }) { + return forbidden || new Date(expires) < new Date(); +} diff --git a/packages/service/support/user/team/invitationLink/schema.ts b/packages/service/support/user/team/invitationLink/schema.ts new file mode 100644 index 000000000..71a1b6721 --- /dev/null +++ b/packages/service/support/user/team/invitationLink/schema.ts @@ -0,0 +1,54 @@ +import { + TeamCollectionName, + TeamMemberCollectionName +} from '@fastgpt/global/support/user/team/constant'; +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import { InvitationSchemaType } from './type'; +import addDays from 'date-fns/esm/fp/addDays/index.js'; +const { Schema } = connectionMongo; + +export const InvitationCollectionName = 'team_invitation_links'; + +const InvitationSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + usedTimesLimit: { + type: Number + }, + forbidden: { + type: Boolean + }, + expires: { + type: Date + }, + description: { + type: String + }, + members: { + type: [String], + default: [] + } +}); + +InvitationSchema.virtual('team', { + ref: TeamCollectionName, + localField: 'teamId', + foreignField: '_id', + justOne: true +}); + +InvitationSchema.index({ expires: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 }); + +try { + InvitationSchema.index({ teamId: 1 }, { background: true }); +} catch (error) { + console.log(error); +} + +export const MongoInvitationLink = getMongoModel( + InvitationCollectionName, + InvitationSchema +); diff --git a/packages/service/support/user/team/invitationLink/type.ts b/packages/service/support/user/team/invitationLink/type.ts new file mode 100644 index 000000000..033827378 --- /dev/null +++ b/packages/service/support/user/team/invitationLink/type.ts @@ -0,0 +1,37 @@ +import { TeamMemberSchema } from '@fastgpt/global/support/user/team/type'; + +export type InvitationSchemaType = { + _id: string; + teamId: string; + usedTimesLimit?: number; + forbidden?: boolean; + expires: Date; + description: string; + members: string[]; +}; + +export type InvitationType = Omit & { + members: { + tmbId: string; + avatar: string; + name: string; + }[]; +}; + +export type InvitationLinkExpiresType = '30m' | '7d' | '1y'; + +export type InvitationLinkCreateType = { + description: string; + expires: InvitationLinkExpiresType; + usedTimesLimit: number; +}; +export type InvitationLinkUpdateType = Partial< + Omit +> & { + linkId: string; +}; + +export type InvitationInfoType = InvitationSchemaType & { + teamAvatar: string; + teamName: string; +}; diff --git a/packages/web/components/common/Avatar/AvatarGroup.tsx b/packages/web/components/common/Avatar/AvatarGroup.tsx index 199373f05..6f9f3ce03 100644 --- a/packages/web/components/common/Avatar/AvatarGroup.tsx +++ b/packages/web/components/common/Avatar/AvatarGroup.tsx @@ -10,15 +10,7 @@ import { Box, Flex } from '@chakra-ui/react'; * @param [groupId] - group id to make the key unique * @returns */ -function AvatarGroup({ - avatars, - max = 3, - groupId -}: { - max?: number; - avatars: string[]; - groupId?: string; -}) { +function AvatarGroup({ avatars, max = 3 }: { max?: number; avatars: string[] }) { return ( {avatars.slice(0, max).map((avatar, index) => ( diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 1a91861de..430a26490 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -61,6 +61,8 @@ export const iconPaths = { 'common/leftArrowLight': () => import('./icons/common/leftArrowLight.svg'), 'common/line': () => import('./icons/common/line.svg'), 'common/lineChange': () => import('./icons/common/lineChange.svg'), + 'common/lineStop': () => import('./icons/common/lineStop.svg'), + 'common/link': () => import('./icons/common/link.svg'), 'common/linkBlue': () => import('./icons/common/linkBlue.svg'), 'common/list': () => import('./icons/common/list.svg'), 'common/loading': () => import('./icons/common/loading.svg'), diff --git a/packages/web/components/common/Icon/icons/common/lineStop.svg b/packages/web/components/common/Icon/icons/common/lineStop.svg new file mode 100644 index 000000000..b3f4c143d --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/lineStop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/common/link.svg b/packages/web/components/common/Icon/icons/common/link.svg new file mode 100644 index 000000000..9855cd94a --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index 8641c3c8f..e4c6d90b4 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -35,5 +35,27 @@ "user_team_invite_member": "Invite members", "user_team_leave_team": "Leave the team", "user_team_leave_team_failed": "Failure to leave the team", - "waiting": "To be accepted" + "waiting": "To be accepted", + "invitation_link_list": "Invitation link list", + "create_invitation_link": "Create Invitation Link", + "invitation_link_description": "Link description", + "30mins": "30 Minutes", + "7days": "7 Days", + "1year": "1 Year", + "unlimited": "Unlimited", + "1person": "1 person", + "expires": "Expiration", + "used_times_limit": "Limit", + "invited": "Invited", + "has_forbidden": "Forbidden", + "forbidden": "Forbidden", + "copy_link": "Copy link", + "handle_invitation": "Handle Invitation", + "ignore": "Ignore", + "forbid_success": "Forbid success", + "forbid_hint": "After forbidden, this invitation link will become invalid. This action is irreversible. Are you sure you want to deactivate?", + "confirm_forbidden": "Confirm forbidden", + "invitation_link_auto_clean_hint": "Expired links will be automatically cleaned up after 30 days", + "has_invited": "Invited", + "invitation_link_has_been_invalid": "The invitation link has expired. Please contact the team administrator" } diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index d42335a01..18c8b4559 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -100,6 +100,9 @@ "code_error.team_error.un_auth": "Unauthorized to Operate This Team", "code_error.team_error.user_not_active": "The user did not accept or has left the team", "code_error.team_error.website_sync_not_enough": "The free version cannot be synchronized with the web site ~", + "code_error.team_error.invitation_link_invalid": "Invitation link is invalid", + "code_error.team_error.you_have_been_in_the_team": "You are already in this team", + "code_error.team_error.too_many_invitations": "You have reached the maximum number of active invitation links, please clean up some links first", "code_error.token_error_code.403": "Invalid Login Status, Please Re-login", "code_error.user_error.balance_not_enough": "Insufficient Account Balance", "code_error.user_error.bin_visitor_guest": "You Are Currently a Guest, Unauthorized to Operate", diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index d0fa3eb6d..fd70a1b74 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -51,5 +51,27 @@ "confirm_delete_from_org": "确认将 {{username}} 移出部门?", "search_org": "搜索部门", "notification_recieve": "团队通知接收", - "set_name_avatar": "团队头像 & 团队名" + "set_name_avatar": "团队头像 & 团队名", + "invitation_link_list": "链接列表", + "create_invitation_link": "创建邀请链接", + "invitation_link_description": "链接描述", + "30mins": "30分钟", + "7days": "7天", + "1year": "1年", + "unlimited": "无限制", + "1person": "1人", + "expires": "有效期", + "used_times_limit": "有效人数", + "invited": "已邀请", + "has_forbidden": "已失效", + "forbidden": "停用", + "copy_link": "复制链接", + "handle_invitation": "处理团队邀请", + "ignore": "忽略", + "forbid_success": "停用成功", + "forbid_hint": "停用后,该邀请链接将失效。 该操作不可撤销,是否确认停用?", + "confirm_forbidden": "确认停用", + "invitation_link_auto_clean_hint": "已失效链接将在30天后自动清理", + "has_invited": "已邀请", + "invitation_link_has_been_invalid": "邀请链接已失效,请联系团队管理员" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 5317f35f0..6d95d9406 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -104,6 +104,9 @@ "code_error.team_error.un_auth": "无权操作该团队", "code_error.team_error.user_not_active": "用户未接受或已离开团队", "code_error.team_error.website_sync_not_enough": "免费版无法使用Web站点同步~", + "code_error.team_error.invitation_link_invalid": "邀请链接无效", + "code_error.team_error.you_have_been_in_the_team": "你已经在该团队中", + "code_error.team_error.too_many_invitations": "您的有效邀请链接数已达上限,请先清理链接", "code_error.token_error_code.403": "登录状态无效,请重新登录", "code_error.user_error.balance_not_enough": "账号余额不足~", "code_error.user_error.bin_visitor_guest": "您当前身份为游客,无权操作", diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index 94a21818b..7e952eb45 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -35,5 +35,27 @@ "user_team_invite_member": "邀請成員", "user_team_leave_team": "離開團隊", "user_team_leave_team_failed": "離開團隊失敗", - "waiting": "待接受" + "waiting": "待接受", + "invitation_link_list": "連結列表", + "create_invitation_link": "建立邀請連結", + "invitation_link_description": "連結描述", + "30mins": "30分鐘", + "7days": "7天", + "1year": "1年", + "unlimited": "無限制", + "1person": "1人", + "expires": "有效期", + "used_times_limit": "有效人數", + "invited": "已邀請", + "has_forbidden": "已失效", + "forbidden": "停用", + "copy_link": "複製連結", + "handle_invitation": "處理團隊邀請", + "ignore": "忽略", + "forbid_success": "停用成功", + "forbid_hint": "停用後,該邀請連結將失效。 該操作不可撤銷,是否確認停用?", + "confirm_forbidden": "確認停用", + "invitation_link_auto_clean_hint": "已失效連結將在30天後自動清理", + "has_invited": "已邀請", + "invitation_link_has_been_invalid": "邀請連結已失效,請聯繫團隊管理員" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 63da238a9..c21a19d25 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -99,6 +99,9 @@ "code_error.team_error.un_auth": "無權操作此團隊", "code_error.team_error.user_not_active": "使用者未接受或已離開團隊", "code_error.team_error.website_sync_not_enough": "免費版無法使用Web站點同步~", + "code_error.team_error.invitation_link_invalid": "邀請連結無效", + "code_error.team_error.you_have_been_in_the_team": "你已經在該團隊中", + "code_error.team_error.too_many_invitations": "您的有效邀請連結數已達上限,請先清理連結", "code_error.token_error_code.403": "登入狀態無效,請重新登入", "code_error.user_error.balance_not_enough": "帳戶餘額不足", "code_error.user_error.bin_visitor_guest": "您目前身份為訪客,無權操作", diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index 2ece77c27..0be42f608 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -18,7 +18,6 @@ import WorkorderButton from './WorkorderButton'; const Navbar = dynamic(() => import('./navbar')); const NavbarPhone = dynamic(() => import('./navbarPhone')); -const UpdateInviteModal = dynamic(() => import('@/components/support/user/team/UpdateInviteModal')); const NotSufficientModal = dynamic(() => import('@/components/support/wallet/NotSufficientModal')); const SystemMsgModal = dynamic(() => import('@/components/support/user/inform/SystemMsgModal')); const ImportantInform = dynamic(() => import('@/components/support/user/inform/ImportantInform')); @@ -151,7 +150,6 @@ const Layout = ({ children }: { children: JSX.Element }) => { {feConfigs?.isPlus && ( <> - {!!userInfo && } {notSufficientModalType && } {!!userInfo && } {showUpdateNotification && ( diff --git a/projects/app/src/components/support/user/team/UpdateInviteModal/index.tsx b/projects/app/src/components/support/user/team/UpdateInviteModal/index.tsx deleted file mode 100644 index a710da4e1..000000000 --- a/projects/app/src/components/support/user/team/UpdateInviteModal/index.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'next-i18next'; -import MyModal from '@fastgpt/web/components/common/MyModal'; -import { Button, ModalFooter, ModalBody, Flex, Box, useTheme } from '@chakra-ui/react'; -import { getTeamList, updateInviteResult } from '@/web/support/user/team/api'; -import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant'; -import Avatar from '@fastgpt/web/components/common/Avatar'; -import { useToast } from '@fastgpt/web/hooks/useToast'; -import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; -import { useSystemStore } from '@/web/common/system/useSystemStore'; -import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; -import { useUserStore } from '@/web/support/user/useUserStore'; - -const UpdateInviteModal = () => { - const { t } = useTranslation(); - const theme = useTheme(); - const { toast } = useToast(); - const { feConfigs } = useSystemStore(); - const { initUserInfo } = useUserStore(); - - const { ConfirmModal, openConfirm } = useConfirm({}); - - const { data: inviteList = [], run: fetchInviteList } = useRequest2( - async () => (feConfigs.isPlus ? getTeamList(TeamMemberStatusEnum.waiting) : []), - { - manual: false - } - ); - - const { runAsync: onAccept, loading: isLoadingAccept } = useRequest2(updateInviteResult, { - onSuccess() { - toast({ - status: 'success', - title: t('common:user.team.invite.Accepted') - }); - fetchInviteList(); - initUserInfo(); - } - }); - const { runAsync: onReject, loading: isLoadingReject } = useRequest2(updateInviteResult, { - onSuccess() { - toast({ - status: 'success', - title: t('common:user.team.invite.Reject') - }); - fetchInviteList(); - initUserInfo(); - } - }); - - return ( - 0} - iconSrc="/imgs/modal/team.svg" - title={ - - {t('common:user.team.Processing invitations')} - - {t('common:user.team.Processing invitations Tips', { amount: inviteList?.length })} - - - } - maxW={['90vw', '500px']} - > - - {inviteList?.map((item) => ( - - - {item.teamName} - - - - - ))} - - - {t('common:user.team.invite.Deal Width Footer Tip')} - - - - - ); -}; - -export default React.memo(UpdateInviteModal); diff --git a/projects/app/src/pageComponents/account/team/CreateInvitationModal.tsx b/projects/app/src/pageComponents/account/team/CreateInvitationModal.tsx new file mode 100644 index 000000000..7d824f467 --- /dev/null +++ b/projects/app/src/pageComponents/account/team/CreateInvitationModal.tsx @@ -0,0 +1,99 @@ +import { postCreateInvitationLink } from '@/web/support/user/team/api'; +import { + Box, + Button, + Grid, + HStack, + Input, + ModalBody, + ModalCloseButton, + ModalFooter +} from '@chakra-ui/react'; +import { + InvitationLinkCreateType, + InvitationLinkExpiresType +} from '@fastgpt/service/support/user/team/invitationLink/type'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import MySelect from '@fastgpt/web/components/common/MySelect'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { useTranslation } from 'next-i18next'; +import { useForm } from 'react-hook-form'; + +function CreateInvitationModal({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const expiresOptions: Array<{ label: string; value: InvitationLinkExpiresType }> = [ + { label: t('account_team:30mins'), value: '30m' }, // 30 mins + { label: t('account_team:7days'), value: '7d' }, // 7 days + { label: t('account_team:1year'), value: '1y' } // 1 year + ]; + + const usedTimesLimitOptions = [ + { label: t('account_team:unlimited'), value: -1 }, + { label: t('account_team:1person'), value: 1 } + ]; + const { register, handleSubmit, watch, setValue } = useForm({ + defaultValues: { + description: '', + expires: expiresOptions[1].value, + usedTimesLimit: usedTimesLimitOptions[1].value + } + }); + + const expires = watch('expires'); + const usedTimesLimit = watch('usedTimesLimit'); + + const { runAsync: createInvitationLink } = useRequest2(postCreateInvitationLink, { + manual: true, + successToast: t('common:common.Create Success'), + errorToast: t('common:common.Create Failed'), + onFinally: () => onClose() + }); + + return ( + {t('account_team:create_invitation_link')}} + minW={'500px'} + > + + + + {t('account_team:invitation_link_description')} + + + {t('account_team:expires')} + setValue('expires', val)} + minW="120px" + /> + + {t('account_team:used_times_limit')} + setValue('usedTimesLimit', val)} + minW="120px" + /> + + + + + + + + ); +} + +export default CreateInvitationModal; diff --git a/projects/app/src/pageComponents/account/team/GroupManage/index.tsx b/projects/app/src/pageComponents/account/team/GroupManage/index.tsx index bd568d815..dc9ae458c 100644 --- a/projects/app/src/pageComponents/account/team/GroupManage/index.tsx +++ b/projects/app/src/pageComponents/account/team/GroupManage/index.tsx @@ -172,7 +172,7 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { {group.name === DefaultGroupName ? ( - v.avatar)} groupId={group._id} /> + v.avatar)} /> ) : hasGroupManagePer(group) ? ( onManageMember(group)}> @@ -180,7 +180,6 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { avatars={group.members.map( (v) => members.find((m) => m.tmbId === v.tmbId)?.avatar ?? '' )} - groupId={group._id} /> @@ -189,7 +188,6 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { avatars={group.members.map( (v) => members.find((m) => m.tmbId === v.tmbId)?.avatar ?? '' )} - groupId={group._id} /> )} diff --git a/projects/app/src/pageComponents/account/team/HandleInviteModal.tsx b/projects/app/src/pageComponents/account/team/HandleInviteModal.tsx new file mode 100644 index 000000000..40fc5d35c --- /dev/null +++ b/projects/app/src/pageComponents/account/team/HandleInviteModal.tsx @@ -0,0 +1,106 @@ +import { getInvitationInfo, postAcceptInvitationLink } from '@/web/support/user/team/api'; +import { + Box, + Button, + CloseButton, + Flex, + ModalBody, + ModalCloseButton, + ModalHeader +} from '@chakra-ui/react'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; +import { useCallback } from 'react'; +import { useContextSelector } from 'use-context-selector'; +import { TeamContext } from './context'; +import { isForbidden } from '@fastgpt/service/support/user/team/invitationLink/controllers'; +import { useToast } from '@fastgpt/web/hooks/useToast'; + +function Invite({ invitelinkid }: { invitelinkid: string }) { + const router = useRouter(); + const { t } = useTranslation(); + + const { onSwitchTeam, refetchMembers } = useContextSelector(TeamContext, (v) => v); + + const onClose = () => { + router.push('/account/team'); + }; + + const { toast } = useToast(); + + const { data: invitationInfo } = useRequest2(() => getInvitationInfo(invitelinkid), { + manual: false, + onSuccess: (data) => { + if (isForbidden(data)) { + toast({ + description: t('account_team:invitation_link_has_been_invalid'), + status: 'warning' + }); + onClose(); + } + }, + onError: onClose + }); + + const { runAsync: acceptInvitation } = useRequest2(() => postAcceptInvitationLink(invitelinkid), { + manual: true, + successToast: t('common:common.Success'), + onSuccess: () => { + toast({ + description: t('common:common.Success'), + status: 'success' + }); + onSwitchTeam(invitationInfo!.teamId); + refetchMembers(); + onClose(); + }, + onError: (e) => { + toast({ + description: t('common:common.Error'), + status: 'error' + }); + onClose(); + } + }); + + return ( + <> + {invitationInfo && ( + + + + + + {invitationInfo.teamName} + + + + + + + )} + + ); +} + +export default Invite; diff --git a/projects/app/src/pageComponents/account/team/InviteModal.tsx b/projects/app/src/pageComponents/account/team/InviteModal.tsx index 916ebae21..66ed547ef 100644 --- a/projects/app/src/pageComponents/account/team/InviteModal.tsx +++ b/projects/app/src/pageComponents/account/team/InviteModal.tsx @@ -1,12 +1,41 @@ -import React, { useState } from 'react'; +import MemberTag from '@/components/support/user/team/Info/MemberTag'; +import Empty from '@/pageComponents/chat/Empty'; +import { getInvitationLinkList, putUpdateInvitationInfo } from '@/web/support/user/team/api'; +import { + Box, + Button, + Divider, + Flex, + Grid, + HStack, + ModalBody, + ModalCloseButton, + ModalFooter, + ModalHeader, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, + useDisclosure +} from '@chakra-ui/react'; +import AvatarGroup from '@fastgpt/web/components/common/Avatar/AvatarGroup'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import Icon from '@fastgpt/web/components/common/Icon'; +import MyIconButton from '@fastgpt/web/components/common/Icon/button'; import MyModal from '@fastgpt/web/components/common/MyModal'; -import { useTranslation } from 'next-i18next'; -import { ModalCloseButton, ModalBody, Box, ModalFooter, Button } from '@chakra-ui/react'; -import TagTextarea from '@/components/common/Textarea/TagTextarea'; +import MyPopover from '@fastgpt/web/components/common/MyPopover'; +import Tag from '@fastgpt/web/components/common/Tag'; +import { useCopyData } from '@fastgpt/web/hooks/useCopyData'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; -import { postInviteTeamMember } from '@/web/support/user/team/api'; -import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; -import type { InviteMemberResponse } from '@fastgpt/global/support/user/team/controller.d'; +import format from 'date-fns/format'; +import { useTranslation } from 'next-i18next'; +import dynamic from 'next/dynamic'; +import { useCallback } from 'react'; + +const CreateInvitationModal = dynamic(() => import('./CreateInvitationModal')); const InviteModal = ({ teamId, @@ -18,43 +47,47 @@ const InviteModal = ({ onSuccess: () => void; }) => { const { t } = useTranslation(); - const { ConfirmModal, openConfirm } = useConfirm({ - title: t('user:team.Invite Member Result Tip'), - showCancel: false + + const { + data: invitationLinkList, + loading: isLoadingLink, + runAsync: refetchInvitationLinkList + } = useRequest2(() => getInvitationLinkList(), { + manual: false }); - const [inviteUsernames, setInviteUsernames] = useState([]); + const { isOpen: isOpenCreate, onOpen: onOpenCreate, onClose: onCloseCreate } = useDisclosure(); - const { runAsync: onInvite, loading: isLoading } = useRequest2( - () => - postInviteTeamMember({ - teamId, - usernames: inviteUsernames + const isLoading = isLoadingLink; + const { copyData } = useCopyData(); + + const onCopy = useCallback( + (linkId: string) => { + copyData(location.origin + `/account/team?invitelinkid=${linkId}`); + }, + [copyData] + ); + + const { runAsync: onForbid } = useRequest2( + (linkId: string) => + putUpdateInvitationInfo({ + linkId, + forbidden: true }), { - onSuccess(res: InviteMemberResponse) { - onSuccess(); - openConfirm( - () => onClose(), - undefined, - - {t('user:team.Invite Member Success Tip', { - success: res.invite.length, - inValid: res.inValid.map((item) => item.username).join(', '), - inTeam: res.inTeam.map((item) => item.username).join(', ') - })} - - )(); - }, - errorToast: t('user:team.Invite Member Failed Tip') + manual: true, + onSuccess: refetchInvitationLinkList, + successToast: t('account_team:forbid_success') } ); return ( {t('common:user.team.Invite Member')} @@ -63,26 +96,177 @@ const InviteModal = ({ } - maxW={['90vw', '400px']} + maxW={['90vw']} overflow={'unset'} > - - {t('common:user.Account')} - + + + + + + {t('account_team:invitation_link_list')} + + + + + + + + + + + + + + + + + + {!!invitationLinkList?.length && ( + + {invitationLinkList?.map((item) => { + const isForbidden = item.forbidden || new Date(item.expires) < new Date(); + return ( + + + + + + + + ); + })} + + )} +
+ {t('account_team:invitation_link_description')} + {t('account_team:expires')}{t('account_team:used_times_limit')}{t('account_team:invited')} + {t('common:common.Action')} +
+ {item.description} + + {isForbidden ? ( + {t('account_team:has_forbidden')} + ) : ( + format(new Date(item.expires), 'yyyy-MM-dd HH:mm') + )} + + {item.usedTimesLimit === -1 + ? t('account_team:unlimited') + : item.usedTimesLimit} + + + i.avatar)} /> + + } + trigger="click" + closeOnBlur={true} + > + {() => ( + + + {t('account_team:has_invited')} + + {item.members.length} + + + + + {item.members.map((member) => ( + + + + ))} + + + )} + + + {!isForbidden && ( + <> + + + + {t('account_team:forbidden')} + + } + closeOnBlur={true} + > + {({ onClose: onClosePopover }) => ( + + + {t('account_team:forbid_hint')} + + + + + + + )} + + + )} +
+ {!invitationLinkList?.length && } +
- - + + + {t('account_team:invitation_link_auto_clean_hint')} + - + {isOpenCreate && ( + Promise.all([onCloseCreate(), refetchInvitationLinkList()])} + /> + )}
); }; diff --git a/projects/app/src/pageComponents/account/team/MemberTable.tsx b/projects/app/src/pageComponents/account/team/MemberTable.tsx index fe9968da8..d3f9ee12c 100644 --- a/projects/app/src/pageComponents/account/team/MemberTable.tsx +++ b/projects/app/src/pageComponents/account/team/MemberTable.tsx @@ -17,7 +17,7 @@ import { import { useTranslation } from 'next-i18next'; import { useUserStore } from '@/web/support/user/useUserStore'; import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; -import { delRemoveMember, updateStatus } from '@/web/support/user/team/api'; +import { delRemoveMember, postRestoreMember } from '@/web/support/user/team/api'; import Tag from '@fastgpt/web/components/common/Tag'; import Icon from '@fastgpt/web/components/common/Icon'; import { useContextSelector } from 'use-context-selector'; @@ -118,7 +118,7 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { errorToast: t('account_team:sync_member_failed') }); - const { runAsync: onRestore, loading: isUpdateInvite } = useRequest2(updateStatus, { + const { runAsync: onRestore, loading: isUpdateInvite } = useRequest2(postRestoreMember, { onSuccess() { refetchMembers(); }, @@ -253,12 +253,7 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { {member.memberName} - {member.status === 'waiting' && ( - - {t('account_team:waiting')} - - )} - {member.status === 'leave' && ( + {member.status !== 'active' && ( {t('account_team:leave')} @@ -295,7 +290,7 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { {userInfo?.team.permission.hasManagePer && member.role !== TeamMemberRoleEnum.owner && member.tmbId !== userInfo?.team.tmbId && - (member.status !== TeamMemberStatusEnum.leave ? ( + (member.status === TeamMemberStatusEnum.active ? ( ) : ( - { - openRestoreMember( - () => - onRestore({ - tmbId: member.tmbId, - status: TeamMemberStatusEnum.active - }), - undefined, - t('account_team:restore_tip', { - username: member.memberName - }) - )(); - }} - /> + member.status === TeamMemberStatusEnum.forbidden && ( + { + openRestoreMember( + () => onRestore(member.tmbId), + undefined, + t('account_team:restore_tip', { + username: member.memberName + }) + )(); + }} + /> + ) ))} diff --git a/projects/app/src/pages/account/team/index.tsx b/projects/app/src/pages/account/team/index.tsx index 37b3b4ce6..257fc2a7d 100644 --- a/projects/app/src/pages/account/team/index.tsx +++ b/projects/app/src/pages/account/team/index.tsx @@ -20,6 +20,7 @@ const PermissionManage = dynamic( ); const GroupManage = dynamic(() => import('@/pageComponents/account/team/GroupManage/index')); const OrgManage = dynamic(() => import('@/pageComponents/account/team/OrgManage/index')); +const HandleInviteModal = dynamic(() => import('@/pageComponents/account/team/HandleInviteModal')); export enum TeamTabEnum { member = 'member', @@ -30,6 +31,16 @@ export enum TeamTabEnum { const Team = () => { const router = useRouter(); + + const invitelinkid = useMemo(() => { + const _id = router.query.invitelinkid; + if (!_id && typeof _id !== 'string') { + return ''; + } else { + return _id as string; + } + }, [router.query.invitelinkid]); + const { teamTab = TeamTabEnum.member } = router.query as { teamTab: `${TeamTabEnum}` }; const { t } = useTranslation(); @@ -142,6 +153,7 @@ const Team = () => { {teamTab === TeamTabEnum.permission && }
+ {invitelinkid && } ); }; diff --git a/projects/app/src/web/support/user/team/api.ts b/projects/app/src/web/support/user/team/api.ts index aa857a356..058e1fe3c 100644 --- a/projects/app/src/web/support/user/team/api.ts +++ b/projects/app/src/web/support/user/team/api.ts @@ -9,7 +9,6 @@ import { InviteMemberProps, InviteMemberResponse, UpdateInviteProps, - UpdateStatusProps, UpdateTeamProps } from '@fastgpt/global/support/user/team/controller.d'; import type { TeamTagItemType, TeamTagSchema } from '@fastgpt/global/support/user/team/type'; @@ -21,6 +20,12 @@ import { import { FeTeamPlanStatusType, TeamSubSchema } from '@fastgpt/global/support/wallet/sub/type'; import { TeamInvoiceHeaderType } from '@fastgpt/global/support/user/team/type'; import { PaginationProps, PaginationResponse } from '@fastgpt/web/common/fetch/type'; +import { + InvitationInfoType, + InvitationLinkCreateType, + InvitationLinkUpdateType, + InvitationType +} from '@fastgpt/service/support/user/team/invitationLink/type'; /* --------------- team ---------------- */ export const getTeamList = (status: `${TeamMemberSchema['status']}`) => @@ -34,18 +39,37 @@ export const putSwitchTeam = (teamId: string) => /* --------------- team member ---------------- */ export const getTeamMembers = (props: PaginationProps<{ withLeaved?: boolean }>) => GET>(`/proApi/support/user/team/member/list`, props); -export const postInviteTeamMember = (data: InviteMemberProps) => - POST(`/proApi/support/user/team/member/invite`, data); + +// export const postInviteTeamMember = (data: InviteMemberProps) => +// POST(`/proApi/support/user/team/member/invite`, data); + export const putUpdateMemberName = (name: string) => PUT(`/proApi/support/user/team/member/updateName`, { name }); export const delRemoveMember = (tmbId: string) => DELETE(`/proApi/support/user/team/member/delete`, { tmbId }); export const updateInviteResult = (data: UpdateInviteProps) => PUT('/proApi/support/user/team/member/updateInvite', data); -export const updateStatus = (data: UpdateStatusProps) => - PUT('/proApi/support/user/team/member/updateStatus', data); +export const postRestoreMember = (tmbId: string) => + POST('/proApi/support/user/team/member/restore', { tmbId }); export const delLeaveTeam = () => DELETE('/proApi/support/user/team/member/leave'); +/* -------------- team invitaionlink -------------------- */ + +export const postCreateInvitationLink = (data: InvitationLinkCreateType) => + POST(`/proApi/support/user/team/invitationLink/create`, data); + +export const getInvitationLinkList = () => + GET(`/proApi/support/user/team/invitationLink/list`); + +export const postAcceptInvitationLink = (linkId: string) => + POST(`/proApi/support/user/team/invitationLink/accept`, { linkId }); + +export const getInvitationInfo = (linkId: string) => + GET(`/proApi/support/user/team/invitationLink/info`, { linkId }); + +export const putUpdateInvitationInfo = (data: InvitationLinkUpdateType) => + PUT('/proApi/support/user/team/invitationLink/update', data); + /* -------------- team collaborator -------------------- */ export const getTeamClbs = () => GET(`/proApi/support/user/team/collaborator/list`);